mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 11:18:04 +02:00
Compare commits
340 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 91c141b7ee | |||
| 4577ce5ca7 | |||
| 7d843ca02a | |||
| 89de08e08c | |||
| 02213d1edf | |||
| 9dedb66800 | |||
| 24408e0cba | |||
| 4d251a2a0d | |||
| 52972eefef | |||
| 471b412dc5 | |||
| 4c85a8f05e | |||
| 0c441b2c86 | |||
| f6ddd62b13 | |||
| 5fd67e53c4 | |||
| 1206c57d6c | |||
| 38815fa0aa | |||
| 959206b0be | |||
| 7b7ee523fd | |||
| b1220b4c19 | |||
| 62b894a7ba | |||
| 45188ef87d | |||
| 2185ccf2c9 | |||
| e8d95178c7 | |||
| a3ebe27b76 | |||
| b39d4fa15a | |||
| f86b2b816b | |||
| 308a75037e | |||
| bda41a0c1a | |||
| ec5e7ed1cd | |||
| 6528d445d3 | |||
| aacf1bef9c | |||
| 4262553036 | |||
| a3d6c6f916 | |||
| bce05eb071 | |||
| e35ea061d5 | |||
| 8c7c168fdf | |||
| 7a811ad55e | |||
| 8d29be2a6d | |||
| 36567f40a1 | |||
| 76e0de7d20 | |||
| 74a41a2d49 | |||
| 1ba82df9e3 | |||
| ccf995ec11 | |||
| 159b7c2ab6 | |||
| dc60ad1137 | |||
| 1c46708303 | |||
| 532042e39e | |||
| 5cb6faa2d6 | |||
| 91b88a40ea | |||
| 72a9691c55 | |||
| 00e8280d22 | |||
| ed2c7606c3 | |||
| c1a6262d96 | |||
| 59b953d577 | |||
| 0854174140 | |||
| 3802df0abd | |||
| 57f09cc50c | |||
| 5257a035d7 | |||
| 0cab036718 | |||
| 4fabd8cfd0 | |||
| 6b7f63d784 | |||
| 95b56c80c5 | |||
| f2020b7653 | |||
| 97bb4f6641 | |||
| adeed5ae13 | |||
| 5e9cce4b8c | |||
| 22a8fab1c7 | |||
| f6a0cc9fad | |||
| 765f0d63b6 | |||
| c193e88990 | |||
| 6398d8f7eb | |||
| 030b8c5459 | |||
| 469af1ac5d | |||
| 6c635dfd01 | |||
| 299e93ffe3 | |||
| 4dc5b2ee30 | |||
| 6613461eea | |||
| 9de442b0c8 | |||
| 6bc65fe559 | |||
| 7da423ee1b | |||
| c6ccefbd72 | |||
| 72d25f576c | |||
| 7ab9481fab | |||
| f494e03a6e | |||
| a90dce95a3 | |||
| a35f6ad939 | |||
| 80e704fd26 | |||
| 88fc958729 | |||
| 12d6940a6e | |||
| 34bf726cc1 | |||
| 68af217b7b | |||
| 5eda1c4cb0 | |||
| f5c934f744 | |||
| 89c71f6b16 | |||
| 4523bc5532 | |||
| b9c66e0c7b | |||
| f26c7cfe02 | |||
| 3bbe29c2e8 | |||
| e0cc1f7cb2 | |||
| 9faa1b7961 | |||
| 142d7e639b | |||
| 9e115902b7 | |||
| d2ec68808c | |||
| 352186eb40 | |||
| 68e742b670 | |||
| d7c4586358 | |||
| 84f784e538 | |||
| 5f9822f726 | |||
| 41a1b94811 | |||
| e4b1d39c4e | |||
| 136de95290 | |||
| 1afe66cb66 | |||
| bdf1626273 | |||
| 87131ad633 | |||
| 439418e419 | |||
| 0eb4e15a8f | |||
| 5f3928399c | |||
| 0c0f2d54ad | |||
| 2f54fe6cf6 | |||
| e827a26458 | |||
| c886d55317 | |||
| 0189f576c7 | |||
| 13c46d0f5e | |||
| 60175108df | |||
| 7c2b87f49a | |||
| f33ee40779 | |||
| b9142bc40c | |||
| 680e0e0976 | |||
| 7b22bbf25f | |||
| 06f2b9ec97 | |||
| 7fee4cea4f | |||
| 526897b23b | |||
| c10c2a290c | |||
| fb5204b0a6 | |||
| 9db4048bc0 | |||
| 63c68b4d4d | |||
| 953ef37882 | |||
| da85a2dcc2 | |||
| 49869792cf | |||
| fb2dda1ed1 | |||
| fad4c4ea36 | |||
| 6b5345a6e5 | |||
| ca413a16fa | |||
| b8b670642c | |||
| 2a2e2924eb | |||
| adea3de737 | |||
| 7d300a39c9 | |||
| 688a5f2add | |||
| d736e5aafe | |||
| 3a536ad348 | |||
| 5dedeb4971 | |||
| 7624e24ea6 | |||
| 7b248d8ab4 | |||
| fdb2009856 | |||
| 8419a75b04 | |||
| 5d474d6fe8 | |||
| e597505a1c | |||
| 8675d263e7 | |||
| cfda124995 | |||
| 212f1cacca | |||
| dd89de7cad | |||
| 8b4372dc7f | |||
| 2a25557632 | |||
| 0cbb339948 | |||
| 1496f51e30 | |||
| d1c5fe0605 | |||
| 56786f60ff | |||
| af5d36f69f | |||
| e40da71ef8 | |||
| 26b8bf422c | |||
| 0a545706bd | |||
| 9ebac610c7 | |||
| 5fc8a6af2a | |||
| 1623f443bb | |||
| aa47bc4499 | |||
| f461322842 | |||
| cce05a0077 | |||
| 98dc868f47 | |||
| 821a41c10e | |||
| 853ccd657a | |||
| 680fc81db2 | |||
| 36470eda24 | |||
| a37dd6c8cb | |||
| 588f742871 | |||
| ff25a10e5b | |||
| 499457f66a | |||
| 6d15050009 | |||
| 5ba30031c3 | |||
| 82c0eef504 | |||
| 616267e997 | |||
| 161b0c8c21 | |||
| facd185d6c | |||
| 42858bf336 | |||
| 716be88caf | |||
| b296726a9d | |||
| 092f18d7a5 | |||
| f1ef33e319 | |||
| fc9bc95418 | |||
| c61e64f332 | |||
| 70ebb8ef1a | |||
| a4c6a92478 | |||
| 76b453e535 | |||
| 19acdd87f5 | |||
| 492e1335ef | |||
| 23cde7add3 | |||
| a20c28db25 | |||
| f07d46c49e | |||
| e9781a24a6 | |||
| 15be15ba58 | |||
| 8011d41e53 | |||
| 5412f23d26 | |||
| 0c39ff47f2 | |||
| 537af905f6 | |||
| 6b4f70bde3 | |||
| be2b6d2c1f | |||
| 0c1a6d8f19 | |||
| 2821997260 | |||
| 0546a33b10 | |||
| deb98d8dfb | |||
| 72c658eda7 | |||
| df17f10c8a | |||
| 9cacf2dc8e | |||
| c7bc9f5b1c | |||
| 49ba8ae0d2 | |||
| 3a62442ed0 | |||
| 3a1b92f9c4 | |||
| 30f97394ec | |||
| 592308c1c6 | |||
| 8bcfc63da0 | |||
| a9cfff2692 | |||
| 9e7ff56113 | |||
| 9071143bbd | |||
| 40770aff15 | |||
| 2bc5ef34ee | |||
| 6b9a3d95cd | |||
| 4fe51cef96 | |||
| 672ce024f8 | |||
| 8224e93447 | |||
| 1ba810fffb | |||
| 1a725d0d31 | |||
| 51c5b42a78 | |||
| 2908827018 | |||
| b985cbf694 | |||
| 1293d92896 | |||
| 705d41931d | |||
| 29de69d323 | |||
| 28727d89f6 | |||
| 4704bcf52f | |||
| 13c148fb6c | |||
| e6079452f9 | |||
| b68b7d5c9b | |||
| 741fcdb4d9 | |||
| 642f8c5398 | |||
| 1c15d5e7d3 | |||
| e71090338c | |||
| 7c0feaaae0 | |||
| 5aa3ff4bb5 | |||
| d4c83db428 | |||
| 9f2d51fd4d | |||
| 36137e8970 | |||
| 823e56926f | |||
| dd8a54dd43 | |||
| 1ff33b96fa | |||
| 4be9273768 | |||
| f458ac2162 | |||
| b5ea2bb4c1 | |||
| 284d257921 | |||
| 30bf6b7f9a | |||
| 4941b6bd23 | |||
| 33d99817ec | |||
| 37e1af50ad | |||
| 8a6efb1303 | |||
| 7823b19b89 | |||
| 2a9aa544a9 | |||
| f387c8ff85 | |||
| 7e537aec0b | |||
| 66cd465565 | |||
| 83afa40423 | |||
| 486e7eb101 | |||
| 05eb9e60d3 | |||
| dde7095644 | |||
| f1e9a2915d | |||
| ae3495d373 | |||
| 6fb2c1b688 | |||
| 1526c558e7 | |||
| 324e0f053b | |||
| 25cb33c78e | |||
| 942b6d9569 | |||
| cd46c79383 | |||
| 0bdcdcc229 | |||
| 1a5863a7fb | |||
| 701015ad55 | |||
| 63cfac626a | |||
| e6c5a21bfc | |||
| 2d80739141 | |||
| 6494102e15 | |||
| 0e6aa2efd9 | |||
| f412c216c5 | |||
| af15e3d914 | |||
| b00ff3f3f0 | |||
| 1607e6830e | |||
| 817e0bf2bd | |||
| 0f12fbce6a | |||
| 953a09d75f | |||
| 5098989614 | |||
| 5828bcffdd | |||
| ae87a7d58f | |||
| 32ab78a213 | |||
| 69583d172c | |||
| 38367c1c77 | |||
| 2f6bf91a1c | |||
| 60b062bbaf | |||
| 30e8b604a9 | |||
| 7c3ab92e17 | |||
| 37b101c70f | |||
| b7be46e6ae | |||
| bf1f79866b | |||
| a6460426a2 | |||
| 304ba14d20 | |||
| db47233d92 | |||
| 74eeb98be8 | |||
| 331da0f897 | |||
| 73964ee648 | |||
| a5e8402141 | |||
| c5e7fcf29b | |||
| d3cf6d30a7 | |||
| 74e14f7a43 | |||
| 02e347adb0 | |||
| 56983cb85b | |||
| 7917c656b0 | |||
| fc34c1e548 | |||
| f32aeaa0ff | |||
| 86097a932c | |||
| f74f24c41f | |||
| 8e99e7b07e | |||
| e06aab6e87 | |||
| a81e56fb26 | |||
| 9a09b119c5 | |||
| 4b28ca1055 | |||
| d684d9f8d1 |
@@ -257,6 +257,15 @@ jobs:
|
||||
- name: Get Flutter dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Normalize ffmpeg plugin shell scripts (strip CRLF)
|
||||
run: |
|
||||
find "$HOME/.pub-cache/hosted" -path "*ffmpeg_kit_flutter_new_full*/scripts/*.sh" -type f -print0 |
|
||||
while IFS= read -r -d '' f; do
|
||||
perl -pi -e 's/\r$//' "$f"
|
||||
chmod +x "$f"
|
||||
echo "Normalized line endings: $f"
|
||||
done
|
||||
|
||||
- name: Generate app icons
|
||||
run: dart run flutter_launcher_icons
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ extension/
|
||||
AGENTS.md
|
||||
|
||||
# Temp/misc
|
||||
.tmp/
|
||||
nul
|
||||
NUL
|
||||
network_requests.txt
|
||||
|
||||
@@ -59,7 +59,7 @@ Extensions let the community add new music sources and features without waiting
|
||||
## Related Projects
|
||||
|
||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music available for Windows, macOS & Linux.
|
||||
Download music in true lossless FLAC from extension-provided sources on 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).
|
||||
@@ -80,7 +80,7 @@ Starting from version 3.8.0, SpotiFLAC uses a decentralized extension repository
|
||||
<summary><b>Why is my download failing with "Song not found"?</b></summary>
|
||||
<br>
|
||||
|
||||
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.
|
||||
The track may not be available from your enabled providers. Try enabling more providers under **Settings > Extensions > Provider Priority**, or install additional download extensions from the Store.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -88,10 +88,7 @@ The track may not be available on the streaming services. Try enabling more prov
|
||||
<summary><b>Why are some tracks downloading in lower quality?</b></summary>
|
||||
<br>
|
||||
|
||||
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
|
||||
Quality depends on what's available from the source and the installed download extension. Check each extension's quality options and service notes in the app.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -307,6 +307,8 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
".mp3" -> "audio/mpeg"
|
||||
".opus" -> "audio/ogg"
|
||||
".flac" -> "audio/flac"
|
||||
".wav" -> "audio/wav"
|
||||
".aiff", ".aif", ".aifc" -> "audio/aiff"
|
||||
".lrc" -> "application/octet-stream"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
@@ -791,6 +793,8 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
"audio/mpeg" -> ".mp3"
|
||||
"audio/ogg" -> ".opus"
|
||||
"audio/flac" -> ".flac"
|
||||
"audio/wav", "audio/x-wav", "audio/wave", "audio/vnd.wave" -> ".wav"
|
||||
"audio/aiff", "audio/x-aiff" -> ".aiff"
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
@@ -1113,6 +1117,16 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a", ".mp4", ".aac"
|
||||
)
|
||||
|
||||
// Audio file extensions that the local library scanner accepts. Must stay in
|
||||
// sync with supportedAudioFormats in go_backend/library_scan.go so that every
|
||||
// format the Go engine can read (FLAC, M4A/MP4/AAC, MP3, Opus/OGG, APE/WV/MPC,
|
||||
// WAV, AIFF) is also enumerated here during the SAF folder walk. (.cue is
|
||||
// handled separately.)
|
||||
private val libraryScanAudioExtensions = setOf(
|
||||
".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg",
|
||||
".ape", ".wv", ".mpc", ".wav", ".aiff", ".aif"
|
||||
)
|
||||
|
||||
private fun getSafChildFileLookup(
|
||||
dir: DocumentFile,
|
||||
cache: MutableMap<String, Map<String, DocumentFile>>,
|
||||
@@ -1182,7 +1196,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
it.currentFile = "Scanning folders..."
|
||||
}
|
||||
|
||||
val supportedAudioExt = setOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg")
|
||||
val supportedAudioExt = libraryScanAudioExtensions
|
||||
val audioFiles = mutableListOf<Pair<DocumentFile, String>>()
|
||||
val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
||||
val visitedDirUris = mutableSetOf<String>()
|
||||
@@ -1482,7 +1496,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
it.currentFile = "Scanning folders..."
|
||||
}
|
||||
|
||||
val supportedAudioExt = setOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg")
|
||||
val supportedAudioExt = libraryScanAudioExtensions
|
||||
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>()
|
||||
val cueFilesToScan = mutableListOf<Triple<DocumentFile, DocumentFile, Long>>()
|
||||
val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
"name": "SpotiFLAC Mobile",
|
||||
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||
"developerName": "zarzet",
|
||||
"version": "4.5.5",
|
||||
"versionDate": "2026-05-14",
|
||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.5.5/SpotiFLAC-v4.5.5-ios-unsigned.ipa",
|
||||
"version": "4.6.0",
|
||||
"versionDate": "2026-06-13",
|
||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.6.0/SpotiFLAC-v4.6.0-ios-unsigned.ipa",
|
||||
"localizedDescription": "SpotiFLAC Mobile is 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": 34915749
|
||||
"size": 34347687
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+4
-5
@@ -3,9 +3,11 @@ files:
|
||||
translation: /lib/l10n/arb/app_%locale%.arb
|
||||
languages_mapping:
|
||||
locale:
|
||||
# Short codes for single-variant languages
|
||||
# Keys MUST be the project's Crowdin language ids; values are the
|
||||
# %locale% suffix used in app_%locale%.arb (underscores so Flutter
|
||||
# gen-l10n parses them — hyphenated filenames break gen-l10n).
|
||||
ar: ar
|
||||
de: de
|
||||
es: es
|
||||
es-ES: es_ES
|
||||
fr: fr
|
||||
hi: hi
|
||||
@@ -13,12 +15,9 @@ files:
|
||||
ja: ja
|
||||
ko: ko
|
||||
nl: nl
|
||||
pt: pt
|
||||
pt-PT: pt_PT
|
||||
ru: ru
|
||||
tr: tr
|
||||
uk: uk
|
||||
zh: zh
|
||||
# Full codes for Chinese variants
|
||||
zh-CN: zh_CN
|
||||
zh-TW: zh_TW
|
||||
|
||||
@@ -1624,6 +1624,9 @@ func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, strin
|
||||
}
|
||||
return data, mimeType, nil
|
||||
|
||||
case ".wav", ".aiff", ".aif", ".aifc":
|
||||
return extractWAVAIFFCover(filePath)
|
||||
|
||||
default:
|
||||
return nil, "", fmt.Errorf("unsupported format: %s", ext)
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ func ResolveCueAudioPath(cuePath string, cueFileName string) string {
|
||||
}
|
||||
|
||||
baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
|
||||
commonExts := []string{".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
|
||||
commonExts := []string{".flac", ".wav", ".aiff", ".aif", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
|
||||
for _, ext := range commonExts {
|
||||
candidate = filepath.Join(cueDir, baseName+ext)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
|
||||
+72
-1
@@ -1160,6 +1160,8 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
isApe := strings.HasSuffix(lower, ".ape")
|
||||
isWv := strings.HasSuffix(lower, ".wv")
|
||||
isMpc := strings.HasSuffix(lower, ".mpc")
|
||||
isWav := strings.HasSuffix(lower, ".wav")
|
||||
isAiff := strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc")
|
||||
|
||||
result := map[string]interface{}{
|
||||
"title": "",
|
||||
@@ -1406,6 +1408,51 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak
|
||||
}
|
||||
}
|
||||
} else if isWav || isAiff {
|
||||
var meta *AudioMetadata
|
||||
var quality *WAVQuality
|
||||
var qualityErr error
|
||||
if isAiff {
|
||||
result["format"] = "aiff"
|
||||
result["audio_codec"] = "pcm"
|
||||
meta, _ = ReadAIFFTags(filePath)
|
||||
quality, qualityErr = GetAIFFQuality(filePath)
|
||||
} else {
|
||||
result["format"] = "wav"
|
||||
result["audio_codec"] = "pcm"
|
||||
meta, _ = ReadWAVTags(filePath)
|
||||
quality, qualityErr = GetWAVQuality(filePath)
|
||||
}
|
||||
if 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["total_tracks"] = meta.TotalTracks
|
||||
result["disc_number"] = meta.DiscNumber
|
||||
result["total_discs"] = meta.TotalDiscs
|
||||
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
|
||||
result["replaygain_track_gain"] = meta.ReplayGainTrackGain
|
||||
result["replaygain_track_peak"] = meta.ReplayGainTrackPeak
|
||||
result["replaygain_album_gain"] = meta.ReplayGainAlbumGain
|
||||
result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak
|
||||
}
|
||||
if qualityErr == nil && quality != nil {
|
||||
result["bit_depth"] = quality.BitDepth
|
||||
result["sample_rate"] = quality.SampleRate
|
||||
result["duration"] = quality.Duration
|
||||
}
|
||||
} else {
|
||||
return "", fmt.Errorf("unsupported file format: %s", filePath)
|
||||
}
|
||||
@@ -1474,6 +1521,8 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
||||
isFlac := strings.HasSuffix(lower, ".flac")
|
||||
isApeFile := strings.HasSuffix(lower, ".ape") || strings.HasSuffix(lower, ".wv") || strings.HasSuffix(lower, ".mpc")
|
||||
isM4AFile := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".m4b")
|
||||
isWavFile := strings.HasSuffix(lower, ".wav")
|
||||
isAiffFile := strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc")
|
||||
coverPath := strings.TrimSpace(fields["cover_path"])
|
||||
|
||||
if hasOnlyM4AReplayGainFields(fields) && (isM4AFile || isMP4ContainerFile(filePath)) {
|
||||
@@ -1502,6 +1551,24 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// WAV / AIFF: write tags into an embedded ID3v2.4 chunk natively.
|
||||
if isWavFile {
|
||||
if err := WriteWAVTags(filePath, fields); err != nil {
|
||||
return "", fmt.Errorf("failed to write WAV metadata: %w", err)
|
||||
}
|
||||
resp := map[string]any{"success": true, "method": "native_wav"}
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
if isAiffFile {
|
||||
if err := WriteAIFFTags(filePath, fields); err != nil {
|
||||
return "", fmt.Errorf("failed to write AIFF metadata: %w", err)
|
||||
}
|
||||
resp := map[string]any{"success": true, "method": "native_aiff"}
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// APE/WV/MPC: write APEv2 tags natively
|
||||
if isApeFile {
|
||||
trackNum := 0
|
||||
@@ -1751,9 +1818,13 @@ func GetLyricsLRCWithSource(spotifyID, trackName, artistName string, filePath st
|
||||
if filePath != "" {
|
||||
lyrics, err := ExtractLyrics(filePath)
|
||||
if err == nil && lyrics != "" {
|
||||
source := extractLyricsSourceFromLRC(lyrics)
|
||||
if source == "" {
|
||||
source = "Embedded"
|
||||
}
|
||||
result := map[string]interface{}{
|
||||
"lyrics": lyrics,
|
||||
"source": "Embedded",
|
||||
"source": source,
|
||||
"sync_type": "EMBEDDED",
|
||||
"instrumental": false,
|
||||
}
|
||||
|
||||
@@ -118,7 +118,11 @@ func (ext *loadedExtension) lockReadyVM() (*goja.Runtime, error) {
|
||||
}
|
||||
|
||||
type extensionManager struct {
|
||||
mu sync.RWMutex
|
||||
mu sync.RWMutex
|
||||
// mutationMu serializes install/upgrade/remove (heavy FS + goja VM
|
||||
// teardown/reload), which are not safe to run concurrently. Acquired before
|
||||
// m.mu; "*Locked" helpers assume it is held.
|
||||
mutationMu sync.Mutex
|
||||
extensions map[string]*loadedExtension
|
||||
extensionsDir string
|
||||
dataDir string
|
||||
@@ -156,6 +160,12 @@ func (m *extensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
||||
}
|
||||
|
||||
func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtension, error) {
|
||||
m.mutationMu.Lock()
|
||||
defer m.mutationMu.Unlock()
|
||||
return m.loadExtensionFromFileLocked(filePath)
|
||||
}
|
||||
|
||||
func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loadedExtension, error) {
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
|
||||
}
|
||||
@@ -212,7 +222,7 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
|
||||
if exists {
|
||||
versionCompare := compareVersions(manifest.Version, existingVersion)
|
||||
if versionCompare > 0 {
|
||||
return m.UpgradeExtension(filePath)
|
||||
return m.upgradeExtensionLocked(filePath)
|
||||
} else if versionCompare == 0 {
|
||||
return nil, fmt.Errorf("extension '%s' v%s is already installed", existingDisplayName, existingVersion)
|
||||
} else {
|
||||
@@ -736,6 +746,9 @@ func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedEx
|
||||
}
|
||||
|
||||
func (m *extensionManager) RemoveExtension(extensionID string) error {
|
||||
m.mutationMu.Lock()
|
||||
defer m.mutationMu.Unlock()
|
||||
|
||||
ext, err := m.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -756,6 +769,12 @@ func (m *extensionManager) RemoveExtension(extensionID string) error {
|
||||
|
||||
// Only allows upgrades (new version > current version), not downgrades
|
||||
func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension, error) {
|
||||
m.mutationMu.Lock()
|
||||
defer m.mutationMu.Unlock()
|
||||
return m.upgradeExtensionLocked(filePath)
|
||||
}
|
||||
|
||||
func (m *extensionManager) upgradeExtensionLocked(filePath string) (*loadedExtension, error) {
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
|
||||
}
|
||||
|
||||
@@ -2483,11 +2483,13 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
if providerID == "" {
|
||||
continue
|
||||
}
|
||||
if providerID == req.Source {
|
||||
// Skip the origin extension only when it differs from the explicitly
|
||||
// selected provider; otherwise it must still be attempted here.
|
||||
if providerID == req.Source && req.Source != selectedProvider {
|
||||
continue
|
||||
}
|
||||
|
||||
if !isExtensionFallbackAllowed(providerID) {
|
||||
if providerID != selectedProvider && !isExtensionFallbackAllowed(providerID) {
|
||||
GoLog("[DownloadWithExtensionFallback] Skipping extension provider %s (not enabled for fallback)\n", providerID)
|
||||
continue
|
||||
}
|
||||
@@ -2530,7 +2532,16 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
StartItemProgress(req.ItemID)
|
||||
}
|
||||
|
||||
result, err := provider.Download(availability.TrackID, req.Quality, outputPath, req.ItemID, func(percent int) {
|
||||
// Fallback provider: request its own highest quality, not the
|
||||
// source provider's quality token.
|
||||
fallbackQuality := req.Quality
|
||||
if len(ext.Manifest.QualityOptions) > 0 {
|
||||
if best := strings.TrimSpace(ext.Manifest.QualityOptions[0].ID); best != "" {
|
||||
fallbackQuality = best
|
||||
}
|
||||
}
|
||||
|
||||
result, err := provider.Download(availability.TrackID, fallbackQuality, outputPath, req.ItemID, func(percent int) {
|
||||
if req.ItemID != "" {
|
||||
normalized := float64(percent) / 100.0
|
||||
if normalized < 0 {
|
||||
|
||||
@@ -504,6 +504,7 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
utilsObj.Set("decrypt", r.cryptoDecrypt)
|
||||
utilsObj.Set("encryptBlockCipher", r.encryptBlockCipher)
|
||||
utilsObj.Set("decryptBlockCipher", r.decryptBlockCipher)
|
||||
utilsObj.Set("decryptCTRSegments", r.decryptCTRSegments)
|
||||
utilsObj.Set("generateKey", r.cryptoGenerateKey)
|
||||
utilsObj.Set("randomUserAgent", r.randomUserAgent)
|
||||
utilsObj.Set("appVersion", r.appVersion)
|
||||
|
||||
@@ -158,6 +158,11 @@ func decodeRuntimeBytesValue(raw interface{}, encoding string) ([]byte, error) {
|
||||
cloned := make([]byte, len(value))
|
||||
copy(cloned, value)
|
||||
return cloned, nil
|
||||
case goja.ArrayBuffer:
|
||||
src := value.Bytes()
|
||||
cloned := make([]byte, len(src))
|
||||
copy(cloned, src)
|
||||
return cloned, nil
|
||||
case []interface{}:
|
||||
decoded := make([]byte, len(value))
|
||||
for i, item := range value {
|
||||
@@ -279,7 +284,10 @@ func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
if parsedOptions.Mode != "cbc" {
|
||||
switch parsedOptions.Mode {
|
||||
case "cbc", "ctr":
|
||||
// supported
|
||||
default:
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("unsupported block cipher mode: %s", parsedOptions.Mode),
|
||||
@@ -303,37 +311,49 @@ func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt
|
||||
}
|
||||
|
||||
if len(parsedOptions.IV) != block.BlockSize() {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("iv must be %d bytes for %s", block.BlockSize(), parsedOptions.Algorithm),
|
||||
})
|
||||
}
|
||||
|
||||
data := inputData
|
||||
if !decrypt && parsedOptions.Padding == "pkcs7" {
|
||||
data = applyPKCS7Padding(data, block.BlockSize())
|
||||
}
|
||||
if len(data)%block.BlockSize() != 0 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("input length must be a multiple of %d bytes", block.BlockSize()),
|
||||
})
|
||||
}
|
||||
|
||||
output := make([]byte, len(data))
|
||||
if decrypt {
|
||||
cipher.NewCBCDecrypter(block, parsedOptions.IV).CryptBlocks(output, data)
|
||||
if parsedOptions.Padding == "pkcs7" {
|
||||
output, err = removePKCS7Padding(output, block.BlockSize())
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
ivLabel := "iv"
|
||||
if parsedOptions.Mode == "ctr" {
|
||||
ivLabel = "iv (counter)"
|
||||
}
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("%s must be %d bytes for %s", ivLabel, block.BlockSize(), parsedOptions.Algorithm),
|
||||
})
|
||||
}
|
||||
|
||||
var output []byte
|
||||
if parsedOptions.Mode == "ctr" {
|
||||
// CTR is a stream mode: encryption and decryption are identical,
|
||||
// require no padding, and accept arbitrary input lengths.
|
||||
output = make([]byte, len(inputData))
|
||||
cipher.NewCTR(block, parsedOptions.IV).XORKeyStream(output, inputData)
|
||||
} else {
|
||||
cipher.NewCBCEncrypter(block, parsedOptions.IV).CryptBlocks(output, data)
|
||||
data := inputData
|
||||
if !decrypt && parsedOptions.Padding == "pkcs7" {
|
||||
data = applyPKCS7Padding(data, block.BlockSize())
|
||||
}
|
||||
if len(data)%block.BlockSize() != 0 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("input length must be a multiple of %d bytes", block.BlockSize()),
|
||||
})
|
||||
}
|
||||
|
||||
output = make([]byte, len(data))
|
||||
if decrypt {
|
||||
cipher.NewCBCDecrypter(block, parsedOptions.IV).CryptBlocks(output, data)
|
||||
if parsedOptions.Padding == "pkcs7" {
|
||||
output, err = removePKCS7Padding(output, block.BlockSize())
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cipher.NewCBCEncrypter(block, parsedOptions.IV).CryptBlocks(output, data)
|
||||
}
|
||||
}
|
||||
|
||||
encoded, err := encodeRuntimeBytes(output, parsedOptions.OutputEncoding)
|
||||
@@ -358,3 +378,158 @@ func (r *extensionRuntime) encryptBlockCipher(call goja.FunctionCall) goja.Value
|
||||
func (r *extensionRuntime) decryptBlockCipher(call goja.FunctionCall) goja.Value {
|
||||
return r.transformBlockCipher(call, true)
|
||||
}
|
||||
|
||||
// decryptCTRSegments decrypts many independently-IV'd AES-CTR segments inside a
|
||||
// single buffer in one host call. This exists to avoid thousands of JS->Go
|
||||
// bridge crossings when an extension decrypts per-sample CENC media (each
|
||||
// sample has its own IV/counter and cannot be merged into one stream).
|
||||
//
|
||||
// It is a generic primitive: any extension can use it for "one buffer, many
|
||||
// CTR segments" workloads, not just Apple CENC.
|
||||
//
|
||||
// For best performance, pass the buffer as an ArrayBuffer/Uint8Array and set
|
||||
// outputEncoding:"bytes" to get an ArrayBuffer back. This avoids base64
|
||||
// encode/decode of the (potentially multi-MB) payload entirely, which is the
|
||||
// dominant cost under the goja interpreter.
|
||||
//
|
||||
// JS signature:
|
||||
// utils.decryptCTRSegments(data, {
|
||||
// algorithm: "aes", // optional, default "aes"
|
||||
// key: "<hex>", keyEncoding: "hex",
|
||||
// segments: [ { offset: <int>, size: <int>, iv: "<base64>" }, ... ],
|
||||
// ivEncoding: "base64", // encoding of each segment.iv, default base64
|
||||
// inputEncoding: "bytes", // "bytes" for ArrayBuffer/Uint8Array, else base64/hex
|
||||
// outputEncoding: "bytes" // "bytes" -> ArrayBuffer; else base64/hex string
|
||||
// })
|
||||
// Returns { success, data, segments_processed } or { success:false, error }.
|
||||
func (r *extensionRuntime) decryptCTRSegments(call goja.FunctionCall) goja.Value {
|
||||
fail := func(msg string) goja.Value {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": msg,
|
||||
})
|
||||
}
|
||||
|
||||
if len(call.Arguments) < 2 {
|
||||
return fail("data and options are required")
|
||||
}
|
||||
|
||||
options := parseRuntimeOptionsArgument(call, 1)
|
||||
if options == nil {
|
||||
return fail("options object is required")
|
||||
}
|
||||
|
||||
algorithm := strings.ToLower(runtimeOptionString(options, "algorithm", "aes"))
|
||||
inputEncoding := strings.ToLower(runtimeOptionString(options, "inputEncoding", "base64"))
|
||||
outputEncoding := strings.ToLower(runtimeOptionString(options, "outputEncoding", "base64"))
|
||||
ivEncoding := strings.ToLower(runtimeOptionString(options, "ivEncoding", "base64"))
|
||||
|
||||
key, err := decodeRuntimeBytesString(
|
||||
runtimeOptionString(options, "key", ""),
|
||||
runtimeOptionString(options, "keyEncoding", "hex"),
|
||||
)
|
||||
if err != nil {
|
||||
return fail(fmt.Sprintf("invalid key: %v", err))
|
||||
}
|
||||
if len(key) == 0 {
|
||||
return fail("key is required")
|
||||
}
|
||||
|
||||
var block cipher.Block
|
||||
switch algorithm {
|
||||
case "aes":
|
||||
block, err = aes.NewCipher(key)
|
||||
case "blowfish":
|
||||
block, err = blowfish.NewCipher(key)
|
||||
default:
|
||||
return fail("unsupported algorithm: " + algorithm)
|
||||
}
|
||||
if err != nil {
|
||||
return fail(err.Error())
|
||||
}
|
||||
blockSize := block.BlockSize()
|
||||
|
||||
// Decode the payload. For "bytes" input we operate on the raw []byte
|
||||
// (ArrayBuffer/Uint8Array) without any base64 round-trip.
|
||||
var data []byte
|
||||
if inputEncoding == "bytes" || inputEncoding == "raw" {
|
||||
data, err = decodeRuntimeBytesValue(call.Arguments[0].Export(), "")
|
||||
if err != nil {
|
||||
return fail("invalid byte payload: " + err.Error())
|
||||
}
|
||||
} else {
|
||||
data, err = decodeRuntimeBytesValue(call.Arguments[0].Export(), inputEncoding)
|
||||
if err != nil {
|
||||
return fail(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
rawSegments, ok := options["segments"]
|
||||
if !ok || rawSegments == nil {
|
||||
return fail("segments array is required")
|
||||
}
|
||||
segments, ok := rawSegments.([]interface{})
|
||||
if !ok {
|
||||
return fail("segments must be an array")
|
||||
}
|
||||
|
||||
processed := 0
|
||||
for i, rawSeg := range segments {
|
||||
seg, ok := rawSeg.(map[string]interface{})
|
||||
if !ok {
|
||||
return fail(fmt.Sprintf("segment %d is not an object", i))
|
||||
}
|
||||
|
||||
offset := int(runtimeOptionInt64(seg, "offset", -1))
|
||||
size := int(runtimeOptionInt64(seg, "size", -1))
|
||||
if offset < 0 || size < 0 {
|
||||
return fail(fmt.Sprintf("segment %d has invalid offset/size", i))
|
||||
}
|
||||
if size == 0 {
|
||||
continue
|
||||
}
|
||||
if offset+size > len(data) {
|
||||
return fail(fmt.Sprintf("segment %d out of bounds (offset=%d size=%d len=%d)", i, offset, size, len(data)))
|
||||
}
|
||||
|
||||
iv, err := decodeRuntimeBytesString(runtimeOptionString(seg, "iv", ""), ivEncoding)
|
||||
if err != nil {
|
||||
return fail(fmt.Sprintf("segment %d has invalid iv: %v", i, err))
|
||||
}
|
||||
if len(iv) != blockSize {
|
||||
// Accept short IVs by left-aligning into a block-sized counter
|
||||
// (CENC commonly uses 8-byte IVs for a 16-byte AES counter).
|
||||
if len(iv) > blockSize {
|
||||
return fail(fmt.Sprintf("segment %d iv longer than block size (%d > %d)", i, len(iv), blockSize))
|
||||
}
|
||||
padded := make([]byte, blockSize)
|
||||
copy(padded, iv)
|
||||
iv = padded
|
||||
}
|
||||
|
||||
segData := data[offset : offset+size]
|
||||
cipher.NewCTR(block, iv).XORKeyStream(segData, segData)
|
||||
processed++
|
||||
}
|
||||
|
||||
// Return raw bytes as an ArrayBuffer when requested (zero-copy-ish, no
|
||||
// base64). Otherwise fall back to an encoded string.
|
||||
if outputEncoding == "bytes" || outputEncoding == "raw" {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": r.vm.NewArrayBuffer(data),
|
||||
"segments_processed": processed,
|
||||
})
|
||||
}
|
||||
|
||||
encoded, err := encodeRuntimeBytes(data, outputEncoding)
|
||||
if err != nil {
|
||||
return fail(err.Error())
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": encoded,
|
||||
"segments_processed": processed,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -183,3 +183,303 @@ func TestExtensionRuntime_BlockCipherCBCSupportsAES(t *testing.T) {
|
||||
t.Fatalf("unexpected decrypted value: %q", result.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_BlockCipherCTRSupportsAES(t *testing.T) {
|
||||
vm := newBinaryTestRuntime(t, false)
|
||||
|
||||
// NIST SP 800-38A, F.5.1 CTR-AES128.Encrypt test vector.
|
||||
// Key: 2b7e151628aed2a6abf7158809cf4f3c
|
||||
// Counter: f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff
|
||||
// Plaintext: 6bc1bee22e409f96e93d7e117393172a (block 1)
|
||||
// Ciphertext: 874d6191b620e3261bef6864990db6ce (block 1)
|
||||
result, err := vm.RunString(`
|
||||
(function() {
|
||||
var options = {
|
||||
algorithm: "aes",
|
||||
mode: "ctr",
|
||||
key: "2b7e151628aed2a6abf7158809cf4f3c",
|
||||
keyEncoding: "hex",
|
||||
iv: "f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff",
|
||||
ivEncoding: "hex",
|
||||
inputEncoding: "hex",
|
||||
outputEncoding: "hex"
|
||||
};
|
||||
var enc = utils.encryptBlockCipher("6bc1bee22e409f96e93d7e117393172a", options);
|
||||
if (!enc.success) throw new Error(enc.error);
|
||||
// CTR is symmetric: decrypt is the same transform as encrypt.
|
||||
var dec = utils.decryptBlockCipher(enc.data, options);
|
||||
if (!dec.success) throw new Error(dec.error);
|
||||
return JSON.stringify({enc: enc.data, dec: dec.data});
|
||||
})()
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("aes ctr block cipher failed: %v", err)
|
||||
}
|
||||
|
||||
decoded := decodeJSONResult[struct {
|
||||
Enc string `json:"enc"`
|
||||
Dec string `json:"dec"`
|
||||
}](t, result)
|
||||
|
||||
if decoded.Enc != "874d6191b620e3261bef6864990db6ce" {
|
||||
t.Fatalf("ctr ciphertext = %q, want NIST vector 874d6191b620e3261bef6864990db6ce", decoded.Enc)
|
||||
}
|
||||
if decoded.Dec != "6bc1bee22e409f96e93d7e117393172a" {
|
||||
t.Fatalf("ctr round-trip dec = %q", decoded.Dec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_BlockCipherCTRHandlesNonBlockLength(t *testing.T) {
|
||||
vm := newBinaryTestRuntime(t, false)
|
||||
|
||||
// CTR is a stream mode, so arbitrary (non-16-byte-aligned) input lengths
|
||||
// must round-trip without any padding.
|
||||
result, err := vm.RunString(`
|
||||
(function() {
|
||||
var options = {
|
||||
algorithm: "aes",
|
||||
mode: "ctr",
|
||||
key: "000102030405060708090a0b0c0d0e0f",
|
||||
keyEncoding: "hex",
|
||||
iv: "0f0e0d0c0b0a09080706050403020100",
|
||||
ivEncoding: "hex",
|
||||
inputEncoding: "utf8",
|
||||
outputEncoding: "base64"
|
||||
};
|
||||
var enc = utils.encryptBlockCipher("stream ctr of odd length", options);
|
||||
if (!enc.success) throw new Error(enc.error);
|
||||
var dec = utils.decryptBlockCipher(enc.data, {
|
||||
algorithm: "aes",
|
||||
mode: "ctr",
|
||||
key: options.key,
|
||||
keyEncoding: options.keyEncoding,
|
||||
iv: options.iv,
|
||||
ivEncoding: options.ivEncoding,
|
||||
inputEncoding: "base64",
|
||||
outputEncoding: "utf8"
|
||||
});
|
||||
if (!dec.success) throw new Error(dec.error);
|
||||
return dec.data;
|
||||
})()
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("aes ctr stream length failed: %v", err)
|
||||
}
|
||||
|
||||
if result.String() != "stream ctr of odd length" {
|
||||
t.Fatalf("unexpected ctr decrypted value: %q", result.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_BlockCipherCTRRejectsBadIV(t *testing.T) {
|
||||
vm := newBinaryTestRuntime(t, false)
|
||||
|
||||
result, err := vm.RunString(`
|
||||
(function() {
|
||||
var res = utils.encryptBlockCipher("00112233", {
|
||||
algorithm: "aes",
|
||||
mode: "ctr",
|
||||
key: "000102030405060708090a0b0c0d0e0f",
|
||||
keyEncoding: "hex",
|
||||
iv: "0001",
|
||||
ivEncoding: "hex",
|
||||
inputEncoding: "hex",
|
||||
outputEncoding: "hex"
|
||||
});
|
||||
return JSON.stringify({success: res.success, error: res.error || ""});
|
||||
})()
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("aes ctr bad iv eval failed: %v", err)
|
||||
}
|
||||
|
||||
decoded := decodeJSONResult[struct {
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error"`
|
||||
}](t, result)
|
||||
|
||||
if decoded.Success {
|
||||
t.Fatal("expected failure for undersized CTR iv")
|
||||
}
|
||||
if decoded.Error == "" {
|
||||
t.Fatal("expected error message for undersized CTR iv")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_DecryptCTRSegmentsMatchesPerSegment(t *testing.T) {
|
||||
vm := newBinaryTestRuntime(t, false)
|
||||
|
||||
// Build a buffer of 3 segments encrypted with distinct 8-byte IVs (CENC
|
||||
// style), then verify the batch primitive decrypts all of them in one call,
|
||||
// matching what per-segment decryptBlockCipher would produce.
|
||||
result, err := vm.RunString(`
|
||||
(function() {
|
||||
var keyHex = "000102030405060708090a0b0c0d0e0f";
|
||||
function b64(bytes){return utils.base64Encode(utils.toHex ? bytes : bytes);}
|
||||
|
||||
// segment plaintexts (hex) and 8-byte IVs (hex)
|
||||
var segs = [
|
||||
{ pt: "11111111111111111111", iv: "0000000000000001" },
|
||||
{ pt: "2222222222", iv: "0000000000000002" },
|
||||
{ pt: "333333333333333333333333", iv: "00000000000000ff" }
|
||||
];
|
||||
|
||||
// Encrypt each segment individually using single-shot CTR with a
|
||||
// 16-byte counter (8-byte iv left-aligned), producing ciphertext hex.
|
||||
function ivToB64(ivHex){
|
||||
// pad 8-byte hex iv to 16 bytes then base64
|
||||
var full = ivHex + "00000000000000000000000000000000".slice(ivHex.length);
|
||||
return utils.base64Encode(utils.hexToBytes ? utils.hexToBytes(full) : full);
|
||||
}
|
||||
|
||||
var cipherHex = "";
|
||||
var offsets = [];
|
||||
var off = 0;
|
||||
var ivB64s = [];
|
||||
for (var i=0;i<segs.length;i++){
|
||||
var ivFullHex = (segs[i].iv + "00000000000000000000000000000000").slice(0,32);
|
||||
var enc = utils.encryptBlockCipher(segs[i].pt, {
|
||||
algorithm:"aes", mode:"ctr", key:keyHex, keyEncoding:"hex",
|
||||
iv: ivFullHex, ivEncoding:"hex",
|
||||
inputEncoding:"hex", outputEncoding:"hex"
|
||||
});
|
||||
if(!enc.success) throw new Error("enc seg "+i+": "+enc.error);
|
||||
cipherHex += enc.data;
|
||||
var sz = segs[i].pt.length/2;
|
||||
offsets.push({offset: off, size: sz, ivHex: ivFullHex});
|
||||
off += sz;
|
||||
}
|
||||
|
||||
// Now decrypt the whole concatenated buffer in ONE batch call.
|
||||
var segments = offsets.map(function(o){
|
||||
return { offset:o.offset, size:o.size, iv:o.ivHex };
|
||||
});
|
||||
var batch = utils.decryptCTRSegments(cipherHex, {
|
||||
algorithm:"aes", key:keyHex, keyEncoding:"hex",
|
||||
segments: segments, ivEncoding:"hex",
|
||||
inputEncoding:"hex", outputEncoding:"hex"
|
||||
});
|
||||
if(!batch.success) throw new Error("batch: "+batch.error);
|
||||
|
||||
var expected = "";
|
||||
for (var j=0;j<segs.length;j++) expected += segs[j].pt;
|
||||
|
||||
return JSON.stringify({
|
||||
out: batch.data,
|
||||
expected: expected,
|
||||
processed: batch.segments_processed
|
||||
});
|
||||
})()
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("batch CTR eval failed: %v", err)
|
||||
}
|
||||
|
||||
decoded := decodeJSONResult[struct {
|
||||
Out string `json:"out"`
|
||||
Expected string `json:"expected"`
|
||||
Processed int `json:"processed"`
|
||||
}](t, result)
|
||||
|
||||
if decoded.Out != decoded.Expected {
|
||||
t.Fatalf("batch decrypt mismatch:\n got=%s\nwant=%s", decoded.Out, decoded.Expected)
|
||||
}
|
||||
if decoded.Processed != 3 {
|
||||
t.Fatalf("segments_processed = %d, want 3", decoded.Processed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_DecryptCTRSegmentsRejectsOutOfBounds(t *testing.T) {
|
||||
vm := newBinaryTestRuntime(t, false)
|
||||
|
||||
result, err := vm.RunString(`
|
||||
(function() {
|
||||
var res = utils.decryptCTRSegments("00112233", {
|
||||
algorithm:"aes", key:"000102030405060708090a0b0c0d0e0f", keyEncoding:"hex",
|
||||
inputEncoding:"hex", outputEncoding:"hex",
|
||||
ivEncoding:"hex",
|
||||
segments: [ { offset: 0, size: 99, iv: "00000000000000000000000000000000" } ]
|
||||
});
|
||||
return JSON.stringify({ success: res.success, error: res.error || "" });
|
||||
})()
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("oob eval failed: %v", err)
|
||||
}
|
||||
|
||||
decoded := decodeJSONResult[struct {
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error"`
|
||||
}](t, result)
|
||||
|
||||
if decoded.Success {
|
||||
t.Fatal("expected out-of-bounds segment to fail")
|
||||
}
|
||||
if decoded.Error == "" {
|
||||
t.Fatal("expected error message for out-of-bounds segment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_DecryptCTRSegmentsRawBytes(t *testing.T) {
|
||||
vm := newBinaryTestRuntime(t, false)
|
||||
|
||||
// Verify the zero-base64 path: pass an ArrayBuffer in, request bytes out,
|
||||
// and confirm round-trip correctness against single-shot CTR.
|
||||
result, err := vm.RunString(`
|
||||
(function() {
|
||||
var keyHex = "000102030405060708090a0b0c0d0e0f";
|
||||
var ivFullHex = "0000000000000001" + "00000000000000000000000000000000".slice(16);
|
||||
|
||||
// Plaintext as a Uint8Array of 20 bytes.
|
||||
var pt = new Uint8Array(20);
|
||||
for (var i = 0; i < pt.length; i++) pt[i] = (i * 7 + 3) & 0xff;
|
||||
|
||||
// Encrypt single-shot to get ciphertext (hex output for clarity).
|
||||
var ptHex = "";
|
||||
for (var j = 0; j < pt.length; j++) { var h = pt[j].toString(16); ptHex += (h.length === 1 ? "0" : "") + h; }
|
||||
var enc = utils.encryptBlockCipher(ptHex, {
|
||||
algorithm:"aes", mode:"ctr", key:keyHex, keyEncoding:"hex",
|
||||
iv: ivFullHex, ivEncoding:"hex", inputEncoding:"hex", outputEncoding:"base64"
|
||||
});
|
||||
if (!enc.success) throw new Error("enc: " + enc.error);
|
||||
|
||||
// Decode ciphertext base64 into a Uint8Array to feed the raw path.
|
||||
var cipherBytes = utils.base64Decode ? null : null;
|
||||
// Build ArrayBuffer from base64 via Uint8Array manually:
|
||||
var b64 = enc.data;
|
||||
var bin = (typeof atob === "function") ? null : null;
|
||||
|
||||
// Simpler: ask the host to give us bytes by decrypting nothing is hard,
|
||||
// so just pass the base64 ciphertext through decryptCTRSegments using
|
||||
// base64 input but bytes output, then re-run with bytes input.
|
||||
var step1 = utils.decryptCTRSegments(b64, {
|
||||
algorithm:"aes", key:keyHex, keyEncoding:"hex",
|
||||
segments: [ { offset:0, size:20, iv: ivFullHex } ],
|
||||
ivEncoding:"hex", inputEncoding:"base64", outputEncoding:"bytes"
|
||||
});
|
||||
if (!step1.success) throw new Error("step1: " + step1.error);
|
||||
if (typeof step1.data === "string") throw new Error("expected ArrayBuffer output, got string");
|
||||
|
||||
var outArr = new Uint8Array(step1.data);
|
||||
var outHex = "";
|
||||
for (var k = 0; k < outArr.length; k++) { var hh = outArr[k].toString(16); outHex += (hh.length === 1 ? "0" : "") + hh; }
|
||||
return JSON.stringify({ out: outHex, expected: ptHex, len: outArr.length });
|
||||
})()
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("raw-bytes eval failed: %v", err)
|
||||
}
|
||||
|
||||
decoded := decodeJSONResult[struct {
|
||||
Out string `json:"out"`
|
||||
Expected string `json:"expected"`
|
||||
Len int `json:"len"`
|
||||
}](t, result)
|
||||
|
||||
if decoded.Out != decoded.Expected {
|
||||
t.Fatalf("raw-bytes decrypt mismatch:\n got=%s\nwant=%s", decoded.Out, decoded.Expected)
|
||||
}
|
||||
if decoded.Len != 20 {
|
||||
t.Fatalf("output length = %d, want 20", decoded.Len)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -663,7 +663,6 @@ func (r *extensionRuntime) fileReadBytes(call goja.FunctionCall) goja.Value {
|
||||
"error": "offset must be >= 0",
|
||||
})
|
||||
}
|
||||
|
||||
file, err := os.Open(fullPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -716,6 +715,20 @@ func (r *extensionRuntime) fileReadBytes(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
if strings.EqualFold(strings.TrimSpace(encoding), "bytes") ||
|
||||
strings.EqualFold(strings.TrimSpace(encoding), "raw") {
|
||||
// Return raw bytes as an ArrayBuffer to avoid base64 encode/decode of
|
||||
// large payloads under the goja interpreter.
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": r.vm.NewArrayBuffer(data),
|
||||
"bytes_read": len(data),
|
||||
"offset": offset,
|
||||
"size": size,
|
||||
"eof": offset+int64(len(data)) >= size,
|
||||
})
|
||||
}
|
||||
|
||||
encoded, err := encodeRuntimeBytes(data, encoding)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -733,7 +746,6 @@ func (r *extensionRuntime) fileReadBytes(call goja.FunctionCall) goja.Value {
|
||||
"eof": offset+int64(len(data)) >= size,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
|
||||
+10
-10
@@ -5,25 +5,25 @@ go 1.25.0
|
||||
toolchain go1.25.9
|
||||
|
||||
require (
|
||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
|
||||
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d
|
||||
github.com/go-flac/flacpicture/v2 v2.0.2
|
||||
github.com/go-flac/flacvorbis/v2 v2.0.2
|
||||
github.com/go-flac/go-flac/v2 v2.0.4
|
||||
github.com/refraction-networking/utls v1.8.2
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/mobile v0.0.0-20260529142300-ecb4cd65260a
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/text v0.37.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/mobile v0.0.0-20260602190626-68735029466e
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/text v0.38.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.12.0 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||
github.com/google/pprof v0.0.0-20260507013755-92041b743c96 // indirect
|
||||
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
golang.org/x/mod v0.36.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/tools v0.45.0 // indirect
|
||||
)
|
||||
|
||||
+24
-24
@@ -1,13 +1,13 @@
|
||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
|
||||
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk=
|
||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d h1:xbM5U2EvWKkHxzEQJ2DEn20FwolWZahuTnVHr6WL3Q4=
|
||||
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d/go.mod h1:Sc+QOu1WruvaaeT/cxFez/pXHpI9ZDjg/E8QNfSVveI=
|
||||
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
||||
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
|
||||
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
|
||||
@@ -16,10 +16,12 @@ github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sY
|
||||
github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20260507013755-92041b743c96 h1:YDDnaZ9afWajDboPMt9Vikqca/yWAX7KAxVzb4lJU1M=
|
||||
github.com/google/pprof v0.0.0-20260507013755-92041b743c96/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 h1:h1QTMDl6q9wDvDCJVpKQSjgleGFYnd2fOxmg2K+6BGE=
|
||||
github.com/google/pprof v0.0.0-20260604005048-7023385849c0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -30,23 +32,21 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/mobile v0.0.0-20260529142300-ecb4cd65260a h1:sEcsLeiCTTaHGWn+v81+PLAOzzOA9wmzNRqr1WfCmVY=
|
||||
golang.org/x/mobile v0.0.0-20260529142300-ecb4cd65260a/go.mod h1:ltIbhcRzKgwHa4ZxKJeiv0nyzcXUUYCqMyO0Y+vPmXw=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/mobile v0.0.0-20260602190626-68735029466e h1:YxPXu/HWDTcSSrzSX+sCltsfcNCa/ZYVG43oslMouNU=
|
||||
golang.org/x/mobile v0.0.0-20260602190626-68735029466e/go.mod h1:ltIbhcRzKgwHa4ZxKJeiv0nyzcXUUYCqMyO0Y+vPmXw=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -76,6 +76,9 @@ var supportedAudioFormats = map[string]bool{
|
||||
".ape": true,
|
||||
".wv": true,
|
||||
".mpc": true,
|
||||
".wav": true,
|
||||
".aiff": true,
|
||||
".aif": true,
|
||||
".cue": true,
|
||||
}
|
||||
|
||||
@@ -340,6 +343,10 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ
|
||||
return scanOggFile(filePath, result, displayNameHint)
|
||||
case ".ape", ".wv", ".mpc":
|
||||
return scanAPEFile(filePath, result, displayNameHint)
|
||||
case ".wav":
|
||||
return scanWAVFile(filePath, result, displayNameHint)
|
||||
case ".aiff", ".aif", ".aifc":
|
||||
return scanAIFFFile(filePath, result, displayNameHint)
|
||||
default:
|
||||
return scanFromFilename(filePath, displayNameHint, result)
|
||||
}
|
||||
@@ -479,7 +486,7 @@ func libraryFormatForM4ACodec(codec string) string {
|
||||
|
||||
func isLosslessLibraryFormat(format string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
case "flac", "alac":
|
||||
case "flac", "alac", "wav", "aiff", "aif", "aifc":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
+84
-1
@@ -31,6 +31,7 @@ const (
|
||||
LyricsProviderYouTube = "youtube"
|
||||
LyricsProviderKugou = "kugou"
|
||||
LyricsProviderGenius = "genius"
|
||||
LyricsProviderLyricsPlus = "lyricsplus"
|
||||
)
|
||||
|
||||
var DefaultLyricsProviders = []string{
|
||||
@@ -112,6 +113,7 @@ func SetLyricsProviderOrder(providers []string) {
|
||||
LyricsProviderYouTube: true,
|
||||
LyricsProviderKugou: true,
|
||||
LyricsProviderGenius: true,
|
||||
LyricsProviderLyricsPlus: true,
|
||||
}
|
||||
|
||||
var valid []string
|
||||
@@ -151,6 +153,7 @@ func GetAvailableLyricsProviders() []map[string]interface{} {
|
||||
{"id": LyricsProviderYouTube, "name": "YouTube", "has_proxy_dependency": true, "description": "YouTube lyrics"},
|
||||
{"id": LyricsProviderKugou, "name": "Kugou", "has_proxy_dependency": true, "description": "Kugou lyrics"},
|
||||
{"id": LyricsProviderGenius, "name": "Genius", "has_proxy_dependency": true, "description": "Genius lyrics"},
|
||||
{"id": LyricsProviderLyricsPlus, "name": "LyricsPlus", "has_proxy_dependency": true, "description": "Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ)"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,6 +615,37 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
||||
lyrics, err = geniusClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
|
||||
}
|
||||
|
||||
case LyricsProviderLyricsPlus:
|
||||
lyricsPlusClient := NewLyricsPlusClient()
|
||||
lyrics, err = lyricsPlusClient.FetchLyrics(
|
||||
trackName,
|
||||
primaryArtist,
|
||||
"",
|
||||
durationSec,
|
||||
fetchOptions.MultiPersonWordByWord,
|
||||
fetchOptions.AppleElrcWordSync,
|
||||
)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = lyricsPlusClient.FetchLyrics(
|
||||
trackName,
|
||||
artistName,
|
||||
"",
|
||||
durationSec,
|
||||
fetchOptions.MultiPersonWordByWord,
|
||||
fetchOptions.AppleElrcWordSync,
|
||||
)
|
||||
}
|
||||
if err != nil && simplifiedTrack != trackName {
|
||||
lyrics, err = lyricsPlusClient.FetchLyrics(
|
||||
simplifiedTrack,
|
||||
primaryArtist,
|
||||
"",
|
||||
durationSec,
|
||||
fetchOptions.MultiPersonWordByWord,
|
||||
fetchOptions.AppleElrcWordSync,
|
||||
)
|
||||
}
|
||||
|
||||
default:
|
||||
GoLog("[Lyrics] Unknown provider: %s, skipping\n", providerName)
|
||||
continue
|
||||
@@ -843,6 +877,41 @@ func msToLRCTimestampInline(ms int64) string {
|
||||
return fmt.Sprintf("%02d:%02d.%02d", minutes, seconds, centiseconds)
|
||||
}
|
||||
|
||||
// extractLyricsSourceFromLRC reads the provider recorded in the LRC [by:] tag,
|
||||
// e.g. "[by:SpotiFLAC-Mobile (source: LRCLIB)]". Returns "" when absent.
|
||||
const lrcSourceMarker = "(source: "
|
||||
|
||||
func lyricsSourceUsesPaxsenix(source string) bool {
|
||||
s := strings.ToLower(strings.TrimSpace(source))
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(s, "lrclib") ||
|
||||
strings.HasPrefix(s, "extension:") ||
|
||||
strings.HasPrefix(s, "heuristic") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func extractLyricsSourceFromLRC(lrc string) string {
|
||||
for _, line := range strings.Split(lrc, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(strings.ToLower(trimmed), "[by:") {
|
||||
continue
|
||||
}
|
||||
idx := strings.Index(trimmed, lrcSourceMarker)
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := strings.TrimSpace(trimmed[idx+len(lrcSourceMarker):])
|
||||
rest = strings.TrimSuffix(rest, "]")
|
||||
rest = strings.TrimSuffix(rest, ")")
|
||||
return strings.TrimSpace(rest)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
|
||||
if lyrics == nil || len(lyrics.Lines) == 0 {
|
||||
return ""
|
||||
@@ -852,7 +921,21 @@ func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName stri
|
||||
|
||||
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
|
||||
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
|
||||
builder.WriteString("[by:Implemented by SpotiFLAC-Mobile using Paxsenix API]\n")
|
||||
source := strings.TrimSpace(lyrics.Source)
|
||||
if source == "" {
|
||||
source = strings.TrimSpace(lyrics.Provider)
|
||||
}
|
||||
credit := "SpotiFLAC-Mobile"
|
||||
if lyricsSourceUsesPaxsenix(source) {
|
||||
credit = "SpotiFLAC-Mobile via Paxsenix API"
|
||||
}
|
||||
if source == "" {
|
||||
builder.WriteString(fmt.Sprintf("[by:%s]\n", credit))
|
||||
} else {
|
||||
builder.WriteString(
|
||||
fmt.Sprintf("[by:%s %s%s)]\n", credit, lrcSourceMarker, source),
|
||||
)
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
|
||||
if lyrics.SyncType == "LINE_SYNCED" {
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LyricsPlus (KPOE) provider.
|
||||
//
|
||||
// LyricsPlus aggregates word-by-word ("karaoke") synced lyrics from Apple
|
||||
// Music, Musixmatch, Spotify and QQ Music via a community-run backend. It
|
||||
// frequently has word-level timing for tracks that other providers only offer
|
||||
// line-synced or not at all.
|
||||
//
|
||||
// API: GET {server}/v2/lyrics/get?title=&artist=&album=&duration=&isrc=
|
||||
// The response is the KPOE JSON format which we convert into the same enhanced
|
||||
// LRC text the Apple/QQ providers emit, so embedding/export behaves identically.
|
||||
|
||||
// Public LyricsPlus / KPOE servers (mirrors). Tried in order with failover.
|
||||
// Sourced from the upstream YouLy+ client server list.
|
||||
var lyricsPlusServers = []string{
|
||||
"https://lyricsplus.prjktla.my.id",
|
||||
"https://lyricsplus.atomix.one",
|
||||
"https://lyricsplus.binimum.org",
|
||||
"https://lyricsplus.prjktla.workers.dev",
|
||||
"https://lyricsplus-seven.vercel.app",
|
||||
"https://lyrics-plus-backend.vercel.app",
|
||||
}
|
||||
|
||||
type LyricsPlusClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewLyricsPlusClient() *LyricsPlusClient {
|
||||
return &LyricsPlusClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
|
||||
}
|
||||
|
||||
type lyricsPlusSyllable struct {
|
||||
Text string `json:"text"`
|
||||
Time float64 `json:"time"` // absolute ms
|
||||
Duration float64 `json:"duration"` // ms
|
||||
IsBackground bool `json:"isBackground"`
|
||||
}
|
||||
|
||||
type lyricsPlusLine struct {
|
||||
Time float64 `json:"time"` // absolute ms
|
||||
Duration float64 `json:"duration"` // ms
|
||||
Text string `json:"text"`
|
||||
Syllabus []lyricsPlusSyllable `json:"syllabus"`
|
||||
}
|
||||
|
||||
type lyricsPlusResponse struct {
|
||||
Type string `json:"type"` // "Word" | "Line" | "Syllable" | "None"
|
||||
Lyrics []lyricsPlusLine `json:"lyrics"`
|
||||
}
|
||||
|
||||
// FetchLyrics tries each LyricsPlus server in order until one returns usable
|
||||
// lyrics. multiPersonWordByWord and preserveWordTiming mirror the Apple/QQ
|
||||
// options so word/background timing is only emitted when the user enabled it.
|
||||
func (c *LyricsPlusClient) FetchLyrics(
|
||||
trackName,
|
||||
artistName,
|
||||
isrc string,
|
||||
durationSec float64,
|
||||
multiPersonWordByWord bool,
|
||||
preserveWordTiming bool,
|
||||
) (*LyricsResponse, error) {
|
||||
if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" {
|
||||
return nil, fmt.Errorf("lyricsplus: missing track or artist")
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, server := range lyricsPlusServers {
|
||||
lyrics, err := c.fetchFromServer(server, trackName, artistName, isrc, durationSec, multiPersonWordByWord, preserveWordTiming)
|
||||
if err == nil && lyricsHasUsableText(lyrics) {
|
||||
return lyrics, nil
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
GoLog("[Lyrics] LyricsPlus server %s failed: %v\n", server, err)
|
||||
}
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
return nil, fmt.Errorf("lyricsplus: no lyrics found")
|
||||
}
|
||||
|
||||
func (c *LyricsPlusClient) fetchFromServer(
|
||||
server,
|
||||
trackName,
|
||||
artistName,
|
||||
isrc string,
|
||||
durationSec float64,
|
||||
multiPersonWordByWord bool,
|
||||
preserveWordTiming bool,
|
||||
) (*LyricsResponse, error) {
|
||||
base := strings.TrimRight(strings.TrimSpace(server), "/")
|
||||
if base == "" {
|
||||
return nil, fmt.Errorf("empty server")
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("title", trackName)
|
||||
params.Set("artist", artistName)
|
||||
if durationSec > 0 {
|
||||
params.Set("duration", strconv.FormatFloat(durationSec, 'f', 3, 64))
|
||||
}
|
||||
if strings.TrimSpace(isrc) != "" {
|
||||
params.Set("isrc", strings.TrimSpace(isrc))
|
||||
}
|
||||
|
||||
fullURL := base + "/v2/lyrics/get?" + 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("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
// Retry without the ISRC filter, which can be too strict.
|
||||
if strings.TrimSpace(isrc) != "" {
|
||||
return c.fetchFromServer(server, trackName, artistName, "", durationSec, multiPersonWordByWord, preserveWordTiming)
|
||||
}
|
||||
return nil, fmt.Errorf("lyrics not found")
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var payload lyricsPlusResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode lyricsplus response: %w", err)
|
||||
}
|
||||
if len(payload.Lyrics) == 0 {
|
||||
return nil, fmt.Errorf("lyricsplus returned no lines")
|
||||
}
|
||||
|
||||
lrcText := buildLyricsPlusLRC(&payload, multiPersonWordByWord, preserveWordTiming)
|
||||
if strings.TrimSpace(lrcText) == "" {
|
||||
return nil, fmt.Errorf("lyricsplus produced empty lyrics")
|
||||
}
|
||||
|
||||
lyrics := lyricsResponseFromText(lrcText, "LyricsPlus")
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
// buildLyricsPlusLRC converts the KPOE JSON into enhanced LRC text. When word
|
||||
// timing is available and enabled, each syllable is emitted as an inline
|
||||
// <mm:ss.xx> tag (matching the Apple/QQ output); otherwise a line-synced LRC
|
||||
// is produced from the full line text.
|
||||
func buildLyricsPlusLRC(resp *lyricsPlusResponse, multiPersonWordByWord bool, preserveWordTiming bool) string {
|
||||
isWordType := strings.EqualFold(resp.Type, "Word") || strings.EqualFold(resp.Type, "Syllable")
|
||||
|
||||
var sb strings.Builder
|
||||
first := true
|
||||
for _, line := range resp.Lyrics {
|
||||
lineText := line.Text
|
||||
hasSyllables := len(line.Syllabus) > 0
|
||||
|
||||
timestamp := msToLRCTimestamp(int64(line.Time))
|
||||
|
||||
if isWordType && preserveWordTiming && hasSyllables {
|
||||
mainSyllables := make([]lyricsPlusSyllable, 0, len(line.Syllabus))
|
||||
bgSyllables := make([]lyricsPlusSyllable, 0)
|
||||
for _, syl := range line.Syllabus {
|
||||
if syl.IsBackground {
|
||||
bgSyllables = append(bgSyllables, syl)
|
||||
} else {
|
||||
mainSyllables = append(mainSyllables, syl)
|
||||
}
|
||||
}
|
||||
if len(mainSyllables) == 0 {
|
||||
mainSyllables = line.Syllabus
|
||||
bgSyllables = nil
|
||||
}
|
||||
|
||||
if !first {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
first = false
|
||||
|
||||
sb.WriteString(timestamp)
|
||||
appendLyricsPlusSyllables(&sb, mainSyllables)
|
||||
|
||||
if multiPersonWordByWord && len(bgSyllables) > 0 {
|
||||
sb.WriteString("\n[bg:")
|
||||
appendLyricsPlusSyllables(&sb, bgSyllables)
|
||||
sb.WriteString("]")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Line-synced fallback. Reconstruct text from syllables if needed.
|
||||
if strings.TrimSpace(lineText) == "" && hasSyllables {
|
||||
var lineBuilder strings.Builder
|
||||
for _, syl := range line.Syllabus {
|
||||
lineBuilder.WriteString(syl.Text)
|
||||
}
|
||||
lineText = lineBuilder.String()
|
||||
}
|
||||
|
||||
lineText = strings.TrimSpace(lineText)
|
||||
if lineText == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if !first {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
first = false
|
||||
|
||||
sb.WriteString(timestamp)
|
||||
sb.WriteString(lineText)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
// appendLyricsPlusSyllables writes each syllable as "<mm:ss.xx>text". KPOE
|
||||
// already embeds spacing inside the syllable text, so no extra spaces are added.
|
||||
func appendLyricsPlusSyllables(sb *strings.Builder, syllables []lyricsPlusSyllable) {
|
||||
for _, syl := range syllables {
|
||||
sb.WriteString("<")
|
||||
sb.WriteString(msToLRCTimestampInline(int64(syl.Time)))
|
||||
sb.WriteString(">")
|
||||
sb.WriteString(syl.Text)
|
||||
}
|
||||
}
|
||||
@@ -906,6 +906,32 @@ func ExtractLyrics(filePath string) (string, error) {
|
||||
return extractLyricsFromSidecarLRC(filePath)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(lower, ".wav") {
|
||||
meta, err := ReadWAVTags(filePath)
|
||||
if err == nil && meta != nil {
|
||||
if strings.TrimSpace(meta.Lyrics) != "" {
|
||||
return meta.Lyrics, nil
|
||||
}
|
||||
if looksLikeEmbeddedLyrics(meta.Comment) {
|
||||
return meta.Comment, nil
|
||||
}
|
||||
}
|
||||
return extractLyricsFromSidecarLRC(filePath)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc") {
|
||||
meta, err := ReadAIFFTags(filePath)
|
||||
if err == nil && meta != nil {
|
||||
if strings.TrimSpace(meta.Lyrics) != "" {
|
||||
return meta.Lyrics, nil
|
||||
}
|
||||
if looksLikeEmbeddedLyrics(meta.Comment) {
|
||||
return meta.Comment, nil
|
||||
}
|
||||
}
|
||||
return extractLyricsFromSidecarLRC(filePath)
|
||||
}
|
||||
|
||||
return extractLyricsFromSidecarLRC(filePath)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,975 @@
|
||||
package gobackend
|
||||
|
||||
// WAV (RIFF) and AIFF/AIFC support: quality probing, tag reading/writing, and
|
||||
// cover-art extraction. These containers are not handled by go-flac, so chunks
|
||||
// are parsed/written by hand here.
|
||||
//
|
||||
// Tags are stored as an embedded ID3v2.4 tag (UTF-8): WAV uses a lowercase
|
||||
// "id3 " chunk, AIFF uses an uppercase "ID3 " chunk. ID3v2.4 is chosen because
|
||||
// the existing ID3 reader (parseID3v23Frames with version=4) reads synchsafe
|
||||
// frame sizes and UTF-8 text, so anything we write is read back losslessly.
|
||||
//
|
||||
// Reading also recognises a WAV "LIST"/"INFO" block as a fallback for files
|
||||
// that carry only RIFF INFO tags (common from other taggers).
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// WAVQuality / AIFFQuality mirror the other GetXQuality result shapes.
|
||||
type WAVQuality struct {
|
||||
SampleRate int
|
||||
BitDepth int
|
||||
Channels int
|
||||
Duration int
|
||||
}
|
||||
|
||||
const (
|
||||
wavMaxMetaChunk = 16 * 1024 * 1024 // safety cap for buffering a metadata chunk
|
||||
id3ChunkWAV = "id3 "
|
||||
id3ChunkAIFF = "ID3 "
|
||||
wavFormatPCM = 0x0001
|
||||
wavFormatFloat = 0x0003
|
||||
wavFormatExtensn = 0xFFFE
|
||||
)
|
||||
|
||||
// ---------- low-level chunk size helpers ----------
|
||||
|
||||
func putUint32(dst []byte, le bool, v uint32) {
|
||||
if le {
|
||||
binary.LittleEndian.PutUint32(dst, v)
|
||||
} else {
|
||||
binary.BigEndian.PutUint32(dst, v)
|
||||
}
|
||||
}
|
||||
|
||||
func readUint32(b []byte, le bool) uint32 {
|
||||
if le {
|
||||
return binary.LittleEndian.Uint32(b)
|
||||
}
|
||||
return binary.BigEndian.Uint32(b)
|
||||
}
|
||||
|
||||
func synchsafeEncode(n int) []byte {
|
||||
return []byte{
|
||||
byte((n >> 21) & 0x7f),
|
||||
byte((n >> 14) & 0x7f),
|
||||
byte((n >> 7) & 0x7f),
|
||||
byte(n & 0x7f),
|
||||
}
|
||||
}
|
||||
|
||||
func synchsafeDecode(b []byte) int {
|
||||
if len(b) < 4 {
|
||||
return 0
|
||||
}
|
||||
return int(b[0])<<21 | int(b[1])<<14 | int(b[2])<<7 | int(b[3])
|
||||
}
|
||||
|
||||
// parseExtendedFloat80 decodes an 80-bit IEEE 754 extended float (used by the
|
||||
// AIFF COMM chunk for the sample rate).
|
||||
func parseExtendedFloat80(b []byte) float64 {
|
||||
if len(b) < 10 {
|
||||
return 0
|
||||
}
|
||||
sign := 1.0
|
||||
if b[0]&0x80 != 0 {
|
||||
sign = -1.0
|
||||
}
|
||||
exponent := int(b[0]&0x7f)<<8 | int(b[1])
|
||||
var mantissa uint64
|
||||
for i := 2; i < 10; i++ {
|
||||
mantissa = mantissa<<8 | uint64(b[i])
|
||||
}
|
||||
if exponent == 0 && mantissa == 0 {
|
||||
return 0
|
||||
}
|
||||
return sign * float64(mantissa) * math.Pow(2, float64(exponent-16383-63))
|
||||
}
|
||||
|
||||
// ---------- WAV (RIFF) ----------
|
||||
|
||||
type wavProbe struct {
|
||||
sampleRate int
|
||||
bitDepth int
|
||||
channels int
|
||||
byteRate int
|
||||
dataSize int64
|
||||
id3 []byte
|
||||
info map[string]string
|
||||
}
|
||||
|
||||
// streamProbeWAV walks the top-level RIFF chunks, buffering only the small
|
||||
// metadata chunks (fmt/id3/LIST) and skipping the large data chunk.
|
||||
func streamProbeWAV(f *os.File) (*wavProbe, error) {
|
||||
header := make([]byte, 12)
|
||||
if _, err := io.ReadFull(f, header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if string(header[0:4]) != "RIFF" || string(header[8:12]) != "WAVE" {
|
||||
return nil, fmt.Errorf("not a WAVE file")
|
||||
}
|
||||
|
||||
p := &wavProbe{info: map[string]string{}}
|
||||
hdr := make([]byte, 8)
|
||||
for {
|
||||
if _, err := io.ReadFull(f, hdr); err != nil {
|
||||
break
|
||||
}
|
||||
id := string(hdr[0:4])
|
||||
size := readUint32(hdr[4:8], true)
|
||||
pad := int64(size) & 1
|
||||
|
||||
switch id {
|
||||
case "fmt ":
|
||||
buf := make([]byte, size)
|
||||
if _, err := io.ReadFull(f, buf); err != nil {
|
||||
return p, nil
|
||||
}
|
||||
if len(buf) >= 16 {
|
||||
format := binary.LittleEndian.Uint16(buf[0:2])
|
||||
p.channels = int(binary.LittleEndian.Uint16(buf[2:4]))
|
||||
p.sampleRate = int(binary.LittleEndian.Uint32(buf[4:8]))
|
||||
p.byteRate = int(binary.LittleEndian.Uint32(buf[8:12]))
|
||||
p.bitDepth = int(binary.LittleEndian.Uint16(buf[14:16]))
|
||||
if format == wavFormatExtensn && len(buf) >= 26 {
|
||||
// Valid bits per sample lives in the extension; the real
|
||||
// PCM format tag is in the GUID, but bitDepth from the
|
||||
// container field is sufficient for display.
|
||||
if vb := int(binary.LittleEndian.Uint16(buf[18:20])); vb > 0 {
|
||||
p.bitDepth = vb
|
||||
}
|
||||
}
|
||||
}
|
||||
if pad == 1 {
|
||||
f.Seek(pad, io.SeekCurrent)
|
||||
}
|
||||
case "data":
|
||||
p.dataSize = int64(size)
|
||||
f.Seek(int64(size)+pad, io.SeekCurrent)
|
||||
case id3ChunkWAV, "ID3 ":
|
||||
if size > 0 && size <= wavMaxMetaChunk {
|
||||
buf := make([]byte, size)
|
||||
if _, err := io.ReadFull(f, buf); err == nil {
|
||||
p.id3 = buf
|
||||
}
|
||||
if pad == 1 {
|
||||
f.Seek(pad, io.SeekCurrent)
|
||||
}
|
||||
} else {
|
||||
f.Seek(int64(size)+pad, io.SeekCurrent)
|
||||
}
|
||||
case "LIST":
|
||||
if size > 0 && size <= wavMaxMetaChunk {
|
||||
buf := make([]byte, size)
|
||||
if _, err := io.ReadFull(f, buf); err == nil {
|
||||
parseRIFFInfo(buf, p.info)
|
||||
}
|
||||
if pad == 1 {
|
||||
f.Seek(pad, io.SeekCurrent)
|
||||
}
|
||||
} else {
|
||||
f.Seek(int64(size)+pad, io.SeekCurrent)
|
||||
}
|
||||
default:
|
||||
f.Seek(int64(size)+pad, io.SeekCurrent)
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// parseRIFFInfo reads a LIST/INFO block ("INFO" + sub-chunks like INAM, IART).
|
||||
func parseRIFFInfo(buf []byte, out map[string]string) {
|
||||
if len(buf) < 4 || string(buf[0:4]) != "INFO" {
|
||||
return
|
||||
}
|
||||
pos := 4
|
||||
for pos+8 <= len(buf) {
|
||||
id := string(buf[pos : pos+4])
|
||||
size := int(binary.LittleEndian.Uint32(buf[pos+4 : pos+8]))
|
||||
pos += 8
|
||||
if size <= 0 || pos+size > len(buf) {
|
||||
break
|
||||
}
|
||||
val := strings.TrimRight(string(buf[pos:pos+size]), "\x00")
|
||||
out[id] = strings.TrimSpace(val)
|
||||
pos += size
|
||||
if size&1 == 1 {
|
||||
pos++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func wavMetadataFromProbe(p *wavProbe) *AudioMetadata {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
if len(p.id3) > 0 {
|
||||
if meta, err := readID3v2FromBytes(p.id3); err == nil && meta != nil &&
|
||||
(meta.Title != "" || meta.Artist != "" || meta.Album != "") {
|
||||
return meta
|
||||
}
|
||||
}
|
||||
if len(p.info) > 0 {
|
||||
meta := &AudioMetadata{
|
||||
Title: p.info["INAM"],
|
||||
Artist: p.info["IART"],
|
||||
Album: p.info["IPRD"],
|
||||
Genre: cleanGenre(p.info["IGNR"]),
|
||||
Date: p.info["ICRD"],
|
||||
Comment: p.info["ICMT"],
|
||||
Copyright: p.info["ICOP"],
|
||||
Composer: p.info["IMUS"],
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(p.info["ITRK"])); err == nil {
|
||||
meta.TrackNumber = n
|
||||
}
|
||||
if meta.Date != "" && len(meta.Date) >= 4 {
|
||||
meta.Year = meta.Date[:4]
|
||||
}
|
||||
if meta.Title != "" || meta.Artist != "" || meta.Album != "" {
|
||||
return meta
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetWAVQuality probes PCM parameters and computes duration from the data size.
|
||||
func GetWAVQuality(filePath string) (*WAVQuality, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
p, err := streamProbeWAV(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := &WAVQuality{
|
||||
SampleRate: p.sampleRate,
|
||||
BitDepth: p.bitDepth,
|
||||
Channels: p.channels,
|
||||
}
|
||||
if p.byteRate > 0 && p.dataSize > 0 {
|
||||
q.Duration = int(p.dataSize / int64(p.byteRate))
|
||||
} else if p.sampleRate > 0 && p.channels > 0 && p.bitDepth > 0 && p.dataSize > 0 {
|
||||
bytesPerSec := int64(p.sampleRate * p.channels * p.bitDepth / 8)
|
||||
if bytesPerSec > 0 {
|
||||
q.Duration = int(p.dataSize / bytesPerSec)
|
||||
}
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
// ReadWAVTags reads tags from a WAV file (ID3 chunk preferred, RIFF INFO fallback).
|
||||
func ReadWAVTags(filePath string) (*AudioMetadata, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
p, err := streamProbeWAV(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta := wavMetadataFromProbe(p)
|
||||
if meta == nil {
|
||||
return nil, fmt.Errorf("no WAV tags found")
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// ---------- AIFF / AIFC ----------
|
||||
|
||||
type aiffProbe struct {
|
||||
sampleRate int
|
||||
bitDepth int
|
||||
channels int
|
||||
numFrames int64
|
||||
id3 []byte
|
||||
nameChunk string
|
||||
authChunk string
|
||||
annoChunk string
|
||||
copyrightChunk string
|
||||
}
|
||||
|
||||
func streamProbeAIFF(f *os.File) (*aiffProbe, error) {
|
||||
header := make([]byte, 12)
|
||||
if _, err := io.ReadFull(f, header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
form := string(header[8:12])
|
||||
if string(header[0:4]) != "FORM" || (form != "AIFF" && form != "AIFC") {
|
||||
return nil, fmt.Errorf("not an AIFF file")
|
||||
}
|
||||
|
||||
p := &aiffProbe{}
|
||||
hdr := make([]byte, 8)
|
||||
for {
|
||||
if _, err := io.ReadFull(f, hdr); err != nil {
|
||||
break
|
||||
}
|
||||
id := string(hdr[0:4])
|
||||
size := readUint32(hdr[4:8], false)
|
||||
pad := int64(size) & 1
|
||||
|
||||
switch id {
|
||||
case "COMM":
|
||||
buf := make([]byte, size)
|
||||
if _, err := io.ReadFull(f, buf); err != nil {
|
||||
return p, nil
|
||||
}
|
||||
if len(buf) >= 18 {
|
||||
p.channels = int(binary.BigEndian.Uint16(buf[0:2]))
|
||||
p.numFrames = int64(binary.BigEndian.Uint32(buf[2:6]))
|
||||
p.bitDepth = int(binary.BigEndian.Uint16(buf[6:8]))
|
||||
p.sampleRate = int(parseExtendedFloat80(buf[8:18]) + 0.5)
|
||||
}
|
||||
if pad == 1 {
|
||||
f.Seek(pad, io.SeekCurrent)
|
||||
}
|
||||
case id3ChunkAIFF, "id3 ":
|
||||
if size > 0 && size <= wavMaxMetaChunk {
|
||||
buf := make([]byte, size)
|
||||
if _, err := io.ReadFull(f, buf); err == nil {
|
||||
p.id3 = buf
|
||||
}
|
||||
if pad == 1 {
|
||||
f.Seek(pad, io.SeekCurrent)
|
||||
}
|
||||
} else {
|
||||
f.Seek(int64(size)+pad, io.SeekCurrent)
|
||||
}
|
||||
case "NAME", "AUTH", "ANNO", "(c) ":
|
||||
if size > 0 && size <= wavMaxMetaChunk {
|
||||
buf := make([]byte, size)
|
||||
if _, err := io.ReadFull(f, buf); err == nil {
|
||||
val := strings.TrimRight(strings.TrimSpace(string(buf)), "\x00")
|
||||
switch id {
|
||||
case "NAME":
|
||||
p.nameChunk = val
|
||||
case "AUTH":
|
||||
p.authChunk = val
|
||||
case "ANNO":
|
||||
p.annoChunk = val
|
||||
case "(c) ":
|
||||
p.copyrightChunk = val
|
||||
}
|
||||
}
|
||||
if pad == 1 {
|
||||
f.Seek(pad, io.SeekCurrent)
|
||||
}
|
||||
} else {
|
||||
f.Seek(int64(size)+pad, io.SeekCurrent)
|
||||
}
|
||||
default:
|
||||
f.Seek(int64(size)+pad, io.SeekCurrent)
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func aiffMetadataFromProbe(p *aiffProbe) *AudioMetadata {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
if len(p.id3) > 0 {
|
||||
if meta, err := readID3v2FromBytes(p.id3); err == nil && meta != nil &&
|
||||
(meta.Title != "" || meta.Artist != "" || meta.Album != "") {
|
||||
return meta
|
||||
}
|
||||
}
|
||||
if p.nameChunk != "" || p.authChunk != "" {
|
||||
meta := &AudioMetadata{
|
||||
Title: p.nameChunk,
|
||||
Artist: p.authChunk,
|
||||
Comment: p.annoChunk,
|
||||
Copyright: p.copyrightChunk,
|
||||
}
|
||||
return meta
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAIFFQuality probes PCM parameters and computes duration from frame count.
|
||||
func GetAIFFQuality(filePath string) (*WAVQuality, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
p, err := streamProbeAIFF(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := &WAVQuality{
|
||||
SampleRate: p.sampleRate,
|
||||
BitDepth: p.bitDepth,
|
||||
Channels: p.channels,
|
||||
}
|
||||
if p.sampleRate > 0 && p.numFrames > 0 {
|
||||
q.Duration = int(p.numFrames / int64(p.sampleRate))
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
// ReadAIFFTags reads tags from an AIFF file (ID3 chunk preferred, AIFF text chunks fallback).
|
||||
func ReadAIFFTags(filePath string) (*AudioMetadata, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
p, err := streamProbeAIFF(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta := aiffMetadataFromProbe(p)
|
||||
if meta == nil {
|
||||
return nil, fmt.Errorf("no AIFF tags found")
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// ---------- ID3v2 reading from a buffered chunk ----------
|
||||
|
||||
// readID3v2FromBytes parses an in-memory ID3v2 tag (the contents of a WAV "id3 "
|
||||
// or AIFF "ID3 " chunk) by reusing the existing frame parsers.
|
||||
func readID3v2FromBytes(data []byte) (*AudioMetadata, error) {
|
||||
if len(data) < 10 || string(data[0:3]) != "ID3" {
|
||||
return nil, fmt.Errorf("no ID3v2 header")
|
||||
}
|
||||
majorVersion := data[3]
|
||||
flags := data[5]
|
||||
unsync := (flags & 0x80) != 0
|
||||
extendedHeader := (flags & 0x40) != 0
|
||||
footerPresent := (flags & 0x10) != 0
|
||||
|
||||
size := synchsafeDecode(data[6:10])
|
||||
if size <= 0 || 10+size > len(data) {
|
||||
size = len(data) - 10
|
||||
}
|
||||
tagData := data[10 : 10+size]
|
||||
|
||||
if footerPresent && len(tagData) >= 10 {
|
||||
footerStart := len(tagData) - 10
|
||||
if footerStart >= 0 && string(tagData[footerStart:footerStart+3]) == "3DI" {
|
||||
tagData = tagData[:footerStart]
|
||||
}
|
||||
}
|
||||
if extendedHeader {
|
||||
if skip := extendedHeaderSize(tagData, majorVersion); skip > 0 && skip < len(tagData) {
|
||||
tagData = tagData[skip:]
|
||||
}
|
||||
}
|
||||
|
||||
metadata := &AudioMetadata{}
|
||||
if majorVersion == 2 {
|
||||
parseID3v22Frames(tagData, metadata, unsync)
|
||||
} else {
|
||||
parseID3v23Frames(tagData, metadata, majorVersion, unsync)
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
// extractAPICFromID3 returns the first embedded picture (APIC/PIC) and its MIME.
|
||||
func extractAPICFromID3(tag []byte) ([]byte, string) {
|
||||
if len(tag) < 10 || string(tag[0:3]) != "ID3" {
|
||||
return nil, ""
|
||||
}
|
||||
ver := tag[3]
|
||||
size := synchsafeDecode(tag[6:10])
|
||||
if size <= 0 || 10+size > len(tag) {
|
||||
size = len(tag) - 10
|
||||
}
|
||||
data := tag[10 : 10+size]
|
||||
|
||||
pos := 0
|
||||
for {
|
||||
if ver == 2 {
|
||||
if pos+6 > len(data) || data[pos] == 0 {
|
||||
break
|
||||
}
|
||||
id := string(data[pos : pos+3])
|
||||
fsz := int(data[pos+3])<<16 | int(data[pos+4])<<8 | int(data[pos+5])
|
||||
if fsz <= 0 || pos+6+fsz > len(data) {
|
||||
break
|
||||
}
|
||||
if id == "PIC" {
|
||||
return parseAPICFrame(data[pos+6:pos+6+fsz], ver)
|
||||
}
|
||||
pos += 6 + fsz
|
||||
continue
|
||||
}
|
||||
|
||||
if pos+10 > len(data) || data[pos] == 0 {
|
||||
break
|
||||
}
|
||||
id := string(data[pos : pos+4])
|
||||
var fsz int
|
||||
if ver == 4 {
|
||||
fsz = synchsafeDecode(data[pos+4 : pos+8])
|
||||
} else {
|
||||
fsz = int(binary.BigEndian.Uint32(data[pos+4 : pos+8]))
|
||||
}
|
||||
if fsz <= 0 || pos+10+fsz > len(data) {
|
||||
break
|
||||
}
|
||||
if id == "APIC" {
|
||||
return parseAPICFrame(data[pos+10:pos+10+fsz], ver)
|
||||
}
|
||||
pos += 10 + fsz
|
||||
}
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// ---------- ID3v2.4 building ----------
|
||||
|
||||
// buildID3v24Tag builds a UTF-8 ID3v2.4 tag from metadata plus optional cover.
|
||||
func buildID3v24Tag(meta *AudioMetadata, coverData []byte, coverMIME string) []byte {
|
||||
var frames bytes.Buffer
|
||||
|
||||
writeFrame := func(id string, payload []byte) {
|
||||
frames.WriteString(id)
|
||||
frames.Write(synchsafeEncode(len(payload)))
|
||||
frames.Write([]byte{0, 0})
|
||||
frames.Write(payload)
|
||||
}
|
||||
writeText := func(id, val string) {
|
||||
if strings.TrimSpace(val) == "" {
|
||||
return
|
||||
}
|
||||
payload := append([]byte{0x03}, []byte(val)...)
|
||||
writeFrame(id, payload)
|
||||
}
|
||||
|
||||
writeText("TIT2", meta.Title)
|
||||
writeText("TPE1", meta.Artist)
|
||||
writeText("TALB", meta.Album)
|
||||
writeText("TPE2", meta.AlbumArtist)
|
||||
writeText("TCON", meta.Genre)
|
||||
writeText("TCOM", meta.Composer)
|
||||
writeText("TPUB", meta.Label)
|
||||
writeText("TCOP", meta.Copyright)
|
||||
writeText("TSRC", meta.ISRC)
|
||||
|
||||
date := meta.Date
|
||||
if date == "" {
|
||||
date = meta.Year
|
||||
}
|
||||
writeText("TDRC", date)
|
||||
|
||||
if meta.TrackNumber > 0 {
|
||||
if meta.TotalTracks > 0 {
|
||||
writeText("TRCK", fmt.Sprintf("%d/%d", meta.TrackNumber, meta.TotalTracks))
|
||||
} else {
|
||||
writeText("TRCK", strconv.Itoa(meta.TrackNumber))
|
||||
}
|
||||
}
|
||||
if meta.DiscNumber > 0 {
|
||||
if meta.TotalDiscs > 0 {
|
||||
writeText("TPOS", fmt.Sprintf("%d/%d", meta.DiscNumber, meta.TotalDiscs))
|
||||
} else {
|
||||
writeText("TPOS", strconv.Itoa(meta.DiscNumber))
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(meta.Comment) != "" {
|
||||
// COMM: encoding + language(3) + short desc(null) + text
|
||||
payload := []byte{0x03}
|
||||
payload = append(payload, []byte("eng")...)
|
||||
payload = append(payload, 0x00) // empty description
|
||||
payload = append(payload, []byte(meta.Comment)...)
|
||||
writeFrame("COMM", payload)
|
||||
}
|
||||
if strings.TrimSpace(meta.Lyrics) != "" {
|
||||
payload := []byte{0x03}
|
||||
payload = append(payload, []byte("eng")...)
|
||||
payload = append(payload, 0x00)
|
||||
payload = append(payload, []byte(meta.Lyrics)...)
|
||||
writeFrame("USLT", payload)
|
||||
}
|
||||
|
||||
// ReplayGain as TXXX (description\0value), UTF-8.
|
||||
writeTXXX := func(desc, val string) {
|
||||
if strings.TrimSpace(val) == "" {
|
||||
return
|
||||
}
|
||||
payload := []byte{0x03}
|
||||
payload = append(payload, []byte(desc)...)
|
||||
payload = append(payload, 0x00)
|
||||
payload = append(payload, []byte(val)...)
|
||||
writeFrame("TXXX", payload)
|
||||
}
|
||||
writeTXXX("REPLAYGAIN_TRACK_GAIN", meta.ReplayGainTrackGain)
|
||||
writeTXXX("REPLAYGAIN_TRACK_PEAK", meta.ReplayGainTrackPeak)
|
||||
writeTXXX("REPLAYGAIN_ALBUM_GAIN", meta.ReplayGainAlbumGain)
|
||||
writeTXXX("REPLAYGAIN_ALBUM_PEAK", meta.ReplayGainAlbumPeak)
|
||||
|
||||
if len(coverData) > 0 {
|
||||
if strings.TrimSpace(coverMIME) == "" {
|
||||
coverMIME = "image/jpeg"
|
||||
}
|
||||
// APIC: encoding + mime(null) + picture-type(0x03 front) + desc(null) + data
|
||||
payload := []byte{0x03}
|
||||
payload = append(payload, []byte(coverMIME)...)
|
||||
payload = append(payload, 0x00)
|
||||
payload = append(payload, 0x03)
|
||||
payload = append(payload, 0x00)
|
||||
payload = append(payload, coverData...)
|
||||
writeFrame("APIC", payload)
|
||||
}
|
||||
|
||||
body := frames.Bytes()
|
||||
var out bytes.Buffer
|
||||
out.WriteString("ID3")
|
||||
out.Write([]byte{0x04, 0x00}) // v2.4.0
|
||||
out.WriteByte(0x00) // flags
|
||||
out.Write(synchsafeEncode(len(body)))
|
||||
out.Write(body)
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
// ---------- tag writing (streaming chunk rewrite) ----------
|
||||
|
||||
// writeID3Chunk rewrites filePath, replacing any existing tag chunk (chunkID,
|
||||
// matched case-insensitively) with a fresh ID3v2.4 chunk appended at the end.
|
||||
// The audio data and all other chunks are preserved; container size is patched.
|
||||
func writeID3Chunk(filePath, expectMagic, chunkID string, le bool, id3 []byte) error {
|
||||
in, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
header := make([]byte, 12)
|
||||
if _, err := io.ReadFull(in, header); err != nil {
|
||||
return err
|
||||
}
|
||||
if string(header[0:4]) != expectMagic {
|
||||
return fmt.Errorf("unexpected container magic %q", string(header[0:4]))
|
||||
}
|
||||
|
||||
tmpPath := filePath + ".tagtmp"
|
||||
out, err := os.Create(tmpPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cleanup := func() {
|
||||
out.Close()
|
||||
os.Remove(tmpPath)
|
||||
}
|
||||
|
||||
if _, err := out.Write(header); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
|
||||
var bodyLen int64 = 4 // the 4-byte form type after the size field
|
||||
hdr := make([]byte, 8)
|
||||
for {
|
||||
n, rerr := io.ReadFull(in, hdr)
|
||||
if n < 8 {
|
||||
break
|
||||
}
|
||||
if rerr != nil {
|
||||
break
|
||||
}
|
||||
id := string(hdr[0:4])
|
||||
size := readUint32(hdr[4:8], le)
|
||||
pad := int64(size) & 1
|
||||
|
||||
if strings.EqualFold(id, chunkID) {
|
||||
// Drop the existing tag chunk.
|
||||
if _, err := in.Seek(int64(size)+pad, io.SeekCurrent); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := out.Write(hdr); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
if _, err := io.CopyN(out, in, int64(size)+pad); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
bodyLen += 8 + int64(size) + pad
|
||||
}
|
||||
|
||||
// Append the new tag chunk.
|
||||
newSize := len(id3)
|
||||
chunkHdr := make([]byte, 8)
|
||||
copy(chunkHdr[0:4], chunkID)
|
||||
putUint32(chunkHdr[4:8], le, uint32(newSize))
|
||||
if _, err := out.Write(chunkHdr); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
if _, err := out.Write(id3); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
if newSize&1 == 1 {
|
||||
if _, err := out.Write([]byte{0}); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
}
|
||||
bodyLen += 8 + int64(newSize) + int64(newSize&1)
|
||||
|
||||
// Patch the container size field (bytes 4..8).
|
||||
sizeBuf := make([]byte, 4)
|
||||
putUint32(sizeBuf, le, uint32(bodyLen))
|
||||
if _, err := out.WriteAt(sizeBuf, 4); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
|
||||
if err := out.Close(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
in.Close()
|
||||
|
||||
return os.Rename(tmpPath, filePath)
|
||||
}
|
||||
|
||||
func loadCoverForTag(fields map[string]string) ([]byte, string) {
|
||||
coverPath := strings.TrimSpace(fields["cover_path"])
|
||||
if coverPath == "" {
|
||||
return nil, ""
|
||||
}
|
||||
data, err := os.ReadFile(coverPath)
|
||||
if err != nil || len(data) == 0 {
|
||||
return nil, ""
|
||||
}
|
||||
mime := "image/jpeg"
|
||||
if len(data) >= 8 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 {
|
||||
mime = "image/png"
|
||||
}
|
||||
return data, mime
|
||||
}
|
||||
|
||||
func audioMetadataFromEditFields(fields map[string]string) *AudioMetadata {
|
||||
atoi := func(k string) int {
|
||||
n := 0
|
||||
if v, ok := fields[k]; ok && strings.TrimSpace(v) != "" {
|
||||
fmt.Sscanf(strings.TrimSpace(v), "%d", &n)
|
||||
}
|
||||
return n
|
||||
}
|
||||
return &AudioMetadata{
|
||||
Title: fields["title"],
|
||||
Artist: fields["artist"],
|
||||
Album: fields["album"],
|
||||
AlbumArtist: fields["album_artist"],
|
||||
Date: fields["date"],
|
||||
TrackNumber: atoi("track_number"),
|
||||
TotalTracks: atoi("track_total"),
|
||||
DiscNumber: atoi("disc_number"),
|
||||
TotalDiscs: atoi("disc_total"),
|
||||
ISRC: fields["isrc"],
|
||||
Lyrics: fields["lyrics"],
|
||||
Genre: fields["genre"],
|
||||
Label: fields["label"],
|
||||
Copyright: fields["copyright"],
|
||||
Composer: fields["composer"],
|
||||
Comment: fields["comment"],
|
||||
ReplayGainTrackGain: fields["replaygain_track_gain"],
|
||||
ReplayGainTrackPeak: fields["replaygain_track_peak"],
|
||||
ReplayGainAlbumGain: fields["replaygain_album_gain"],
|
||||
ReplayGainAlbumPeak: fields["replaygain_album_peak"],
|
||||
}
|
||||
}
|
||||
|
||||
// mergeWAVEditFields merges edit fields onto existing tags so untouched fields
|
||||
// (and cover art, when no new cover is provided) are preserved.
|
||||
func mergeEditFieldsOntoExisting(existing *AudioMetadata, fields map[string]string) *AudioMetadata {
|
||||
meta := audioMetadataFromEditFields(fields)
|
||||
if existing == nil {
|
||||
return meta
|
||||
}
|
||||
// Only overwrite fields that are present as keys in the edit set; otherwise
|
||||
// keep the existing value. An empty value with the key present clears it.
|
||||
keep := func(key, newVal, oldVal string) string {
|
||||
if _, ok := fields[key]; ok {
|
||||
return newVal
|
||||
}
|
||||
return oldVal
|
||||
}
|
||||
meta.Title = keep("title", meta.Title, existing.Title)
|
||||
meta.Artist = keep("artist", meta.Artist, existing.Artist)
|
||||
meta.Album = keep("album", meta.Album, existing.Album)
|
||||
meta.AlbumArtist = keep("album_artist", meta.AlbumArtist, existing.AlbumArtist)
|
||||
meta.Genre = keep("genre", meta.Genre, existing.Genre)
|
||||
meta.Composer = keep("composer", meta.Composer, existing.Composer)
|
||||
meta.Label = keep("label", meta.Label, existing.Label)
|
||||
meta.Copyright = keep("copyright", meta.Copyright, existing.Copyright)
|
||||
meta.ISRC = keep("isrc", meta.ISRC, existing.ISRC)
|
||||
meta.Lyrics = keep("lyrics", meta.Lyrics, existing.Lyrics)
|
||||
meta.Comment = keep("comment", meta.Comment, existing.Comment)
|
||||
meta.Date = keep("date", meta.Date, existing.Date)
|
||||
if _, ok := fields["track_number"]; !ok {
|
||||
meta.TrackNumber = existing.TrackNumber
|
||||
}
|
||||
if _, ok := fields["track_total"]; !ok {
|
||||
meta.TotalTracks = existing.TotalTracks
|
||||
}
|
||||
if _, ok := fields["disc_number"]; !ok {
|
||||
meta.DiscNumber = existing.DiscNumber
|
||||
}
|
||||
if _, ok := fields["disc_total"]; !ok {
|
||||
meta.TotalDiscs = existing.TotalDiscs
|
||||
}
|
||||
if _, ok := fields["replaygain_track_gain"]; !ok {
|
||||
meta.ReplayGainTrackGain = existing.ReplayGainTrackGain
|
||||
}
|
||||
if _, ok := fields["replaygain_track_peak"]; !ok {
|
||||
meta.ReplayGainTrackPeak = existing.ReplayGainTrackPeak
|
||||
}
|
||||
if _, ok := fields["replaygain_album_gain"]; !ok {
|
||||
meta.ReplayGainAlbumGain = existing.ReplayGainAlbumGain
|
||||
}
|
||||
if _, ok := fields["replaygain_album_peak"]; !ok {
|
||||
meta.ReplayGainAlbumPeak = existing.ReplayGainAlbumPeak
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
// WriteWAVTags writes/merges tags into a WAV file's "id3 " chunk.
|
||||
func WriteWAVTags(filePath string, fields map[string]string) error {
|
||||
existing, _ := ReadWAVTags(filePath)
|
||||
meta := mergeEditFieldsOntoExisting(existing, fields)
|
||||
|
||||
coverData, coverMIME := loadCoverForTag(fields)
|
||||
if coverData == nil {
|
||||
// Preserve an existing embedded cover when no new one is supplied.
|
||||
if f, err := os.Open(filePath); err == nil {
|
||||
if p, perr := streamProbeWAV(f); perr == nil && len(p.id3) > 0 {
|
||||
coverData, coverMIME = extractAPICFromID3(p.id3)
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
|
||||
tag := buildID3v24Tag(meta, coverData, coverMIME)
|
||||
return writeID3Chunk(filePath, "RIFF", id3ChunkWAV, true, tag)
|
||||
}
|
||||
|
||||
// WriteAIFFTags writes/merges tags into an AIFF file's "ID3 " chunk.
|
||||
func WriteAIFFTags(filePath string, fields map[string]string) error {
|
||||
existing, _ := ReadAIFFTags(filePath)
|
||||
meta := mergeEditFieldsOntoExisting(existing, fields)
|
||||
|
||||
coverData, coverMIME := loadCoverForTag(fields)
|
||||
if coverData == nil {
|
||||
if f, err := os.Open(filePath); err == nil {
|
||||
if p, perr := streamProbeAIFF(f); perr == nil && len(p.id3) > 0 {
|
||||
coverData, coverMIME = extractAPICFromID3(p.id3)
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
|
||||
tag := buildID3v24Tag(meta, coverData, coverMIME)
|
||||
return writeID3Chunk(filePath, "FORM", id3ChunkAIFF, false, tag)
|
||||
}
|
||||
|
||||
// ---------- library scan integration ----------
|
||||
|
||||
func scanWAVFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||
if metadata, err := ReadWAVTags(filePath); err == nil && metadata != nil {
|
||||
applyAudioMetadataToScan(metadata, result)
|
||||
}
|
||||
if quality, err := GetWAVQuality(filePath); err == nil && quality != nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
result.Duration = quality.Duration
|
||||
}
|
||||
result.Bitrate = 0 // lossless PCM
|
||||
result.Format = "wav"
|
||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanAIFFFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||
if metadata, err := ReadAIFFTags(filePath); err == nil && metadata != nil {
|
||||
applyAudioMetadataToScan(metadata, result)
|
||||
}
|
||||
if quality, err := GetAIFFQuality(filePath); err == nil && quality != nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
result.Duration = quality.Duration
|
||||
}
|
||||
result.Bitrate = 0 // lossless PCM
|
||||
result.Format = "aiff"
|
||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func applyAudioMetadataToScan(metadata *AudioMetadata, result *LibraryScanResult) {
|
||||
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.TotalTracks = metadata.TotalTracks
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.TotalDiscs = metadata.TotalDiscs
|
||||
if metadata.Date != "" {
|
||||
result.ReleaseDate = metadata.Date
|
||||
} else {
|
||||
result.ReleaseDate = metadata.Year
|
||||
}
|
||||
result.Genre = metadata.Genre
|
||||
result.Composer = metadata.Composer
|
||||
result.Label = metadata.Label
|
||||
result.Copyright = metadata.Copyright
|
||||
}
|
||||
|
||||
// extractWAVAIFFCover returns embedded cover art (from the ID3 chunk) for a
|
||||
// WAV or AIFF file, or an error when none is present.
|
||||
func extractWAVAIFFCover(filePath string) ([]byte, string, error) {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var id3 []byte
|
||||
switch ext {
|
||||
case ".aiff", ".aif", ".aifc":
|
||||
if p, perr := streamProbeAIFF(f); perr == nil {
|
||||
id3 = p.id3
|
||||
}
|
||||
default:
|
||||
if p, perr := streamProbeWAV(f); perr == nil {
|
||||
id3 = p.id3
|
||||
}
|
||||
}
|
||||
if len(id3) == 0 {
|
||||
return nil, "", fmt.Errorf("no embedded cover")
|
||||
}
|
||||
data, mime := extractAPICFromID3(id3)
|
||||
if len(data) == 0 {
|
||||
return nil, "", fmt.Errorf("no embedded cover")
|
||||
}
|
||||
return data, mime, nil
|
||||
}
|
||||
@@ -20,6 +20,11 @@ import Gobackend // Import Go framework
|
||||
|
||||
/// Currently accessed security-scoped URL for library folder
|
||||
private var activeSecurityScopedURL: URL?
|
||||
|
||||
/// Whether a download queue is active; while true a background task is
|
||||
/// started on each background entry to extend execution time. Main-thread only.
|
||||
private var downloadsActive = false
|
||||
private var downloadBackgroundTask: UIBackgroundTaskIdentifier = .invalid
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
@@ -233,6 +238,20 @@ import Gobackend // Import Go framework
|
||||
}
|
||||
|
||||
private func handleMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
case "beginBackgroundDownloadTask":
|
||||
downloadsActive = true
|
||||
result(nil)
|
||||
return
|
||||
case "endBackgroundDownloadTask":
|
||||
downloadsActive = false
|
||||
endBackgroundDownloadTask()
|
||||
result(nil)
|
||||
return
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
do {
|
||||
let response = try self.invokeGoMethod(call: call)
|
||||
@@ -246,6 +265,34 @@ import Gobackend // Import Go framework
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func applicationDidEnterBackground(_ application: UIApplication) {
|
||||
super.applicationDidEnterBackground(application)
|
||||
if downloadsActive {
|
||||
beginBackgroundDownloadTask()
|
||||
}
|
||||
}
|
||||
|
||||
override func applicationWillEnterForeground(_ application: UIApplication) {
|
||||
super.applicationWillEnterForeground(application)
|
||||
endBackgroundDownloadTask()
|
||||
}
|
||||
|
||||
private func beginBackgroundDownloadTask() {
|
||||
if downloadBackgroundTask != .invalid { return }
|
||||
downloadBackgroundTask = UIApplication.shared.beginBackgroundTask(
|
||||
withName: "SpotiFLACDownloads"
|
||||
) { [weak self] in
|
||||
self?.endBackgroundDownloadTask()
|
||||
}
|
||||
}
|
||||
|
||||
private func endBackgroundDownloadTask() {
|
||||
if downloadBackgroundTask != .invalid {
|
||||
UIApplication.shared.endBackgroundTask(downloadBackgroundTask)
|
||||
downloadBackgroundTask = .invalid
|
||||
}
|
||||
}
|
||||
|
||||
private func invokeGoMethod(call: FlutterMethodCall) throws -> Any? {
|
||||
var error: NSError?
|
||||
|
||||
@@ -114,6 +114,15 @@ class SpotiFLACApp extends ConsumerWidget {
|
||||
scrollBehavior: scrollBehavior,
|
||||
themeAnimationDuration: const Duration(milliseconds: 300),
|
||||
themeAnimationCurve: Curves.easeInOut,
|
||||
// Treat the display as one continuous surface so bottom sheets and
|
||||
// dialogs stay centered on large/foldable devices.
|
||||
builder: (context, child) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
return MediaQuery(
|
||||
data: mediaQuery.copyWith(displayFeatures: const []),
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
routerConfig: router,
|
||||
locale: locale,
|
||||
localeResolutionCallback: (deviceLocale, supportedLocales) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class AppInfo {
|
||||
static const String version = '4.5.6';
|
||||
static const String buildNumber = '133';
|
||||
static const String version = '4.6.0';
|
||||
static const String buildNumber = '135';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
static String get displayVersion => kDebugMode ? 'Internal' : version;
|
||||
|
||||
+103
-56
@@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations_ar.dart';
|
||||
import 'app_localizations_de.dart';
|
||||
import 'app_localizations_en.dart';
|
||||
import 'app_localizations_es.dart';
|
||||
@@ -106,6 +107,7 @@ abstract class AppLocalizations {
|
||||
|
||||
/// A list of this localizations delegate's supported locales.
|
||||
static const List<Locale> supportedLocales = <Locale>[
|
||||
Locale('ar'),
|
||||
Locale('de'),
|
||||
Locale('en'),
|
||||
Locale('es'),
|
||||
@@ -378,10 +380,10 @@ abstract class AppLocalizations {
|
||||
/// **'Choose which tab opens first for new search results.'**
|
||||
String get optionsDefaultSearchTabSubtitle;
|
||||
|
||||
/// Hint to switch back to built-in providers
|
||||
/// Hint to switch back from extension search
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tap Deezer or Spotify to switch back from extension'**
|
||||
/// **'Choose the default search provider to switch back from an extension'**
|
||||
String get optionsSwitchBack;
|
||||
|
||||
/// Auto-retry with other services
|
||||
@@ -396,7 +398,7 @@ abstract class AppLocalizations {
|
||||
/// **'Try other services if download fails'**
|
||||
String get optionsAutoFallbackSubtitle;
|
||||
|
||||
/// Enable extension download providers
|
||||
/// Legacy setting label for extension download providers
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Use Extension Providers'**
|
||||
@@ -405,13 +407,13 @@ abstract class AppLocalizations {
|
||||
/// Status when extension providers enabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Extensions will be tried first'**
|
||||
/// **'Extension providers are enabled'**
|
||||
String get optionsUseExtensionProvidersOn;
|
||||
|
||||
/// Status when extension providers disabled
|
||||
/// Legacy status when extension providers would be disabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Using built-in providers only'**
|
||||
/// **'Extension providers are required'**
|
||||
String get optionsUseExtensionProvidersOff;
|
||||
|
||||
/// Embed lyrics in audio files
|
||||
@@ -456,6 +458,66 @@ abstract class AppLocalizations {
|
||||
/// **'Disabled: no loudness normalization tags'**
|
||||
String get optionsReplayGainSubtitleOff;
|
||||
|
||||
/// Three-dot menu option to scan loudness and write ReplayGain tags
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Rescan ReplayGain'**
|
||||
String get trackReplayGain;
|
||||
|
||||
/// Subtitle for the rescan ReplayGain menu option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Analyze loudness and write ReplayGain tags'**
|
||||
String get trackReplayGainSubtitle;
|
||||
|
||||
/// Snackbar/progress message while scanning ReplayGain for a single track
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Analyzing loudness...'**
|
||||
String get trackReplayGainScanning;
|
||||
|
||||
/// Snackbar message after ReplayGain tags written for a single track
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'ReplayGain tags added'**
|
||||
String get trackReplayGainSuccess;
|
||||
|
||||
/// Snackbar message when ReplayGain scan/write fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to add ReplayGain tags'**
|
||||
String get trackReplayGainFailed;
|
||||
|
||||
/// Batch selection action button label for ReplayGain
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'ReplayGain ({count})'**
|
||||
String selectionReplayGainCount(int count);
|
||||
|
||||
/// Title of the batch ReplayGain confirmation dialog
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add ReplayGain'**
|
||||
String get replayGainBatchConfirmTitle;
|
||||
|
||||
/// Message of the batch ReplayGain confirmation dialog
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Analyze loudness and write ReplayGain tags to {count} track(s)?'**
|
||||
String replayGainBatchConfirmMessage(int count);
|
||||
|
||||
/// Progress dialog title while batch scanning ReplayGain
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Analyzing ReplayGain...'**
|
||||
String get replayGainBatchAnalyzing;
|
||||
|
||||
/// Snackbar after batch ReplayGain completes
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'ReplayGain added to {success} of {total} tracks'**
|
||||
String replayGainBatchSuccess(int success, int total);
|
||||
|
||||
/// Setting title for how artist metadata is written into files
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -492,30 +554,6 @@ abstract class AppLocalizations {
|
||||
/// **'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.'**
|
||||
String get optionsArtistTagModeSplitVorbisSubtitle;
|
||||
|
||||
/// Number of parallel downloads
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Concurrent Downloads'**
|
||||
String get optionsConcurrentDownloads;
|
||||
|
||||
/// Download one at a time
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sequential (1 at a time)'**
|
||||
String get optionsConcurrentSequential;
|
||||
|
||||
/// Multiple parallel downloads
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count} parallel downloads'**
|
||||
String optionsConcurrentParallel(int count);
|
||||
|
||||
/// Warning about rate limits
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Parallel downloads may trigger rate limiting'**
|
||||
String get optionsConcurrentWarning;
|
||||
|
||||
/// Show/hide store tab
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -819,13 +857,13 @@ abstract class AppLocalizations {
|
||||
/// Credit description for binimum
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!'**
|
||||
/// **'The creator of QQDL & HiFi API. This project helped shape lossless download support.'**
|
||||
String get aboutBinimumDesc;
|
||||
|
||||
/// Credit description for sachinsenal0x64
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The original HiFi project creator. The foundation of Tidal integration!'**
|
||||
/// **'The original HiFi project creator. A foundation for lossless-source integration.'**
|
||||
String get aboutSachinsenalDesc;
|
||||
|
||||
/// Credit description for sjdonado
|
||||
@@ -1788,10 +1826,10 @@ abstract class AppLocalizations {
|
||||
/// **'Only enabled extensions with download-provider capability are listed here.'**
|
||||
String get providerPriorityFallbackExtensionsHint;
|
||||
|
||||
/// Label for built-in providers (Tidal/Qobuz)
|
||||
/// Legacy label retained for old generated localization compatibility
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Built-in'**
|
||||
/// **'Legacy'**
|
||||
String get providerBuiltIn;
|
||||
|
||||
/// Label for extension-provided providers
|
||||
@@ -2517,13 +2555,13 @@ abstract class AppLocalizations {
|
||||
/// Default search provider option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Default (Deezer)'**
|
||||
/// **'Default Search'**
|
||||
String get extensionDefaultProvider;
|
||||
|
||||
/// Subtitle for default provider
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Use built-in search'**
|
||||
/// **'Use the default metadata search'**
|
||||
String get extensionDefaultProviderSubtitle;
|
||||
|
||||
/// Extension detail - author
|
||||
@@ -2814,73 +2852,73 @@ abstract class AppLocalizations {
|
||||
/// **'24-bit / up to 192kHz'**
|
||||
String get qualityHiResFlacMaxSubtitle;
|
||||
|
||||
/// Quality option label for Tidal lossy 320kbps
|
||||
/// Quality option label for lossy 320kbps
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lossy 320kbps'**
|
||||
String get downloadLossy320;
|
||||
|
||||
/// Setting title to pick output format for Tidal lossy downloads
|
||||
/// Setting title to pick output format for lossy downloads
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lossy Format'**
|
||||
String get downloadLossyFormat;
|
||||
|
||||
/// Title of the Tidal lossy format picker bottom sheet
|
||||
/// Title of the lossy format picker bottom sheet
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lossy 320kbps Format'**
|
||||
String get downloadLossy320Format;
|
||||
|
||||
/// Description in the Tidal lossy format picker
|
||||
/// Description in the 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.'**
|
||||
/// **'Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.'**
|
||||
String get downloadLossy320FormatDesc;
|
||||
|
||||
/// Tidal lossy format option - MP3 320kbps
|
||||
/// Lossy format option - MP3 320kbps
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'MP3 320kbps'**
|
||||
String get downloadLossyMp3;
|
||||
|
||||
/// Subtitle for MP3 320kbps Tidal lossy option
|
||||
/// Subtitle for MP3 320kbps lossy option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Best compatibility, ~10MB per track'**
|
||||
String get downloadLossyMp3Subtitle;
|
||||
|
||||
/// Tidal lossy format option - AAC in M4A container at 320kbps
|
||||
/// Lossy format option - AAC in M4A container at 320kbps
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'AAC/M4A 320kbps'**
|
||||
String get downloadLossyAac;
|
||||
|
||||
/// Subtitle for AAC/M4A 320kbps Tidal lossy option
|
||||
/// Subtitle for AAC/M4A 320kbps lossy option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Best mobile compatibility, M4A container'**
|
||||
String get downloadLossyAacSubtitle;
|
||||
|
||||
/// Tidal lossy format option - Opus 256kbps
|
||||
/// Lossy format option - Opus 256kbps
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Opus 256kbps'**
|
||||
String get downloadLossyOpus256;
|
||||
|
||||
/// Subtitle for Opus 256kbps Tidal lossy option
|
||||
/// Subtitle for Opus 256kbps lossy option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Best quality Opus, ~8MB per track'**
|
||||
String get downloadLossyOpus256Subtitle;
|
||||
|
||||
/// Tidal lossy format option - Opus 128kbps
|
||||
/// Lossy format option - Opus 128kbps
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Opus 128kbps'**
|
||||
String get downloadLossyOpus128;
|
||||
|
||||
/// Subtitle for Opus 128kbps Tidal lossy option
|
||||
/// Subtitle for Opus 128kbps lossy option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Smallest size, ~4MB per track'**
|
||||
@@ -3495,7 +3533,7 @@ abstract class AppLocalizations {
|
||||
/// Description of local library feature
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'**
|
||||
/// **'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, ALAC, M4A, MP3, Opus, OGG, WAV, AIFF, and APE formats. Metadata is read from file tags when available.'**
|
||||
String get libraryAboutDescription;
|
||||
|
||||
/// Unit label for tracks count (without the number itself)
|
||||
@@ -3777,7 +3815,7 @@ abstract class AppLocalizations {
|
||||
/// Tutorial welcome tip 2
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Get FLAC quality audio from Tidal, Qobuz, or Deezer'**
|
||||
/// **'Get FLAC quality audio from installed download extensions'**
|
||||
String get tutorialWelcomeTip2;
|
||||
|
||||
/// Tutorial welcome tip 3
|
||||
@@ -4844,7 +4882,7 @@ abstract class AppLocalizations {
|
||||
/// Info tip on lyrics provider priority page
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.'**
|
||||
/// **'Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.'**
|
||||
String get lyricsProvidersInfoText;
|
||||
|
||||
/// Section header for enabled providers
|
||||
@@ -4907,6 +4945,12 @@ abstract class AppLocalizations {
|
||||
/// **'QQ Music (good for Chinese songs, via proxy)'**
|
||||
String get lyricsProviderQqMusicDesc;
|
||||
|
||||
/// Description for LyricsPlus provider
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)'**
|
||||
String get lyricsProviderLyricsPlusDesc;
|
||||
|
||||
/// Generic description for extension-based lyrics providers
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -5164,13 +5208,13 @@ abstract class AppLocalizations {
|
||||
/// Subtitle when quality picker is disabled due to extension service
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select Tidal or Qobuz to enable this option'**
|
||||
/// **'Select a provider with quality options to enable this option'**
|
||||
String get downloadSelectServiceToEnable;
|
||||
|
||||
/// Info shown when a non-built-in service is selected
|
||||
/// Legacy info shown when a provider does not expose quality options
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select Tidal or Qobuz to choose audio quality'**
|
||||
/// **'Select a provider with quality options to choose audio quality'**
|
||||
String get downloadSelectTidalQobuz;
|
||||
|
||||
/// Subtitle when lyrics embedding is blocked by metadata toggle
|
||||
@@ -5725,7 +5769,7 @@ abstract class AppLocalizations {
|
||||
/// **'Re-analyzing audio...'**
|
||||
String get audioAnalysisRescanning;
|
||||
|
||||
/// Extensions page - subtitle for built-in search provider option
|
||||
/// Extensions page - subtitle for default metadata search provider option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Search with {providerName}'**
|
||||
@@ -7085,6 +7129,7 @@ class _AppLocalizationsDelegate
|
||||
|
||||
@override
|
||||
bool isSupported(Locale locale) => <String>[
|
||||
'ar',
|
||||
'de',
|
||||
'en',
|
||||
'es',
|
||||
@@ -7138,6 +7183,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
|
||||
|
||||
// Lookup logic when only language code is specified.
|
||||
switch (locale.languageCode) {
|
||||
case 'ar':
|
||||
return AppLocalizationsAr();
|
||||
case 'de':
|
||||
return AppLocalizationsDe();
|
||||
case 'en':
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+444
-395
File diff suppressed because it is too large
Load Diff
@@ -142,7 +142,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Tap Deezer or Spotify to switch back from extension';
|
||||
'Choose the default search provider to switch back from an extension';
|
||||
|
||||
@override
|
||||
String get optionsAutoFallback => 'Auto Fallback';
|
||||
@@ -155,10 +155,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
|
||||
String get optionsUseExtensionProvidersOn =>
|
||||
'Extension providers are enabled';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
|
||||
String get optionsUseExtensionProvidersOff =>
|
||||
'Extension providers are required';
|
||||
|
||||
@override
|
||||
String get optionsEmbedLyrics => 'Embed Lyrics';
|
||||
@@ -185,6 +187,43 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get trackReplayGain => 'Rescan ReplayGain';
|
||||
|
||||
@override
|
||||
String get trackReplayGainSubtitle =>
|
||||
'Analyze loudness and write ReplayGain tags';
|
||||
|
||||
@override
|
||||
String get trackReplayGainScanning => 'Analyzing loudness...';
|
||||
|
||||
@override
|
||||
String get trackReplayGainSuccess => 'ReplayGain tags added';
|
||||
|
||||
@override
|
||||
String get trackReplayGainFailed => 'Failed to add ReplayGain tags';
|
||||
|
||||
@override
|
||||
String selectionReplayGainCount(int count) {
|
||||
return 'ReplayGain ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get replayGainBatchConfirmTitle => 'Add ReplayGain';
|
||||
|
||||
@override
|
||||
String replayGainBatchConfirmMessage(int count) {
|
||||
return 'Analyze loudness and write ReplayGain tags to $count track(s)?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...';
|
||||
|
||||
@override
|
||||
String replayGainBatchSuccess(int success, int total) {
|
||||
return 'ReplayGain added to $success of $total tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@@ -206,21 +245,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentSequential => 'Sequential (1 at a time)';
|
||||
|
||||
@override
|
||||
String optionsConcurrentParallel(int count) {
|
||||
return '$count parallel downloads';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsConcurrentWarning =>
|
||||
'Parallel downloads may trigger rate limiting';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStore => 'Extension Repo';
|
||||
|
||||
@@ -385,11 +409,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutBinimumDesc =>
|
||||
'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!';
|
||||
'The creator of QQDL & HiFi API. This project helped shape lossless download support.';
|
||||
|
||||
@override
|
||||
String get aboutSachinsenalDesc =>
|
||||
'The original HiFi project creator. The foundation of Tidal integration!';
|
||||
'The original HiFi project creator. A foundation for lossless-source integration.';
|
||||
|
||||
@override
|
||||
String get aboutSjdonadoDesc =>
|
||||
@@ -953,7 +977,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => 'Built-in';
|
||||
String get providerBuiltIn => 'Legacy';
|
||||
|
||||
@override
|
||||
String get providerExtension => 'Extension';
|
||||
@@ -1346,10 +1370,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'Default (Deezer)';
|
||||
String get extensionDefaultProvider => 'Default Search';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProviderSubtitle => 'Use built-in search';
|
||||
String get extensionDefaultProviderSubtitle =>
|
||||
'Use the default metadata search';
|
||||
|
||||
@override
|
||||
String get extensionAuthor => 'Author';
|
||||
@@ -1523,7 +1548,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
'Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
@@ -1910,7 +1935,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, ALAC, M4A, MP3, Opus, OGG, WAV, AIFF, and APE formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksUnit(int count) {
|
||||
@@ -2093,7 +2118,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||
'Get FLAC quality audio from installed download extensions';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -2791,7 +2816,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get lyricsProvidersInfoText =>
|
||||
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
|
||||
'Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.';
|
||||
|
||||
@override
|
||||
String lyricsProvidersEnabledSection(int count) {
|
||||
@@ -2833,6 +2858,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get lyricsProviderQqMusicDesc =>
|
||||
'QQ Music (good for Chinese songs, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLyricsPlusDesc =>
|
||||
'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderExtensionDesc => 'Extension provider';
|
||||
|
||||
@@ -2987,11 +3016,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select Tidal or Qobuz to enable this option';
|
||||
'Select a provider with quality options to enable this option';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz to choose audio quality';
|
||||
'Select a provider with quality options to choose audio quality';
|
||||
|
||||
@override
|
||||
String get downloadEmbedLyricsDisabled => 'Enable metadata embedding first';
|
||||
|
||||
+1244
-452
File diff suppressed because it is too large
Load Diff
+1239
-1094
File diff suppressed because it is too large
Load Diff
@@ -126,7 +126,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get optionsPrimaryProviderSubtitle =>
|
||||
'Service used when searching by track name.';
|
||||
'Service used for searching by track or album name';
|
||||
|
||||
@override
|
||||
String optionsUsingExtension(String extensionName) {
|
||||
@@ -142,7 +142,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Tap Deezer or Spotify to switch back from extension';
|
||||
'Choose the default search provider to switch back from an extension';
|
||||
|
||||
@override
|
||||
String get optionsAutoFallback => 'Auto Fallback';
|
||||
@@ -155,17 +155,19 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
|
||||
String get optionsUseExtensionProvidersOn =>
|
||||
'Extension providers are enabled';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
|
||||
String get optionsUseExtensionProvidersOff =>
|
||||
'Extension providers are required';
|
||||
|
||||
@override
|
||||
String get optionsEmbedLyrics => 'Embed Lyrics';
|
||||
|
||||
@override
|
||||
String get optionsEmbedLyricsSubtitle =>
|
||||
'Embed synced lyrics into FLAC files';
|
||||
'Save synced lyrics alongside your downloaded tracks';
|
||||
|
||||
@override
|
||||
String get optionsMaxQualityCover => 'Max Quality Cover';
|
||||
@@ -185,6 +187,43 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get trackReplayGain => 'Rescan ReplayGain';
|
||||
|
||||
@override
|
||||
String get trackReplayGainSubtitle =>
|
||||
'Analyze loudness and write ReplayGain tags';
|
||||
|
||||
@override
|
||||
String get trackReplayGainScanning => 'Analyzing loudness...';
|
||||
|
||||
@override
|
||||
String get trackReplayGainSuccess => 'ReplayGain tags added';
|
||||
|
||||
@override
|
||||
String get trackReplayGainFailed => 'Failed to add ReplayGain tags';
|
||||
|
||||
@override
|
||||
String selectionReplayGainCount(int count) {
|
||||
return 'ReplayGain ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get replayGainBatchConfirmTitle => 'Add ReplayGain';
|
||||
|
||||
@override
|
||||
String replayGainBatchConfirmMessage(int count) {
|
||||
return 'Analyze loudness and write ReplayGain tags to $count track(s)?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...';
|
||||
|
||||
@override
|
||||
String replayGainBatchSuccess(int success, int total) {
|
||||
return 'ReplayGain added to $success of $total tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@@ -206,21 +245,6 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentSequential => 'Sequential (1 at a time)';
|
||||
|
||||
@override
|
||||
String optionsConcurrentParallel(int count) {
|
||||
return '$count parallel downloads';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsConcurrentWarning =>
|
||||
'Parallel downloads may trigger rate limiting';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStore => 'Extension Repo';
|
||||
|
||||
@@ -385,11 +409,11 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutBinimumDesc =>
|
||||
'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!';
|
||||
'The creator of QQDL & HiFi API. This project helped shape lossless download support.';
|
||||
|
||||
@override
|
||||
String get aboutSachinsenalDesc =>
|
||||
'The original HiFi project creator. The foundation of Tidal integration!';
|
||||
'The original HiFi project creator. A foundation for lossless-source integration.';
|
||||
|
||||
@override
|
||||
String get aboutSjdonadoDesc =>
|
||||
@@ -953,7 +977,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => 'Built-in';
|
||||
String get providerBuiltIn => 'Legacy';
|
||||
|
||||
@override
|
||||
String get providerExtension => 'Extension';
|
||||
@@ -1121,10 +1145,10 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get settingsAppearanceSubtitle => 'Theme, colors, display';
|
||||
|
||||
@override
|
||||
String get settingsDownloadSubtitle => 'Service, quality, filename format';
|
||||
String get settingsDownloadSubtitle => 'Service, quality, fallback';
|
||||
|
||||
@override
|
||||
String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates';
|
||||
String get settingsOptionsSubtitle => 'Fallback, metadata, lyrics, cover art';
|
||||
|
||||
@override
|
||||
String get settingsExtensionsSubtitle => 'Manage download providers';
|
||||
@@ -1346,10 +1370,11 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'Default (Deezer)';
|
||||
String get extensionDefaultProvider => 'Default Search';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProviderSubtitle => 'Use built-in search';
|
||||
String get extensionDefaultProviderSubtitle =>
|
||||
'Use the default metadata search';
|
||||
|
||||
@override
|
||||
String get extensionAuthor => 'Author';
|
||||
@@ -1523,7 +1548,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
'Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
@@ -2093,7 +2118,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||
'Get FLAC quality audio from installed download extensions';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -2423,7 +2448,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle =>
|
||||
'Convert to MP3, Opus, ALAC, or FLAC';
|
||||
'Convert to AAC/M4A, MP3, Opus, ALAC, or FLAC';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
@@ -2776,14 +2801,14 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||
'Artist folders use Album Artist when available';
|
||||
'Folder named after Album Artist tag';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
'Folder named after Track Artist tag';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersTitle => 'Lyrics Providers';
|
||||
String get lyricsProvidersTitle => 'Lyrics Provider Priority';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersDescription =>
|
||||
@@ -2791,7 +2816,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get lyricsProvidersInfoText =>
|
||||
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
|
||||
'Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.';
|
||||
|
||||
@override
|
||||
String lyricsProvidersEnabledSection(int count) {
|
||||
@@ -2833,6 +2858,10 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get lyricsProviderQqMusicDesc =>
|
||||
'QQ Music (good for Chinese songs, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLyricsPlusDesc =>
|
||||
'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderExtensionDesc => 'Extension provider';
|
||||
|
||||
@@ -2851,10 +2880,10 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get safMigrationSuccess => 'Download folder updated to SAF mode';
|
||||
|
||||
@override
|
||||
String get settingsDonate => 'Donate';
|
||||
String get settingsDonate => 'Support Development';
|
||||
|
||||
@override
|
||||
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
|
||||
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
||||
|
||||
@override
|
||||
String get tooltipLoveAll => 'Love All';
|
||||
@@ -2914,20 +2943,20 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadLocationSubtitle =>
|
||||
'Choose storage mode for downloaded files.';
|
||||
'Choose where to save your downloaded tracks';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolder => 'App folder (non-SAF)';
|
||||
String get storageModeAppFolder => 'App Folder (Recommended)';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
|
||||
String get storageModeAppFolderSubtitle =>
|
||||
'Saves to Music/SpotiFLAC by default';
|
||||
|
||||
@override
|
||||
String get storageModeSaf => 'SAF folder';
|
||||
String get storageModeSaf => 'Custom Folder (SAF)';
|
||||
|
||||
@override
|
||||
String get storageModeSafSubtitle =>
|
||||
'Pick folder via Android Storage Access Framework';
|
||||
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
|
||||
|
||||
@override
|
||||
String downloadFilenameDescription(
|
||||
@@ -2939,62 +2968,62 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
Object track,
|
||||
Object year,
|
||||
) {
|
||||
return 'Customize how your files are named.';
|
||||
return 'Use $artist, $title, $album, $track, $year, $date, $disc as placeholders.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadFilenameInsertTag => 'Tap to insert tag:';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
|
||||
String get downloadSeparateSinglesEnabled =>
|
||||
'Singles and EPs saved in a separate folder';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesDisabled => 'All files in same structure';
|
||||
String get downloadSeparateSinglesDisabled =>
|
||||
'Singles and albums saved in the same folder';
|
||||
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
String get downloadCreatePlaylistSourceFolder => 'Playlist Source Folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
'A subfolder is created for each playlist';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
'All tracks saved directly to download folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
'Handled by folder organization setting';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
|
||||
String get downloadNetworkCompatibilityMode => 'Network Compatibility Mode';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeEnabled =>
|
||||
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
|
||||
'Using legacy TLS settings for older networks';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeDisabled =>
|
||||
'Off: strict HTTPS certificate validation (recommended)';
|
||||
'Using standard network settings';
|
||||
|
||||
@override
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
'Select a provider with quality options to enable this option';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
'Select a provider with quality options to choose audio quality';
|
||||
|
||||
@override
|
||||
String get downloadEmbedLyricsDisabled =>
|
||||
'Disabled while Embed Metadata is turned off';
|
||||
String get downloadEmbedLyricsDisabled => 'Enable metadata embedding first';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslation =>
|
||||
@@ -3002,11 +3031,11 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationEnabled =>
|
||||
'Append translated lyrics when available';
|
||||
'Chinese translation lines included';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationDisabled =>
|
||||
'Use original lyrics only';
|
||||
'Original lyrics only';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanization =>
|
||||
@@ -3014,21 +3043,21 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationEnabled =>
|
||||
'Append romanized lyrics when available';
|
||||
'Romanization lines included';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'No romanization';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
|
||||
String get downloadAppleQqMultiPerson => 'Apple / QQ: Multi-Person Lyrics';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonEnabled =>
|
||||
'Enable v1/v2 speaker and [bg:] tags';
|
||||
'Speaker labels included for duets and group tracks';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Simplified word-by-word formatting';
|
||||
'Standard lyrics without speaker labels';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
|
||||
@@ -3045,46 +3074,45 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
|
||||
String get downloadMusixmatchLanguageAuto => 'Auto (original language)';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributing =>
|
||||
'Filter contributing artists in Album Artist';
|
||||
String get downloadFilterContributing => 'Filter Contributing Artists';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingEnabled =>
|
||||
'Album Artist metadata uses primary artist only';
|
||||
'Contributing artists removed from Album Artist folder name';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingDisabled =>
|
||||
'Keep full Album Artist metadata value';
|
||||
'Full Album Artist string used';
|
||||
|
||||
@override
|
||||
String get downloadProvidersNoneEnabled => 'None enabled';
|
||||
String get downloadProvidersNoneEnabled => 'No providers enabled';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageCode => 'Language code';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
|
||||
String get downloadMusixmatchLanguageHint => 'e.g. en, de, ja';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageDesc =>
|
||||
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
|
||||
'Enter a BCP-47 language code (e.g. en, de, ja) to request translated lyrics from Musixmatch.';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
String get downloadNetworkAnySubtitle => 'Use WiFi or mobile data';
|
||||
|
||||
@override
|
||||
String get downloadNetworkWifiOnlySubtitle =>
|
||||
'Pause downloads on mobile data';
|
||||
'Downloads pause when on mobile data';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegionDesc =>
|
||||
'Used as userCountry for SongLink API lookup.';
|
||||
'Region used when resolving track links via SongLink. Choose the country where your streaming services are available.';
|
||||
|
||||
@override
|
||||
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
|
||||
@@ -3470,7 +3498,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String notifTracksDownloadedSuccess(int count) {
|
||||
return '$count tracks downloaded successfully';
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks downloaded successfully',
|
||||
one: '1 track downloaded successfully',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
+161
-111
@@ -21,7 +21,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get navSettings => 'Pengaturan';
|
||||
|
||||
@override
|
||||
String get navStore => 'Repo';
|
||||
String get navStore => 'Repositori';
|
||||
|
||||
@override
|
||||
String get homeTitle => 'Beranda';
|
||||
@@ -30,10 +30,10 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get homeSubtitle => 'Paste a supported URL or search by name';
|
||||
|
||||
@override
|
||||
String get homeEmptyTitle => 'Belum ada provider pencarian';
|
||||
String get homeEmptyTitle => 'Belum ada penyedia pencarian';
|
||||
|
||||
@override
|
||||
String get homeEmptySubtitle => 'Pasang ekstensi untuk melanjutkan.';
|
||||
String get homeEmptySubtitle => 'Instal ekstensi untuk melanjutkan.';
|
||||
|
||||
@override
|
||||
String get homeSupports => 'Mendukung: URL Track, Album, Playlist, Artis';
|
||||
@@ -82,11 +82,11 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get downloadFilenameFormat => 'Format Nama File';
|
||||
|
||||
@override
|
||||
String get downloadSingleFilenameFormat => 'Single Filename Format';
|
||||
String get downloadSingleFilenameFormat => 'Format Nama Berkas Tunggal';
|
||||
|
||||
@override
|
||||
String get downloadSingleFilenameFormatDescription =>
|
||||
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
|
||||
'Pola nama file untuk single dan EP. Menggunakan tag yang sama dengan format album.';
|
||||
|
||||
@override
|
||||
String get downloadFolderOrganization => 'Organisasi Folder';
|
||||
@@ -127,7 +127,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get optionsPrimaryProviderSubtitle =>
|
||||
'Layanan yang digunakan saat mencari berdasarkan nama lagu.';
|
||||
'Layanan yang digunakan untuk mencari berdasarkan nama lagu atau album';
|
||||
|
||||
@override
|
||||
String optionsUsingExtension(String extensionName) {
|
||||
@@ -139,11 +139,11 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTabSubtitle =>
|
||||
'Choose which tab opens first for new search results.';
|
||||
'Pilih tab mana yang terbuka terlebih dahulu untuk hasil pencarian baru.';
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Ketuk Deezer atau Spotify untuk beralih dari ekstensi';
|
||||
'Choose the default search provider to switch back from an extension';
|
||||
|
||||
@override
|
||||
String get optionsAutoFallback => 'Cadangan Otomatis';
|
||||
@@ -157,18 +157,18 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOn =>
|
||||
'Ekstensi akan dicoba terlebih dahulu';
|
||||
'Extension providers are enabled';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOff =>
|
||||
'Hanya menggunakan provider bawaan';
|
||||
'Extension providers are required';
|
||||
|
||||
@override
|
||||
String get optionsEmbedLyrics => 'Sematkan Lirik';
|
||||
|
||||
@override
|
||||
String get optionsEmbedLyricsSubtitle =>
|
||||
'Sematkan lirik sinkron ke file FLAC';
|
||||
'Simpan lirik yang disinkronkan bersama dengan lagu yang Anda unduh';
|
||||
|
||||
@override
|
||||
String get optionsMaxQualityCover => 'Cover Kualitas Maksimal';
|
||||
@@ -182,47 +182,69 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOn =>
|
||||
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||
'Pindai kenyaringan dan sematkan tag ReplayGain (EBU R128)';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
'Dinonaktifkan: tidak ada tag normalisasi kenyaringan';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
String get trackReplayGain => 'Rescan ReplayGain';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeDescription =>
|
||||
'Choose how multiple artists are written into embedded tags.';
|
||||
String get trackReplayGainSubtitle =>
|
||||
'Analyze loudness and write ReplayGain tags';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||
String get trackReplayGainScanning => 'Analyzing loudness...';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoinedSubtitle =>
|
||||
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||
String get trackReplayGainSuccess => 'ReplayGain tags added';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||
String get trackReplayGainFailed => 'Failed to add ReplayGain tags';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentDownloads => 'Unduhan Bersamaan';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentSequential => 'Berurutan (1 per waktu)';
|
||||
|
||||
@override
|
||||
String optionsConcurrentParallel(int count) {
|
||||
return '$count unduhan paralel';
|
||||
String selectionReplayGainCount(int count) {
|
||||
return 'ReplayGain ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsConcurrentWarning =>
|
||||
'Unduhan paralel dapat memicu pembatasan rate';
|
||||
String get replayGainBatchConfirmTitle => 'Add ReplayGain';
|
||||
|
||||
@override
|
||||
String replayGainBatchConfirmMessage(int count) {
|
||||
return 'Analyze loudness and write ReplayGain tags to $count track(s)?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...';
|
||||
|
||||
@override
|
||||
String replayGainBatchSuccess(int success, int total) {
|
||||
return 'ReplayGain added to $success of $total tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Mode Tag Artis';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeDescription =>
|
||||
'Pilih bagaimana beberapa artis dicantumkan dalam tag yang disematkan.';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoined => 'Nilai gabungan tunggal';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoinedSubtitle =>
|
||||
'Tuliskan satu nilai ARTIS seperti \"Artis A, Artis B\" untuk kompatibilitas pemain maksimal.';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbis => 'Tag terpisah untuk FLAC/Opus';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||
'Tulis satu tag artis per artis untuk FLAC dan Opus; MP3 dan M4A tetap tergabung.';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStore => 'Extension Repo';
|
||||
@@ -388,11 +410,11 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutBinimumDesc =>
|
||||
'Pembuat QQDL & HiFi API. Tanpa API ini, unduhan Tidal tidak akan ada!';
|
||||
'The creator of QQDL & HiFi API. This project helped shape lossless download support.';
|
||||
|
||||
@override
|
||||
String get aboutSachinsenalDesc =>
|
||||
'Pembuat proyek HiFi asli. Fondasi dari integrasi Tidal!';
|
||||
'The original HiFi project creator. A foundation for lossless-source integration.';
|
||||
|
||||
@override
|
||||
String get aboutSjdonadoDesc =>
|
||||
@@ -746,7 +768,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get errorNoTracksFound => 'Tidak ada lagu ditemukan';
|
||||
|
||||
@override
|
||||
String get searchEmptyResultSubtitle => 'Coba kata kunci lain';
|
||||
String get searchEmptyResultSubtitle => 'Try another keyword';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Tautan tidak dikenali';
|
||||
@@ -956,7 +978,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => 'Bawaan';
|
||||
String get providerBuiltIn => 'Legacy';
|
||||
|
||||
@override
|
||||
String get providerExtension => 'Ekstensi';
|
||||
@@ -1127,10 +1149,10 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get settingsAppearanceSubtitle => 'Tema, warna, tampilan';
|
||||
|
||||
@override
|
||||
String get settingsDownloadSubtitle => 'Layanan, kualitas, format nama file';
|
||||
String get settingsDownloadSubtitle => 'Service, quality, fallback';
|
||||
|
||||
@override
|
||||
String get settingsOptionsSubtitle => 'Fallback, lirik, cover art, pembaruan';
|
||||
String get settingsOptionsSubtitle => 'Fallback, metadata, lyrics, cover art';
|
||||
|
||||
@override
|
||||
String get settingsExtensionsSubtitle => 'Kelola provider unduhan';
|
||||
@@ -1352,10 +1374,11 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'Default (Deezer)';
|
||||
String get extensionDefaultProvider => 'Default Search';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProviderSubtitle => 'Gunakan pencarian bawaan';
|
||||
String get extensionDefaultProviderSubtitle =>
|
||||
'Use the default metadata search';
|
||||
|
||||
@override
|
||||
String get extensionAuthor => 'Pembuat';
|
||||
@@ -1531,7 +1554,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
'Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
@@ -1919,7 +1942,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
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.';
|
||||
'Memindai koleksi musik yang sudah ada untuk mendeteksi duplikat saat mengunduh. Mendukung format FLAC, ALAC, M4A, MP3, Opus, OGG, WAV, AIFF, dan APE. Metadata dibaca dari tag file jika tersedia.';
|
||||
|
||||
@override
|
||||
String libraryTracksUnit(int count) {
|
||||
@@ -2102,7 +2125,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Dapatkan audio berkualitas FLAC dari Tidal, Qobuz, atau Deezer';
|
||||
'Get FLAC quality audio from installed download extensions';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -2432,7 +2455,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle =>
|
||||
'Convert to MP3, Opus, ALAC, or FLAC';
|
||||
'Convert to AAC/M4A, MP3, Opus, ALAC, or FLAC';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
@@ -2785,14 +2808,14 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||
'Artist folders use Album Artist when available';
|
||||
'Folder named after Album Artist tag';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
'Folder named after Track Artist tag';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersTitle => 'Lyrics Providers';
|
||||
String get lyricsProvidersTitle => 'Lyrics Provider Priority';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersDescription =>
|
||||
@@ -2800,7 +2823,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get lyricsProvidersInfoText =>
|
||||
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
|
||||
'Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.';
|
||||
|
||||
@override
|
||||
String lyricsProvidersEnabledSection(int count) {
|
||||
@@ -2842,6 +2865,10 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get lyricsProviderQqMusicDesc =>
|
||||
'QQ Music (good for Chinese songs, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLyricsPlusDesc =>
|
||||
'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderExtensionDesc => 'Extension provider';
|
||||
|
||||
@@ -2860,10 +2887,10 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get safMigrationSuccess => 'Download folder updated to SAF mode';
|
||||
|
||||
@override
|
||||
String get settingsDonate => 'Donate';
|
||||
String get settingsDonate => 'Support Development';
|
||||
|
||||
@override
|
||||
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
|
||||
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
||||
|
||||
@override
|
||||
String get tooltipLoveAll => 'Love All';
|
||||
@@ -2923,20 +2950,20 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadLocationSubtitle =>
|
||||
'Choose storage mode for downloaded files.';
|
||||
'Choose where to save your downloaded tracks';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolder => 'App folder (non-SAF)';
|
||||
String get storageModeAppFolder => 'App Folder (Recommended)';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
|
||||
String get storageModeAppFolderSubtitle =>
|
||||
'Saves to Music/SpotiFLAC by default';
|
||||
|
||||
@override
|
||||
String get storageModeSaf => 'SAF folder';
|
||||
String get storageModeSaf => 'Custom Folder (SAF)';
|
||||
|
||||
@override
|
||||
String get storageModeSafSubtitle =>
|
||||
'Pick folder via Android Storage Access Framework';
|
||||
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
|
||||
|
||||
@override
|
||||
String downloadFilenameDescription(
|
||||
@@ -2948,62 +2975,62 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
Object track,
|
||||
Object year,
|
||||
) {
|
||||
return 'Customize how your files are named.';
|
||||
return 'Use $artist, $title, $album, $track, $year, $date, $disc as placeholders.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadFilenameInsertTag => 'Tap to insert tag:';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
|
||||
String get downloadSeparateSinglesEnabled =>
|
||||
'Singles and EPs saved in a separate folder';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesDisabled => 'All files in same structure';
|
||||
String get downloadSeparateSinglesDisabled =>
|
||||
'Singles and albums saved in the same folder';
|
||||
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
String get downloadCreatePlaylistSourceFolder => 'Playlist Source Folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
'A subfolder is created for each playlist';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
'All tracks saved directly to download folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
'Handled by folder organization setting';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
|
||||
String get downloadNetworkCompatibilityMode => 'Network Compatibility Mode';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeEnabled =>
|
||||
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
|
||||
'Using legacy TLS settings for older networks';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeDisabled =>
|
||||
'Off: strict HTTPS certificate validation (recommended)';
|
||||
'Using standard network settings';
|
||||
|
||||
@override
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
'Select a provider with quality options to enable this option';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
'Select a provider with quality options to choose audio quality';
|
||||
|
||||
@override
|
||||
String get downloadEmbedLyricsDisabled =>
|
||||
'Disabled while Embed Metadata is turned off';
|
||||
String get downloadEmbedLyricsDisabled => 'Enable metadata embedding first';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslation =>
|
||||
@@ -3011,11 +3038,11 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationEnabled =>
|
||||
'Append translated lyrics when available';
|
||||
'Chinese translation lines included';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationDisabled =>
|
||||
'Use original lyrics only';
|
||||
'Original lyrics only';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanization =>
|
||||
@@ -3023,21 +3050,21 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationEnabled =>
|
||||
'Append romanized lyrics when available';
|
||||
'Romanization lines included';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'No romanization';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
|
||||
String get downloadAppleQqMultiPerson => 'Apple / QQ: Multi-Person Lyrics';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonEnabled =>
|
||||
'Enable v1/v2 speaker and [bg:] tags';
|
||||
'Speaker labels included for duets and group tracks';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Simplified word-by-word formatting';
|
||||
'Standard lyrics without speaker labels';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
|
||||
@@ -3054,46 +3081,45 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
|
||||
String get downloadMusixmatchLanguageAuto => 'Auto (original language)';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributing =>
|
||||
'Filter contributing artists in Album Artist';
|
||||
String get downloadFilterContributing => 'Filter Contributing Artists';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingEnabled =>
|
||||
'Album Artist metadata uses primary artist only';
|
||||
'Contributing artists removed from Album Artist folder name';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingDisabled =>
|
||||
'Keep full Album Artist metadata value';
|
||||
'Full Album Artist string used';
|
||||
|
||||
@override
|
||||
String get downloadProvidersNoneEnabled => 'None enabled';
|
||||
String get downloadProvidersNoneEnabled => 'No providers enabled';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageCode => 'Language code';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
|
||||
String get downloadMusixmatchLanguageHint => 'e.g. en, de, ja';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageDesc =>
|
||||
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
|
||||
'Enter a BCP-47 language code (e.g. en, de, ja) to request translated lyrics from Musixmatch.';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
String get downloadNetworkAnySubtitle => 'Use WiFi or mobile data';
|
||||
|
||||
@override
|
||||
String get downloadNetworkWifiOnlySubtitle =>
|
||||
'Pause downloads on mobile data';
|
||||
'Downloads pause when on mobile data';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegionDesc =>
|
||||
'Used as userCountry for SongLink API lookup.';
|
||||
'Region used when resolving track links via SongLink. Choose the country where your streaming services are available.';
|
||||
|
||||
@override
|
||||
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
|
||||
@@ -3471,28 +3497,52 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String notifDownloadsFinished(int completed, int failed) {
|
||||
return 'Unduhan Selesai ($completed selesai, $failed gagal)';
|
||||
return 'Downloads Finished ($completed done, $failed failed)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get notifAllDownloadsComplete => 'Semua Unduhan Selesai';
|
||||
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||
|
||||
@override
|
||||
String notifTracksDownloadedSuccess(int count) {
|
||||
return '$count lagu berhasil diunduh';
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks downloaded successfully',
|
||||
one: '1 track downloaded successfully',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String notifDownloadsFinishedBody(int completed, int failed) {
|
||||
return '$completed lagu diunduh, $failed gagal';
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
completed,
|
||||
locale: localeName,
|
||||
other: '$completed tracks downloaded',
|
||||
one: '1 track downloaded',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
failed,
|
||||
locale: localeName,
|
||||
other: '$failed failed',
|
||||
one: '1 failed',
|
||||
);
|
||||
return '$_temp0, $_temp1';
|
||||
}
|
||||
|
||||
@override
|
||||
String get notifDownloadsCanceledTitle => 'Unduhan dibatalkan';
|
||||
String get notifDownloadsCanceledTitle => 'Downloads canceled';
|
||||
|
||||
@override
|
||||
String notifDownloadsCanceledBody(int count) {
|
||||
return '$count unduhan dibatalkan oleh pengguna';
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count downloads canceled by user',
|
||||
one: '1 download canceled by user',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -3657,11 +3707,11 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Layanan sedang membatasi permintaan';
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'Lagu ini mungkin masih tersedia. Tunggu beberapa menit, kurangi unduhan paralel, lalu coba lagi.';
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
@@ -4191,44 +4241,44 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get artistReleases => 'Releases';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectNone => 'Tidak ada';
|
||||
String get editMetadataSelectNone => 'None';
|
||||
|
||||
@override
|
||||
String queueRetryAllFailed(int count) {
|
||||
return 'Coba ulang $count gagal';
|
||||
return 'Retry $count failed';
|
||||
}
|
||||
|
||||
@override
|
||||
String get settingsSaveDownloadHistory => 'Simpan riwayat unduhan';
|
||||
String get settingsSaveDownloadHistory => 'Save download history';
|
||||
|
||||
@override
|
||||
String get settingsSaveDownloadHistorySubtitle =>
|
||||
'Simpan unduhan selesai di riwayat dan tampilan pustaka';
|
||||
'Keep completed downloads in history and library views';
|
||||
|
||||
@override
|
||||
String get dialogDisableHistoryTitle => 'Matikan riwayat unduhan?';
|
||||
String get dialogDisableHistoryTitle => 'Turn off download history?';
|
||||
|
||||
@override
|
||||
String get dialogDisableHistoryMessage =>
|
||||
'Riwayat yang ada akan dihapus. File unduhan tidak akan dihapus.';
|
||||
'Existing history will be cleared. Downloaded files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get dialogDisableAndClear => 'Matikan dan hapus';
|
||||
String get dialogDisableAndClear => 'Turn off and clear';
|
||||
|
||||
@override
|
||||
String get openInOtherServices => 'Buka di Layanan Lain';
|
||||
String get openInOtherServices => 'Open in Other Services';
|
||||
|
||||
@override
|
||||
String get shareSheetNoExtensions => 'Tidak ada layanan lain yang kompatibel';
|
||||
String get shareSheetNoExtensions => 'No other compatible services';
|
||||
|
||||
@override
|
||||
String get shareSheetNotFound => 'Tidak ditemukan';
|
||||
String get shareSheetNotFound => 'Not found';
|
||||
|
||||
@override
|
||||
String get shareSheetCopyLink => 'Salin Tautan';
|
||||
String get shareSheetCopyLink => 'Copy Link';
|
||||
|
||||
@override
|
||||
String shareSheetLinkCopied(Object service) {
|
||||
return 'Tautan $service disalin';
|
||||
return '$service link copied';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get optionsPrimaryProviderSubtitle =>
|
||||
'Service used when searching by track name.';
|
||||
'Service used for searching by track or album name';
|
||||
|
||||
@override
|
||||
String optionsUsingExtension(String extensionName) {
|
||||
@@ -142,7 +142,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Tap Deezer or Spotify to switch back from extension';
|
||||
'Choose the default search provider to switch back from an extension';
|
||||
|
||||
@override
|
||||
String get optionsAutoFallback => 'Auto Fallback';
|
||||
@@ -155,16 +155,19 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get optionsUseExtensionProviders => '拡張のプロバイダーを使用する';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOn => '最初に拡張で試みます';
|
||||
String get optionsUseExtensionProvidersOn =>
|
||||
'Extension providers are enabled';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOff => '内蔵のプロバイダーのみを使用する';
|
||||
String get optionsUseExtensionProvidersOff =>
|
||||
'Extension providers are required';
|
||||
|
||||
@override
|
||||
String get optionsEmbedLyrics => '歌詞を埋め込む';
|
||||
|
||||
@override
|
||||
String get optionsEmbedLyricsSubtitle => '同期する歌詞を FLAC ファイルに埋め込む';
|
||||
String get optionsEmbedLyricsSubtitle =>
|
||||
'Save synced lyrics alongside your downloaded tracks';
|
||||
|
||||
@override
|
||||
String get optionsMaxQualityCover => '最大品質のカバー';
|
||||
@@ -183,6 +186,43 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get trackReplayGain => 'Rescan ReplayGain';
|
||||
|
||||
@override
|
||||
String get trackReplayGainSubtitle =>
|
||||
'Analyze loudness and write ReplayGain tags';
|
||||
|
||||
@override
|
||||
String get trackReplayGainScanning => 'Analyzing loudness...';
|
||||
|
||||
@override
|
||||
String get trackReplayGainSuccess => 'ReplayGain tags added';
|
||||
|
||||
@override
|
||||
String get trackReplayGainFailed => 'Failed to add ReplayGain tags';
|
||||
|
||||
@override
|
||||
String selectionReplayGainCount(int count) {
|
||||
return 'ReplayGain ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get replayGainBatchConfirmTitle => 'Add ReplayGain';
|
||||
|
||||
@override
|
||||
String replayGainBatchConfirmMessage(int count) {
|
||||
return 'Analyze loudness and write ReplayGain tags to $count track(s)?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...';
|
||||
|
||||
@override
|
||||
String replayGainBatchSuccess(int success, int total) {
|
||||
return 'ReplayGain added to $success of $total tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@@ -204,21 +244,6 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentDownloads => '同時ダウンロード';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentSequential => 'Sequential (1 at a time)';
|
||||
|
||||
@override
|
||||
String optionsConcurrentParallel(int count) {
|
||||
return '$count 件の分割ダウンロード';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsConcurrentWarning =>
|
||||
'Parallel downloads may trigger rate limiting';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStore => 'Extension Repo';
|
||||
|
||||
@@ -381,11 +406,11 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutBinimumDesc =>
|
||||
'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!';
|
||||
'The creator of QQDL & HiFi API. This project helped shape lossless download support.';
|
||||
|
||||
@override
|
||||
String get aboutSachinsenalDesc =>
|
||||
'The original HiFi project creator. The foundation of Tidal integration!';
|
||||
'The original HiFi project creator. A foundation for lossless-source integration.';
|
||||
|
||||
@override
|
||||
String get aboutSjdonadoDesc =>
|
||||
@@ -947,7 +972,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => '内蔵';
|
||||
String get providerBuiltIn => 'Legacy';
|
||||
|
||||
@override
|
||||
String get providerExtension => '拡張';
|
||||
@@ -1115,10 +1140,10 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get settingsAppearanceSubtitle => 'テーマ、カラー、画面';
|
||||
|
||||
@override
|
||||
String get settingsDownloadSubtitle => 'サービス、品質、ファイル名、形式';
|
||||
String get settingsDownloadSubtitle => 'Service, quality, fallback';
|
||||
|
||||
@override
|
||||
String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates';
|
||||
String get settingsOptionsSubtitle => 'Fallback, metadata, lyrics, cover art';
|
||||
|
||||
@override
|
||||
String get settingsExtensionsSubtitle => 'ダウンロードプロバイダーを管理';
|
||||
@@ -1340,10 +1365,11 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'Default (Deezer)';
|
||||
String get extensionDefaultProvider => 'Default Search';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProviderSubtitle => '内蔵の検索を使用する';
|
||||
String get extensionDefaultProviderSubtitle =>
|
||||
'Use the default metadata search';
|
||||
|
||||
@override
|
||||
String get extensionAuthor => '作者';
|
||||
@@ -1513,7 +1539,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
'Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
@@ -2080,7 +2106,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||
'Get FLAC quality audio from installed download extensions';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -2410,7 +2436,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle =>
|
||||
'Convert to MP3, Opus, ALAC, or FLAC';
|
||||
'Convert to AAC/M4A, MP3, Opus, ALAC, or FLAC';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'オーディオを変換';
|
||||
@@ -2763,14 +2789,14 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||
'Artist folders use Album Artist when available';
|
||||
'Folder named after Album Artist tag';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
'Folder named after Track Artist tag';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersTitle => 'Lyrics Providers';
|
||||
String get lyricsProvidersTitle => 'Lyrics Provider Priority';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersDescription =>
|
||||
@@ -2778,7 +2804,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get lyricsProvidersInfoText =>
|
||||
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
|
||||
'Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.';
|
||||
|
||||
@override
|
||||
String lyricsProvidersEnabledSection(int count) {
|
||||
@@ -2820,6 +2846,10 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get lyricsProviderQqMusicDesc =>
|
||||
'QQ Music (good for Chinese songs, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLyricsPlusDesc =>
|
||||
'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderExtensionDesc => 'Extension provider';
|
||||
|
||||
@@ -2838,10 +2868,10 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get safMigrationSuccess => 'Download folder updated to SAF mode';
|
||||
|
||||
@override
|
||||
String get settingsDonate => 'Donate';
|
||||
String get settingsDonate => 'Support Development';
|
||||
|
||||
@override
|
||||
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
|
||||
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
||||
|
||||
@override
|
||||
String get tooltipLoveAll => 'Love All';
|
||||
@@ -2901,20 +2931,20 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadLocationSubtitle =>
|
||||
'Choose storage mode for downloaded files.';
|
||||
'Choose where to save your downloaded tracks';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolder => 'App folder (non-SAF)';
|
||||
String get storageModeAppFolder => 'App Folder (Recommended)';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
|
||||
String get storageModeAppFolderSubtitle =>
|
||||
'Saves to Music/SpotiFLAC by default';
|
||||
|
||||
@override
|
||||
String get storageModeSaf => 'SAF folder';
|
||||
String get storageModeSaf => 'Custom Folder (SAF)';
|
||||
|
||||
@override
|
||||
String get storageModeSafSubtitle =>
|
||||
'Pick folder via Android Storage Access Framework';
|
||||
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
|
||||
|
||||
@override
|
||||
String downloadFilenameDescription(
|
||||
@@ -2926,62 +2956,62 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
Object track,
|
||||
Object year,
|
||||
) {
|
||||
return 'Customize how your files are named.';
|
||||
return 'Use $artist, $title, $album, $track, $year, $date, $disc as placeholders.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadFilenameInsertTag => 'Tap to insert tag:';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
|
||||
String get downloadSeparateSinglesEnabled =>
|
||||
'Singles and EPs saved in a separate folder';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesDisabled => 'All files in same structure';
|
||||
String get downloadSeparateSinglesDisabled =>
|
||||
'Singles and albums saved in the same folder';
|
||||
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
String get downloadCreatePlaylistSourceFolder => 'Playlist Source Folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
'A subfolder is created for each playlist';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
'All tracks saved directly to download folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
'Handled by folder organization setting';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
|
||||
String get downloadNetworkCompatibilityMode => 'Network Compatibility Mode';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeEnabled =>
|
||||
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
|
||||
'Using legacy TLS settings for older networks';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeDisabled =>
|
||||
'Off: strict HTTPS certificate validation (recommended)';
|
||||
'Using standard network settings';
|
||||
|
||||
@override
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
'Select a provider with quality options to enable this option';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
'Select a provider with quality options to choose audio quality';
|
||||
|
||||
@override
|
||||
String get downloadEmbedLyricsDisabled =>
|
||||
'Disabled while Embed Metadata is turned off';
|
||||
String get downloadEmbedLyricsDisabled => 'Enable metadata embedding first';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslation =>
|
||||
@@ -2989,11 +3019,11 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationEnabled =>
|
||||
'Append translated lyrics when available';
|
||||
'Chinese translation lines included';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationDisabled =>
|
||||
'Use original lyrics only';
|
||||
'Original lyrics only';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanization =>
|
||||
@@ -3001,21 +3031,21 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationEnabled =>
|
||||
'Append romanized lyrics when available';
|
||||
'Romanization lines included';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'No romanization';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
|
||||
String get downloadAppleQqMultiPerson => 'Apple / QQ: Multi-Person Lyrics';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonEnabled =>
|
||||
'Enable v1/v2 speaker and [bg:] tags';
|
||||
'Speaker labels included for duets and group tracks';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Simplified word-by-word formatting';
|
||||
'Standard lyrics without speaker labels';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
|
||||
@@ -3032,46 +3062,45 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
|
||||
String get downloadMusixmatchLanguageAuto => 'Auto (original language)';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributing =>
|
||||
'Filter contributing artists in Album Artist';
|
||||
String get downloadFilterContributing => 'Filter Contributing Artists';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingEnabled =>
|
||||
'Album Artist metadata uses primary artist only';
|
||||
'Contributing artists removed from Album Artist folder name';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingDisabled =>
|
||||
'Keep full Album Artist metadata value';
|
||||
'Full Album Artist string used';
|
||||
|
||||
@override
|
||||
String get downloadProvidersNoneEnabled => 'None enabled';
|
||||
String get downloadProvidersNoneEnabled => 'No providers enabled';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageCode => 'Language code';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
|
||||
String get downloadMusixmatchLanguageHint => 'e.g. en, de, ja';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageDesc =>
|
||||
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
|
||||
'Enter a BCP-47 language code (e.g. en, de, ja) to request translated lyrics from Musixmatch.';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
String get downloadNetworkAnySubtitle => 'Use WiFi or mobile data';
|
||||
|
||||
@override
|
||||
String get downloadNetworkWifiOnlySubtitle =>
|
||||
'Pause downloads on mobile data';
|
||||
'Downloads pause when on mobile data';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegionDesc =>
|
||||
'Used as userCountry for SongLink API lookup.';
|
||||
'Region used when resolving track links via SongLink. Choose the country where your streaming services are available.';
|
||||
|
||||
@override
|
||||
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
|
||||
@@ -3457,7 +3486,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String notifTracksDownloadedSuccess(int count) {
|
||||
return '$count tracks downloaded successfully';
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks downloaded successfully',
|
||||
one: '1 track downloaded successfully',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -27,7 +27,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get homeTitle => 'Home';
|
||||
|
||||
@override
|
||||
String get homeSubtitle => 'Paste a supported URL or search by name';
|
||||
String get homeSubtitle => '지원되는 URL을 붙여 넣거나, 이름을 검색';
|
||||
|
||||
@override
|
||||
String get homeEmptyTitle => 'No search providers yet';
|
||||
@@ -97,10 +97,10 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get appearanceThemeSystem => 'System';
|
||||
|
||||
@override
|
||||
String get appearanceThemeLight => 'Light';
|
||||
String get appearanceThemeLight => '밝은';
|
||||
|
||||
@override
|
||||
String get appearanceThemeDark => 'Dark';
|
||||
String get appearanceThemeDark => '다크';
|
||||
|
||||
@override
|
||||
String get appearanceDynamicColor => 'Dynamic Color';
|
||||
@@ -124,7 +124,8 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get optionsPrimaryProvider => '기본 제공자';
|
||||
|
||||
@override
|
||||
String get optionsPrimaryProviderSubtitle => '음반 이름으로 검색할 때 사용되는 서비스';
|
||||
String get optionsPrimaryProviderSubtitle =>
|
||||
'Service used for searching by track or album name';
|
||||
|
||||
@override
|
||||
String optionsUsingExtension(String extensionName) {
|
||||
@@ -139,7 +140,8 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
'Choose which tab opens first for new search results.';
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack => 'Deezer 또는 Spotify를 탭하여 확장 기능에서 다시 전환하세요.';
|
||||
String get optionsSwitchBack =>
|
||||
'Choose the default search provider to switch back from an extension';
|
||||
|
||||
@override
|
||||
String get optionsAutoFallback => '자동 재시도';
|
||||
@@ -151,16 +153,19 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get optionsUseExtensionProviders => '확장 기능 사용';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOn => '확장 기능을 우선적으로 사용합니다';
|
||||
String get optionsUseExtensionProvidersOn =>
|
||||
'Extension providers are enabled';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOff => '기본으로 제공되는 기능만 사용';
|
||||
String get optionsUseExtensionProvidersOff =>
|
||||
'Extension providers are required';
|
||||
|
||||
@override
|
||||
String get optionsEmbedLyrics => '가사 삽입';
|
||||
|
||||
@override
|
||||
String get optionsEmbedLyricsSubtitle => 'FLAC 파일에 동기화된 가사를 삽입합니다';
|
||||
String get optionsEmbedLyricsSubtitle =>
|
||||
'Save synced lyrics alongside your downloaded tracks';
|
||||
|
||||
@override
|
||||
String get optionsMaxQualityCover => '고품질 커버 이미지';
|
||||
@@ -179,6 +184,43 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get trackReplayGain => 'Rescan ReplayGain';
|
||||
|
||||
@override
|
||||
String get trackReplayGainSubtitle =>
|
||||
'Analyze loudness and write ReplayGain tags';
|
||||
|
||||
@override
|
||||
String get trackReplayGainScanning => 'Analyzing loudness...';
|
||||
|
||||
@override
|
||||
String get trackReplayGainSuccess => 'ReplayGain tags added';
|
||||
|
||||
@override
|
||||
String get trackReplayGainFailed => 'Failed to add ReplayGain tags';
|
||||
|
||||
@override
|
||||
String selectionReplayGainCount(int count) {
|
||||
return 'ReplayGain ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get replayGainBatchConfirmTitle => 'Add ReplayGain';
|
||||
|
||||
@override
|
||||
String replayGainBatchConfirmMessage(int count) {
|
||||
return 'Analyze loudness and write ReplayGain tags to $count track(s)?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...';
|
||||
|
||||
@override
|
||||
String replayGainBatchSuccess(int success, int total) {
|
||||
return 'ReplayGain added to $success of $total tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@@ -200,20 +242,6 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentDownloads => '동시 다운로드';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentSequential => '순차 다운로드 (한 번에 하나)';
|
||||
|
||||
@override
|
||||
String optionsConcurrentParallel(int count) {
|
||||
return '$count개 동시 다운로드';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsConcurrentWarning => '동시에 다수의 음반을 다운로드하면 속도 제한이 발생할 수 있습니다';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStore => 'Extension Repo';
|
||||
|
||||
@@ -374,10 +402,11 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutBinimumDesc =>
|
||||
'QQDL 및 HiFi API 개발자입니다. 이 API가 없었다면 Tidal 다운로드는 불가능했을 것입니다!';
|
||||
'The creator of QQDL & HiFi API. This project helped shape lossless download support.';
|
||||
|
||||
@override
|
||||
String get aboutSachinsenalDesc => '최초의 하이파이 프로젝트 창시자. 타이달 연동의 기반을 마련한 사람!';
|
||||
String get aboutSachinsenalDesc =>
|
||||
'The original HiFi project creator. A foundation for lossless-source integration.';
|
||||
|
||||
@override
|
||||
String get aboutSjdonadoDesc =>
|
||||
@@ -829,7 +858,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get tooltipPlay => '재생';
|
||||
|
||||
@override
|
||||
String get filenameFormat => '';
|
||||
String get filenameFormat => 'Filename Format';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTags => '고급 태그 표시';
|
||||
@@ -935,7 +964,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => 'Built-in';
|
||||
String get providerBuiltIn => 'Legacy';
|
||||
|
||||
@override
|
||||
String get providerExtension => 'Extension';
|
||||
@@ -1101,10 +1130,10 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get settingsAppearanceSubtitle => 'Theme, colors, display';
|
||||
|
||||
@override
|
||||
String get settingsDownloadSubtitle => 'Service, quality, filename format';
|
||||
String get settingsDownloadSubtitle => 'Service, quality, fallback';
|
||||
|
||||
@override
|
||||
String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates';
|
||||
String get settingsOptionsSubtitle => 'Fallback, metadata, lyrics, cover art';
|
||||
|
||||
@override
|
||||
String get settingsExtensionsSubtitle => 'Manage download providers';
|
||||
@@ -1326,10 +1355,11 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'Default (Deezer)';
|
||||
String get extensionDefaultProvider => 'Default Search';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProviderSubtitle => 'Use built-in search';
|
||||
String get extensionDefaultProviderSubtitle =>
|
||||
'Use the default metadata search';
|
||||
|
||||
@override
|
||||
String get extensionAuthor => 'Author';
|
||||
@@ -1503,7 +1533,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
'Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
@@ -2073,7 +2103,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||
'Get FLAC quality audio from installed download extensions';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -2403,7 +2433,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle =>
|
||||
'Convert to MP3, Opus, ALAC, or FLAC';
|
||||
'Convert to AAC/M4A, MP3, Opus, ALAC, or FLAC';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
@@ -2756,14 +2786,14 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||
'Artist folders use Album Artist when available';
|
||||
'Folder named after Album Artist tag';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
'Folder named after Track Artist tag';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersTitle => 'Lyrics Providers';
|
||||
String get lyricsProvidersTitle => 'Lyrics Provider Priority';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersDescription =>
|
||||
@@ -2771,7 +2801,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get lyricsProvidersInfoText =>
|
||||
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
|
||||
'Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.';
|
||||
|
||||
@override
|
||||
String lyricsProvidersEnabledSection(int count) {
|
||||
@@ -2813,6 +2843,10 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get lyricsProviderQqMusicDesc =>
|
||||
'QQ Music (good for Chinese songs, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLyricsPlusDesc =>
|
||||
'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderExtensionDesc => 'Extension provider';
|
||||
|
||||
@@ -2831,10 +2865,10 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get safMigrationSuccess => 'Download folder updated to SAF mode';
|
||||
|
||||
@override
|
||||
String get settingsDonate => 'Donate';
|
||||
String get settingsDonate => 'Support Development';
|
||||
|
||||
@override
|
||||
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
|
||||
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
||||
|
||||
@override
|
||||
String get tooltipLoveAll => 'Love All';
|
||||
@@ -2894,20 +2928,20 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadLocationSubtitle =>
|
||||
'Choose storage mode for downloaded files.';
|
||||
'Choose where to save your downloaded tracks';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolder => 'App folder (non-SAF)';
|
||||
String get storageModeAppFolder => 'App Folder (Recommended)';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
|
||||
String get storageModeAppFolderSubtitle =>
|
||||
'Saves to Music/SpotiFLAC by default';
|
||||
|
||||
@override
|
||||
String get storageModeSaf => 'SAF folder';
|
||||
String get storageModeSaf => 'Custom Folder (SAF)';
|
||||
|
||||
@override
|
||||
String get storageModeSafSubtitle =>
|
||||
'Pick folder via Android Storage Access Framework';
|
||||
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
|
||||
|
||||
@override
|
||||
String downloadFilenameDescription(
|
||||
@@ -2919,62 +2953,62 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
Object track,
|
||||
Object year,
|
||||
) {
|
||||
return 'Customize how your files are named.';
|
||||
return 'Use $artist, $title, $album, $track, $year, $date, $disc as placeholders.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadFilenameInsertTag => 'Tap to insert tag:';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
|
||||
String get downloadSeparateSinglesEnabled =>
|
||||
'Singles and EPs saved in a separate folder';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesDisabled => 'All files in same structure';
|
||||
String get downloadSeparateSinglesDisabled =>
|
||||
'Singles and albums saved in the same folder';
|
||||
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
String get downloadCreatePlaylistSourceFolder => 'Playlist Source Folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
'A subfolder is created for each playlist';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
'All tracks saved directly to download folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
'Handled by folder organization setting';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
|
||||
String get downloadNetworkCompatibilityMode => 'Network Compatibility Mode';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeEnabled =>
|
||||
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
|
||||
'Using legacy TLS settings for older networks';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeDisabled =>
|
||||
'Off: strict HTTPS certificate validation (recommended)';
|
||||
'Using standard network settings';
|
||||
|
||||
@override
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
'Select a provider with quality options to enable this option';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
'Select a provider with quality options to choose audio quality';
|
||||
|
||||
@override
|
||||
String get downloadEmbedLyricsDisabled =>
|
||||
'Disabled while Embed Metadata is turned off';
|
||||
String get downloadEmbedLyricsDisabled => 'Enable metadata embedding first';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslation =>
|
||||
@@ -2982,11 +3016,11 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationEnabled =>
|
||||
'Append translated lyrics when available';
|
||||
'Chinese translation lines included';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationDisabled =>
|
||||
'Use original lyrics only';
|
||||
'Original lyrics only';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanization =>
|
||||
@@ -2994,21 +3028,21 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationEnabled =>
|
||||
'Append romanized lyrics when available';
|
||||
'Romanization lines included';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'No romanization';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
|
||||
String get downloadAppleQqMultiPerson => 'Apple / QQ: Multi-Person Lyrics';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonEnabled =>
|
||||
'Enable v1/v2 speaker and [bg:] tags';
|
||||
'Speaker labels included for duets and group tracks';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Simplified word-by-word formatting';
|
||||
'Standard lyrics without speaker labels';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
|
||||
@@ -3025,46 +3059,45 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
|
||||
String get downloadMusixmatchLanguageAuto => 'Auto (original language)';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributing =>
|
||||
'Filter contributing artists in Album Artist';
|
||||
String get downloadFilterContributing => 'Filter Contributing Artists';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingEnabled =>
|
||||
'Album Artist metadata uses primary artist only';
|
||||
'Contributing artists removed from Album Artist folder name';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingDisabled =>
|
||||
'Keep full Album Artist metadata value';
|
||||
'Full Album Artist string used';
|
||||
|
||||
@override
|
||||
String get downloadProvidersNoneEnabled => 'None enabled';
|
||||
String get downloadProvidersNoneEnabled => 'No providers enabled';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageCode => 'Language code';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
|
||||
String get downloadMusixmatchLanguageHint => 'e.g. en, de, ja';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageDesc =>
|
||||
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
|
||||
'Enter a BCP-47 language code (e.g. en, de, ja) to request translated lyrics from Musixmatch.';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
String get downloadNetworkAnySubtitle => 'Use WiFi or mobile data';
|
||||
|
||||
@override
|
||||
String get downloadNetworkWifiOnlySubtitle =>
|
||||
'Pause downloads on mobile data';
|
||||
'Downloads pause when on mobile data';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegionDesc =>
|
||||
'Used as userCountry for SongLink API lookup.';
|
||||
'Region used when resolving track links via SongLink. Choose the country where your streaming services are available.';
|
||||
|
||||
@override
|
||||
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
|
||||
@@ -3450,7 +3483,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String notifTracksDownloadedSuccess(int count) {
|
||||
return '$count tracks downloaded successfully';
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks downloaded successfully',
|
||||
one: '1 track downloaded successfully',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -126,7 +126,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get optionsPrimaryProviderSubtitle =>
|
||||
'Service used when searching by track name.';
|
||||
'Service used for searching by track or album name';
|
||||
|
||||
@override
|
||||
String optionsUsingExtension(String extensionName) {
|
||||
@@ -142,7 +142,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Tap Deezer or Spotify to switch back from extension';
|
||||
'Choose the default search provider to switch back from an extension';
|
||||
|
||||
@override
|
||||
String get optionsAutoFallback => 'Auto Fallback';
|
||||
@@ -155,17 +155,19 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
|
||||
String get optionsUseExtensionProvidersOn =>
|
||||
'Extension providers are enabled';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
|
||||
String get optionsUseExtensionProvidersOff =>
|
||||
'Extension providers are required';
|
||||
|
||||
@override
|
||||
String get optionsEmbedLyrics => 'Embed Lyrics';
|
||||
|
||||
@override
|
||||
String get optionsEmbedLyricsSubtitle =>
|
||||
'Embed synced lyrics into FLAC files';
|
||||
'Save synced lyrics alongside your downloaded tracks';
|
||||
|
||||
@override
|
||||
String get optionsMaxQualityCover => 'Max Quality Cover';
|
||||
@@ -185,6 +187,43 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get trackReplayGain => 'Rescan ReplayGain';
|
||||
|
||||
@override
|
||||
String get trackReplayGainSubtitle =>
|
||||
'Analyze loudness and write ReplayGain tags';
|
||||
|
||||
@override
|
||||
String get trackReplayGainScanning => 'Analyzing loudness...';
|
||||
|
||||
@override
|
||||
String get trackReplayGainSuccess => 'ReplayGain tags added';
|
||||
|
||||
@override
|
||||
String get trackReplayGainFailed => 'Failed to add ReplayGain tags';
|
||||
|
||||
@override
|
||||
String selectionReplayGainCount(int count) {
|
||||
return 'ReplayGain ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get replayGainBatchConfirmTitle => 'Add ReplayGain';
|
||||
|
||||
@override
|
||||
String replayGainBatchConfirmMessage(int count) {
|
||||
return 'Analyze loudness and write ReplayGain tags to $count track(s)?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...';
|
||||
|
||||
@override
|
||||
String replayGainBatchSuccess(int success, int total) {
|
||||
return 'ReplayGain added to $success of $total tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@@ -206,21 +245,6 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentSequential => 'Sequentiële (1 per keer)';
|
||||
|
||||
@override
|
||||
String optionsConcurrentParallel(int count) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsConcurrentWarning =>
|
||||
'Parallel downloaden kan leiden tot rate-limiting';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStore => 'Extension Repo';
|
||||
|
||||
@@ -323,7 +347,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get aboutContributors => 'Contributors';
|
||||
|
||||
@override
|
||||
String get aboutMobileDeveloper => '';
|
||||
String get aboutMobileDeveloper => 'Mobile version developer';
|
||||
|
||||
@override
|
||||
String get aboutOriginalCreator => 'Creator of the original SpotiFLAC';
|
||||
@@ -385,11 +409,11 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutBinimumDesc =>
|
||||
'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!';
|
||||
'The creator of QQDL & HiFi API. This project helped shape lossless download support.';
|
||||
|
||||
@override
|
||||
String get aboutSachinsenalDesc =>
|
||||
'The original HiFi project creator. The foundation of Tidal integration!';
|
||||
'The original HiFi project creator. A foundation for lossless-source integration.';
|
||||
|
||||
@override
|
||||
String get aboutSjdonadoDesc =>
|
||||
@@ -953,7 +977,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => 'Built-in';
|
||||
String get providerBuiltIn => 'Legacy';
|
||||
|
||||
@override
|
||||
String get providerExtension => 'Extension';
|
||||
@@ -1121,10 +1145,10 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get settingsAppearanceSubtitle => 'Theme, colors, display';
|
||||
|
||||
@override
|
||||
String get settingsDownloadSubtitle => 'Service, quality, filename format';
|
||||
String get settingsDownloadSubtitle => 'Service, quality, fallback';
|
||||
|
||||
@override
|
||||
String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates';
|
||||
String get settingsOptionsSubtitle => 'Fallback, metadata, lyrics, cover art';
|
||||
|
||||
@override
|
||||
String get settingsExtensionsSubtitle => 'Manage download providers';
|
||||
@@ -1346,10 +1370,11 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'Default (Deezer)';
|
||||
String get extensionDefaultProvider => 'Default Search';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProviderSubtitle => 'Use built-in search';
|
||||
String get extensionDefaultProviderSubtitle =>
|
||||
'Use the default metadata search';
|
||||
|
||||
@override
|
||||
String get extensionAuthor => 'Author';
|
||||
@@ -1523,7 +1548,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
'Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
@@ -2093,7 +2118,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||
'Get FLAC quality audio from installed download extensions';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -2423,7 +2448,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle =>
|
||||
'Convert to MP3, Opus, ALAC, or FLAC';
|
||||
'Convert to AAC/M4A, MP3, Opus, ALAC, or FLAC';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
@@ -2776,14 +2801,14 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||
'Artist folders use Album Artist when available';
|
||||
'Folder named after Album Artist tag';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
'Folder named after Track Artist tag';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersTitle => 'Lyrics Providers';
|
||||
String get lyricsProvidersTitle => 'Lyrics Provider Priority';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersDescription =>
|
||||
@@ -2791,7 +2816,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get lyricsProvidersInfoText =>
|
||||
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
|
||||
'Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.';
|
||||
|
||||
@override
|
||||
String lyricsProvidersEnabledSection(int count) {
|
||||
@@ -2833,6 +2858,10 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get lyricsProviderQqMusicDesc =>
|
||||
'QQ Music (good for Chinese songs, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLyricsPlusDesc =>
|
||||
'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderExtensionDesc => 'Extension provider';
|
||||
|
||||
@@ -2851,10 +2880,10 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get safMigrationSuccess => 'Download folder updated to SAF mode';
|
||||
|
||||
@override
|
||||
String get settingsDonate => 'Donate';
|
||||
String get settingsDonate => 'Support Development';
|
||||
|
||||
@override
|
||||
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
|
||||
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
||||
|
||||
@override
|
||||
String get tooltipLoveAll => 'Love All';
|
||||
@@ -2914,20 +2943,20 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadLocationSubtitle =>
|
||||
'Choose storage mode for downloaded files.';
|
||||
'Choose where to save your downloaded tracks';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolder => 'App folder (non-SAF)';
|
||||
String get storageModeAppFolder => 'App Folder (Recommended)';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
|
||||
String get storageModeAppFolderSubtitle =>
|
||||
'Saves to Music/SpotiFLAC by default';
|
||||
|
||||
@override
|
||||
String get storageModeSaf => 'SAF folder';
|
||||
String get storageModeSaf => 'Custom Folder (SAF)';
|
||||
|
||||
@override
|
||||
String get storageModeSafSubtitle =>
|
||||
'Pick folder via Android Storage Access Framework';
|
||||
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
|
||||
|
||||
@override
|
||||
String downloadFilenameDescription(
|
||||
@@ -2939,62 +2968,62 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
Object track,
|
||||
Object year,
|
||||
) {
|
||||
return 'Customize how your files are named.';
|
||||
return 'Use $artist, $title, $album, $track, $year, $date, $disc as placeholders.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadFilenameInsertTag => 'Tap to insert tag:';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
|
||||
String get downloadSeparateSinglesEnabled =>
|
||||
'Singles and EPs saved in a separate folder';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesDisabled => 'All files in same structure';
|
||||
String get downloadSeparateSinglesDisabled =>
|
||||
'Singles and albums saved in the same folder';
|
||||
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
String get downloadCreatePlaylistSourceFolder => 'Playlist Source Folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
'A subfolder is created for each playlist';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
'All tracks saved directly to download folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
'Handled by folder organization setting';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
|
||||
String get downloadNetworkCompatibilityMode => 'Network Compatibility Mode';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeEnabled =>
|
||||
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
|
||||
'Using legacy TLS settings for older networks';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeDisabled =>
|
||||
'Off: strict HTTPS certificate validation (recommended)';
|
||||
'Using standard network settings';
|
||||
|
||||
@override
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
'Select a provider with quality options to enable this option';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
'Select a provider with quality options to choose audio quality';
|
||||
|
||||
@override
|
||||
String get downloadEmbedLyricsDisabled =>
|
||||
'Disabled while Embed Metadata is turned off';
|
||||
String get downloadEmbedLyricsDisabled => 'Enable metadata embedding first';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslation =>
|
||||
@@ -3002,11 +3031,11 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationEnabled =>
|
||||
'Append translated lyrics when available';
|
||||
'Chinese translation lines included';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationDisabled =>
|
||||
'Use original lyrics only';
|
||||
'Original lyrics only';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanization =>
|
||||
@@ -3014,21 +3043,21 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationEnabled =>
|
||||
'Append romanized lyrics when available';
|
||||
'Romanization lines included';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'No romanization';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
|
||||
String get downloadAppleQqMultiPerson => 'Apple / QQ: Multi-Person Lyrics';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonEnabled =>
|
||||
'Enable v1/v2 speaker and [bg:] tags';
|
||||
'Speaker labels included for duets and group tracks';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Simplified word-by-word formatting';
|
||||
'Standard lyrics without speaker labels';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
|
||||
@@ -3045,46 +3074,45 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
|
||||
String get downloadMusixmatchLanguageAuto => 'Auto (original language)';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributing =>
|
||||
'Filter contributing artists in Album Artist';
|
||||
String get downloadFilterContributing => 'Filter Contributing Artists';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingEnabled =>
|
||||
'Album Artist metadata uses primary artist only';
|
||||
'Contributing artists removed from Album Artist folder name';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingDisabled =>
|
||||
'Keep full Album Artist metadata value';
|
||||
'Full Album Artist string used';
|
||||
|
||||
@override
|
||||
String get downloadProvidersNoneEnabled => 'None enabled';
|
||||
String get downloadProvidersNoneEnabled => 'No providers enabled';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageCode => 'Language code';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
|
||||
String get downloadMusixmatchLanguageHint => 'e.g. en, de, ja';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageDesc =>
|
||||
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
|
||||
'Enter a BCP-47 language code (e.g. en, de, ja) to request translated lyrics from Musixmatch.';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
String get downloadNetworkAnySubtitle => 'Use WiFi or mobile data';
|
||||
|
||||
@override
|
||||
String get downloadNetworkWifiOnlySubtitle =>
|
||||
'Pause downloads on mobile data';
|
||||
'Downloads pause when on mobile data';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegionDesc =>
|
||||
'Used as userCountry for SongLink API lookup.';
|
||||
'Region used when resolving track links via SongLink. Choose the country where your streaming services are available.';
|
||||
|
||||
@override
|
||||
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
|
||||
@@ -3470,7 +3498,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String notifTracksDownloadedSuccess(int count) {
|
||||
return '$count tracks downloaded successfully';
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks downloaded successfully',
|
||||
one: '1 track downloaded successfully',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
+879
-106
File diff suppressed because it is too large
Load Diff
+255
-224
File diff suppressed because it is too large
Load Diff
+246
-206
File diff suppressed because it is too large
Load Diff
@@ -129,7 +129,7 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get optionsPrimaryProviderSubtitle =>
|
||||
'Розширення будуть випробувані першими';
|
||||
'Service used for searching by track or album name';
|
||||
|
||||
@override
|
||||
String optionsUsingExtension(String extensionName) {
|
||||
@@ -137,15 +137,15 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTab => 'Default Search Tab';
|
||||
String get optionsDefaultSearchTab => 'Вкладка пошуку за замовчуванням';
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTabSubtitle =>
|
||||
'Choose which tab opens first for new search results.';
|
||||
'Виберіть, яка вкладка відкриється першою для нових результатів пошуку.';
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Натисніть Deezer або Spotify, щоб повернутися до розширення';
|
||||
'Choose the default search provider to switch back from an extension';
|
||||
|
||||
@override
|
||||
String get optionsAutoFallback => 'Автоматичний резервний варіант';
|
||||
@@ -160,18 +160,18 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOn =>
|
||||
'Розширення будуть випробувані першими';
|
||||
'Extension providers are enabled';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOff =>
|
||||
'Використати лише вбудованих постачальників';
|
||||
'Extension providers are required';
|
||||
|
||||
@override
|
||||
String get optionsEmbedLyrics => 'Вбудований текст пісні';
|
||||
|
||||
@override
|
||||
String get optionsEmbedLyricsSubtitle =>
|
||||
'Вбудовувати синхронізовані тексти пісень у файли FLAC';
|
||||
'Save synced lyrics alongside your downloaded tracks';
|
||||
|
||||
@override
|
||||
String get optionsMaxQualityCover => 'Максимальна якість обкладинки';
|
||||
@@ -191,6 +191,43 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Вимкнено: немає тегів нормалізації гучності';
|
||||
|
||||
@override
|
||||
String get trackReplayGain => 'Rescan ReplayGain';
|
||||
|
||||
@override
|
||||
String get trackReplayGainSubtitle =>
|
||||
'Analyze loudness and write ReplayGain tags';
|
||||
|
||||
@override
|
||||
String get trackReplayGainScanning => 'Analyzing loudness...';
|
||||
|
||||
@override
|
||||
String get trackReplayGainSuccess => 'ReplayGain tags added';
|
||||
|
||||
@override
|
||||
String get trackReplayGainFailed => 'Failed to add ReplayGain tags';
|
||||
|
||||
@override
|
||||
String selectionReplayGainCount(int count) {
|
||||
return 'ReplayGain ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get replayGainBatchConfirmTitle => 'Add ReplayGain';
|
||||
|
||||
@override
|
||||
String replayGainBatchConfirmMessage(int count) {
|
||||
return 'Analyze loudness and write ReplayGain tags to $count track(s)?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...';
|
||||
|
||||
@override
|
||||
String replayGainBatchSuccess(int success, int total) {
|
||||
return 'ReplayGain added to $success of $total tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Режим тегу виконавця';
|
||||
|
||||
@@ -212,21 +249,6 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||
'Для FLAC та Opus на кожного виконавця додати окремий тег виконавця; MP3 та M4A залишаються об’єднаними.';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentDownloads => 'Кількість одночасних завантажень';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentSequential => 'Послідовно (по одному за раз)';
|
||||
|
||||
@override
|
||||
String optionsConcurrentParallel(int count) {
|
||||
return '$count паралельних завантажень';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsConcurrentWarning =>
|
||||
'Паралельні завантаження можуть призвести до обмеження швидкості';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStore => 'Репозиторій розширень';
|
||||
|
||||
@@ -395,11 +417,11 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutBinimumDesc =>
|
||||
'Творець QQDL та HiFi API. Без цього API завантажень Tidal\'а не існувало б!';
|
||||
'The creator of QQDL & HiFi API. This project helped shape lossless download support.';
|
||||
|
||||
@override
|
||||
String get aboutSachinsenalDesc =>
|
||||
'Оригінальний творець HiFi-проектів. Основа інтеграції Tidal!';
|
||||
'The original HiFi project creator. A foundation for lossless-source integration.';
|
||||
|
||||
@override
|
||||
String get aboutSjdonadoDesc =>
|
||||
@@ -959,14 +981,14 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Виберіть, які встановлені розширення завантаження можна використовувати під час автоматичного відновлення до попереднього режиму. Вбудовані постачальники все одно дотримуються порядку пріоритетності, зазначеного вище.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
'Тут перелічені лише ввімкнені розширення з можливістю завантаження через постачальника послуг.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => 'Вбудований';
|
||||
String get providerBuiltIn => 'Legacy';
|
||||
|
||||
@override
|
||||
String get providerExtension => 'Розширення';
|
||||
@@ -1137,11 +1159,10 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
String get settingsAppearanceSubtitle => 'Тема, кольори, дисплей';
|
||||
|
||||
@override
|
||||
String get settingsDownloadSubtitle => 'Сервіс, якість, формат назви файлу';
|
||||
String get settingsDownloadSubtitle => 'Service, quality, fallback';
|
||||
|
||||
@override
|
||||
String get settingsOptionsSubtitle =>
|
||||
'Резервний варіант, тексти пісень, обкладинка, оновлення';
|
||||
String get settingsOptionsSubtitle => 'Fallback, metadata, lyrics, cover art';
|
||||
|
||||
@override
|
||||
String get settingsExtensionsSubtitle =>
|
||||
@@ -1368,10 +1389,11 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
String get storeEmptyNoResults => 'Розширень не знайдено';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'За замовчуванням (Deezer)';
|
||||
String get extensionDefaultProvider => 'Default Search';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProviderSubtitle => 'Використати вбудований пошук';
|
||||
String get extensionDefaultProviderSubtitle =>
|
||||
'Use the default metadata search';
|
||||
|
||||
@override
|
||||
String get extensionAuthor => 'Автор';
|
||||
@@ -1547,7 +1569,7 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Виберіть вихідний формат для завантажень Tidal 320 кбіт/с із втратами. Оригінальний потік AAC буде конвертовано у вибраний вами формат.';
|
||||
'Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320 кбіт/с';
|
||||
@@ -2129,7 +2151,7 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Отримуйте аудіо у якості FLAC з Tidal, Qobuz або Deezer';
|
||||
'Get FLAC quality audio from installed download extensions';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -2464,7 +2486,7 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle =>
|
||||
'Конвертувати в MP3, Opus, ALAC або FLAC';
|
||||
'Convert to AAC/M4A, MP3, Opus, ALAC, or FLAC';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Конвертувати аудіо';
|
||||
@@ -2821,14 +2843,14 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||
'Папки виконавців використовують \"Виконавець альбому\", коли це можливо';
|
||||
'Folder named after Album Artist tag';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Папки виконавців використовують лише виконавця доріжки';
|
||||
'Folder named after Track Artist tag';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersTitle => 'Постачальники текстів пісень';
|
||||
String get lyricsProvidersTitle => 'Lyrics Provider Priority';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersDescription =>
|
||||
@@ -2836,7 +2858,7 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get lyricsProvidersInfoText =>
|
||||
'Постачальники розширених текстів пісень завжди запускаються перед вбудованими постачальниками. Принаймні один постачальник має залишатися ввімкненим.';
|
||||
'Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.';
|
||||
|
||||
@override
|
||||
String lyricsProvidersEnabledSection(int count) {
|
||||
@@ -2880,6 +2902,10 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
String get lyricsProviderQqMusicDesc =>
|
||||
'QQ Music (добре для китайських пісень, через проксі)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLyricsPlusDesc =>
|
||||
'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderExtensionDesc => 'Постачальник розширень';
|
||||
|
||||
@@ -2898,11 +2924,10 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
String get safMigrationSuccess => 'Папку завантажень оновлено до режиму SAF';
|
||||
|
||||
@override
|
||||
String get settingsDonate => 'Пожертвувати кошти';
|
||||
String get settingsDonate => 'Support Development';
|
||||
|
||||
@override
|
||||
String get settingsDonateSubtitle =>
|
||||
'Підтримка розробки SpotiFLAC для мобільних пристроїв';
|
||||
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
||||
|
||||
@override
|
||||
String get tooltipLoveAll => 'Уподобати всіх';
|
||||
@@ -2965,21 +2990,20 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadLocationSubtitle =>
|
||||
'Виберіть режим зберігання для завантажених файлів.';
|
||||
'Choose where to save your downloaded tracks';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolder => 'Папка додатку (не SAF)';
|
||||
String get storageModeAppFolder => 'App Folder (Recommended)';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolderSubtitle =>
|
||||
'Використовувати шлях Music/SpotiFLAC за замовчуванням';
|
||||
'Saves to Music/SpotiFLAC by default';
|
||||
|
||||
@override
|
||||
String get storageModeSaf => 'Папка SAF';
|
||||
String get storageModeSaf => 'Custom Folder (SAF)';
|
||||
|
||||
@override
|
||||
String get storageModeSafSubtitle =>
|
||||
'Вибрати папку через Android Storage Access Framework';
|
||||
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
|
||||
|
||||
@override
|
||||
String downloadFilenameDescription(
|
||||
@@ -2991,73 +3015,73 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
Object track,
|
||||
Object year,
|
||||
) {
|
||||
return 'Налаштувати спосіб іменування ваших файлів.';
|
||||
return 'Use $artist, $title, $album, $track, $year, $date, $disc as placeholders.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadFilenameInsertTag => 'Натисніть, щоб вставити тег:';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesEnabled => 'Папки «Альбоми» та «Сингли»';
|
||||
String get downloadSeparateSinglesEnabled =>
|
||||
'Singles and EPs saved in a separate folder';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesDisabled => 'Всі файли в одній структурі';
|
||||
String get downloadSeparateSinglesDisabled =>
|
||||
'Singles and albums saved in the same folder';
|
||||
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Фільтри імені виконавця';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Створити папку джерела списку відтворення';
|
||||
String get downloadCreatePlaylistSourceFolder => 'Playlist Source Folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Завантаження списків відтворення використовує Playlist/ плюс вашу звичайну структуру папок.';
|
||||
'A subfolder is created for each playlist';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Завантаження списків відтворення використовують лише звичайну структуру папок.';
|
||||
'All tracks saved directly to download folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'За допомогою списку відтворення завантаження вже розміщуються в папці зі списком відтворення.';
|
||||
'Handled by folder organization setting';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'Регіон SongLink';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityMode => 'Режим сумісності з мережею';
|
||||
String get downloadNetworkCompatibilityMode => 'Network Compatibility Mode';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeEnabled =>
|
||||
'Увімкнено: спробувати HTTP + прийняти недійсні сертифікати TLS (небезпечно)';
|
||||
'Using legacy TLS settings for older networks';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeDisabled =>
|
||||
'Вимкнено: сувора перевірка сертифіката HTTPS (рекомендовано)';
|
||||
'Using standard network settings';
|
||||
|
||||
@override
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Виберіть вбудовану службу, яку потрібно ввімкнути';
|
||||
'Select a provider with quality options to enable this option';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Виберіть Tidal або Qobuz вище, щоб налаштувати якість';
|
||||
'Select a provider with quality options to choose audio quality';
|
||||
|
||||
@override
|
||||
String get downloadEmbedLyricsDisabled =>
|
||||
'Вимкнено, якщо вимкнено функцію «Вбудувати метадані»';
|
||||
String get downloadEmbedLyricsDisabled => 'Enable metadata embedding first';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslation => 'Netease: Включити переклад';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationEnabled =>
|
||||
'Додати перекладені тексти пісень, коли вони доступні';
|
||||
'Chinese translation lines included';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationDisabled =>
|
||||
'Використовувати лише оригінальні тексти пісень';
|
||||
'Original lyrics only';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanization =>
|
||||
@@ -3065,22 +3089,21 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationEnabled =>
|
||||
'Додати романізовані тексти пісень, коли це можливо';
|
||||
'Romanization lines included';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'Вимкнути';
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'No romanization';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPerson =>
|
||||
'Apple/QQ Багатокористувацький переклад слово за словом';
|
||||
String get downloadAppleQqMultiPerson => 'Apple / QQ: Multi-Person Lyrics';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonEnabled =>
|
||||
'Увімкнути теги динаміка v1/v2 та [bg:]';
|
||||
'Speaker labels included for duets and group tracks';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Спрощене послівне форматування';
|
||||
'Standard lyrics without speaker labels';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
|
||||
@@ -3097,46 +3120,45 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
String get downloadMusixmatchLanguage => 'Мова Musixmatch';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageAuto => 'Авто (оригінал)';
|
||||
String get downloadMusixmatchLanguageAuto => 'Auto (original language)';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributing =>
|
||||
'Фільтрувати виконавців-учасників у розділі «Виконавець альбому»';
|
||||
String get downloadFilterContributing => 'Filter Contributing Artists';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingEnabled =>
|
||||
'Метадані виконавця альбому використовують лише основного виконавця';
|
||||
'Contributing artists removed from Album Artist folder name';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingDisabled =>
|
||||
'Зберегти повне значення метаданих виконавця альбому';
|
||||
'Full Album Artist string used';
|
||||
|
||||
@override
|
||||
String get downloadProvidersNoneEnabled => 'Не ввімкнено';
|
||||
String get downloadProvidersNoneEnabled => 'No providers enabled';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageCode => 'Код мови';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageHint => 'авто / en / es / ja';
|
||||
String get downloadMusixmatchLanguageHint => 'e.g. en, de, ja';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageDesc =>
|
||||
'Встановити потрібний код мови (наприклад: en, es, ja). Залиште поле порожнім для автоматичного вибору.';
|
||||
'Enter a BCP-47 language code (e.g. en, de, ja) to request translated lyrics from Musixmatch.';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Авто';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'Wi-Fi + мобільний інтернет';
|
||||
String get downloadNetworkAnySubtitle => 'Use WiFi or mobile data';
|
||||
|
||||
@override
|
||||
String get downloadNetworkWifiOnlySubtitle =>
|
||||
'Призупинити завантаження через мобільний інтернет';
|
||||
'Downloads pause when on mobile data';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegionDesc =>
|
||||
'Використовувати як userCountry для пошуку SongLink API.';
|
||||
'Region used when resolving track links via SongLink. Choose the country where your streaming services are available.';
|
||||
|
||||
@override
|
||||
String get snackbarUnsupportedAudioFormat => 'Непідтримуваний аудіоформат';
|
||||
@@ -3529,7 +3551,13 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String notifTracksDownloadedSuccess(int count) {
|
||||
return '$count треки успішно завантажено';
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks downloaded successfully',
|
||||
one: '1 track downloaded successfully',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -3609,7 +3637,7 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String notifDownloadingUpdate(String version) {
|
||||
return 'Завантаження SpotiFLAC Mobile v$version';
|
||||
return 'Downloading SpotiFLAC Mobile v$version';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -3622,7 +3650,7 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String notifUpdateReadyBody(String version) {
|
||||
return 'SpotiFLAC Mobile v$version завантажений. Натисніть щоб установити.';
|
||||
return 'SpotiFLAC Mobile v$version downloaded. Tap to install.';
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
+1715
-191
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1908
-285
File diff suppressed because it is too large
Load Diff
+94
-53
@@ -174,9 +174,9 @@
|
||||
"@optionsDefaultSearchTabSubtitle": {
|
||||
"description": "Subtitle for the preferred default search tab setting"
|
||||
},
|
||||
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
|
||||
"optionsSwitchBack": "Choose the default search provider to switch back from an extension",
|
||||
"@optionsSwitchBack": {
|
||||
"description": "Hint to switch back to built-in providers"
|
||||
"description": "Hint to switch back from extension search"
|
||||
},
|
||||
"optionsAutoFallback": "Auto Fallback",
|
||||
"@optionsAutoFallback": {
|
||||
@@ -188,15 +188,15 @@
|
||||
},
|
||||
"optionsUseExtensionProviders": "Use Extension Providers",
|
||||
"@optionsUseExtensionProviders": {
|
||||
"description": "Enable extension download providers"
|
||||
"description": "Legacy setting label for extension download providers"
|
||||
},
|
||||
"optionsUseExtensionProvidersOn": "Extensions will be tried first",
|
||||
"optionsUseExtensionProvidersOn": "Extension providers are enabled",
|
||||
"@optionsUseExtensionProvidersOn": {
|
||||
"description": "Status when extension providers enabled"
|
||||
},
|
||||
"optionsUseExtensionProvidersOff": "Using built-in providers only",
|
||||
"optionsUseExtensionProvidersOff": "Extension providers are required",
|
||||
"@optionsUseExtensionProvidersOff": {
|
||||
"description": "Status when extension providers disabled"
|
||||
"description": "Legacy status when extension providers would be disabled"
|
||||
},
|
||||
"optionsEmbedLyrics": "Embed Lyrics",
|
||||
"@optionsEmbedLyrics": {
|
||||
@@ -226,6 +226,64 @@
|
||||
"@optionsReplayGainSubtitleOff": {
|
||||
"description": "Subtitle when ReplayGain is disabled"
|
||||
},
|
||||
"trackReplayGain": "Rescan ReplayGain",
|
||||
"@trackReplayGain": {
|
||||
"description": "Three-dot menu option to scan loudness and write ReplayGain tags"
|
||||
},
|
||||
"trackReplayGainSubtitle": "Analyze loudness and write ReplayGain tags",
|
||||
"@trackReplayGainSubtitle": {
|
||||
"description": "Subtitle for the rescan ReplayGain menu option"
|
||||
},
|
||||
"trackReplayGainScanning": "Analyzing loudness...",
|
||||
"@trackReplayGainScanning": {
|
||||
"description": "Snackbar/progress message while scanning ReplayGain for a single track"
|
||||
},
|
||||
"trackReplayGainSuccess": "ReplayGain tags added",
|
||||
"@trackReplayGainSuccess": {
|
||||
"description": "Snackbar message after ReplayGain tags written for a single track"
|
||||
},
|
||||
"trackReplayGainFailed": "Failed to add ReplayGain tags",
|
||||
"@trackReplayGainFailed": {
|
||||
"description": "Snackbar message when ReplayGain scan/write fails"
|
||||
},
|
||||
"selectionReplayGainCount": "ReplayGain ({count})",
|
||||
"@selectionReplayGainCount": {
|
||||
"description": "Batch selection action button label for ReplayGain",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"replayGainBatchConfirmTitle": "Add ReplayGain",
|
||||
"@replayGainBatchConfirmTitle": {
|
||||
"description": "Title of the batch ReplayGain confirmation dialog"
|
||||
},
|
||||
"replayGainBatchConfirmMessage": "Analyze loudness and write ReplayGain tags to {count} track(s)?",
|
||||
"@replayGainBatchConfirmMessage": {
|
||||
"description": "Message of the batch ReplayGain confirmation dialog",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"replayGainBatchAnalyzing": "Analyzing ReplayGain...",
|
||||
"@replayGainBatchAnalyzing": {
|
||||
"description": "Progress dialog title while batch scanning ReplayGain"
|
||||
},
|
||||
"replayGainBatchSuccess": "ReplayGain added to {success} of {total} tracks",
|
||||
"@replayGainBatchSuccess": {
|
||||
"description": "Snackbar after batch ReplayGain completes",
|
||||
"placeholders": {
|
||||
"success": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"optionsArtistTagMode": "Artist Tag Mode",
|
||||
"@optionsArtistTagMode": {
|
||||
"description": "Setting title for how artist metadata is written into files"
|
||||
@@ -250,27 +308,6 @@
|
||||
"@optionsArtistTagModeSplitVorbisSubtitle": {
|
||||
"description": "Subtitle for split Vorbis artist tag mode"
|
||||
},
|
||||
"optionsConcurrentDownloads": "Concurrent Downloads",
|
||||
"@optionsConcurrentDownloads": {
|
||||
"description": "Number of parallel downloads"
|
||||
},
|
||||
"optionsConcurrentSequential": "Sequential (1 at a time)",
|
||||
"@optionsConcurrentSequential": {
|
||||
"description": "Download one at a time"
|
||||
},
|
||||
"optionsConcurrentParallel": "{count} parallel downloads",
|
||||
"@optionsConcurrentParallel": {
|
||||
"description": "Multiple parallel downloads",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting",
|
||||
"@optionsConcurrentWarning": {
|
||||
"description": "Warning about rate limits"
|
||||
},
|
||||
"optionsExtensionStore": "Extension Repo",
|
||||
"@optionsExtensionStore": {
|
||||
"description": "Show/hide store tab"
|
||||
@@ -486,11 +523,11 @@
|
||||
"@aboutVersion": {
|
||||
"description": "Version info label"
|
||||
},
|
||||
"aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!",
|
||||
"aboutBinimumDesc": "The creator of QQDL & HiFi API. This project helped shape lossless download support.",
|
||||
"@aboutBinimumDesc": {
|
||||
"description": "Credit description for binimum"
|
||||
},
|
||||
"aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!",
|
||||
"aboutSachinsenalDesc": "The original HiFi project creator. A foundation for lossless-source integration.",
|
||||
"@aboutSachinsenalDesc": {
|
||||
"description": "Credit description for sachinsenal0x64"
|
||||
},
|
||||
@@ -1235,9 +1272,9 @@
|
||||
"@providerPriorityFallbackExtensionsHint": {
|
||||
"description": "Hint below the extension fallback selection list"
|
||||
},
|
||||
"providerBuiltIn": "Built-in",
|
||||
"providerBuiltIn": "Legacy",
|
||||
"@providerBuiltIn": {
|
||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||
"description": "Legacy label retained for old generated localization compatibility"
|
||||
},
|
||||
"providerExtension": "Extension",
|
||||
"@providerExtension": {
|
||||
@@ -1759,11 +1796,11 @@
|
||||
"@storeEmptyNoResults": {
|
||||
"description": "Message when search/filter returns no results"
|
||||
},
|
||||
"extensionDefaultProvider": "Default (Deezer)",
|
||||
"extensionDefaultProvider": "Default Search",
|
||||
"@extensionDefaultProvider": {
|
||||
"description": "Default search provider option"
|
||||
},
|
||||
"extensionDefaultProviderSubtitle": "Use built-in search",
|
||||
"extensionDefaultProviderSubtitle": "Use the default metadata search",
|
||||
"@extensionDefaultProviderSubtitle": {
|
||||
"description": "Subtitle for default provider"
|
||||
},
|
||||
@@ -1992,51 +2029,51 @@
|
||||
},
|
||||
"downloadLossy320": "Lossy 320kbps",
|
||||
"@downloadLossy320": {
|
||||
"description": "Quality option label for Tidal lossy 320kbps"
|
||||
"description": "Quality option label for lossy 320kbps"
|
||||
},
|
||||
"downloadLossyFormat": "Lossy Format",
|
||||
"@downloadLossyFormat": {
|
||||
"description": "Setting title to pick output format for Tidal lossy downloads"
|
||||
"description": "Setting title to pick output format for lossy downloads"
|
||||
},
|
||||
"downloadLossy320Format": "Lossy 320kbps Format",
|
||||
"@downloadLossy320Format": {
|
||||
"description": "Title of the Tidal lossy format picker bottom sheet"
|
||||
"description": "Title of the 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": "Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.",
|
||||
"@downloadLossy320FormatDesc": {
|
||||
"description": "Description in the Tidal lossy format picker"
|
||||
"description": "Description in the lossy format picker"
|
||||
},
|
||||
"downloadLossyMp3": "MP3 320kbps",
|
||||
"@downloadLossyMp3": {
|
||||
"description": "Tidal lossy format option - MP3 320kbps"
|
||||
"description": "Lossy format option - MP3 320kbps"
|
||||
},
|
||||
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
|
||||
"@downloadLossyMp3Subtitle": {
|
||||
"description": "Subtitle for MP3 320kbps Tidal lossy option"
|
||||
"description": "Subtitle for MP3 320kbps lossy option"
|
||||
},
|
||||
"downloadLossyAac": "AAC/M4A 320kbps",
|
||||
"@downloadLossyAac": {
|
||||
"description": "Tidal lossy format option - AAC in M4A container at 320kbps"
|
||||
"description": "Lossy format option - AAC in M4A container at 320kbps"
|
||||
},
|
||||
"downloadLossyAacSubtitle": "Best mobile compatibility, M4A container",
|
||||
"@downloadLossyAacSubtitle": {
|
||||
"description": "Subtitle for AAC/M4A 320kbps Tidal lossy option"
|
||||
"description": "Subtitle for AAC/M4A 320kbps lossy option"
|
||||
},
|
||||
"downloadLossyOpus256": "Opus 256kbps",
|
||||
"@downloadLossyOpus256": {
|
||||
"description": "Tidal lossy format option - Opus 256kbps"
|
||||
"description": "Lossy format option - Opus 256kbps"
|
||||
},
|
||||
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
|
||||
"@downloadLossyOpus256Subtitle": {
|
||||
"description": "Subtitle for Opus 256kbps Tidal lossy option"
|
||||
"description": "Subtitle for Opus 256kbps lossy option"
|
||||
},
|
||||
"downloadLossyOpus128": "Opus 128kbps",
|
||||
"@downloadLossyOpus128": {
|
||||
"description": "Tidal lossy format option - Opus 128kbps"
|
||||
"description": "Lossy format option - Opus 128kbps"
|
||||
},
|
||||
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
|
||||
"@downloadLossyOpus128Subtitle": {
|
||||
"description": "Subtitle for Opus 128kbps Tidal lossy option"
|
||||
"description": "Subtitle for Opus 128kbps lossy option"
|
||||
},
|
||||
"qualityNote": "Actual quality depends on track availability from the service",
|
||||
"@qualityNote": {
|
||||
@@ -2519,7 +2556,7 @@
|
||||
"@libraryAbout": {
|
||||
"description": "Section header for about info"
|
||||
},
|
||||
"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.",
|
||||
"libraryAboutDescription": "Scans your existing music collection to detect duplicates when downloading. Supports FLAC, ALAC, M4A, MP3, Opus, OGG, WAV, AIFF, and APE formats. Metadata is read from file tags when available.",
|
||||
"@libraryAboutDescription": {
|
||||
"description": "Description of local library feature"
|
||||
},
|
||||
@@ -2745,7 +2782,7 @@
|
||||
"@tutorialWelcomeTip1": {
|
||||
"description": "Tutorial welcome tip 1"
|
||||
},
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from installed download extensions",
|
||||
"@tutorialWelcomeTip2": {
|
||||
"description": "Tutorial welcome tip 2"
|
||||
},
|
||||
@@ -3704,7 +3741,7 @@
|
||||
"@lyricsProvidersDescription": {
|
||||
"description": "Description on the lyrics provider priority page"
|
||||
},
|
||||
"lyricsProvidersInfoText": "Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.",
|
||||
"lyricsProvidersInfoText": "Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.",
|
||||
"@lyricsProvidersInfoText": {
|
||||
"description": "Info tip on lyrics provider priority page"
|
||||
},
|
||||
@@ -3758,6 +3795,10 @@
|
||||
"@lyricsProviderQqMusicDesc": {
|
||||
"description": "Description for QQ Music provider"
|
||||
},
|
||||
"lyricsProviderLyricsPlusDesc": "Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)",
|
||||
"@lyricsProviderLyricsPlusDesc": {
|
||||
"description": "Description for LyricsPlus provider"
|
||||
},
|
||||
"lyricsProviderExtensionDesc": "Extension provider",
|
||||
"@lyricsProviderExtensionDesc": {
|
||||
"description": "Generic description for extension-based lyrics providers"
|
||||
@@ -3942,13 +3983,13 @@
|
||||
"@downloadNetworkCompatibilityModeDisabled": {
|
||||
"description": "Subtitle when network compatibility mode is off"
|
||||
},
|
||||
"downloadSelectServiceToEnable": "Select Tidal or Qobuz to enable this option",
|
||||
"downloadSelectServiceToEnable": "Select a provider with quality options to enable this option",
|
||||
"@downloadSelectServiceToEnable": {
|
||||
"description": "Subtitle when quality picker is disabled due to extension service"
|
||||
},
|
||||
"downloadSelectTidalQobuz": "Select Tidal or Qobuz to choose audio quality",
|
||||
"downloadSelectTidalQobuz": "Select a provider with quality options to choose audio quality",
|
||||
"@downloadSelectTidalQobuz": {
|
||||
"description": "Info shown when a non-built-in service is selected"
|
||||
"description": "Legacy info shown when a provider does not expose quality options"
|
||||
},
|
||||
"downloadEmbedLyricsDisabled": "Enable metadata embedding first",
|
||||
"@downloadEmbedLyricsDisabled": {
|
||||
@@ -4358,7 +4399,7 @@
|
||||
},
|
||||
"extensionsSearchWith": "Search with {providerName}",
|
||||
"@extensionsSearchWith": {
|
||||
"description": "Extensions page - subtitle for built-in search provider option",
|
||||
"description": "Extensions page - subtitle for default metadata search provider option",
|
||||
"placeholders": {
|
||||
"providerName": {
|
||||
"type": "String"
|
||||
|
||||
+29
-50
@@ -142,9 +142,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
|
||||
"optionsSwitchBack": "Choose the default search provider to switch back from an extension",
|
||||
"@optionsSwitchBack": {
|
||||
"description": "Hint to switch back to built-in providers"
|
||||
"description": "Hint to switch back from extension search"
|
||||
},
|
||||
"optionsAutoFallback": "Auto Fallback",
|
||||
"@optionsAutoFallback": {
|
||||
@@ -156,15 +156,15 @@
|
||||
},
|
||||
"optionsUseExtensionProviders": "Use Extension Providers",
|
||||
"@optionsUseExtensionProviders": {
|
||||
"description": "Enable extension download providers"
|
||||
"description": "Legacy setting label for extension download providers"
|
||||
},
|
||||
"optionsUseExtensionProvidersOn": "Extensions will be tried first",
|
||||
"optionsUseExtensionProvidersOn": "Extension providers are enabled",
|
||||
"@optionsUseExtensionProvidersOn": {
|
||||
"description": "Status when extension providers enabled"
|
||||
},
|
||||
"optionsUseExtensionProvidersOff": "Using built-in providers only",
|
||||
"optionsUseExtensionProvidersOff": "Extension providers are required",
|
||||
"@optionsUseExtensionProvidersOff": {
|
||||
"description": "Status when extension providers disabled"
|
||||
"description": "Legacy status when extension providers would be disabled"
|
||||
},
|
||||
"optionsEmbedLyrics": "Embed Lyrics",
|
||||
"@optionsEmbedLyrics": {
|
||||
@@ -182,27 +182,6 @@
|
||||
"@optionsMaxQualityCoverSubtitle": {
|
||||
"description": "Subtitle for max quality cover"
|
||||
},
|
||||
"optionsConcurrentDownloads": "Concurrent Downloads",
|
||||
"@optionsConcurrentDownloads": {
|
||||
"description": "Number of parallel downloads"
|
||||
},
|
||||
"optionsConcurrentSequential": "Sequential (1 at a time)",
|
||||
"@optionsConcurrentSequential": {
|
||||
"description": "Download one at a time"
|
||||
},
|
||||
"optionsConcurrentParallel": "{count} parallel downloads",
|
||||
"@optionsConcurrentParallel": {
|
||||
"description": "Multiple parallel downloads",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting",
|
||||
"@optionsConcurrentWarning": {
|
||||
"description": "Warning about rate limits"
|
||||
},
|
||||
"optionsExtensionStore": "Extension Store",
|
||||
"@optionsExtensionStore": {
|
||||
"description": "Show/hide store tab"
|
||||
@@ -390,11 +369,11 @@
|
||||
"@aboutVersion": {
|
||||
"description": "Version info label"
|
||||
},
|
||||
"aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!",
|
||||
"aboutBinimumDesc": "The creator of QQDL & HiFi API. This project helped shape lossless download support.",
|
||||
"@aboutBinimumDesc": {
|
||||
"description": "Credit description for binimum"
|
||||
},
|
||||
"aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!",
|
||||
"aboutSachinsenalDesc": "The original HiFi project creator. A foundation for lossless-source integration.",
|
||||
"@aboutSachinsenalDesc": {
|
||||
"description": "Credit description for sachinsenal0x64"
|
||||
},
|
||||
@@ -999,9 +978,9 @@
|
||||
"@providerPriorityInfo": {
|
||||
"description": "Info tip about fallback behavior"
|
||||
},
|
||||
"providerBuiltIn": "Built-in",
|
||||
"providerBuiltIn": "Legacy",
|
||||
"@providerBuiltIn": {
|
||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||
"description": "Legacy label retained for old generated localization compatibility"
|
||||
},
|
||||
"providerExtension": "Extension",
|
||||
"@providerExtension": {
|
||||
@@ -1394,11 +1373,11 @@
|
||||
"@storeClearFilters": {
|
||||
"description": "Button to clear all filters"
|
||||
},
|
||||
"extensionDefaultProvider": "Default (Deezer/Spotify)",
|
||||
"extensionDefaultProvider": "Default Search",
|
||||
"@extensionDefaultProvider": {
|
||||
"description": "Default search provider option"
|
||||
},
|
||||
"extensionDefaultProviderSubtitle": "Use built-in search",
|
||||
"extensionDefaultProviderSubtitle": "Use the default metadata search",
|
||||
"@extensionDefaultProviderSubtitle": {
|
||||
"description": "Subtitle for default provider"
|
||||
},
|
||||
@@ -2071,43 +2050,43 @@
|
||||
},
|
||||
"downloadLossy320": "Lossy 320kbps",
|
||||
"@downloadLossy320": {
|
||||
"description": "Quality option label for Tidal lossy 320kbps"
|
||||
"description": "Quality option label for lossy 320kbps"
|
||||
},
|
||||
"downloadLossyFormat": "Lossy Format",
|
||||
"@downloadLossyFormat": {
|
||||
"description": "Setting title to pick output format for Tidal lossy downloads"
|
||||
"description": "Setting title to pick output format for lossy downloads"
|
||||
},
|
||||
"downloadLossy320Format": "Lossy 320kbps Format",
|
||||
"@downloadLossy320Format": {
|
||||
"description": "Title of the Tidal lossy format picker bottom sheet"
|
||||
"description": "Title of the 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": "Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.",
|
||||
"@downloadLossy320FormatDesc": {
|
||||
"description": "Description in the Tidal lossy format picker"
|
||||
"description": "Description in the lossy format picker"
|
||||
},
|
||||
"downloadLossyMp3": "MP3 320kbps",
|
||||
"@downloadLossyMp3": {
|
||||
"description": "Tidal lossy format option - MP3 320kbps"
|
||||
"description": "lossy format option - MP3 320kbps"
|
||||
},
|
||||
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
|
||||
"@downloadLossyMp3Subtitle": {
|
||||
"description": "Subtitle for MP3 320kbps Tidal lossy option"
|
||||
"description": "Subtitle for MP3 320kbps lossy option"
|
||||
},
|
||||
"downloadLossyOpus256": "Opus 256kbps",
|
||||
"@downloadLossyOpus256": {
|
||||
"description": "Tidal lossy format option - Opus 256kbps"
|
||||
"description": "lossy format option - Opus 256kbps"
|
||||
},
|
||||
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
|
||||
"@downloadLossyOpus256Subtitle": {
|
||||
"description": "Subtitle for Opus 256kbps Tidal lossy option"
|
||||
"description": "Subtitle for Opus 256kbps lossy option"
|
||||
},
|
||||
"downloadLossyOpus128": "Opus 128kbps",
|
||||
"@downloadLossyOpus128": {
|
||||
"description": "Tidal lossy format option - Opus 128kbps"
|
||||
"description": "lossy format option - Opus 128kbps"
|
||||
},
|
||||
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
|
||||
"@downloadLossyOpus128Subtitle": {
|
||||
"description": "Subtitle for Opus 128kbps Tidal lossy option"
|
||||
"description": "Subtitle for Opus 128kbps lossy option"
|
||||
},
|
||||
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
||||
"@downloadUseAlbumArtistForFolders": {
|
||||
@@ -2697,7 +2676,7 @@
|
||||
"@tutorialWelcomeTip1": {
|
||||
"description": "Tutorial welcome tip 1"
|
||||
},
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from installed download extensions",
|
||||
"@tutorialWelcomeTip2": {
|
||||
"description": "Tutorial welcome tip 2"
|
||||
},
|
||||
@@ -3600,7 +3579,7 @@
|
||||
"@lyricsProvidersDescription": {
|
||||
"description": "Description on the lyrics provider priority page"
|
||||
},
|
||||
"lyricsProvidersInfoText": "Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.",
|
||||
"lyricsProvidersInfoText": "Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.",
|
||||
"@lyricsProvidersInfoText": {
|
||||
"description": "Info tip on lyrics provider priority page"
|
||||
},
|
||||
@@ -3838,13 +3817,13 @@
|
||||
"@downloadNetworkCompatibilityModeDisabled": {
|
||||
"description": "Subtitle when network compatibility mode is off"
|
||||
},
|
||||
"downloadSelectServiceToEnable": "Select Tidal or Qobuz to enable this option",
|
||||
"downloadSelectServiceToEnable": "Select a provider with quality options to enable this option",
|
||||
"@downloadSelectServiceToEnable": {
|
||||
"description": "Subtitle when quality picker is disabled due to extension service"
|
||||
},
|
||||
"downloadSelectTidalQobuz": "Select Tidal or Qobuz to choose audio quality",
|
||||
"downloadSelectTidalQobuz": "Select a provider with quality options to choose audio quality",
|
||||
"@downloadSelectTidalQobuz": {
|
||||
"description": "Info shown when a non-built-in service is selected"
|
||||
"description": "Legacy info shown when a provider does not expose quality options"
|
||||
},
|
||||
"downloadEmbedLyricsDisabled": "Enable metadata embedding first",
|
||||
"@downloadEmbedLyricsDisabled": {
|
||||
@@ -4206,7 +4185,7 @@
|
||||
},
|
||||
"extensionsSearchWith": "Search with {providerName}",
|
||||
"@extensionsSearchWith": {
|
||||
"description": "Extensions page - subtitle for built-in search provider option",
|
||||
"description": "Extensions page - subtitle for default metadata search provider option",
|
||||
"placeholders": {
|
||||
"providerName": {
|
||||
"type": "String"
|
||||
|
||||
+2176
-553
File diff suppressed because it is too large
Load Diff
+2580
-957
File diff suppressed because it is too large
Load Diff
+1770
-147
File diff suppressed because it is too large
Load Diff
+2020
-498
File diff suppressed because it is too large
Load Diff
+2153
-530
File diff suppressed because it is too large
Load Diff
+2562
-939
File diff suppressed because it is too large
Load Diff
+1771
-148
File diff suppressed because it is too large
Load Diff
+29
-50
@@ -142,9 +142,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
|
||||
"optionsSwitchBack": "Choose the default search provider to switch back from an extension",
|
||||
"@optionsSwitchBack": {
|
||||
"description": "Hint to switch back to built-in providers"
|
||||
"description": "Hint to switch back from extension search"
|
||||
},
|
||||
"optionsAutoFallback": "Auto Fallback",
|
||||
"@optionsAutoFallback": {
|
||||
@@ -156,15 +156,15 @@
|
||||
},
|
||||
"optionsUseExtensionProviders": "Use Extension Providers",
|
||||
"@optionsUseExtensionProviders": {
|
||||
"description": "Enable extension download providers"
|
||||
"description": "Legacy setting label for extension download providers"
|
||||
},
|
||||
"optionsUseExtensionProvidersOn": "Extensions will be tried first",
|
||||
"optionsUseExtensionProvidersOn": "Extension providers are enabled",
|
||||
"@optionsUseExtensionProvidersOn": {
|
||||
"description": "Status when extension providers enabled"
|
||||
},
|
||||
"optionsUseExtensionProvidersOff": "Using built-in providers only",
|
||||
"optionsUseExtensionProvidersOff": "Extension providers are required",
|
||||
"@optionsUseExtensionProvidersOff": {
|
||||
"description": "Status when extension providers disabled"
|
||||
"description": "Legacy status when extension providers would be disabled"
|
||||
},
|
||||
"optionsEmbedLyrics": "Embed Lyrics",
|
||||
"@optionsEmbedLyrics": {
|
||||
@@ -182,27 +182,6 @@
|
||||
"@optionsMaxQualityCoverSubtitle": {
|
||||
"description": "Subtitle for max quality cover"
|
||||
},
|
||||
"optionsConcurrentDownloads": "Concurrent Downloads",
|
||||
"@optionsConcurrentDownloads": {
|
||||
"description": "Number of parallel downloads"
|
||||
},
|
||||
"optionsConcurrentSequential": "Sequential (1 at a time)",
|
||||
"@optionsConcurrentSequential": {
|
||||
"description": "Download one at a time"
|
||||
},
|
||||
"optionsConcurrentParallel": "{count} parallel downloads",
|
||||
"@optionsConcurrentParallel": {
|
||||
"description": "Multiple parallel downloads",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting",
|
||||
"@optionsConcurrentWarning": {
|
||||
"description": "Warning about rate limits"
|
||||
},
|
||||
"optionsExtensionStore": "Extension Store",
|
||||
"@optionsExtensionStore": {
|
||||
"description": "Show/hide store tab"
|
||||
@@ -390,11 +369,11 @@
|
||||
"@aboutVersion": {
|
||||
"description": "Version info label"
|
||||
},
|
||||
"aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!",
|
||||
"aboutBinimumDesc": "The creator of QQDL & HiFi API. This project helped shape lossless download support.",
|
||||
"@aboutBinimumDesc": {
|
||||
"description": "Credit description for binimum"
|
||||
},
|
||||
"aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!",
|
||||
"aboutSachinsenalDesc": "The original HiFi project creator. A foundation for lossless-source integration.",
|
||||
"@aboutSachinsenalDesc": {
|
||||
"description": "Credit description for sachinsenal0x64"
|
||||
},
|
||||
@@ -999,9 +978,9 @@
|
||||
"@providerPriorityInfo": {
|
||||
"description": "Info tip about fallback behavior"
|
||||
},
|
||||
"providerBuiltIn": "Built-in",
|
||||
"providerBuiltIn": "Legacy",
|
||||
"@providerBuiltIn": {
|
||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||
"description": "Legacy label retained for old generated localization compatibility"
|
||||
},
|
||||
"providerExtension": "Extension",
|
||||
"@providerExtension": {
|
||||
@@ -1394,11 +1373,11 @@
|
||||
"@storeClearFilters": {
|
||||
"description": "Button to clear all filters"
|
||||
},
|
||||
"extensionDefaultProvider": "Default (Deezer/Spotify)",
|
||||
"extensionDefaultProvider": "Default Search",
|
||||
"@extensionDefaultProvider": {
|
||||
"description": "Default search provider option"
|
||||
},
|
||||
"extensionDefaultProviderSubtitle": "Use built-in search",
|
||||
"extensionDefaultProviderSubtitle": "Use the default metadata search",
|
||||
"@extensionDefaultProviderSubtitle": {
|
||||
"description": "Subtitle for default provider"
|
||||
},
|
||||
@@ -2071,43 +2050,43 @@
|
||||
},
|
||||
"downloadLossy320": "Lossy 320kbps",
|
||||
"@downloadLossy320": {
|
||||
"description": "Quality option label for Tidal lossy 320kbps"
|
||||
"description": "Quality option label for lossy 320kbps"
|
||||
},
|
||||
"downloadLossyFormat": "Lossy Format",
|
||||
"@downloadLossyFormat": {
|
||||
"description": "Setting title to pick output format for Tidal lossy downloads"
|
||||
"description": "Setting title to pick output format for lossy downloads"
|
||||
},
|
||||
"downloadLossy320Format": "Lossy 320kbps Format",
|
||||
"@downloadLossy320Format": {
|
||||
"description": "Title of the Tidal lossy format picker bottom sheet"
|
||||
"description": "Title of the 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": "Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.",
|
||||
"@downloadLossy320FormatDesc": {
|
||||
"description": "Description in the Tidal lossy format picker"
|
||||
"description": "Description in the lossy format picker"
|
||||
},
|
||||
"downloadLossyMp3": "MP3 320kbps",
|
||||
"@downloadLossyMp3": {
|
||||
"description": "Tidal lossy format option - MP3 320kbps"
|
||||
"description": "lossy format option - MP3 320kbps"
|
||||
},
|
||||
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
|
||||
"@downloadLossyMp3Subtitle": {
|
||||
"description": "Subtitle for MP3 320kbps Tidal lossy option"
|
||||
"description": "Subtitle for MP3 320kbps lossy option"
|
||||
},
|
||||
"downloadLossyOpus256": "Opus 256kbps",
|
||||
"@downloadLossyOpus256": {
|
||||
"description": "Tidal lossy format option - Opus 256kbps"
|
||||
"description": "lossy format option - Opus 256kbps"
|
||||
},
|
||||
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
|
||||
"@downloadLossyOpus256Subtitle": {
|
||||
"description": "Subtitle for Opus 256kbps Tidal lossy option"
|
||||
"description": "Subtitle for Opus 256kbps lossy option"
|
||||
},
|
||||
"downloadLossyOpus128": "Opus 128kbps",
|
||||
"@downloadLossyOpus128": {
|
||||
"description": "Tidal lossy format option - Opus 128kbps"
|
||||
"description": "lossy format option - Opus 128kbps"
|
||||
},
|
||||
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
|
||||
"@downloadLossyOpus128Subtitle": {
|
||||
"description": "Subtitle for Opus 128kbps Tidal lossy option"
|
||||
"description": "Subtitle for Opus 128kbps lossy option"
|
||||
},
|
||||
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
||||
"@downloadUseAlbumArtistForFolders": {
|
||||
@@ -2697,7 +2676,7 @@
|
||||
"@tutorialWelcomeTip1": {
|
||||
"description": "Tutorial welcome tip 1"
|
||||
},
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from installed download extensions",
|
||||
"@tutorialWelcomeTip2": {
|
||||
"description": "Tutorial welcome tip 2"
|
||||
},
|
||||
@@ -3600,7 +3579,7 @@
|
||||
"@lyricsProvidersDescription": {
|
||||
"description": "Description on the lyrics provider priority page"
|
||||
},
|
||||
"lyricsProvidersInfoText": "Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.",
|
||||
"lyricsProvidersInfoText": "Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.",
|
||||
"@lyricsProvidersInfoText": {
|
||||
"description": "Info tip on lyrics provider priority page"
|
||||
},
|
||||
@@ -3838,13 +3817,13 @@
|
||||
"@downloadNetworkCompatibilityModeDisabled": {
|
||||
"description": "Subtitle when network compatibility mode is off"
|
||||
},
|
||||
"downloadSelectServiceToEnable": "Select Tidal or Qobuz to enable this option",
|
||||
"downloadSelectServiceToEnable": "Select a provider with quality options to enable this option",
|
||||
"@downloadSelectServiceToEnable": {
|
||||
"description": "Subtitle when quality picker is disabled due to extension service"
|
||||
},
|
||||
"downloadSelectTidalQobuz": "Select Tidal or Qobuz to choose audio quality",
|
||||
"downloadSelectTidalQobuz": "Select a provider with quality options to choose audio quality",
|
||||
"@downloadSelectTidalQobuz": {
|
||||
"description": "Info shown when a non-built-in service is selected"
|
||||
"description": "Legacy info shown when a provider does not expose quality options"
|
||||
},
|
||||
"downloadEmbedLyricsDisabled": "Enable metadata embedding first",
|
||||
"@downloadEmbedLyricsDisabled": {
|
||||
@@ -4206,7 +4185,7 @@
|
||||
},
|
||||
"extensionsSearchWith": "Search with {providerName}",
|
||||
"@extensionsSearchWith": {
|
||||
"description": "Extensions page - subtitle for built-in search provider option",
|
||||
"description": "Extensions page - subtitle for default metadata search provider option",
|
||||
"placeholders": {
|
||||
"providerName": {
|
||||
"type": "String"
|
||||
|
||||
+1780
-157
File diff suppressed because it is too large
Load Diff
+1925
-302
File diff suppressed because it is too large
Load Diff
+2040
-421
File diff suppressed because it is too large
Load Diff
+1775
-152
File diff suppressed because it is too large
Load Diff
+29
-50
@@ -142,9 +142,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
|
||||
"optionsSwitchBack": "Choose the default search provider to switch back from an extension",
|
||||
"@optionsSwitchBack": {
|
||||
"description": "Hint to switch back to built-in providers"
|
||||
"description": "Hint to switch back from extension search"
|
||||
},
|
||||
"optionsAutoFallback": "Auto Fallback",
|
||||
"@optionsAutoFallback": {
|
||||
@@ -156,15 +156,15 @@
|
||||
},
|
||||
"optionsUseExtensionProviders": "Use Extension Providers",
|
||||
"@optionsUseExtensionProviders": {
|
||||
"description": "Enable extension download providers"
|
||||
"description": "Legacy setting label for extension download providers"
|
||||
},
|
||||
"optionsUseExtensionProvidersOn": "Extensions will be tried first",
|
||||
"optionsUseExtensionProvidersOn": "Extension providers are enabled",
|
||||
"@optionsUseExtensionProvidersOn": {
|
||||
"description": "Status when extension providers enabled"
|
||||
},
|
||||
"optionsUseExtensionProvidersOff": "Using built-in providers only",
|
||||
"optionsUseExtensionProvidersOff": "Extension providers are required",
|
||||
"@optionsUseExtensionProvidersOff": {
|
||||
"description": "Status when extension providers disabled"
|
||||
"description": "Legacy status when extension providers would be disabled"
|
||||
},
|
||||
"optionsEmbedLyrics": "Embed Lyrics",
|
||||
"@optionsEmbedLyrics": {
|
||||
@@ -182,27 +182,6 @@
|
||||
"@optionsMaxQualityCoverSubtitle": {
|
||||
"description": "Subtitle for max quality cover"
|
||||
},
|
||||
"optionsConcurrentDownloads": "Concurrent Downloads",
|
||||
"@optionsConcurrentDownloads": {
|
||||
"description": "Number of parallel downloads"
|
||||
},
|
||||
"optionsConcurrentSequential": "Sequential (1 at a time)",
|
||||
"@optionsConcurrentSequential": {
|
||||
"description": "Download one at a time"
|
||||
},
|
||||
"optionsConcurrentParallel": "{count} parallel downloads",
|
||||
"@optionsConcurrentParallel": {
|
||||
"description": "Multiple parallel downloads",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting",
|
||||
"@optionsConcurrentWarning": {
|
||||
"description": "Warning about rate limits"
|
||||
},
|
||||
"optionsExtensionStore": "Extension Store",
|
||||
"@optionsExtensionStore": {
|
||||
"description": "Show/hide store tab"
|
||||
@@ -390,11 +369,11 @@
|
||||
"@aboutVersion": {
|
||||
"description": "Version info label"
|
||||
},
|
||||
"aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!",
|
||||
"aboutBinimumDesc": "The creator of QQDL & HiFi API. This project helped shape lossless download support.",
|
||||
"@aboutBinimumDesc": {
|
||||
"description": "Credit description for binimum"
|
||||
},
|
||||
"aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!",
|
||||
"aboutSachinsenalDesc": "The original HiFi project creator. A foundation for lossless-source integration.",
|
||||
"@aboutSachinsenalDesc": {
|
||||
"description": "Credit description for sachinsenal0x64"
|
||||
},
|
||||
@@ -999,9 +978,9 @@
|
||||
"@providerPriorityInfo": {
|
||||
"description": "Info tip about fallback behavior"
|
||||
},
|
||||
"providerBuiltIn": "Built-in",
|
||||
"providerBuiltIn": "Legacy",
|
||||
"@providerBuiltIn": {
|
||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||
"description": "Legacy label retained for old generated localization compatibility"
|
||||
},
|
||||
"providerExtension": "Extension",
|
||||
"@providerExtension": {
|
||||
@@ -1394,11 +1373,11 @@
|
||||
"@storeClearFilters": {
|
||||
"description": "Button to clear all filters"
|
||||
},
|
||||
"extensionDefaultProvider": "Default (Deezer/Spotify)",
|
||||
"extensionDefaultProvider": "Default Search",
|
||||
"@extensionDefaultProvider": {
|
||||
"description": "Default search provider option"
|
||||
},
|
||||
"extensionDefaultProviderSubtitle": "Use built-in search",
|
||||
"extensionDefaultProviderSubtitle": "Use the default metadata search",
|
||||
"@extensionDefaultProviderSubtitle": {
|
||||
"description": "Subtitle for default provider"
|
||||
},
|
||||
@@ -2071,43 +2050,43 @@
|
||||
},
|
||||
"downloadLossy320": "Lossy 320kbps",
|
||||
"@downloadLossy320": {
|
||||
"description": "Quality option label for Tidal lossy 320kbps"
|
||||
"description": "Quality option label for lossy 320kbps"
|
||||
},
|
||||
"downloadLossyFormat": "Lossy Format",
|
||||
"@downloadLossyFormat": {
|
||||
"description": "Setting title to pick output format for Tidal lossy downloads"
|
||||
"description": "Setting title to pick output format for lossy downloads"
|
||||
},
|
||||
"downloadLossy320Format": "Lossy 320kbps Format",
|
||||
"@downloadLossy320Format": {
|
||||
"description": "Title of the Tidal lossy format picker bottom sheet"
|
||||
"description": "Title of the 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": "Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.",
|
||||
"@downloadLossy320FormatDesc": {
|
||||
"description": "Description in the Tidal lossy format picker"
|
||||
"description": "Description in the lossy format picker"
|
||||
},
|
||||
"downloadLossyMp3": "MP3 320kbps",
|
||||
"@downloadLossyMp3": {
|
||||
"description": "Tidal lossy format option - MP3 320kbps"
|
||||
"description": "lossy format option - MP3 320kbps"
|
||||
},
|
||||
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
|
||||
"@downloadLossyMp3Subtitle": {
|
||||
"description": "Subtitle for MP3 320kbps Tidal lossy option"
|
||||
"description": "Subtitle for MP3 320kbps lossy option"
|
||||
},
|
||||
"downloadLossyOpus256": "Opus 256kbps",
|
||||
"@downloadLossyOpus256": {
|
||||
"description": "Tidal lossy format option - Opus 256kbps"
|
||||
"description": "lossy format option - Opus 256kbps"
|
||||
},
|
||||
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
|
||||
"@downloadLossyOpus256Subtitle": {
|
||||
"description": "Subtitle for Opus 256kbps Tidal lossy option"
|
||||
"description": "Subtitle for Opus 256kbps lossy option"
|
||||
},
|
||||
"downloadLossyOpus128": "Opus 128kbps",
|
||||
"@downloadLossyOpus128": {
|
||||
"description": "Tidal lossy format option - Opus 128kbps"
|
||||
"description": "lossy format option - Opus 128kbps"
|
||||
},
|
||||
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
|
||||
"@downloadLossyOpus128Subtitle": {
|
||||
"description": "Subtitle for Opus 128kbps Tidal lossy option"
|
||||
"description": "Subtitle for Opus 128kbps lossy option"
|
||||
},
|
||||
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
||||
"@downloadUseAlbumArtistForFolders": {
|
||||
@@ -2697,7 +2676,7 @@
|
||||
"@tutorialWelcomeTip1": {
|
||||
"description": "Tutorial welcome tip 1"
|
||||
},
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from installed download extensions",
|
||||
"@tutorialWelcomeTip2": {
|
||||
"description": "Tutorial welcome tip 2"
|
||||
},
|
||||
@@ -3600,7 +3579,7 @@
|
||||
"@lyricsProvidersDescription": {
|
||||
"description": "Description on the lyrics provider priority page"
|
||||
},
|
||||
"lyricsProvidersInfoText": "Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.",
|
||||
"lyricsProvidersInfoText": "Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.",
|
||||
"@lyricsProvidersInfoText": {
|
||||
"description": "Info tip on lyrics provider priority page"
|
||||
},
|
||||
@@ -3838,13 +3817,13 @@
|
||||
"@downloadNetworkCompatibilityModeDisabled": {
|
||||
"description": "Subtitle when network compatibility mode is off"
|
||||
},
|
||||
"downloadSelectServiceToEnable": "Select Tidal or Qobuz to enable this option",
|
||||
"downloadSelectServiceToEnable": "Select a provider with quality options to enable this option",
|
||||
"@downloadSelectServiceToEnable": {
|
||||
"description": "Subtitle when quality picker is disabled due to extension service"
|
||||
},
|
||||
"downloadSelectTidalQobuz": "Select Tidal or Qobuz to choose audio quality",
|
||||
"downloadSelectTidalQobuz": "Select a provider with quality options to choose audio quality",
|
||||
"@downloadSelectTidalQobuz": {
|
||||
"description": "Info shown when a non-built-in service is selected"
|
||||
"description": "Legacy info shown when a provider does not expose quality options"
|
||||
},
|
||||
"downloadEmbedLyricsDisabled": "Enable metadata embedding first",
|
||||
"@downloadEmbedLyricsDisabled": {
|
||||
@@ -4206,7 +4185,7 @@
|
||||
},
|
||||
"extensionsSearchWith": "Search with {providerName}",
|
||||
"@extensionsSearchWith": {
|
||||
"description": "Extensions page - subtitle for built-in search provider option",
|
||||
"description": "Extensions page - subtitle for default metadata search provider option",
|
||||
"placeholders": {
|
||||
"providerName": {
|
||||
"type": "String"
|
||||
|
||||
+1882
-259
File diff suppressed because it is too large
Load Diff
+1772
-149
File diff suppressed because it is too large
Load Diff
@@ -14,23 +14,27 @@ const int translationThreshold = 70;
|
||||
/// Only these languages will be available in the app.
|
||||
const List<Locale> filteredSupportedLocales = <Locale>[
|
||||
Locale('en'),
|
||||
Locale('ru'),
|
||||
Locale('fr'),
|
||||
Locale('de'),
|
||||
Locale('es', 'ES'),
|
||||
Locale('id'),
|
||||
Locale('pt', 'PT'),
|
||||
Locale('ja'),
|
||||
Locale('tr'),
|
||||
Locale('uk'),
|
||||
Locale('ru'),
|
||||
Locale('tr'),
|
||||
Locale('id'),
|
||||
Locale('ja'),
|
||||
Locale('pt', 'PT'),
|
||||
];
|
||||
|
||||
/// Set of locale codes for quick lookup.
|
||||
const Set<String> filteredLocaleCodes = <String>{
|
||||
'en',
|
||||
'ru',
|
||||
'fr',
|
||||
'de',
|
||||
'es_ES',
|
||||
'id',
|
||||
'pt_PT',
|
||||
'ja',
|
||||
'tr',
|
||||
'uk',
|
||||
'ru',
|
||||
'tr',
|
||||
'id',
|
||||
'ja',
|
||||
'pt_PT',
|
||||
};
|
||||
|
||||
+34
-11
@@ -15,20 +15,43 @@ import 'package:spotiflac_android/services/notification_service.dart';
|
||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/utils/local_library_scan_prefs.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
final runtimeProfile = await _resolveRuntimeProfile();
|
||||
_configureImageCache(runtimeProfile);
|
||||
final _log = AppLogger('Main');
|
||||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
child: _EagerInitialization(
|
||||
child: SpotiFLACApp(
|
||||
disableOverscrollEffects: runtimeProfile.disableOverscrollEffects,
|
||||
void main() {
|
||||
// Catch uncaught Dart errors so a failing async path is logged, not fatal.
|
||||
// Native (Go) crashes still can't be caught here.
|
||||
runZonedGuarded(
|
||||
() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final previousOnError = FlutterError.onError;
|
||||
FlutterError.onError = (details) {
|
||||
previousOnError?.call(details);
|
||||
_log.e('Uncaught Flutter error: ${details.exceptionAsString()}');
|
||||
};
|
||||
WidgetsBinding.instance.platformDispatcher.onError = (error, stack) {
|
||||
_log.e('Uncaught platform error: $error');
|
||||
return true;
|
||||
};
|
||||
|
||||
final runtimeProfile = await _resolveRuntimeProfile();
|
||||
_configureImageCache(runtimeProfile);
|
||||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
child: _EagerInitialization(
|
||||
child: SpotiFLACApp(
|
||||
disableOverscrollEffects: runtimeProfile.disableOverscrollEffects,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
(error, stack) {
|
||||
_log.e('Uncaught zone error: $error');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ class AppSettings {
|
||||
final bool embedReplayGain; // Calculate and embed ReplayGain tags
|
||||
final bool maxQualityCover;
|
||||
final bool isFirstLaunch;
|
||||
final int concurrentDownloads;
|
||||
final bool checkForUpdates;
|
||||
final String updateChannel;
|
||||
final bool hasSearchedBefore;
|
||||
@@ -47,7 +46,7 @@ class AppSettings {
|
||||
final String locale;
|
||||
final String lyricsMode;
|
||||
final String
|
||||
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'aac_320', 'opus_256', or 'opus_128'
|
||||
tidalHighFormat; // Legacy key for 320kbps lossy output format: 'mp3_320', 'aac_320', 'opus_256', or 'opus_128'
|
||||
final bool
|
||||
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
||||
final bool
|
||||
@@ -108,7 +107,6 @@ class AppSettings {
|
||||
this.embedReplayGain = false,
|
||||
this.maxQualityCover = true,
|
||||
this.isFirstLaunch = true,
|
||||
this.concurrentDownloads = 1,
|
||||
this.checkForUpdates = true,
|
||||
this.updateChannel = 'stable',
|
||||
this.hasSearchedBefore = false,
|
||||
@@ -171,7 +169,6 @@ class AppSettings {
|
||||
bool? embedReplayGain,
|
||||
bool? maxQualityCover,
|
||||
bool? isFirstLaunch,
|
||||
int? concurrentDownloads,
|
||||
bool? checkForUpdates,
|
||||
String? updateChannel,
|
||||
bool? hasSearchedBefore,
|
||||
@@ -237,7 +234,6 @@ class AppSettings {
|
||||
embedReplayGain: embedReplayGain ?? this.embedReplayGain,
|
||||
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
|
||||
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
||||
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
|
||||
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
|
||||
updateChannel: updateChannel ?? this.updateChannel,
|
||||
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
||||
|
||||
@@ -21,7 +21,6 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
embedReplayGain: json['embedReplayGain'] as bool? ?? false,
|
||||
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
|
||||
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
||||
concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1,
|
||||
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
|
||||
updateChannel: json['updateChannel'] as String? ?? 'stable',
|
||||
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
||||
@@ -102,7 +101,6 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'embedReplayGain': instance.embedReplayGain,
|
||||
'maxQualityCover': instance.maxQualityCover,
|
||||
'isFirstLaunch': instance.isFirstLaunch,
|
||||
'concurrentDownloads': instance.concurrentDownloads,
|
||||
'checkForUpdates': instance.checkForUpdates,
|
||||
'updateChannel': instance.updateChannel,
|
||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||
|
||||
@@ -1767,7 +1767,6 @@ class DownloadQueueState {
|
||||
final String singleFilenameFormat;
|
||||
final String audioQuality;
|
||||
final bool autoFallback;
|
||||
final int concurrentDownloads;
|
||||
|
||||
const DownloadQueueState({
|
||||
this.items = const [],
|
||||
@@ -1780,7 +1779,6 @@ class DownloadQueueState {
|
||||
this.singleFilenameFormat = '{title} - {artist}',
|
||||
this.audioQuality = 'LOSSLESS',
|
||||
this.autoFallback = true,
|
||||
this.concurrentDownloads = 1,
|
||||
});
|
||||
|
||||
DownloadQueueState copyWith({
|
||||
@@ -1794,7 +1792,6 @@ class DownloadQueueState {
|
||||
String? singleFilenameFormat,
|
||||
String? audioQuality,
|
||||
bool? autoFallback,
|
||||
int? concurrentDownloads,
|
||||
}) {
|
||||
final resolvedItems = items ?? this.items;
|
||||
return DownloadQueueState(
|
||||
@@ -1814,7 +1811,6 @@ class DownloadQueueState {
|
||||
singleFilenameFormat: singleFilenameFormat ?? this.singleFilenameFormat,
|
||||
audioQuality: audioQuality ?? this.audioQuality,
|
||||
autoFallback: autoFallback ?? this.autoFallback,
|
||||
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1962,14 +1958,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
@override
|
||||
DownloadQueueState build() {
|
||||
ref.listen<AppSettings>(settingsProvider, (previous, next) {
|
||||
final previousConcurrent =
|
||||
previous?.concurrentDownloads ?? state.concurrentDownloads;
|
||||
updateSettings(next);
|
||||
if (previousConcurrent != next.concurrentDownloads) {
|
||||
_log.i(
|
||||
'Concurrent downloads updated: $previousConcurrent -> ${next.concurrentDownloads}',
|
||||
);
|
||||
}
|
||||
if (previous?.downloadNetworkMode != next.downloadNetworkMode) {
|
||||
_handleDownloadNetworkModeChanged(next.downloadNetworkMode);
|
||||
}
|
||||
@@ -3095,7 +3084,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (extensionPreferred != null) {
|
||||
return extensionPreferred;
|
||||
}
|
||||
if (_usesBuiltInCompatibleDownloadProvider(service, 'tidal') &&
|
||||
if (_downloadProviderReplacesLegacyProvider(service, 'tidal') &&
|
||||
quality == 'HIGH') {
|
||||
return '.m4a';
|
||||
}
|
||||
@@ -3106,13 +3095,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return '.flac';
|
||||
}
|
||||
|
||||
bool _usesBuiltInCompatibleDownloadProvider(
|
||||
bool _downloadProviderReplacesLegacyProvider(
|
||||
String service,
|
||||
String builtInProviderId,
|
||||
String legacyProviderId,
|
||||
) {
|
||||
return ref
|
||||
.read(extensionProvider.notifier)
|
||||
.downloadProviderMatchesBuiltIn(service, builtInProviderId);
|
||||
.downloadProviderReplacesLegacyProvider(service, legacyProviderId);
|
||||
}
|
||||
|
||||
String _normalizeQueuedService(String service) {
|
||||
@@ -3601,7 +3590,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
void updateSettings(AppSettings settings) {
|
||||
final concurrentDownloads = settings.concurrentDownloads.clamp(1, 5);
|
||||
state = state.copyWith(
|
||||
outputDir: settings.downloadDirectory.isNotEmpty
|
||||
? settings.downloadDirectory
|
||||
@@ -3610,7 +3598,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
singleFilenameFormat: settings.singleFilenameFormat,
|
||||
audioQuality: settings.audioQuality,
|
||||
autoFallback: settings.autoFallback,
|
||||
concurrentDownloads: concurrentDownloads,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5607,13 +5594,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
String payloadTidalId = '';
|
||||
if (trackForPayload.id.startsWith('qobuz:')) {
|
||||
payloadQobuzId = trackForPayload.id.substring(6);
|
||||
if (_usesBuiltInCompatibleDownloadProvider(item.service, 'qobuz')) {
|
||||
if (_downloadProviderReplacesLegacyProvider(item.service, 'qobuz')) {
|
||||
payloadSpotifyId = '';
|
||||
}
|
||||
}
|
||||
if (trackForPayload.id.startsWith('tidal:')) {
|
||||
payloadTidalId = trackForPayload.id.substring(6);
|
||||
if (_usesBuiltInCompatibleDownloadProvider(item.service, 'tidal')) {
|
||||
if (_downloadProviderReplacesLegacyProvider(item.service, 'tidal')) {
|
||||
payloadSpotifyId = '';
|
||||
}
|
||||
}
|
||||
@@ -6689,6 +6676,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
// iOS: request a background execution window (no foreground service).
|
||||
if (Platform.isIOS && _totalQueuedAtStart > 0) {
|
||||
await PlatformBridge.beginBackgroundDownloadTask();
|
||||
}
|
||||
|
||||
if (!isSafMode && state.outputDir.isEmpty) {
|
||||
_log.d('Output dir empty, initializing...');
|
||||
await _initOutputDir();
|
||||
@@ -6781,9 +6773,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
_log.d('Concurrent downloads: ${state.concurrentDownloads}');
|
||||
try {
|
||||
await _processQueueParallel();
|
||||
await _processQueueSequential();
|
||||
} finally {
|
||||
if (iosDownloadBookmarkActive) {
|
||||
await PlatformBridge.stopAccessingIosBookmark();
|
||||
@@ -6808,6 +6799,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
if (Platform.isIOS) {
|
||||
await PlatformBridge.endBackgroundDownloadTask();
|
||||
}
|
||||
|
||||
if (_downloadCount > 0) {
|
||||
_log.d('Final connection cleanup...');
|
||||
try {
|
||||
@@ -6859,19 +6854,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _processQueueParallel() async {
|
||||
Future<void> _processQueueSequential() async {
|
||||
final activeDownloads = <String, Future<void>>{};
|
||||
var lastLoggedMaxConcurrent = -1;
|
||||
|
||||
_startMultiProgressPolling();
|
||||
|
||||
while (true) {
|
||||
if (state.isPaused) {
|
||||
if (activeDownloads.isEmpty) {
|
||||
_log.d('Queue is paused and no active downloads remain');
|
||||
_log.d('Queue is paused and no active download remains');
|
||||
break;
|
||||
}
|
||||
_log.d('Queue is paused, waiting for active downloads...');
|
||||
_log.d('Queue is paused, waiting for active download...');
|
||||
await Future.any([
|
||||
Future.wait(activeDownloads.values),
|
||||
Future<void>.delayed(_queueSchedulingInterval),
|
||||
@@ -6879,12 +6873,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
continue;
|
||||
}
|
||||
|
||||
final maxConcurrent = max(1, state.concurrentDownloads);
|
||||
if (lastLoggedMaxConcurrent != maxConcurrent) {
|
||||
_log.d('Parallel worker max concurrency now: $maxConcurrent');
|
||||
lastLoggedMaxConcurrent = maxConcurrent;
|
||||
}
|
||||
|
||||
final queuedItems = state.items
|
||||
.where(
|
||||
(item) =>
|
||||
@@ -6898,7 +6886,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
break;
|
||||
}
|
||||
|
||||
while (activeDownloads.length < maxConcurrent &&
|
||||
// One download at a time: only start the next item once the current
|
||||
// download has finished, to stay within the API's single-request limit.
|
||||
if (activeDownloads.isEmpty &&
|
||||
queuedItems.isNotEmpty &&
|
||||
!state.isPaused) {
|
||||
final item = queuedItems.removeAt(0);
|
||||
@@ -6911,9 +6901,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
});
|
||||
|
||||
activeDownloads[item.id] = future;
|
||||
_log.d(
|
||||
'Started parallel download: ${item.track.name} (${activeDownloads.length}/$maxConcurrent active)',
|
||||
);
|
||||
_log.d('Started download: ${item.track.name}');
|
||||
}
|
||||
|
||||
if (activeDownloads.isNotEmpty) {
|
||||
@@ -7323,13 +7311,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
String payloadTidalId = '';
|
||||
if (trackToDownload.id.startsWith('qobuz:')) {
|
||||
payloadQobuzId = trackToDownload.id.substring(6);
|
||||
if (_usesBuiltInCompatibleDownloadProvider(item.service, 'qobuz')) {
|
||||
if (_downloadProviderReplacesLegacyProvider(item.service, 'qobuz')) {
|
||||
payloadSpotifyId = '';
|
||||
}
|
||||
}
|
||||
if (trackToDownload.id.startsWith('tidal:')) {
|
||||
payloadTidalId = trackToDownload.id.substring(6);
|
||||
if (_usesBuiltInCompatibleDownloadProvider(item.service, 'tidal')) {
|
||||
if (_downloadProviderReplacesLegacyProvider(item.service, 'tidal')) {
|
||||
payloadSpotifyId = '';
|
||||
}
|
||||
}
|
||||
@@ -7664,28 +7652,28 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
(filePath.endsWith('.flac') ||
|
||||
resultOutputExt == '.flac' ||
|
||||
(mimeType != null && mimeType.contains('flac')));
|
||||
final shouldForceTidalSafM4aHandling =
|
||||
final shouldForceDashSafM4aHandling =
|
||||
!wasExisting &&
|
||||
isContentUriPath &&
|
||||
effectiveSafMode &&
|
||||
_usesBuiltInCompatibleDownloadProvider(actualService, 'tidal') &&
|
||||
_downloadProviderReplacesLegacyProvider(actualService, 'tidal') &&
|
||||
filePath.endsWith('.flac') &&
|
||||
(mimeType == null || mimeType.contains('flac'));
|
||||
|
||||
if (shouldForceTidalSafM4aHandling) {
|
||||
if (shouldForceDashSafM4aHandling) {
|
||||
_log.w(
|
||||
'Tidal SAF file is labeled FLAC but backend returned DASH/M4A stream; converting it back to FLAC.',
|
||||
'SAF file is labeled FLAC but backend returned DASH/M4A stream; converting it back to FLAC.',
|
||||
);
|
||||
}
|
||||
|
||||
if (isM4aFile || shouldForceTidalSafM4aHandling) {
|
||||
if (isM4aFile || shouldForceDashSafM4aHandling) {
|
||||
final currentFilePath = filePath;
|
||||
|
||||
if (isContentUriPath && effectiveSafMode) {
|
||||
if (quality == 'HIGH') {
|
||||
final tidalHighFormat = settings.tidalHighFormat;
|
||||
_log.i(
|
||||
'Tidal HIGH quality (SAF), converting M4A to $tidalHighFormat...',
|
||||
'Lossy 320kbps quality (SAF), converting M4A to $tidalHighFormat...',
|
||||
);
|
||||
|
||||
final tempPath = await _copySafToTemp(currentFilePath);
|
||||
@@ -8003,7 +7991,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (quality == 'HIGH') {
|
||||
final tidalHighFormat = settings.tidalHighFormat;
|
||||
_log.i(
|
||||
'Tidal HIGH quality download, converting M4A to $tidalHighFormat...',
|
||||
'Lossy 320kbps quality download, converting M4A to $tidalHighFormat...',
|
||||
);
|
||||
|
||||
try {
|
||||
|
||||
@@ -1283,20 +1283,20 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
bool downloadProviderMatchesBuiltIn(
|
||||
bool downloadProviderReplacesLegacyProvider(
|
||||
String providerId,
|
||||
String builtInProviderId,
|
||||
String legacyProviderId,
|
||||
) {
|
||||
final normalizedProvider = providerId.trim().toLowerCase();
|
||||
final normalizedBuiltIn = builtInProviderId.trim().toLowerCase();
|
||||
if (normalizedProvider.isEmpty || normalizedBuiltIn.isEmpty) return false;
|
||||
if (normalizedProvider == normalizedBuiltIn) return true;
|
||||
final normalizedLegacy = legacyProviderId.trim().toLowerCase();
|
||||
if (normalizedProvider.isEmpty || normalizedLegacy.isEmpty) return false;
|
||||
if (normalizedProvider == normalizedLegacy) return true;
|
||||
|
||||
final extension = state.extensions
|
||||
.where((ext) => ext.enabled && ext.hasDownloadProvider)
|
||||
.where((ext) => ext.id.toLowerCase() == normalizedProvider)
|
||||
.firstOrNull;
|
||||
return extension?.replacesBuiltInProviders.contains(normalizedBuiltIn) ??
|
||||
return extension?.replacesBuiltInProviders.contains(normalizedLegacy) ??
|
||||
false;
|
||||
}
|
||||
|
||||
|
||||
@@ -392,12 +392,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setConcurrentDownloads(int count) {
|
||||
final clamped = count.clamp(1, 5);
|
||||
state = state.copyWith(concurrentDownloads: clamped);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setCheckForUpdates(bool enabled) {
|
||||
state = state.copyWith(checkForUpdates: enabled);
|
||||
_saveSettings();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'dart:async';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
@@ -205,6 +206,21 @@ class StoreState {
|
||||
}
|
||||
|
||||
class StoreNotifier extends Notifier<StoreState> {
|
||||
/// Serializes install/upgrade so two never race the native VM teardown/reload.
|
||||
Future<void> _mutationChain = Future<void>.value();
|
||||
|
||||
Future<T> _runSerialized<T>(Future<T> Function() action) {
|
||||
final completer = Completer<T>();
|
||||
_mutationChain = _mutationChain.then((_) async {
|
||||
try {
|
||||
completer.complete(await action());
|
||||
} catch (e, st) {
|
||||
completer.completeError(e, st);
|
||||
}
|
||||
});
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
@override
|
||||
StoreState build() {
|
||||
return const StoreState();
|
||||
@@ -330,6 +346,16 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
String extensionId,
|
||||
String tempDir,
|
||||
String extensionsDir,
|
||||
) {
|
||||
return _runSerialized(
|
||||
() => _installExtensionInternal(extensionId, tempDir, extensionsDir),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _installExtensionInternal(
|
||||
String extensionId,
|
||||
String tempDir,
|
||||
String extensionsDir,
|
||||
) async {
|
||||
state = state.copyWith(
|
||||
isDownloading: true,
|
||||
@@ -366,7 +392,16 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> updateExtension(String extensionId, String tempDir) async {
|
||||
Future<bool> updateExtension(String extensionId, String tempDir) {
|
||||
return _runSerialized(
|
||||
() => _updateExtensionInternal(extensionId, tempDir),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _updateExtensionInternal(
|
||||
String extensionId,
|
||||
String tempDir,
|
||||
) async {
|
||||
state = state.copyWith(
|
||||
isDownloading: true,
|
||||
downloadingId: extensionId,
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/image_cache_utils.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
||||
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
@@ -336,6 +337,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final tracks = _tracks ?? [];
|
||||
final pageBackgroundColor = colorScheme.surface;
|
||||
final bottomInset = context.navBarBottomInset;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: pageBackgroundColor,
|
||||
@@ -361,7 +363,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
|
||||
_buildTrackList(context, colorScheme, tracks),
|
||||
],
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
SliverToBoxAdapter(child: SizedBox(height: 32 + bottomInset)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -1088,7 +1090,7 @@ class _AlbumTrackItem extends ConsumerWidget {
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
@@ -1097,7 +1099,7 @@ class _AlbumTrackItem extends ConsumerWidget {
|
||||
Icon(
|
||||
Icons.folder_outlined,
|
||||
size: 10,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
@@ -1105,7 +1107,7 @@ class _AlbumTrackItem extends ConsumerWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -15,6 +15,7 @@ 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/utils/nav_bar_inset.dart';
|
||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/home_tab.dart'
|
||||
show ExtensionAlbumScreen;
|
||||
@@ -456,6 +457,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
final albumsOnly = _albumsOnlyBucket;
|
||||
final singles = _singlesBucket;
|
||||
final compilations = _compilationsBucket;
|
||||
final bottomInset = context.navBarBottomInset;
|
||||
|
||||
final hasDiscography =
|
||||
!_isLoadingDiscography && _error == null && albums.isNotEmpty;
|
||||
@@ -542,6 +544,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: _isSelectionMode ? 120 : 32),
|
||||
),
|
||||
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
|
||||
],
|
||||
),
|
||||
if (_isSelectionMode)
|
||||
@@ -1586,7 +1589,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
@@ -1595,7 +1598,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
Icon(
|
||||
Icons.folder_outlined,
|
||||
size: 10,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
@@ -1603,7 +1606,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:share_plus/share_plus.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/replaygain_service.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/history_database.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
@@ -14,8 +15,10 @@ import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/image_cache_utils.dart';
|
||||
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/batch_convert_sheet.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
@@ -356,6 +359,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||
final bottomInset = context.navBarBottomInset;
|
||||
|
||||
final tracksValue = ref.watch(
|
||||
downloadedAlbumTracksProvider(
|
||||
@@ -411,6 +415,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: _isSelectionMode ? 120 : 32),
|
||||
),
|
||||
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -966,18 +971,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
|
||||
if (formats.isEmpty) return;
|
||||
|
||||
String selectedFormat = formats.first;
|
||||
bool isLosslessTarget =
|
||||
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
|
||||
String defaultBitrateForFormat(String format) {
|
||||
if (format == 'Opus') return '128k';
|
||||
if (format == 'AAC') return '256k';
|
||||
return '320k';
|
||||
}
|
||||
|
||||
String selectedBitrate = isLosslessTarget
|
||||
? '320k'
|
||||
: defaultBitrateForFormat(selectedFormat);
|
||||
final sheetTitle = context.l10n.selectionBatchConvertConfirmTitle;
|
||||
final sheetConfirmLabel = context.l10n.selectionConvertCount(
|
||||
_selectedIds.length,
|
||||
);
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@@ -985,145 +982,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (sheetContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setSheetState) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final bitrates = ['128k', '192k', '256k', '320k'];
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant.withValues(
|
||||
alpha: 0.4,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.selectionBatchConvertConfirmTitle,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
context.l10n.trackConvertTargetFormat,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: formats.map((format) {
|
||||
final isSelected = format == selectedFormat;
|
||||
return ChoiceChip(
|
||||
label: Text(format),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() {
|
||||
selectedFormat = format;
|
||||
isLosslessTarget =
|
||||
format == 'ALAC' || format == 'FLAC';
|
||||
if (!isLosslessTarget) {
|
||||
selectedBitrate = defaultBitrateForFormat(
|
||||
format,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
if (!isLosslessTarget) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.trackConvertBitrate,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: bitrates.map((br) {
|
||||
final isSelected = br == selectedBitrate;
|
||||
return ChoiceChip(
|
||||
label: Text(br),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() => selectedBitrate = br);
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
if (isLosslessTarget) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.verified,
|
||||
size: 16,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
context.l10n.trackConvertLosslessHint,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_performBatchConversion(
|
||||
allTracks: allTracks,
|
||||
targetFormat: selectedFormat,
|
||||
bitrate: selectedBitrate,
|
||||
);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.selectionConvertCount(
|
||||
_selectedIds.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
builder: (sheetContext) => BatchConvertSheet(
|
||||
formats: formats,
|
||||
title: sheetTitle,
|
||||
confirmLabel: sheetConfirmLabel,
|
||||
onConvert: (format, bitrate) {
|
||||
Navigator.pop(sheetContext);
|
||||
_performBatchConversion(
|
||||
allTracks: allTracks,
|
||||
targetFormat: format,
|
||||
bitrate: bitrate,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1161,7 +1032,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||
final isLossless = isLosslessConversionTarget(targetFormat);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
@@ -1197,8 +1068,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
final total = selected.length;
|
||||
final historyDb = HistoryDatabase.instance;
|
||||
final newQuality =
|
||||
(targetFormat.toUpperCase() == 'ALAC' ||
|
||||
targetFormat.toUpperCase() == 'FLAC')
|
||||
isLosslessConversionTarget(targetFormat)
|
||||
? '${targetFormat.toUpperCase()} Lossless'
|
||||
: '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
|
||||
final settings = ref.read(settingsProvider);
|
||||
@@ -1303,27 +1173,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
final baseName = dotIdx > 0
|
||||
? oldFileName.substring(0, dotIdx)
|
||||
: oldFileName;
|
||||
String newExt;
|
||||
String mimeType;
|
||||
switch (targetFormat.toLowerCase()) {
|
||||
case 'opus':
|
||||
newExt = '.opus';
|
||||
mimeType = 'audio/opus';
|
||||
break;
|
||||
case 'alac':
|
||||
case 'aac':
|
||||
newExt = '.m4a';
|
||||
mimeType = 'audio/mp4';
|
||||
break;
|
||||
case 'flac':
|
||||
newExt = '.flac';
|
||||
mimeType = 'audio/flac';
|
||||
break;
|
||||
default:
|
||||
newExt = '.mp3';
|
||||
mimeType = 'audio/mpeg';
|
||||
break;
|
||||
}
|
||||
final convTarget = convertTargetExtAndMime(targetFormat);
|
||||
final newExt = convTarget.ext;
|
||||
final mimeType = convTarget.mime;
|
||||
final newFileName = '$baseName$newExt';
|
||||
|
||||
final safUri = await PlatformBridge.createSafFileFromPath(
|
||||
@@ -1412,6 +1264,80 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runBatchReplayGain(List<DownloadHistoryItem> tracks) async {
|
||||
final tracksById = {for (final t in tracks) t.id: t};
|
||||
final selected = <DownloadHistoryItem>[];
|
||||
for (final id in _selectedIds) {
|
||||
final item = tracksById[id];
|
||||
if (item == null) continue;
|
||||
selected.add(item);
|
||||
}
|
||||
|
||||
if (selected.isEmpty) return;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(ctx.l10n.replayGainBatchConfirmTitle),
|
||||
content: Text(
|
||||
ctx.l10n.replayGainBatchConfirmMessage(selected.length),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(ctx.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(ctx.l10n.replayGainBatchConfirmTitle),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true || !mounted) return;
|
||||
|
||||
var cancelled = false;
|
||||
int successCount = 0;
|
||||
final total = selected.length;
|
||||
|
||||
BatchProgressDialog.show(
|
||||
context: context,
|
||||
title: context.l10n.replayGainBatchAnalyzing,
|
||||
total: total,
|
||||
icon: Icons.graphic_eq,
|
||||
onCancel: () {
|
||||
cancelled = true;
|
||||
BatchProgressDialog.dismiss(context);
|
||||
},
|
||||
);
|
||||
|
||||
for (int i = 0; i < total; i++) {
|
||||
if (!mounted || cancelled) break;
|
||||
final item = selected[i];
|
||||
BatchProgressDialog.update(current: i + 1, detail: item.trackName);
|
||||
try {
|
||||
final ok = await ReplayGainService.applyToFile(item.filePath);
|
||||
if (ok) successCount++;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
_exitSelectionMode();
|
||||
|
||||
if (!mounted) return;
|
||||
if (!cancelled) {
|
||||
BatchProgressDialog.dismiss(context);
|
||||
}
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.replayGainBatchSuccess(successCount, total),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSelectionBottomBar(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
@@ -1508,10 +1434,12 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _DownloadedAlbumSelectionActionButton(
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const spacing = 8.0;
|
||||
final itemWidth = (constraints.maxWidth - spacing) / 2;
|
||||
final actions = <Widget>[
|
||||
_DownloadedAlbumSelectionActionButton(
|
||||
icon: Icons.share_outlined,
|
||||
label: context.l10n.selectionShareCount(selectedCount),
|
||||
onPressed: selectedCount > 0
|
||||
@@ -1519,10 +1447,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
: null,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _DownloadedAlbumSelectionActionButton(
|
||||
_DownloadedAlbumSelectionActionButton(
|
||||
icon: Icons.swap_horiz,
|
||||
label: context.l10n.selectionConvertCount(selectedCount),
|
||||
onPressed: selectedCount > 0
|
||||
@@ -1530,8 +1455,27 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
: null,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
),
|
||||
],
|
||||
_DownloadedAlbumSelectionActionButton(
|
||||
icon: Icons.graphic_eq,
|
||||
label: context.l10n.selectionReplayGainCount(
|
||||
selectedCount,
|
||||
),
|
||||
onPressed: selectedCount > 0
|
||||
? () => _runBatchReplayGain(tracks)
|
||||
: null,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
];
|
||||
|
||||
return Wrap(
|
||||
spacing: spacing,
|
||||
runSpacing: spacing,
|
||||
children: [
|
||||
for (final action in actions)
|
||||
SizedBox(width: itemWidth, child: action),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
|
||||
class FavoriteArtistsScreen extends ConsumerWidget {
|
||||
@@ -18,6 +19,7 @@ class FavoriteArtistsScreen extends ConsumerWidget {
|
||||
);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
final bottomInset = context.navBarBottomInset;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
@@ -155,6 +157,7 @@ class FavoriteArtistsScreen extends ConsumerWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -21,6 +21,7 @@ import 'package:spotiflac_android/services/csv_import_service.dart';
|
||||
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/utils/nav_bar_inset.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';
|
||||
@@ -31,6 +32,7 @@ import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
|
||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
part 'home_tab_helpers.dart';
|
||||
part 'home_tab_widgets.dart';
|
||||
@@ -1204,6 +1206,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final screenHeight = mediaQuery.size.height;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
final bottomInset = context.navBarBottomInset;
|
||||
final hasHistoryItems = ref.watch(
|
||||
_homeHistoryPreviewProvider.select((items) => items.isNotEmpty),
|
||||
);
|
||||
@@ -1539,6 +1542,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
);
|
||||
},
|
||||
),
|
||||
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -3560,7 +3564,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
decoration: InputDecoration(
|
||||
hintText: _getSearchHint(),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest,
|
||||
fillColor: settingsGroupColor(context),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||
|
||||
@@ -347,7 +347,7 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
@@ -356,7 +356,7 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
Icon(
|
||||
Icons.folder_outlined,
|
||||
size: 10,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
@@ -364,7 +364,7 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/widgets/bottom_sheet_option_tile.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
||||
|
||||
class LibraryPlaylistsScreen extends ConsumerWidget {
|
||||
const LibraryPlaylistsScreen({super.key});
|
||||
@@ -21,6 +22,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
||||
);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
final bottomInset = context.navBarBottomInset;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
@@ -132,6 +134,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
||||
);
|
||||
}, childCount: playlists.length * 2 - 1),
|
||||
),
|
||||
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
|
||||
@@ -322,6 +323,7 @@ class _LibraryTracksFolderScreenState
|
||||
.maybeWhen(data: (keys) => keys, orElse: () => const <String>{});
|
||||
|
||||
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||
final bottomInset = context.navBarBottomInset;
|
||||
|
||||
return PopScope(
|
||||
canPop: !_isSelectionMode,
|
||||
@@ -379,6 +381,7 @@ class _LibraryTracksFolderScreenState
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: _isSelectionMode ? 200 : 32),
|
||||
),
|
||||
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1234,7 +1237,7 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
@@ -1243,7 +1246,7 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
Icon(
|
||||
Icons.folder_outlined,
|
||||
size: 10,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
@@ -1251,7 +1254,7 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
+142
-189
@@ -12,10 +12,13 @@ import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/image_cache_utils.dart';
|
||||
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/replaygain_service.dart';
|
||||
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
|
||||
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/batch_convert_sheet.dart';
|
||||
import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
@@ -250,6 +253,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||
final bottomInset = context.navBarBottomInset;
|
||||
final tracks = _sortedTracksCache;
|
||||
|
||||
if (tracks.isEmpty) {
|
||||
@@ -286,6 +290,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: _isSelectionMode ? 120 : 32),
|
||||
),
|
||||
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1214,18 +1219,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
|
||||
if (formats.isEmpty) return;
|
||||
|
||||
String selectedFormat = formats.first;
|
||||
bool isLosslessTarget =
|
||||
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
|
||||
String defaultBitrateForFormat(String format) {
|
||||
if (format == 'Opus') return '128k';
|
||||
if (format == 'AAC') return '256k';
|
||||
return '320k';
|
||||
}
|
||||
|
||||
String selectedBitrate = isLosslessTarget
|
||||
? '320k'
|
||||
: defaultBitrateForFormat(selectedFormat);
|
||||
final sheetTitle = context.l10n.selectionBatchConvertConfirmTitle;
|
||||
final sheetConfirmLabel = context.l10n.selectionConvertCount(
|
||||
_selectedIds.length,
|
||||
);
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@@ -1233,145 +1230,19 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (sheetContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setSheetState) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final bitrates = ['128k', '192k', '256k', '320k'];
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant.withValues(
|
||||
alpha: 0.4,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.selectionBatchConvertConfirmTitle,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
context.l10n.trackConvertTargetFormat,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: formats.map((format) {
|
||||
final isSelected = format == selectedFormat;
|
||||
return ChoiceChip(
|
||||
label: Text(format),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() {
|
||||
selectedFormat = format;
|
||||
isLosslessTarget =
|
||||
format == 'ALAC' || format == 'FLAC';
|
||||
if (!isLosslessTarget) {
|
||||
selectedBitrate = defaultBitrateForFormat(
|
||||
format,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
if (!isLosslessTarget) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.trackConvertBitrate,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: bitrates.map((br) {
|
||||
final isSelected = br == selectedBitrate;
|
||||
return ChoiceChip(
|
||||
label: Text(br),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() => selectedBitrate = br);
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
if (isLosslessTarget) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.verified,
|
||||
size: 16,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
context.l10n.trackConvertLosslessHint,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_performBatchConversion(
|
||||
allTracks: allTracks,
|
||||
targetFormat: selectedFormat,
|
||||
bitrate: selectedBitrate,
|
||||
);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.selectionConvertCount(
|
||||
_selectedIds.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
builder: (sheetContext) => BatchConvertSheet(
|
||||
formats: formats,
|
||||
title: sheetTitle,
|
||||
confirmLabel: sheetConfirmLabel,
|
||||
onConvert: (format, bitrate) {
|
||||
Navigator.pop(sheetContext);
|
||||
_performBatchConversion(
|
||||
allTracks: allTracks,
|
||||
targetFormat: format,
|
||||
bitrate: bitrate,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1408,7 +1279,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||
final isLossless = isLosslessConversionTarget(targetFormat);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
@@ -1582,27 +1453,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
final baseName = dotIdx > 0
|
||||
? oldFileName.substring(0, dotIdx)
|
||||
: oldFileName;
|
||||
String newExt;
|
||||
String mimeType;
|
||||
switch (targetFormat.toLowerCase()) {
|
||||
case 'opus':
|
||||
newExt = '.opus';
|
||||
mimeType = 'audio/opus';
|
||||
break;
|
||||
case 'alac':
|
||||
case 'aac':
|
||||
newExt = '.m4a';
|
||||
mimeType = 'audio/mp4';
|
||||
break;
|
||||
case 'flac':
|
||||
newExt = '.flac';
|
||||
mimeType = 'audio/flac';
|
||||
break;
|
||||
default:
|
||||
newExt = '.mp3';
|
||||
mimeType = 'audio/mpeg';
|
||||
break;
|
||||
}
|
||||
final convTarget = convertTargetExtAndMime(targetFormat);
|
||||
final newExt = convTarget.ext;
|
||||
final mimeType = convTarget.mime;
|
||||
final newFileName = '$baseName$newExt';
|
||||
|
||||
final safUri = await PlatformBridge.createSafFileFromPath(
|
||||
@@ -1681,6 +1534,80 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runBatchReplayGain(List<LocalLibraryItem> tracks) async {
|
||||
final tracksById = {for (final t in tracks) t.id: t};
|
||||
final selected = <LocalLibraryItem>[];
|
||||
for (final id in _selectedIds) {
|
||||
final item = tracksById[id];
|
||||
if (item == null) continue;
|
||||
selected.add(item);
|
||||
}
|
||||
|
||||
if (selected.isEmpty) return;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(ctx.l10n.replayGainBatchConfirmTitle),
|
||||
content: Text(
|
||||
ctx.l10n.replayGainBatchConfirmMessage(selected.length),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(ctx.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(ctx.l10n.replayGainBatchConfirmTitle),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true || !mounted) return;
|
||||
|
||||
var cancelled = false;
|
||||
int successCount = 0;
|
||||
final total = selected.length;
|
||||
|
||||
BatchProgressDialog.show(
|
||||
context: context,
|
||||
title: context.l10n.replayGainBatchAnalyzing,
|
||||
total: total,
|
||||
icon: Icons.graphic_eq,
|
||||
onCancel: () {
|
||||
cancelled = true;
|
||||
BatchProgressDialog.dismiss(context);
|
||||
},
|
||||
);
|
||||
|
||||
for (int i = 0; i < total; i++) {
|
||||
if (!mounted || cancelled) break;
|
||||
final item = selected[i];
|
||||
BatchProgressDialog.update(current: i + 1, detail: item.trackName);
|
||||
try {
|
||||
final ok = await ReplayGainService.applyToFile(item.filePath);
|
||||
if (ok) successCount++;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
_exitSelectionMode();
|
||||
|
||||
if (!mounted) return;
|
||||
if (!cancelled) {
|
||||
BatchProgressDialog.dismiss(context);
|
||||
}
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.replayGainBatchSuccess(successCount, total),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSelectionBottomBar(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
@@ -1778,22 +1705,26 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
if (flacEligibleCount > 0) ...[
|
||||
Expanded(
|
||||
child: _LocalAlbumSelectionActionButton(
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const spacing = 8.0;
|
||||
final itemWidth = (constraints.maxWidth - spacing) / 2;
|
||||
final actions = <Widget>[];
|
||||
|
||||
if (flacEligibleCount > 0) {
|
||||
actions.add(
|
||||
_LocalAlbumSelectionActionButton(
|
||||
icon: Icons.download_for_offline_outlined,
|
||||
label:
|
||||
'${context.l10n.queueFlacAction} ($flacEligibleCount)',
|
||||
onPressed: () => _queueSelectedAsFlac(tracks),
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: _LocalAlbumSelectionActionButton(
|
||||
);
|
||||
}
|
||||
|
||||
actions.add(
|
||||
_LocalAlbumSelectionActionButton(
|
||||
icon: Icons.auto_fix_high_outlined,
|
||||
label: '${context.l10n.trackReEnrich} ($selectedCount)',
|
||||
onPressed: selectedCount > 0
|
||||
@@ -1801,10 +1732,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
: null,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _LocalAlbumSelectionActionButton(
|
||||
);
|
||||
|
||||
actions.add(
|
||||
_LocalAlbumSelectionActionButton(
|
||||
icon: Icons.swap_horiz,
|
||||
label: context.l10n.selectionConvertCount(selectedCount),
|
||||
onPressed: selectedCount > 0
|
||||
@@ -1812,8 +1743,30 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
: null,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
actions.add(
|
||||
_LocalAlbumSelectionActionButton(
|
||||
icon: Icons.graphic_eq,
|
||||
label: context.l10n.selectionReplayGainCount(
|
||||
selectedCount,
|
||||
),
|
||||
onPressed: selectedCount > 0
|
||||
? () => _runBatchReplayGain(tracks)
|
||||
: null,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
);
|
||||
|
||||
return Wrap(
|
||||
spacing: spacing,
|
||||
runSpacing: spacing,
|
||||
children: [
|
||||
for (final action in actions)
|
||||
SizedBox(width: itemWidth, child: action),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
+29
-13
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' show ImageFilter;
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -23,6 +24,7 @@ import 'package:spotiflac_android/services/update_checker.dart';
|
||||
import 'package:spotiflac_android/widgets/app_announcement_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/update_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('MainShell');
|
||||
@@ -548,6 +550,7 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
return true;
|
||||
},
|
||||
child: Scaffold(
|
||||
extendBody: true,
|
||||
body: AnimatedBuilder(
|
||||
animation: _tabJumpTransitionController,
|
||||
child: PageView.builder(
|
||||
@@ -570,20 +573,33 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
);
|
||||
},
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _currentIndex.clamp(0, maxIndex),
|
||||
onDestinationSelected: _onNavTap,
|
||||
animationDuration: const Duration(milliseconds: 500),
|
||||
backgroundColor: Theme.of(context).brightness == Brightness.dark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.05),
|
||||
Theme.of(context).colorScheme.surface,
|
||||
)
|
||||
: Color.alphaBlend(
|
||||
Colors.black.withValues(alpha: 0.03),
|
||||
Theme.of(context).colorScheme.surface,
|
||||
bottomNavigationBar: ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
|
||||
child: DecoratedBox(
|
||||
position: DecorationPosition.foreground,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
destinations: destinations,
|
||||
),
|
||||
child: NavigationBar(
|
||||
selectedIndex: _currentIndex.clamp(0, maxIndex),
|
||||
onDestinationSelected: _onNavTap,
|
||||
animationDuration: const Duration(milliseconds: 500),
|
||||
elevation: 0,
|
||||
height: 64,
|
||||
backgroundColor: settingsGroupColor(
|
||||
context,
|
||||
).withValues(alpha: 0.72),
|
||||
destinations: destinations,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:ui' show ImageFilter;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
@@ -9,6 +10,7 @@ import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/image_cache_utils.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/utils/nav_bar_inset.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';
|
||||
@@ -253,7 +255,9 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
_buildAppBar(context, colorScheme),
|
||||
_buildInfoCard(context, colorScheme),
|
||||
_buildTrackList(context, colorScheme),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: 32 + context.navBarBottomInset),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -296,14 +300,17 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (_coverUrl != null)
|
||||
CachedCoverImage(
|
||||
imageUrl: _highResCoverUrl(_coverUrl) ?? _coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheWidth,
|
||||
placeholder: (_, _) =>
|
||||
Container(color: colorScheme.surface),
|
||||
errorWidget: (_, _, _) =>
|
||||
Container(color: colorScheme.surface),
|
||||
ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32),
|
||||
child: CachedCoverImage(
|
||||
imageUrl: _highResCoverUrl(_coverUrl) ?? _coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheWidth,
|
||||
placeholder: (_, _) =>
|
||||
Container(color: colorScheme.surface),
|
||||
errorWidget: (_, _, _) =>
|
||||
Container(color: colorScheme.surface),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
@@ -314,91 +321,148 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: expandedHeight * 0.65,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withValues(alpha: 0.85),
|
||||
],
|
||||
Container(color: Colors.black.withValues(alpha: 0.35)),
|
||||
if (_coverUrl != null)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: expandedHeight * 0.65,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withValues(alpha: 0.6),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 20,
|
||||
right: 20,
|
||||
bottom: 40,
|
||||
Positioned.fill(
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
opacity: showContent ? 1.0 : 0.0,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_playlistName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
height: 1.2,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (_tracks.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.playlist_play,
|
||||
size: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
context.l10n.tracksCount(_tracks.length),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
20,
|
||||
kToolbarHeight + 8,
|
||||
20,
|
||||
28,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (_coverUrl != null) ...[
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final coverSize =
|
||||
(constraints.maxWidth * 0.5).clamp(
|
||||
140.0,
|
||||
220.0,
|
||||
);
|
||||
return Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(
|
||||
alpha: 0.45,
|
||||
),
|
||||
blurRadius: 24,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: CachedCoverImage(
|
||||
imageUrl:
|
||||
_highResCoverUrl(_coverUrl) ??
|
||||
_coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheWidth,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
placeholder: (_, _) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
errorWidget: (_, _, _) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
Text(
|
||||
_playlistName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
height: 1.2,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (_tracks.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.playlist_play,
|
||||
size: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
context.l10n.tracksCount(_tracks.length),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildLoveAllButton(),
|
||||
const SizedBox(width: 12),
|
||||
_buildDownloadAllCenterButton(context),
|
||||
const SizedBox(width: 12),
|
||||
_buildAddToPlaylistButton(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildLoveAllButton(),
|
||||
const SizedBox(width: 12),
|
||||
_buildDownloadAllCenterButton(context),
|
||||
const SizedBox(width: 12),
|
||||
_buildAddToPlaylistButton(context),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -900,7 +964,7 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
@@ -909,7 +973,7 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
||||
Icon(
|
||||
Icons.folder_outlined,
|
||||
size: 10,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
@@ -917,7 +981,7 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
+920
-461
File diff suppressed because it is too large
Load Diff
@@ -89,6 +89,8 @@ class _FilterChip extends StatelessWidget {
|
||||
selected: isSelected,
|
||||
onSelected: (_) => onTap(),
|
||||
showCheckmark: false,
|
||||
backgroundColor: settingsGroupColor(context),
|
||||
side: BorderSide(color: colorScheme.outlineVariant.withValues(alpha: 0.6)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
import 'package:spotiflac_android/screens/store/extension_details_screen.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
||||
|
||||
class RepoTab extends ConsumerStatefulWidget {
|
||||
const RepoTab({super.key});
|
||||
@@ -76,6 +77,7 @@ class _RepoTabState extends ConsumerState<RepoTab> {
|
||||
}
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
final bottomInset = context.navBarBottomInset;
|
||||
|
||||
return Scaffold(
|
||||
body: RefreshIndicator(
|
||||
@@ -172,7 +174,7 @@ class _RepoTabState extends ConsumerState<RepoTab> {
|
||||
),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest,
|
||||
fillColor: settingsGroupColor(context),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 16,
|
||||
@@ -311,6 +313,7 @@ class _RepoTabState extends ConsumerState<RepoTab> {
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
],
|
||||
],
|
||||
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -651,6 +654,7 @@ class _CategoryChip extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return FilterChip(
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -659,6 +663,8 @@ class _CategoryChip extends StatelessWidget {
|
||||
selected: isSelected,
|
||||
onSelected: (_) => onTap(),
|
||||
showCheckmark: false,
|
||||
backgroundColor: settingsGroupColor(context),
|
||||
side: BorderSide(color: colorScheme.outlineVariant.withValues(alpha: 0.6)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,6 +548,12 @@ class _TranslatorsSection extends StatelessWidget {
|
||||
language: 'Turkish',
|
||||
flag: '🇹🇷',
|
||||
),
|
||||
_Translator(
|
||||
name: 'Mickael81',
|
||||
crowdinUsername: 'Mickael81',
|
||||
language: 'French',
|
||||
flag: '🇫🇷',
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
|
||||
@@ -40,10 +40,13 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
final qualityOptions =
|
||||
selectedDownloadExtension?.qualityOptions ?? const <QualityOption>[];
|
||||
final canSelectQuality = qualityOptions.isNotEmpty;
|
||||
final isTidalService = selectedDownloadService.isNotEmpty
|
||||
final usesTidalCompatibilityOptions = selectedDownloadService.isNotEmpty
|
||||
? ref
|
||||
.read(extensionProvider.notifier)
|
||||
.downloadProviderMatchesBuiltIn(selectedDownloadService, 'tidal')
|
||||
.downloadProviderReplacesLegacyProvider(
|
||||
selectedDownloadService,
|
||||
'tidal',
|
||||
)
|
||||
: false;
|
||||
final nativeWorkerAvailable = Platform.isAndroid && hasDownloadExtensions;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
@@ -148,17 +151,19 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
.setAudioQuality(quality.id),
|
||||
showDivider:
|
||||
quality != qualityOptions.last ||
|
||||
(isTidalService && settings.audioQuality == 'HIGH'),
|
||||
(usesTidalCompatibilityOptions &&
|
||||
settings.audioQuality == 'HIGH'),
|
||||
),
|
||||
if (isTidalService && settings.audioQuality == 'HIGH')
|
||||
if (usesTidalCompatibilityOptions &&
|
||||
settings.audioQuality == 'HIGH')
|
||||
SettingsItem(
|
||||
icon: Icons.tune,
|
||||
title: context.l10n.downloadLossyFormat,
|
||||
subtitle: _getTidalHighFormatLabel(
|
||||
subtitle: _getLossyCompatibilityFormatLabel(
|
||||
context,
|
||||
settings.tidalHighFormat,
|
||||
),
|
||||
onTap: () => _showTidalHighFormatPicker(
|
||||
onTap: () => _showLossyCompatibilityFormatPicker(
|
||||
context,
|
||||
ref,
|
||||
settings.tidalHighFormat,
|
||||
@@ -178,12 +183,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
_ConcurrentDownloadsItem(
|
||||
currentValue: settings.concurrentDownloads,
|
||||
onChanged: (v) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setConcurrentDownloads(v),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.wifi,
|
||||
title: context.l10n.settingsDownloadNetwork,
|
||||
@@ -369,7 +368,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
case 'HI_RES_LOSSLESS':
|
||||
return context.l10n.qualityHiResFlacMaxSubtitle;
|
||||
case 'HIGH':
|
||||
return _getTidalHighFormatLabel(
|
||||
return _getLossyCompatibilityFormatLabel(
|
||||
context,
|
||||
ref.read(settingsProvider).tidalHighFormat,
|
||||
);
|
||||
@@ -378,7 +377,10 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
String _getTidalHighFormatLabel(BuildContext context, String format) {
|
||||
String _getLossyCompatibilityFormatLabel(
|
||||
BuildContext context,
|
||||
String format,
|
||||
) {
|
||||
switch (format) {
|
||||
case 'mp3_320':
|
||||
return context.l10n.downloadLossyMp3;
|
||||
@@ -393,7 +395,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
void _showTidalHighFormatPicker(
|
||||
void _showLossyCompatibilityFormatPicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String current,
|
||||
@@ -1015,139 +1017,6 @@ class _ServiceChip extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ConcurrentDownloadsItem extends StatelessWidget {
|
||||
final int currentValue;
|
||||
final ValueChanged<int> onChanged;
|
||||
const _ConcurrentDownloadsItem({
|
||||
required this.currentValue,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.download_for_offline,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.optionsConcurrentDownloads,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
currentValue == 1
|
||||
? context.l10n.optionsConcurrentSequential
|
||||
: context.l10n.optionsConcurrentParallel(
|
||||
currentValue,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
for (final n in [1, 2, 3, 4, 5]) ...[
|
||||
if (n > 1) const SizedBox(width: 8),
|
||||
_ConcurrentChip(
|
||||
label: '$n',
|
||||
isSelected: currentValue == n,
|
||||
onTap: () => onChanged(n),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
size: 16,
|
||||
color: colorScheme.error,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.optionsConcurrentWarning,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: colorScheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConcurrentChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
const _ConcurrentChip({
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final unselectedColor = isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.05),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
return Expanded(
|
||||
child: Material(
|
||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Center(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetadataSourceSelector extends ConsumerWidget {
|
||||
const _MetadataSourceSelector();
|
||||
|
||||
|
||||
@@ -708,52 +708,9 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
|
||||
String? title,
|
||||
String? description,
|
||||
}) {
|
||||
final controller = TextEditingController(text: current);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final basicTags = [
|
||||
'{artist}',
|
||||
'{title}',
|
||||
'{album}',
|
||||
'{track}',
|
||||
'{year}',
|
||||
'{date}',
|
||||
'{disc}',
|
||||
];
|
||||
final advancedTags = [
|
||||
'{track_raw}',
|
||||
'{track:02}',
|
||||
'{track:1}',
|
||||
'{date:%Y}',
|
||||
'{date:%Y-%m-%d}',
|
||||
'{disc_raw}',
|
||||
'{disc:02}',
|
||||
];
|
||||
var showAdvancedTags = RegExp(
|
||||
r'\{(?:track_raw|disc_raw|track:\d+|disc:\d+|date:[^}]+)\}',
|
||||
caseSensitive: false,
|
||||
).hasMatch(current);
|
||||
|
||||
void insertTag(String tag) {
|
||||
final text = controller.text;
|
||||
final selection = controller.selection;
|
||||
final start = selection.start >= 0 ? selection.start : text.length;
|
||||
final end = selection.end >= 0 ? selection.end : text.length;
|
||||
String insertion = tag;
|
||||
if (start > 0) {
|
||||
final before = text.substring(0, start);
|
||||
if (!before.trim().endsWith('-')) {
|
||||
insertion = ' - $tag';
|
||||
} else if (before.trim().endsWith('-') && !before.endsWith(' ')) {
|
||||
insertion = ' $tag';
|
||||
}
|
||||
}
|
||||
final newText = text.replaceRange(start, end, insertion);
|
||||
controller.value = TextEditingValue(
|
||||
text: newText,
|
||||
selection: TextSelection.collapsed(offset: start + insertion.length),
|
||||
);
|
||||
}
|
||||
final save =
|
||||
onSave ?? ref.read(settingsProvider.notifier).setFilenameFormat;
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@@ -763,178 +720,13 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setModalState) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
title ?? context.l10n.filenameFormat,
|
||||
style: Theme.of(context).textTheme.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
description ??
|
||||
context.l10n.downloadFilenameDescription(
|
||||
'{album}',
|
||||
'{artist}',
|
||||
'{date}',
|
||||
'{disc}',
|
||||
'{title}',
|
||||
'{track}',
|
||||
'{year}',
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: '{artist} - {title}',
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.3),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
autofocus: true,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
context.l10n.downloadFilenameInsertTag,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: basicTags.map((tag) {
|
||||
return ActionChip(
|
||||
label: Text(tag),
|
||||
onPressed: () => insertTag(tag),
|
||||
backgroundColor: colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.5),
|
||||
side: BorderSide.none,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
labelStyle: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile(
|
||||
value: showAdvancedTags,
|
||||
onChanged: (value) =>
|
||||
setModalState(() => showAdvancedTags = value),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(context.l10n.filenameShowAdvancedTags),
|
||||
subtitle: Text(
|
||||
context.l10n.filenameShowAdvancedTagsDescription,
|
||||
),
|
||||
),
|
||||
if (showAdvancedTags) ...[
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: advancedTags.map((tag) {
|
||||
return ActionChip(
|
||||
label: Text(tag),
|
||||
onPressed: () => insertTag(tag),
|
||||
backgroundColor: colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.5),
|
||||
side: BorderSide.none,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
labelStyle: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
final save =
|
||||
onSave ??
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setFilenameFormat;
|
||||
save(controller.text);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Text(context.l10n.dialogSave),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
builder: (context) => _FilenameFormatEditorSheet(
|
||||
initialText: current,
|
||||
onSave: save,
|
||||
title: title,
|
||||
description: description,
|
||||
),
|
||||
).whenComplete(controller.dispose);
|
||||
);
|
||||
}
|
||||
|
||||
void _showAlbumFolderStructurePicker(
|
||||
@@ -1140,3 +932,245 @@ class _FolderOption extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Bottom sheet for editing a filename format. Owns its controller and disposes
|
||||
/// it in [dispose] to avoid use-after-dispose during the close animation.
|
||||
class _FilenameFormatEditorSheet extends StatefulWidget {
|
||||
final String initialText;
|
||||
final void Function(String) onSave;
|
||||
final String? title;
|
||||
final String? description;
|
||||
|
||||
const _FilenameFormatEditorSheet({
|
||||
required this.initialText,
|
||||
required this.onSave,
|
||||
this.title,
|
||||
this.description,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_FilenameFormatEditorSheet> createState() =>
|
||||
_FilenameFormatEditorSheetState();
|
||||
}
|
||||
|
||||
class _FilenameFormatEditorSheetState
|
||||
extends State<_FilenameFormatEditorSheet> {
|
||||
static const _basicTags = [
|
||||
'{artist}',
|
||||
'{title}',
|
||||
'{album}',
|
||||
'{track}',
|
||||
'{year}',
|
||||
'{date}',
|
||||
'{disc}',
|
||||
];
|
||||
static const _advancedTags = [
|
||||
'{track_raw}',
|
||||
'{track:02}',
|
||||
'{track:1}',
|
||||
'{date:%Y}',
|
||||
'{date:%Y-%m-%d}',
|
||||
'{disc_raw}',
|
||||
'{disc:02}',
|
||||
];
|
||||
|
||||
late final TextEditingController _controller;
|
||||
late bool _showAdvancedTags;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.initialText);
|
||||
_showAdvancedTags = RegExp(
|
||||
r'\{(?:track_raw|disc_raw|track:\d+|disc:\d+|date:[^}]+)\}',
|
||||
caseSensitive: false,
|
||||
).hasMatch(widget.initialText);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _insertTag(String tag) {
|
||||
final text = _controller.text;
|
||||
final selection = _controller.selection;
|
||||
final start = selection.start >= 0 ? selection.start : text.length;
|
||||
final end = selection.end >= 0 ? selection.end : text.length;
|
||||
String insertion = tag;
|
||||
if (start > 0) {
|
||||
final before = text.substring(0, start);
|
||||
if (!before.trim().endsWith('-')) {
|
||||
insertion = ' - $tag';
|
||||
} else if (before.trim().endsWith('-') && !before.endsWith(' ')) {
|
||||
insertion = ' $tag';
|
||||
}
|
||||
}
|
||||
final newText = text.replaceRange(start, end, insertion);
|
||||
_controller.value = TextEditingValue(
|
||||
text: newText,
|
||||
selection: TextSelection.collapsed(offset: start + insertion.length),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tagChip(ColorScheme colorScheme, String tag) {
|
||||
return ActionChip(
|
||||
label: Text(tag),
|
||||
onPressed: () => _insertTag(tag),
|
||||
backgroundColor: colorScheme.surfaceContainerHighest.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
side: BorderSide.none,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
labelStyle: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: SingleChildScrollView(
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
widget.title ?? context.l10n.filenameFormat,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.description ??
|
||||
context.l10n.downloadFilenameDescription(
|
||||
'{album}',
|
||||
'{artist}',
|
||||
'{date}',
|
||||
'{disc}',
|
||||
'{title}',
|
||||
'{track}',
|
||||
'{year}',
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: '{artist} - {title}',
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
autofocus: true,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
context.l10n.downloadFilenameInsertTag,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _basicTags
|
||||
.map((tag) => _tagChip(colorScheme, tag))
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile(
|
||||
value: _showAdvancedTags,
|
||||
onChanged: (value) =>
|
||||
setState(() => _showAdvancedTags = value),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(context.l10n.filenameShowAdvancedTags),
|
||||
subtitle: Text(
|
||||
context.l10n.filenameShowAdvancedTagsDescription,
|
||||
),
|
||||
),
|
||||
if (_showAdvancedTags) ...[
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _advancedTags
|
||||
.map((tag) => _tagChip(colorScheme, tag))
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
widget.onSave(_controller.text);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Text(context.l10n.dialogSave),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ class _LyricsProviderPriorityPageState
|
||||
'youtube',
|
||||
'kugou',
|
||||
'genius',
|
||||
'lyricsplus',
|
||||
];
|
||||
|
||||
late List<String> _enabledProviders;
|
||||
@@ -245,6 +246,12 @@ class _LyricsProviderPriorityPageState
|
||||
description: context.l10n.lyricsProviderExtensionDesc,
|
||||
icon: Icons.auto_awesome_outlined,
|
||||
);
|
||||
case 'lyricsplus':
|
||||
return _LyricsProviderInfo(
|
||||
name: 'LyricsPlus',
|
||||
description: context.l10n.lyricsProviderLyricsPlusDesc,
|
||||
icon: Icons.lyrics_outlined,
|
||||
);
|
||||
default:
|
||||
return _LyricsProviderInfo(
|
||||
name: id,
|
||||
|
||||
@@ -221,6 +221,7 @@ class LyricsSettingsPage extends ConsumerWidget {
|
||||
'youtube': 'YouTube',
|
||||
'kugou': 'Kugou',
|
||||
'genius': 'Genius',
|
||||
'lyricsplus': 'LyricsPlus',
|
||||
};
|
||||
|
||||
String _getLyricsProvidersSubtitle(
|
||||
|
||||
@@ -15,6 +15,7 @@ import 'package:spotiflac_android/screens/settings/cache_management_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/donate_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/log_screen.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
|
||||
@@ -25,6 +26,7 @@ class SettingsTab extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
final bottomInset = context.navBarBottomInset;
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
@@ -183,6 +185,7 @@ class SettingsTab extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
|
||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/store_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
||||
|
||||
class ExtensionDetailsScreen extends ConsumerStatefulWidget {
|
||||
final StoreExtension extension;
|
||||
@@ -69,7 +70,9 @@ class _ExtensionDetailsScreenState
|
||||
),
|
||||
_buildCapabilities(context, liveExtension, colorScheme),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: 32 + context.navBarBottomInset),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1263,7 +1263,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -1275,132 +1275,144 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
),
|
||||
),
|
||||
if (_saving)
|
||||
const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||
child: SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
else
|
||||
FilledButton(
|
||||
FilledButton.icon(
|
||||
onPressed: _save,
|
||||
child: Text(context.l10n.dialogSave),
|
||||
icon: const Icon(Icons.check, size: 18),
|
||||
label: Text(context.l10n.dialogSave),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 18,
|
||||
vertical: 12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: cs.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
padding: const EdgeInsets.fromLTRB(20, 6, 20, 24),
|
||||
children: [
|
||||
const SizedBox(height: 6),
|
||||
_buildCoverEditor(cs),
|
||||
_buildAutoFillSection(cs),
|
||||
_field(context.l10n.editMetadataFieldTitle, _titleCtrl),
|
||||
_field(context.l10n.editMetadataFieldArtist, _artistCtrl),
|
||||
_field(context.l10n.editMetadataFieldAlbum, _albumCtrl),
|
||||
_field(
|
||||
context.l10n.editMetadataFieldAlbumArtist,
|
||||
_albumArtistCtrl,
|
||||
),
|
||||
_field(
|
||||
context.l10n.editMetadataFieldDate,
|
||||
_dateCtrl,
|
||||
hint: context.l10n.editMetadataFieldDateHint,
|
||||
),
|
||||
Row(
|
||||
_sectionCard(
|
||||
icon: Icons.info_outline,
|
||||
title: context.l10n.trackMetadata,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _field(
|
||||
context.l10n.editMetadataFieldTrackNum,
|
||||
_trackNumCtrl,
|
||||
keyboard: TextInputType.number,
|
||||
),
|
||||
_field(context.l10n.editMetadataFieldTitle, _titleCtrl),
|
||||
_field(
|
||||
context.l10n.editMetadataFieldArtist,
|
||||
_artistCtrl,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _field(
|
||||
context.l10n.editMetadataFieldTrackTotal,
|
||||
_trackTotalCtrl,
|
||||
keyboard: TextInputType.number,
|
||||
),
|
||||
_field(context.l10n.editMetadataFieldAlbum, _albumCtrl),
|
||||
_field(
|
||||
context.l10n.editMetadataFieldAlbumArtist,
|
||||
_albumArtistCtrl,
|
||||
),
|
||||
_field(
|
||||
context.l10n.editMetadataFieldDate,
|
||||
_dateCtrl,
|
||||
hint: context.l10n.editMetadataFieldDateHint,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _field(
|
||||
context.l10n.editMetadataFieldTrackNum,
|
||||
_trackNumCtrl,
|
||||
keyboard: TextInputType.number,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _field(
|
||||
context.l10n.editMetadataFieldTrackTotal,
|
||||
_trackTotalCtrl,
|
||||
keyboard: TextInputType.number,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _field(
|
||||
context.l10n.editMetadataFieldDiscNum,
|
||||
_discNumCtrl,
|
||||
keyboard: TextInputType.number,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _field(
|
||||
context.l10n.editMetadataFieldDiscTotal,
|
||||
_discTotalCtrl,
|
||||
keyboard: TextInputType.number,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_field(context.l10n.editMetadataFieldGenre, _genreCtrl),
|
||||
_field(context.l10n.editMetadataFieldIsrc, _isrcCtrl),
|
||||
],
|
||||
),
|
||||
_sectionCard(
|
||||
icon: Icons.lyrics_outlined,
|
||||
title: context.l10n.trackLyrics,
|
||||
children: [
|
||||
_field(
|
||||
context.l10n.trackLyrics,
|
||||
_lyricsCtrl,
|
||||
maxLines: 8,
|
||||
keyboard: TextInputType.multiline,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
_sectionCard(
|
||||
icon: Icons.tune,
|
||||
title: context.l10n.editMetadataAdvanced,
|
||||
onHeaderTap: () =>
|
||||
setState(() => _showAdvanced = !_showAdvanced),
|
||||
expanded: _showAdvanced,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _field(
|
||||
context.l10n.editMetadataFieldDiscNum,
|
||||
_discNumCtrl,
|
||||
keyboard: TextInputType.number,
|
||||
if (_showAdvanced) ...[
|
||||
_field(
|
||||
context.l10n.editMetadataFieldLabel,
|
||||
_labelCtrl,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _field(
|
||||
context.l10n.editMetadataFieldDiscTotal,
|
||||
_discTotalCtrl,
|
||||
keyboard: TextInputType.number,
|
||||
_field(
|
||||
context.l10n.editMetadataFieldCopyright,
|
||||
_copyrightCtrl,
|
||||
),
|
||||
),
|
||||
_field(
|
||||
context.l10n.editMetadataFieldComposer,
|
||||
_composerCtrl,
|
||||
),
|
||||
_field(
|
||||
context.l10n.editMetadataFieldComment,
|
||||
_commentCtrl,
|
||||
maxLines: 3,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
_field(context.l10n.editMetadataFieldGenre, _genreCtrl),
|
||||
_field(context.l10n.editMetadataFieldIsrc, _isrcCtrl),
|
||||
_field(
|
||||
context.l10n.trackLyrics,
|
||||
_lyricsCtrl,
|
||||
maxLines: 8,
|
||||
keyboard: TextInputType.multiline,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
||||
child: InkWell(
|
||||
onTap: () =>
|
||||
setState(() => _showAdvanced = !_showAdvanced),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_showAdvanced
|
||||
? Icons.expand_less
|
||||
: Icons.expand_more,
|
||||
size: 20,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
context.l10n.editMetadataAdvanced,
|
||||
style: Theme.of(context).textTheme.labelLarge
|
||||
?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_showAdvanced) ...[
|
||||
_field(context.l10n.editMetadataFieldLabel, _labelCtrl),
|
||||
_field(
|
||||
context.l10n.editMetadataFieldCopyright,
|
||||
_copyrightCtrl,
|
||||
),
|
||||
_field(
|
||||
context.l10n.editMetadataFieldComposer,
|
||||
_composerCtrl,
|
||||
),
|
||||
_field(
|
||||
context.l10n.editMetadataFieldComment,
|
||||
_commentCtrl,
|
||||
maxLines: 3,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1411,149 +1423,104 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
}
|
||||
|
||||
Widget _buildAutoFillSection(ColorScheme cs) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () => setState(() => _showAutoFill = !_showAutoFill),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.travel_explore, size: 20, color: cs.primary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.editMetadataAutoFill,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: cs.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
_showAutoFill ? Icons.expand_less : Icons.expand_more,
|
||||
size: 20,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
return _sectionCard(
|
||||
icon: Icons.travel_explore,
|
||||
title: context.l10n.editMetadataAutoFill,
|
||||
onHeaderTap: () => setState(() => _showAutoFill = !_showAutoFill),
|
||||
expanded: _showAutoFill,
|
||||
children: [
|
||||
if (_showAutoFill) ...[
|
||||
Text(
|
||||
context.l10n.editMetadataAutoFillDesc,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
_quickSelectButton(
|
||||
label: context.l10n.editMetadataSelectAll,
|
||||
onTap: _selectAllFields,
|
||||
cs: cs,
|
||||
),
|
||||
),
|
||||
if (_showAutoFill) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
context.l10n.editMetadataAutoFillDesc,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_quickSelectButton(
|
||||
label: context.l10n.editMetadataSelectEmpty,
|
||||
onTap: _selectEmptyFields,
|
||||
cs: cs,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
_quickSelectButton(
|
||||
label: context.l10n.editMetadataSelectAll,
|
||||
onTap: _selectAllFields,
|
||||
cs: cs,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_quickSelectButton(
|
||||
label: context.l10n.editMetadataSelectEmpty,
|
||||
onTap: _selectEmptyFields,
|
||||
cs: cs,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_quickSelectButton(
|
||||
label: context.l10n.editMetadataSelectNone,
|
||||
onTap: _selectNoFields,
|
||||
cs: cs,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: _fieldDefs.keys.map((key) {
|
||||
final selected = _autoFillFields.contains(key);
|
||||
return FilterChip(
|
||||
label: Text(_fieldLabel(key)),
|
||||
selected: selected,
|
||||
onSelected: _fetching
|
||||
? null
|
||||
: (val) {
|
||||
setState(() {
|
||||
if (val) {
|
||||
_autoFillFields.add(key);
|
||||
} else {
|
||||
_autoFillFields.remove(key);
|
||||
}
|
||||
});
|
||||
},
|
||||
selectedColor: cs.primaryContainer,
|
||||
checkmarkColor: cs.onPrimaryContainer,
|
||||
labelStyle: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: selected
|
||||
? cs.onPrimaryContainer
|
||||
: cs.onSurfaceVariant,
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12, right: 12, bottom: 12),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: (_fetching || _saving || _autoFillFields.isEmpty)
|
||||
? null
|
||||
: _fetchAndFill,
|
||||
icon: _fetching
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.auto_fix_high),
|
||||
label: Text(
|
||||
_fetching
|
||||
? context.l10n.editMetadataAutoFillSearching
|
||||
: context.l10n.editMetadataAutoFillFetch,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_quickSelectButton(
|
||||
label: context.l10n.editMetadataSelectNone,
|
||||
onTap: _selectNoFields,
|
||||
cs: cs,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: _fieldDefs.keys.map((key) {
|
||||
final selected = _autoFillFields.contains(key);
|
||||
return FilterChip(
|
||||
label: Text(_fieldLabel(key)),
|
||||
selected: selected,
|
||||
onSelected: _fetching
|
||||
? null
|
||||
: (val) {
|
||||
setState(() {
|
||||
if (val) {
|
||||
_autoFillFields.add(key);
|
||||
} else {
|
||||
_autoFillFields.remove(key);
|
||||
}
|
||||
});
|
||||
},
|
||||
selectedColor: cs.primaryContainer,
|
||||
checkmarkColor: cs.onPrimaryContainer,
|
||||
labelStyle: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: selected ? cs.onPrimaryContainer : cs.onSurfaceVariant,
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: (_fetching || _saving || _autoFillFields.isEmpty)
|
||||
? null
|
||||
: _fetchAndFill,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
icon: _fetching
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.auto_fix_high),
|
||||
label: Text(
|
||||
_fetching
|
||||
? context.l10n.editMetadataAutoFillSearching
|
||||
: context.l10n.editMetadataAutoFillFetch,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1566,7 +1533,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
onTap: _fetching ? null : onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: cs.outline.withValues(alpha: 0.5)),
|
||||
@@ -1584,103 +1551,97 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
Widget _buildCoverEditor(ColorScheme cs) {
|
||||
final hasSelectedCover = _hasValue(_selectedCoverPath);
|
||||
final hasCurrentCover = _hasValue(_currentCoverPath);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.editMetadataFieldCover,
|
||||
return _sectionCard(
|
||||
icon: Icons.image_outlined,
|
||||
title: context.l10n.editMetadataFieldCover,
|
||||
children: [
|
||||
if (_loadingCurrentCover)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 8),
|
||||
child: LinearProgressIndicator(minHeight: 2),
|
||||
)
|
||||
else if (!hasCurrentCover && !hasSelectedCover)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
context.l10n.trackCoverNoEmbeddedArt,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelLarge?.copyWith(color: cs.onSurface),
|
||||
).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
if (_loadingCurrentCover)
|
||||
const LinearProgressIndicator(minHeight: 2)
|
||||
else if (!hasCurrentCover)
|
||||
Text(
|
||||
context.l10n.trackCoverNoEmbeddedArt,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _saving ? null : _pickCoverImage,
|
||||
icon: const Icon(Icons.image_outlined),
|
||||
label: Text(
|
||||
hasSelectedCover
|
||||
? context.l10n.trackCoverReplace
|
||||
: context.l10n.trackCoverPick,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _saving ? null : _pickCoverImage,
|
||||
icon: const Icon(Icons.image_outlined),
|
||||
label: Text(
|
||||
hasSelectedCover
|
||||
? context.l10n.trackCoverReplace
|
||||
: context.l10n.trackCoverPick,
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
if (hasSelectedCover) ...[
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
tooltip: context.l10n.trackCoverClearSelected,
|
||||
onPressed: _saving
|
||||
? null
|
||||
: () async {
|
||||
await _cleanupSelectedCoverTemp();
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
},
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (hasCurrentCover || hasSelectedCover) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
if (hasCurrentCover)
|
||||
Expanded(
|
||||
child: _buildCoverPreviewTile(
|
||||
cs: cs,
|
||||
path: _currentCoverPath!,
|
||||
label: context.l10n.trackCoverCurrent,
|
||||
),
|
||||
),
|
||||
if (hasCurrentCover && hasSelectedCover)
|
||||
const SizedBox(width: 12),
|
||||
if (hasSelectedCover)
|
||||
Expanded(
|
||||
child: _buildCoverPreviewTile(
|
||||
cs: cs,
|
||||
path: _selectedCoverPath!,
|
||||
label:
|
||||
_selectedCoverName ??
|
||||
context.l10n.trackCoverSelected,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (hasSelectedCover) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.trackCoverReplaceNotice,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (hasSelectedCover) ...[
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
tooltip: context.l10n.trackCoverClearSelected,
|
||||
onPressed: _saving
|
||||
? null
|
||||
: () async {
|
||||
await _cleanupSelectedCoverTemp();
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
},
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (hasCurrentCover || hasSelectedCover) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
if (hasCurrentCover)
|
||||
Expanded(
|
||||
child: _buildCoverPreviewTile(
|
||||
cs: cs,
|
||||
path: _currentCoverPath!,
|
||||
label: context.l10n.trackCoverCurrent,
|
||||
),
|
||||
),
|
||||
if (hasCurrentCover && hasSelectedCover)
|
||||
const SizedBox(width: 12),
|
||||
if (hasSelectedCover)
|
||||
Expanded(
|
||||
child: _buildCoverPreviewTile(
|
||||
cs: cs,
|
||||
path: _selectedCoverPath!,
|
||||
label:
|
||||
_selectedCoverName ?? context.l10n.trackCoverSelected,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (hasSelectedCover) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.trackCoverReplaceNotice,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1738,6 +1699,15 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Fill for input fields, one step apart from the card so each field reads as
|
||||
/// a distinct surface in light/dark/AMOLED.
|
||||
Color _fieldFill(ColorScheme cs) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
return isDark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), cs.surface)
|
||||
: cs.surface;
|
||||
}
|
||||
|
||||
Widget _field(
|
||||
String label,
|
||||
TextEditingController controller, {
|
||||
@@ -1746,36 +1716,141 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
int maxLines = 1,
|
||||
}) {
|
||||
final cs = widget.colorScheme;
|
||||
final radius = BorderRadius.circular(14);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 6),
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: controller,
|
||||
keyboardType: keyboard,
|
||||
maxLines: maxLines,
|
||||
cursorColor: cs.primary,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
filled: true,
|
||||
fillColor: _fieldFill(cs),
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: radius,
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: radius,
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: radius,
|
||||
borderSide: BorderSide(color: cs.primary, width: 1.5),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
RoundedRectangleBorder _sectionCardShape(ColorScheme cs) {
|
||||
return RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
side: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Titled section card. When [onHeaderTap] is set the header is a full-width
|
||||
/// tappable row (ripple clipped to the card) with an auto chevron.
|
||||
Widget _sectionCard({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required List<Widget> children,
|
||||
VoidCallback? onHeaderTap,
|
||||
bool expanded = true,
|
||||
}) {
|
||||
final cs = widget.colorScheme;
|
||||
final collapsible = onHeaderTap != null;
|
||||
|
||||
final headerRow = Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: cs.primary),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (collapsible)
|
||||
AnimatedRotation(
|
||||
turns: expanded ? 0.5 : 0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutCubic,
|
||||
child: Icon(
|
||||
Icons.expand_more,
|
||||
size: 22,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final Widget header = collapsible
|
||||
? InkWell(
|
||||
onTap: onHeaderTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||
child: headerRow,
|
||||
),
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: headerRow,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
keyboardType: keyboard,
|
||||
maxLines: maxLines,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
hintText: hint,
|
||||
filled: true,
|
||||
fillColor: cs.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: cs.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: cs.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: cs.primary, width: 2),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
margin: EdgeInsets.zero,
|
||||
color: settingsGroupColor(context),
|
||||
shape: _sectionCardShape(cs),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.topCenter,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
header,
|
||||
if (children.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: children,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/replaygain_service.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
@@ -24,8 +25,10 @@ import 'package:spotiflac_android/utils/mime_utils.dart';
|
||||
import 'package:spotiflac_android/utils/image_cache_utils.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/utils/int_utils.dart';
|
||||
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
|
||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
part 'track_metadata_edit_sheet.dart';
|
||||
|
||||
@@ -723,8 +726,24 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
String get _coverHeroTag =>
|
||||
widget.coverHeroTag ??
|
||||
(_isLocalItem ? 'cover_lib_$_itemId' : 'cover_$_itemId');
|
||||
String? get _coverUrl =>
|
||||
_isLocalItem ? null : normalizeRemoteHttpUrl(_downloadItem!.coverUrl);
|
||||
String? get _coverUrl => _isLocalItem
|
||||
? null
|
||||
: _highResCoverUrl(normalizeRemoteHttpUrl(_downloadItem!.coverUrl));
|
||||
|
||||
String? _highResCoverUrl(String? url) {
|
||||
if (url == null) return null;
|
||||
if (url.contains('ab67616d00001e02')) {
|
||||
return url.replaceAll('ab67616d00001e02', 'ab67616d0000b273');
|
||||
}
|
||||
final deezerRegex = RegExp(r'/(\d+)x(\d+)-(\d+)-(\d+)-(\d+)-(\d+)\.jpg$');
|
||||
if (url.contains('cdn-images.dzcdn.net') && deezerRegex.hasMatch(url)) {
|
||||
return url.replaceAllMapped(
|
||||
deezerRegex,
|
||||
(m) => '/1000x1000-${m[3]}-${m[4]}-${m[5]}-${m[6]}.jpg',
|
||||
);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
String? get _localCoverPath =>
|
||||
_isLocalItem ? _localLibraryItem!.coverPath : null;
|
||||
String? get _spotifyId => _isLocalItem ? null : _downloadItem!.spotifyId;
|
||||
@@ -1064,6 +1083,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final expandedHeight = _calculateExpandedHeight(context);
|
||||
final bottomInset = context.navBarBottomInset;
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
@@ -1142,6 +1162,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
SliverToBoxAdapter(
|
||||
child: _buildAnimatedTrackContent(context, ref, colorScheme),
|
||||
),
|
||||
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1466,6 +1487,15 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
RoundedRectangleBorder _sectionCardShape(ColorScheme colorScheme) {
|
||||
return RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
side: BorderSide(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetadataCard(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
@@ -1473,8 +1503,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
color: settingsGroupColor(context),
|
||||
shape: _sectionCardShape(colorScheme),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
@@ -1738,6 +1768,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
return switch (normalized) {
|
||||
'flac' => 'FLAC',
|
||||
'alac' => 'ALAC',
|
||||
'wav' || 'wave' => 'WAV',
|
||||
'aiff' || 'aif' || 'aifc' => 'AIFF',
|
||||
'eac3' || 'ec_3' => 'EAC3',
|
||||
'ac3' || 'ac_3' => 'AC3',
|
||||
'ac4' || 'ac_4' => 'AC4',
|
||||
@@ -1785,8 +1817,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
color: settingsGroupColor(context),
|
||||
shape: _sectionCardShape(colorScheme),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
@@ -1993,8 +2025,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
Widget _buildLyricsCard(BuildContext context, ColorScheme colorScheme) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
color: settingsGroupColor(context),
|
||||
shape: _sectionCardShape(colorScheme),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
@@ -3259,136 +3291,213 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
showModalBottomSheet<void>(
|
||||
context: screenContext,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
isScrollControlled: true,
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(screenContext).size.height * 0.7,
|
||||
),
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.copy),
|
||||
title: Text(sheetContext.l10n.trackCopyFilePath),
|
||||
onTap: () {
|
||||
_closeOptionsMenuAndRun(
|
||||
sheetContext,
|
||||
() => _copyToClipboard(screenContext, cleanFilePath),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (_fileExists)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.edit_outlined),
|
||||
title: Text(sheetContext.l10n.trackEditMetadata),
|
||||
onTap: () {
|
||||
_closeOptionsMenuAndRun(
|
||||
sheetContext,
|
||||
() => _showEditMetadataSheet(
|
||||
screenContext,
|
||||
ref,
|
||||
colorScheme,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (!_isLocalItem && (_coverUrl != null || _fileExists))
|
||||
ListTile(
|
||||
leading: const Icon(Icons.image_outlined),
|
||||
title: Text(sheetContext.l10n.trackSaveCoverArt),
|
||||
subtitle: Text(sheetContext.l10n.trackSaveCoverArtSubtitle),
|
||||
onTap: () {
|
||||
_closeOptionsMenuAndRun(sheetContext, _saveCoverArt);
|
||||
},
|
||||
),
|
||||
if (!_isLocalItem)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.lyrics_outlined),
|
||||
title: Text(sheetContext.l10n.trackSaveLyrics),
|
||||
subtitle: Text(sheetContext.l10n.trackSaveLyricsSubtitle),
|
||||
onTap: () {
|
||||
_closeOptionsMenuAndRun(sheetContext, _saveLyrics);
|
||||
},
|
||||
),
|
||||
if (_fileExists)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.travel_explore),
|
||||
title: Text(sheetContext.l10n.trackReEnrich),
|
||||
subtitle: Text(sheetContext.l10n.trackReEnrichOnlineSubtitle),
|
||||
onTap: () {
|
||||
_closeOptionsMenuAndRun(sheetContext, _reEnrichMetadata);
|
||||
},
|
||||
),
|
||||
if (_fileExists && _isConvertibleFormat)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.swap_horiz),
|
||||
title: Text(sheetContext.l10n.trackConvertFormat),
|
||||
subtitle: Text(sheetContext.l10n.trackConvertFormatSubtitle),
|
||||
onTap: () {
|
||||
_closeOptionsMenuAndRun(
|
||||
sheetContext,
|
||||
() => _showConvertSheet(screenContext),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (_fileExists && _isCueFile)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.call_split),
|
||||
title: Text(sheetContext.l10n.cueSplitTitle),
|
||||
subtitle: Text(sheetContext.l10n.cueSplitSubtitle),
|
||||
onTap: () {
|
||||
_closeOptionsMenuAndRun(
|
||||
sheetContext,
|
||||
() => _showCueSplitSheet(screenContext),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.share),
|
||||
title: Text(sheetContext.l10n.trackMetadataShare),
|
||||
onTap: () {
|
||||
_closeOptionsMenuAndRun(
|
||||
sheetContext,
|
||||
() => _shareFile(screenContext),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.delete, color: colorScheme.error),
|
||||
title: Text(
|
||||
sheetContext.l10n.trackRemoveFromDevice,
|
||||
style: TextStyle(color: colorScheme.error),
|
||||
),
|
||||
onTap: () {
|
||||
_closeOptionsMenuAndRun(
|
||||
sheetContext,
|
||||
() => _confirmDelete(screenContext, ref, colorScheme),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
builder: (sheetContext) {
|
||||
final l10n = sheetContext.l10n;
|
||||
|
||||
final options = <_MetadataOption>[
|
||||
_MetadataOption(
|
||||
icon: Icons.copy_outlined,
|
||||
label: l10n.trackCopyFilePath,
|
||||
onTap: () => _copyToClipboard(screenContext, cleanFilePath),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_fileExists)
|
||||
_MetadataOption(
|
||||
icon: Icons.edit_outlined,
|
||||
label: l10n.trackEditMetadata,
|
||||
onTap: () =>
|
||||
_showEditMetadataSheet(screenContext, ref, colorScheme),
|
||||
),
|
||||
if (!_isLocalItem && (_coverUrl != null || _fileExists))
|
||||
_MetadataOption(
|
||||
icon: Icons.image_outlined,
|
||||
label: l10n.trackSaveCoverArt,
|
||||
onTap: _saveCoverArt,
|
||||
),
|
||||
if (!_isLocalItem)
|
||||
_MetadataOption(
|
||||
icon: Icons.lyrics_outlined,
|
||||
label: l10n.trackSaveLyrics,
|
||||
onTap: _saveLyrics,
|
||||
),
|
||||
if (_fileExists)
|
||||
_MetadataOption(
|
||||
icon: Icons.travel_explore,
|
||||
label: l10n.trackReEnrich,
|
||||
onTap: _reEnrichMetadata,
|
||||
),
|
||||
if (_fileExists && _isConvertibleFormat)
|
||||
_MetadataOption(
|
||||
icon: Icons.swap_horiz,
|
||||
label: l10n.trackConvertFormat,
|
||||
onTap: () => _showConvertSheet(screenContext),
|
||||
),
|
||||
if (_fileExists && !_isCueFile)
|
||||
_MetadataOption(
|
||||
icon: Icons.graphic_eq,
|
||||
label: l10n.trackReplayGain,
|
||||
onTap: () => _rescanReplayGain(),
|
||||
),
|
||||
if (_fileExists && _isCueFile)
|
||||
_MetadataOption(
|
||||
icon: Icons.call_split,
|
||||
label: l10n.cueSplitTitle,
|
||||
onTap: () => _showCueSplitSheet(screenContext),
|
||||
),
|
||||
_MetadataOption(
|
||||
icon: Icons.share_outlined,
|
||||
label: l10n.trackMetadataShare,
|
||||
dividerAbove: true,
|
||||
onTap: () => _shareFile(screenContext),
|
||||
),
|
||||
_MetadataOption(
|
||||
icon: Icons.delete_outline,
|
||||
label: l10n.trackRemoveFromDevice,
|
||||
destructive: true,
|
||||
onTap: () => _confirmDelete(screenContext, ref, colorScheme),
|
||||
),
|
||||
];
|
||||
|
||||
return SafeArea(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(sheetContext).size.height * 0.85,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant.withValues(
|
||||
alpha: 0.4,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: _buildOptionsHeaderCover(colorScheme),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
trackName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(sheetContext)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(sheetContext)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SettingsGroup(
|
||||
children: [
|
||||
for (int i = 0; i < options.length; i++) ...[
|
||||
if (options[i].dividerAbove && i != 0)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: colorScheme.outlineVariant.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
),
|
||||
_MetadataOptionTile(
|
||||
option: options[i],
|
||||
colorScheme: colorScheme,
|
||||
onTap: () => _closeOptionsMenuAndRun(
|
||||
sheetContext,
|
||||
options[i].onTap,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOptionsHeaderCover(ColorScheme colorScheme) {
|
||||
const size = 56.0;
|
||||
const cacheWidth = 112;
|
||||
|
||||
Widget placeholder() => Container(
|
||||
width: size,
|
||||
height: size,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||
);
|
||||
|
||||
if (_coverUrl != null) {
|
||||
return CachedCoverImage(
|
||||
imageUrl: _coverUrl!,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheWidth,
|
||||
memCacheHeight: cacheWidth,
|
||||
errorWidget: (_, _, _) => placeholder(),
|
||||
);
|
||||
}
|
||||
|
||||
if (_localCoverPath != null && _localCoverPath!.isNotEmpty) {
|
||||
return Image.file(
|
||||
File(_localCoverPath!),
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: cacheWidth,
|
||||
errorBuilder: (_, _, _) => placeholder(),
|
||||
);
|
||||
}
|
||||
|
||||
return placeholder();
|
||||
}
|
||||
|
||||
/// Whether the current file format supports conversion
|
||||
bool get _isConvertibleFormat {
|
||||
final lower = cleanFilePath.toLowerCase();
|
||||
@@ -3530,7 +3639,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
String _buildConvertedQualityLabel(String targetFormat, String bitrate) {
|
||||
final upper = targetFormat.toUpperCase();
|
||||
if (upper == 'ALAC' || upper == 'FLAC') {
|
||||
if (isLosslessConversionTarget(targetFormat)) {
|
||||
return '$upper Lossless';
|
||||
}
|
||||
final normalizedBitrate = bitrate.trim().toLowerCase();
|
||||
@@ -3572,17 +3681,50 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
Future<void> _rescanReplayGain() async {
|
||||
if (!_fileExists) return;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
messenger.clearSnackBars();
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.trackReplayGainScanning),
|
||||
duration: const Duration(seconds: 30),
|
||||
),
|
||||
);
|
||||
bool ok = false;
|
||||
try {
|
||||
ok = await ReplayGainService.applyToFile(cleanFilePath);
|
||||
} catch (e) {
|
||||
_log.w('ReplayGain rescan failed: $e');
|
||||
}
|
||||
if (!mounted) return;
|
||||
messenger.hideCurrentSnackBar();
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
ok
|
||||
? context.l10n.trackReplayGainSuccess
|
||||
: context.l10n.trackReplayGainFailed,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showConvertSheet(BuildContext context) {
|
||||
final currentFormat = _currentFileFormat;
|
||||
final isLosslessSource = currentFormat == 'FLAC' || currentFormat == 'M4A';
|
||||
final isLosslessSource = isLosslessConversionSource(currentFormat);
|
||||
|
||||
final formats = <String>[];
|
||||
if (currentFormat == 'FLAC') {
|
||||
formats.addAll(['ALAC', 'AAC', 'MP3', 'Opus']);
|
||||
formats.addAll(['ALAC', 'WAV', 'AIFF', 'AAC', 'MP3', 'Opus']);
|
||||
} else if (currentFormat == 'ALAC') {
|
||||
formats.addAll(['FLAC', 'AAC', 'MP3', 'Opus']);
|
||||
formats.addAll(['FLAC', 'WAV', 'AIFF', 'AAC', 'MP3', 'Opus']);
|
||||
} else if (currentFormat == 'M4A') {
|
||||
formats.addAll(['ALAC', 'FLAC', 'AAC', 'MP3', 'Opus']);
|
||||
formats.addAll(['ALAC', 'FLAC', 'WAV', 'AIFF', 'AAC', 'MP3', 'Opus']);
|
||||
} else if (currentFormat == 'WAV') {
|
||||
formats.addAll(['FLAC', 'ALAC', 'AIFF', 'AAC', 'MP3', 'Opus']);
|
||||
} else if (currentFormat == 'AIFF') {
|
||||
formats.addAll(['FLAC', 'ALAC', 'WAV', 'AAC', 'MP3', 'Opus']);
|
||||
} else if (currentFormat == 'AAC') {
|
||||
formats.addAll(['MP3', 'Opus']);
|
||||
} else if (currentFormat == 'MP3') {
|
||||
@@ -3601,8 +3743,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
String selectedBitrate = defaultBitrateForFormat(selectedFormat);
|
||||
bool isLosslessTarget =
|
||||
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
|
||||
bool isLosslessTarget = isLosslessConversionTarget(selectedFormat);
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@@ -3616,9 +3757,84 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final bitrates = ['128k', '192k', '256k', '320k'];
|
||||
|
||||
Widget card({required Widget child}) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: settingsGroupColor(context),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget sectionLabel(String text) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 2, bottom: 12),
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget choice({
|
||||
required String label,
|
||||
required bool selected,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return Material(
|
||||
color: selected
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 18,
|
||||
vertical: 11,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? Colors.transparent
|
||||
: colorScheme.outlineVariant.withValues(
|
||||
alpha: 0.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: selected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurface,
|
||||
fontWeight: selected
|
||||
? FontWeight.w600
|
||||
: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -3635,96 +3851,112 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
context.l10n.trackConvertTitle,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
context.l10n.trackConvertTargetFormat,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
currentFormat,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: formats.map((format) {
|
||||
final isSelected = format == selectedFormat;
|
||||
return ChoiceChip(
|
||||
label: Text(format),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() {
|
||||
selectedFormat = format;
|
||||
isLosslessTarget =
|
||||
format == 'ALAC' || format == 'FLAC';
|
||||
if (!isLosslessTarget) {
|
||||
selectedBitrate = defaultBitrateForFormat(
|
||||
format,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
if (!isLosslessTarget) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.trackConvertBitrate,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: bitrates.map((br) {
|
||||
final isSelected = br == selectedBitrate;
|
||||
return ChoiceChip(
|
||||
label: Text(br),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() => selectedBitrate = br);
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
|
||||
if (isLosslessTarget && isLosslessSource) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.verified,
|
||||
size: 16,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
context.l10n.trackConvertLosslessHint,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.primary),
|
||||
sectionLabel(context.l10n.trackConvertTargetFormat),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: formats.map((format) {
|
||||
return choice(
|
||||
label: format,
|
||||
selected: format == selectedFormat,
|
||||
onTap: () {
|
||||
setSheetState(() {
|
||||
selectedFormat = format;
|
||||
isLosslessTarget =
|
||||
isLosslessConversionTarget(format);
|
||||
if (!isLosslessTarget) {
|
||||
selectedBitrate =
|
||||
defaultBitrateForFormat(format);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
),
|
||||
|
||||
if (!isLosslessTarget)
|
||||
card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
sectionLabel(context.l10n.trackConvertBitrate),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: bitrates.map((br) {
|
||||
return choice(
|
||||
label: br,
|
||||
selected: br == selectedBitrate,
|
||||
onTap: () => setSheetState(
|
||||
() => selectedBitrate = br,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (isLosslessTarget && isLosslessSource)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer.withValues(
|
||||
alpha: 0.4,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.verified,
|
||||
size: 18,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.trackConvertLosslessHint,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.primary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
child: FilledButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_confirmAndConvert(
|
||||
@@ -3734,20 +3966,20 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
bitrate: selectedBitrate,
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.swap_horiz),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label: Text(
|
||||
isLosslessTarget
|
||||
? '$currentFormat -> $selectedFormat (Lossless)'
|
||||
: '$currentFormat -> $selectedFormat @ $selectedBitrate',
|
||||
? '$currentFormat → $selectedFormat (Lossless)'
|
||||
: '$currentFormat → $selectedFormat @ $selectedBitrate',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -4216,9 +4448,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
required String targetFormat,
|
||||
required String bitrate,
|
||||
}) {
|
||||
final isLossless =
|
||||
targetFormat.toUpperCase() == 'ALAC' ||
|
||||
targetFormat.toUpperCase() == 'FLAC';
|
||||
final isLossless = isLosslessConversionTarget(targetFormat);
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
@@ -4425,30 +4655,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
final baseName = dotIdx > 0
|
||||
? oldFileName.substring(0, dotIdx)
|
||||
: oldFileName;
|
||||
String newExt;
|
||||
String mimeType;
|
||||
switch (targetFormat.toLowerCase()) {
|
||||
case 'opus':
|
||||
newExt = '.opus';
|
||||
mimeType = 'audio/opus';
|
||||
break;
|
||||
case 'aac':
|
||||
newExt = '.m4a';
|
||||
mimeType = 'audio/mp4';
|
||||
break;
|
||||
case 'alac':
|
||||
newExt = '.m4a';
|
||||
mimeType = 'audio/mp4';
|
||||
break;
|
||||
case 'flac':
|
||||
newExt = '.flac';
|
||||
mimeType = 'audio/flac';
|
||||
break;
|
||||
default:
|
||||
newExt = '.mp3';
|
||||
mimeType = 'audio/mpeg';
|
||||
break;
|
||||
}
|
||||
final convTarget = convertTargetExtAndMime(targetFormat);
|
||||
final newExt = convTarget.ext;
|
||||
final mimeType = convTarget.mime;
|
||||
final newFileName = '$baseName$newExt';
|
||||
|
||||
final safUri = await PlatformBridge.createSafFileFromPath(
|
||||
@@ -4859,3 +5068,62 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _MetadataOption {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final bool destructive;
|
||||
final bool dividerAbove;
|
||||
|
||||
const _MetadataOption({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.destructive = false,
|
||||
this.dividerAbove = false,
|
||||
});
|
||||
}
|
||||
|
||||
class _MetadataOptionTile extends StatelessWidget {
|
||||
final _MetadataOption option;
|
||||
final ColorScheme colorScheme;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _MetadataOptionTile({
|
||||
required this.option,
|
||||
required this.colorScheme,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final iconColor = option.destructive
|
||||
? colorScheme.error
|
||||
: colorScheme.onSurfaceVariant;
|
||||
final titleColor = option.destructive ? colorScheme.error : null;
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
splashColor: colorScheme.primary.withValues(alpha: 0.12),
|
||||
highlightColor: Colors.transparent,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(option.icon, color: iconColor, size: 24),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
option.label,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge?.copyWith(color: titleColor),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:ffmpeg_kit_flutter_new_full/ffprobe_kit.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_full/return_code.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_full/session_state.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
@@ -283,6 +284,28 @@ class FFmpegService {
|
||||
}.contains(normalized);
|
||||
}
|
||||
|
||||
/// Probes the source audio bit depth (bits_per_raw_sample, falling back to
|
||||
/// bits_per_sample). Returns null when unknown.
|
||||
static Future<int?> probeBitDepth(String filePath) async {
|
||||
try {
|
||||
final session = await FFprobeKit.getMediaInformation(filePath);
|
||||
final info = session.getMediaInformation();
|
||||
if (info == null) return null;
|
||||
for (final stream in info.getStreams()) {
|
||||
final props = stream.getAllProperties() ?? const <String, dynamic>{};
|
||||
if (props['codec_type']?.toString() != 'audio') continue;
|
||||
final raw = props['bits_per_raw_sample']?.toString();
|
||||
final bps = props['bits_per_sample']?.toString();
|
||||
final v = int.tryParse(raw ?? '') ?? int.tryParse(bps ?? '');
|
||||
if (v != null && v > 0) return v;
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Bit depth probe failed for $filePath: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Returns `true` when [filePath] starts with the native FLAC magic bytes
|
||||
/// (`fLaC`). Useful to distinguish a real FLAC file from a FLAC-in-MP4
|
||||
/// container that carries a `.flac` extension or claims codec=flac.
|
||||
@@ -617,7 +640,7 @@ class FFmpegService {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String?> prepareTidalDashManifestForNativePlayback({
|
||||
static Future<String?> prepareDashManifestForNativePlayback({
|
||||
required String manifestPayload,
|
||||
bool registerAsActive = true,
|
||||
}) async {
|
||||
@@ -630,7 +653,7 @@ class FFmpegService {
|
||||
|
||||
final manifestPath = await _writeTempManifestFile(payload);
|
||||
if (manifestPath == null) {
|
||||
_log.e('Failed to prepare Tidal DASH manifest for native playback');
|
||||
_log.e('Failed to prepare DASH manifest for native playback');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -739,7 +762,7 @@ class FFmpegService {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<LiveDecryptedStreamResult?> startTidalDashLiveStream({
|
||||
static Future<LiveDecryptedStreamResult?> startDashLiveStream({
|
||||
required String manifestPayload,
|
||||
String preferredFormat = 'm4a',
|
||||
}) async {
|
||||
@@ -752,7 +775,7 @@ class FFmpegService {
|
||||
|
||||
final manifestPath = await _writeTempManifestFile(payload);
|
||||
if (manifestPath == null) {
|
||||
_log.e('Failed to prepare Tidal DASH manifest for live stream');
|
||||
_log.e('Failed to prepare DASH manifest for live stream');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -797,7 +820,7 @@ class FFmpegService {
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final manifestPath =
|
||||
'${tempDir.path}${Platform.pathSeparator}tidal_dash_${DateTime.now().microsecondsSinceEpoch}.mpd';
|
||||
'${tempDir.path}${Platform.pathSeparator}dash_${DateTime.now().microsecondsSinceEpoch}.mpd';
|
||||
await File(manifestPath).writeAsString(manifestText, flush: true);
|
||||
return manifestPath;
|
||||
}
|
||||
@@ -875,7 +898,7 @@ class FFmpegService {
|
||||
];
|
||||
|
||||
_log.d(
|
||||
'Starting Tidal DASH tunnel: ${_previewCommandForLog(commandArguments.join(' '))}',
|
||||
'Starting DASH tunnel: ${_previewCommandForLog(commandArguments.join(' '))}',
|
||||
);
|
||||
|
||||
final session = await FFmpegKit.executeWithArgumentsAsync(commandArguments);
|
||||
@@ -891,9 +914,9 @@ class FFmpegService {
|
||||
final state = await session.getState();
|
||||
final output = (await session.getOutput() ?? '').trim();
|
||||
if (output.isNotEmpty) {
|
||||
_log.w('Tidal DASH tunnel failed ($ext): $output');
|
||||
_log.w('DASH tunnel failed ($ext): $output');
|
||||
} else {
|
||||
_log.w('Tidal DASH tunnel failed ($ext) with session state: $state');
|
||||
_log.w('DASH tunnel failed ($ext) with session state: $state');
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1292,6 +1315,71 @@ class FFmpegService {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Write track ReplayGain tags to a file via FFmpeg, replacing it in place.
|
||||
///
|
||||
/// Used for formats that are not handled by the native tag writers
|
||||
/// (MP3/Opus). All existing streams and metadata are preserved via
|
||||
/// `-map 0 -c copy -map_metadata 0`; only the REPLAYGAIN_TRACK_* fields are
|
||||
/// added/overwritten. Returns `true` when the file was rewritten in place.
|
||||
static Future<bool> writeTrackReplayGainTags(
|
||||
String filePath,
|
||||
String trackGain,
|
||||
String trackPeak,
|
||||
) async {
|
||||
final ext = filePath.contains('.')
|
||||
? '.${filePath.split('.').last}'
|
||||
: '.tmp';
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempOutput = _nextTempEmbedPath(tempDir.path, ext);
|
||||
final arguments = <String>[
|
||||
'-v',
|
||||
'error',
|
||||
'-hide_banner',
|
||||
'-i',
|
||||
filePath,
|
||||
'-map',
|
||||
'0',
|
||||
'-c',
|
||||
'copy',
|
||||
'-map_metadata',
|
||||
'0',
|
||||
'-metadata',
|
||||
'REPLAYGAIN_TRACK_GAIN=$trackGain',
|
||||
'-metadata',
|
||||
'REPLAYGAIN_TRACK_PEAK=$trackPeak',
|
||||
tempOutput,
|
||||
'-y',
|
||||
];
|
||||
|
||||
_log.d('Writing track ReplayGain tags via FFmpeg');
|
||||
final result = await _executeWithArguments(arguments);
|
||||
|
||||
if (result.success) {
|
||||
try {
|
||||
final tempFile = File(tempOutput);
|
||||
if (await tempFile.exists()) {
|
||||
final originalFile = File(filePath);
|
||||
if (await originalFile.exists()) {
|
||||
await originalFile.delete();
|
||||
}
|
||||
await tempFile.copy(filePath);
|
||||
await tempFile.delete();
|
||||
_log.d('Track ReplayGain tags written successfully');
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed to replace file with track ReplayGain: $e');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final tempFile = File(tempOutput);
|
||||
if (await tempFile.exists()) await tempFile.delete();
|
||||
} catch (_) {}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<String?> embedMetadata({
|
||||
required String flacPath,
|
||||
String? coverPath,
|
||||
@@ -2054,8 +2142,9 @@ class FFmpegService {
|
||||
}
|
||||
|
||||
/// Unified audio format conversion with full metadata + cover preservation.
|
||||
/// Supports: FLAC/M4A/MP3/Opus -> AAC/M4A/MP3/Opus/ALAC/FLAC.
|
||||
/// ALAC and FLAC targets are lossless (bitrate parameter is ignored).
|
||||
/// Supports: FLAC/M4A/MP3/Opus -> AAC/M4A/MP3/Opus/ALAC/FLAC/WAV/AIFF.
|
||||
/// ALAC, FLAC, WAV and AIFF targets are lossless (bitrate parameter is ignored).
|
||||
/// [sourceBitDepth] (when known) preserves 24-bit resolution for WAV/AIFF.
|
||||
static Future<String?> convertAudioFormat({
|
||||
required String inputPath,
|
||||
required String targetFormat,
|
||||
@@ -2064,9 +2153,19 @@ class FFmpegService {
|
||||
String? coverPath,
|
||||
String artistTagMode = artistTagModeJoined,
|
||||
bool deleteOriginal = true,
|
||||
int? sourceBitDepth,
|
||||
}) async {
|
||||
final format = targetFormat.toLowerCase();
|
||||
if (!const {'mp3', 'opus', 'aac', 'alac', 'flac'}.contains(format)) {
|
||||
if (!const {
|
||||
'mp3',
|
||||
'opus',
|
||||
'aac',
|
||||
'alac',
|
||||
'flac',
|
||||
'wav',
|
||||
'aiff',
|
||||
'aif',
|
||||
}.contains(format)) {
|
||||
_log.e('Unsupported target format: $targetFormat');
|
||||
return null;
|
||||
}
|
||||
@@ -2088,6 +2187,16 @@ class FFmpegService {
|
||||
deleteOriginal: deleteOriginal,
|
||||
);
|
||||
}
|
||||
if (format == 'wav' || format == 'aiff' || format == 'aif') {
|
||||
return _convertToPcm(
|
||||
inputPath: inputPath,
|
||||
metadata: metadata,
|
||||
coverPath: coverPath,
|
||||
container: format == 'wav' ? 'wav' : 'aiff',
|
||||
sourceBitDepth: sourceBitDepth,
|
||||
deleteOriginal: deleteOriginal,
|
||||
);
|
||||
}
|
||||
|
||||
final extension = switch (format) {
|
||||
'opus' => '.opus',
|
||||
@@ -2325,6 +2434,205 @@ class FFmpegService {
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
/// Convert to uncompressed PCM (WAV or AIFF), preserving bit depth when known.
|
||||
/// Tags and cover are written natively into an embedded ID3 chunk by the Go
|
||||
/// backend (RIFF "id3 " for WAV, "ID3 " for AIFF) for full-fidelity tagging.
|
||||
static Future<String?> _convertToPcm({
|
||||
required String inputPath,
|
||||
required Map<String, String> metadata,
|
||||
required String container, // 'wav' or 'aiff'
|
||||
String? coverPath,
|
||||
int? sourceBitDepth,
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
final isAiff = container == 'aiff';
|
||||
final outputPath = _buildOutputPath(inputPath, isAiff ? '.aiff' : '.wav');
|
||||
var depth = sourceBitDepth;
|
||||
if (depth == null || depth <= 0) {
|
||||
depth = await probeBitDepth(inputPath);
|
||||
}
|
||||
final use24 = depth != null && depth >= 24;
|
||||
final codec = isAiff
|
||||
? (use24 ? 'pcm_s24be' : 'pcm_s16be')
|
||||
: (use24 ? 'pcm_s24le' : 'pcm_s16le');
|
||||
|
||||
final arguments = <String>[
|
||||
'-v', 'error', '-hide_banner',
|
||||
'-i', inputPath,
|
||||
'-map', '0:a',
|
||||
'-c:a', codec,
|
||||
'-map_metadata', '-1',
|
||||
outputPath,
|
||||
'-y',
|
||||
];
|
||||
|
||||
_log.i(
|
||||
'Converting ${inputPath.split(Platform.pathSeparator).last} to '
|
||||
'${container.toUpperCase()} (${use24 ? 24 : 16}-bit)',
|
||||
);
|
||||
final result = await _executeWithArguments(arguments);
|
||||
if (!result.success) {
|
||||
_log.e('${container.toUpperCase()} conversion failed: ${result.output}');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Write tags + cover via the native ID3-chunk writer in the Go backend.
|
||||
final hasMetadata = metadata.values.any((v) => v.trim().isNotEmpty);
|
||||
final hasCover = coverPath != null && coverPath.trim().isNotEmpty;
|
||||
if (hasMetadata || hasCover) {
|
||||
final ok = await _embedChunkTagsNative(outputPath, metadata, coverPath);
|
||||
if (!ok) {
|
||||
_log.w(
|
||||
'Native tag embed failed for $container output (file kept untagged)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (deleteOriginal) {
|
||||
try {
|
||||
await File(inputPath).delete();
|
||||
_log.i(
|
||||
'Deleted original: ${inputPath.split(Platform.pathSeparator).last}',
|
||||
);
|
||||
} catch (e) {
|
||||
_log.w('Failed to delete original: $e');
|
||||
}
|
||||
}
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
/// Writes tags + cover into a WAV/AIFF file via the Go native ID3-chunk
|
||||
/// writer (PlatformBridge.editFileMetadata). Maps Vorbis-style metadata keys
|
||||
/// to the lowercase field names the Go editor expects.
|
||||
static Future<bool> _embedChunkTagsNative(
|
||||
String path,
|
||||
Map<String, String> vorbisMetadata,
|
||||
String? coverPath,
|
||||
) async {
|
||||
final fields = _vorbisToNativeChunkFields(vorbisMetadata);
|
||||
if (coverPath != null && coverPath.trim().isNotEmpty) {
|
||||
fields['cover_path'] = coverPath;
|
||||
}
|
||||
if (fields.isEmpty) return true;
|
||||
try {
|
||||
final res = await PlatformBridge.editFileMetadata(path, fields);
|
||||
return res['error'] == null;
|
||||
} catch (e) {
|
||||
_log.w('editFileMetadata for $path failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps Vorbis-comment style metadata (UPPERCASE keys) to the lowercase field
|
||||
/// names consumed by the Go EditFileMetadata native WAV/AIFF tag writer.
|
||||
static Map<String, String> _vorbisToNativeChunkFields(
|
||||
Map<String, String> metadata,
|
||||
) {
|
||||
final out = <String, String>{};
|
||||
|
||||
void setIndexPair(String numberKey, String totalKey, String value) {
|
||||
final v = value.trim();
|
||||
if (v.isEmpty || v == '0') return;
|
||||
if (v.contains('/')) {
|
||||
final parts = v.split('/');
|
||||
out[numberKey] = parts[0].trim();
|
||||
if (parts.length > 1 && parts[1].trim().isNotEmpty) {
|
||||
out[totalKey] = parts[1].trim();
|
||||
}
|
||||
} else {
|
||||
out[numberKey] = v;
|
||||
}
|
||||
}
|
||||
|
||||
for (final entry in metadata.entries) {
|
||||
final normalizedKey = entry.key.toUpperCase().replaceAll(
|
||||
RegExp(r'[^A-Z0-9]'),
|
||||
'',
|
||||
);
|
||||
final value = entry.value;
|
||||
if (value.trim().isEmpty) continue;
|
||||
|
||||
switch (normalizedKey) {
|
||||
case 'TITLE':
|
||||
out['title'] = value;
|
||||
break;
|
||||
case 'ARTIST':
|
||||
out['artist'] = value;
|
||||
break;
|
||||
case 'ALBUM':
|
||||
out['album'] = value;
|
||||
break;
|
||||
case 'ALBUMARTIST':
|
||||
out['album_artist'] = value;
|
||||
break;
|
||||
case 'TRACKNUMBER':
|
||||
case 'TRACK':
|
||||
case 'TRCK':
|
||||
setIndexPair('track_number', 'track_total', value);
|
||||
break;
|
||||
case 'TRACKTOTAL':
|
||||
case 'TOTALTRACKS':
|
||||
if (value.trim() != '0') out['track_total'] = value.trim();
|
||||
break;
|
||||
case 'DISCNUMBER':
|
||||
case 'DISC':
|
||||
case 'TPOS':
|
||||
setIndexPair('disc_number', 'disc_total', value);
|
||||
break;
|
||||
case 'DISCTOTAL':
|
||||
case 'TOTALDISCS':
|
||||
if (value.trim() != '0') out['disc_total'] = value.trim();
|
||||
break;
|
||||
case 'DATE':
|
||||
out['date'] = value;
|
||||
break;
|
||||
case 'YEAR':
|
||||
if ((out['date'] ?? '').isEmpty) out['date'] = value;
|
||||
break;
|
||||
case 'ISRC':
|
||||
out['isrc'] = value;
|
||||
break;
|
||||
case 'GENRE':
|
||||
out['genre'] = value;
|
||||
break;
|
||||
case 'COMPOSER':
|
||||
out['composer'] = value;
|
||||
break;
|
||||
case 'ORGANIZATION':
|
||||
case 'LABEL':
|
||||
case 'PUBLISHER':
|
||||
out['label'] = value;
|
||||
break;
|
||||
case 'COPYRIGHT':
|
||||
out['copyright'] = value;
|
||||
break;
|
||||
case 'COMMENT':
|
||||
case 'DESCRIPTION':
|
||||
out['comment'] = value;
|
||||
break;
|
||||
case 'LYRICS':
|
||||
case 'UNSYNCEDLYRICS':
|
||||
out['lyrics'] = value;
|
||||
break;
|
||||
case 'REPLAYGAINTRACKGAIN':
|
||||
out['replaygain_track_gain'] = value;
|
||||
break;
|
||||
case 'REPLAYGAINTRACKPEAK':
|
||||
out['replaygain_track_peak'] = value;
|
||||
break;
|
||||
case 'REPLAYGAINALBUMGAIN':
|
||||
out['replaygain_album_gain'] = value;
|
||||
break;
|
||||
case 'REPLAYGAINALBUMPEAK':
|
||||
out['replaygain_album_peak'] = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Normalize metadata keys to standard Vorbis comment names, filtering out
|
||||
/// technical fields (bit_depth, sample_rate, duration, etc.).
|
||||
static Map<String, String> _normalizeToVorbisComments(
|
||||
|
||||
@@ -2022,6 +2022,11 @@ class LibraryDatabase {
|
||||
return 'flac';
|
||||
case 'opus':
|
||||
return 'opus';
|
||||
case 'wav':
|
||||
return 'wav';
|
||||
case 'aiff':
|
||||
case 'aif':
|
||||
return 'aiff';
|
||||
default:
|
||||
return 'mp3';
|
||||
}
|
||||
|
||||
@@ -865,6 +865,22 @@ class PlatformBridge {
|
||||
return result as bool;
|
||||
}
|
||||
|
||||
/// iOS only: keep in-flight downloads running briefly after backgrounding.
|
||||
static Future<void> beginBackgroundDownloadTask() async {
|
||||
if (!Platform.isIOS) return;
|
||||
try {
|
||||
await _channel.invokeMethod('beginBackgroundDownloadTask');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// iOS only: stop the background-time extension (queue finished or paused).
|
||||
static Future<void> endBackgroundDownloadTask() async {
|
||||
if (!Platform.isIOS) return;
|
||||
try {
|
||||
await _channel.invokeMethod('endBackgroundDownloadTask');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<void> startNativeDownloadWorker({
|
||||
required List<Map<String, dynamic>> requests,
|
||||
Map<String, dynamic> settings = const {},
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
/// Standalone ReplayGain (re)scanning for existing audio files.
|
||||
///
|
||||
/// Computes EBU R128 loudness via FFmpeg and writes REPLAYGAIN_TRACK_* tags
|
||||
/// back into the file in place:
|
||||
/// - FLAC / M4A / MP4 / APE / WV / MPC -> native tag writer (PlatformBridge)
|
||||
/// - MP3 / Opus / OGG / others -> FFmpeg copy-with-metadata
|
||||
///
|
||||
/// Handles SAF content:// URIs transparently by working on a temporary copy
|
||||
/// and writing it back to the original document.
|
||||
class ReplayGainService {
|
||||
ReplayGainService._();
|
||||
|
||||
static final _log = AppLogger('ReplayGain');
|
||||
|
||||
static const _nativeExtensions = <String>{
|
||||
'.flac',
|
||||
'.m4a',
|
||||
'.mp4',
|
||||
'.m4b',
|
||||
'.ape',
|
||||
'.wv',
|
||||
'.mpc',
|
||||
'.wav',
|
||||
'.aiff',
|
||||
'.aif',
|
||||
'.aifc',
|
||||
};
|
||||
|
||||
static bool _isNativeWritableFormat(String path) {
|
||||
final lower = path.toLowerCase();
|
||||
return _nativeExtensions.any(lower.endsWith);
|
||||
}
|
||||
|
||||
/// Scans [filePath] for loudness and writes track ReplayGain tags in place.
|
||||
///
|
||||
/// Returns `true` when tags were successfully written, `false` otherwise
|
||||
/// (scan failed, write failed, or SAF write-back failed).
|
||||
static Future<bool> applyToFile(String filePath) async {
|
||||
if (filePath.isEmpty) return false;
|
||||
|
||||
final isSaf = isContentUri(filePath);
|
||||
var workingPath = filePath;
|
||||
String? safTempPath;
|
||||
|
||||
try {
|
||||
if (isSaf) {
|
||||
safTempPath = await PlatformBridge.copyContentUriToTemp(filePath);
|
||||
if (safTempPath == null || safTempPath.isEmpty) {
|
||||
_log.w('Failed to copy SAF file to temp for ReplayGain scan');
|
||||
return false;
|
||||
}
|
||||
workingPath = safTempPath;
|
||||
}
|
||||
|
||||
final rg = await FFmpegService.scanReplayGain(workingPath);
|
||||
if (rg == null) {
|
||||
_log.w('ReplayGain scan returned no result for $workingPath');
|
||||
return false;
|
||||
}
|
||||
|
||||
bool written;
|
||||
if (_isNativeWritableFormat(workingPath)) {
|
||||
final result = await PlatformBridge.editFileMetadata(workingPath, {
|
||||
'replaygain_track_gain': rg.trackGain,
|
||||
'replaygain_track_peak': rg.trackPeak,
|
||||
});
|
||||
written = result['error'] == null;
|
||||
if (!written) {
|
||||
_log.w('Native ReplayGain write failed: ${result['error']}');
|
||||
}
|
||||
} else {
|
||||
written = await FFmpegService.writeTrackReplayGainTags(
|
||||
workingPath,
|
||||
rg.trackGain,
|
||||
rg.trackPeak,
|
||||
);
|
||||
}
|
||||
|
||||
if (!written) return false;
|
||||
|
||||
if (isSaf) {
|
||||
final ok = await PlatformBridge.writeTempToSaf(workingPath, filePath);
|
||||
if (!ok) {
|
||||
_log.w('Failed to write ReplayGain temp file back to SAF document');
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
_log.e('Failed to apply ReplayGain', e);
|
||||
return false;
|
||||
} finally {
|
||||
if (safTempPath != null) {
|
||||
try {
|
||||
await File(safTempPath).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/cupertino.dart' show CupertinoPageTransitionsBuilder;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:spotiflac_android/models/theme_settings.dart';
|
||||
@@ -5,6 +6,27 @@ import 'package:spotiflac_android/models/theme_settings.dart';
|
||||
class AppTheme {
|
||||
static const Color defaultSeedColor = Color(kDefaultSeedColor);
|
||||
|
||||
// Override Flutter's default page transitions. Recent Flutter defaults the
|
||||
// Android route transition to PredictiveBackPageTransitionsBuilder, whose
|
||||
// gesture detector mis-routes the predictive-back gesture to a nested
|
||||
// Navigator instead of the topmost route (flutter#152323). That pops the page
|
||||
// *behind* a root modal/sheet/dialog instead of closing the modal first — a
|
||||
// regression introduced by the Flutter upgrade. Forcing a non-predictive
|
||||
// builder restores the correct back order (close modal, then pop page), at the
|
||||
// cost of the predictive-back preview animation.
|
||||
static const PageTransitionsTheme _pageTransitionsTheme = PageTransitionsTheme(
|
||||
builders: <TargetPlatform, PageTransitionsBuilder>{
|
||||
// Android default is PredictiveBackPageTransitionsBuilder, whose
|
||||
// _PredictiveBackGestureDetector mis-routes the back gesture to a nested
|
||||
// Navigator (flutter#152323). For NON-gesture transitions that builder
|
||||
// already delegates to FadeForwardsPageTransitionsBuilder, so we use it
|
||||
// directly: identical push/pop animation, minus the buggy gesture detector.
|
||||
TargetPlatform.android: FadeForwardsPageTransitionsBuilder(),
|
||||
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
|
||||
TargetPlatform.macOS: CupertinoPageTransitionsBuilder(),
|
||||
},
|
||||
);
|
||||
|
||||
static ThemeData light({ColorScheme? dynamicScheme, Color? seedColor}) {
|
||||
final scheme =
|
||||
dynamicScheme ??
|
||||
@@ -16,6 +38,7 @@ class AppTheme {
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
pageTransitionsTheme: _pageTransitionsTheme,
|
||||
appBarTheme: _appBarTheme(scheme),
|
||||
cardTheme: _cardTheme(scheme),
|
||||
elevatedButtonTheme: _elevatedButtonTheme(scheme),
|
||||
@@ -51,6 +74,7 @@ class AppTheme {
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
pageTransitionsTheme: _pageTransitionsTheme,
|
||||
scaffoldBackgroundColor: isAmoled ? Colors.black : null,
|
||||
appBarTheme: _appBarTheme(scheme, isAmoled: isAmoled),
|
||||
cardTheme: _cardTheme(scheme),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const List<String> audioConversionTargetFormats = [
|
||||
'ALAC',
|
||||
'FLAC',
|
||||
'WAV',
|
||||
'AIFF',
|
||||
'AAC',
|
||||
'MP3',
|
||||
'Opus',
|
||||
@@ -8,7 +10,11 @@ const List<String> audioConversionTargetFormats = [
|
||||
|
||||
bool isLosslessConversionTarget(String targetFormat) {
|
||||
final normalized = targetFormat.trim().toLowerCase();
|
||||
return normalized == 'alac' || normalized == 'flac';
|
||||
return normalized == 'alac' ||
|
||||
normalized == 'flac' ||
|
||||
normalized == 'wav' ||
|
||||
normalized == 'aiff' ||
|
||||
normalized == 'aif';
|
||||
}
|
||||
|
||||
bool isLosslessConversionSource(String sourceFormat) {
|
||||
@@ -16,6 +22,9 @@ bool isLosslessConversionSource(String sourceFormat) {
|
||||
case 'FLAC':
|
||||
case 'ALAC':
|
||||
case 'M4A':
|
||||
case 'WAV':
|
||||
case 'AIFF':
|
||||
case 'AIF':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
@@ -66,6 +75,13 @@ String? _convertibleAudioFormatLabel(String? rawFormat) {
|
||||
return 'FLAC';
|
||||
case 'alac':
|
||||
return 'ALAC';
|
||||
case 'wav':
|
||||
case 'wave':
|
||||
return 'WAV';
|
||||
case 'aiff':
|
||||
case 'aif':
|
||||
case 'aifc':
|
||||
return 'AIFF';
|
||||
case 'm4a':
|
||||
case 'mp4':
|
||||
return 'M4A';
|
||||
@@ -95,6 +111,28 @@ String normalizedConvertedAudioFormat(String targetFormat) {
|
||||
return targetFormat.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/// Returns the output file extension (with dot) and MIME type for a conversion
|
||||
/// target format. Used when creating the converted file via SAF so WAV/AIFF and
|
||||
/// the other formats get the correct extension + MIME.
|
||||
({String ext, String mime}) convertTargetExtAndMime(String targetFormat) {
|
||||
switch (targetFormat.trim().toLowerCase()) {
|
||||
case 'opus':
|
||||
return (ext: '.opus', mime: 'audio/opus');
|
||||
case 'alac':
|
||||
case 'aac':
|
||||
return (ext: '.m4a', mime: 'audio/mp4');
|
||||
case 'flac':
|
||||
return (ext: '.flac', mime: 'audio/flac');
|
||||
case 'wav':
|
||||
return (ext: '.wav', mime: 'audio/wav');
|
||||
case 'aiff':
|
||||
case 'aif':
|
||||
return (ext: '.aiff', mime: 'audio/aiff');
|
||||
default:
|
||||
return (ext: '.mp3', mime: 'audio/mpeg');
|
||||
}
|
||||
}
|
||||
|
||||
int? convertedAudioBitrateKbps({
|
||||
required String targetFormat,
|
||||
required String bitrate,
|
||||
|
||||
@@ -16,6 +16,10 @@ String audioMimeTypeForPath(String filePath) {
|
||||
return 'audio/ogg';
|
||||
case 'wav':
|
||||
return 'audio/wav';
|
||||
case 'aiff':
|
||||
case 'aif':
|
||||
case 'aifc':
|
||||
return 'audio/aiff';
|
||||
case 'aac':
|
||||
return 'audio/aac';
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Bottom inset needed to clear the transparent shell navigation bar.
|
||||
///
|
||||
/// The shell Scaffold uses `extendBody: true`, so its body (and any route
|
||||
/// pushed inside the tab navigators) receives the navbar height plus the system
|
||||
/// gesture inset as `MediaQuery.padding.bottom`. Scrollable screens add this as
|
||||
/// trailing padding so their last item can scroll clear of the bar while the
|
||||
/// content still shows faintly behind it.
|
||||
extension NavBarInset on BuildContext {
|
||||
double get navBarBottomInset => MediaQuery.paddingOf(this).bottom;
|
||||
}
|
||||
@@ -19,6 +19,8 @@ const _audioExtensions = <String>[
|
||||
'.opus',
|
||||
'.ogg',
|
||||
'.wav',
|
||||
'.aiff',
|
||||
'.aif',
|
||||
'.aac',
|
||||
];
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user