Compare commits

..

23 Commits

Author SHA1 Message Date
zarzet fb4cd75cb2 feat: expose audio codec in download result and skip lossy-to-lossless conversion
Go backend:
- Add AudioCodec field to DownloadResult and DownloadResponse
- Extension download results can now include audio_codec/audioCodec
- ffmpegGetInfo and probeAudioQuality now return codec field
- Add trackItemBytes option to file.download() for custom progress handling

Flutter:
- Check audio_codec before container conversion
- Skip FLAC conversion if source codec is lossy (AAC, MP3, Opus, etc.)
- Prevents fake upscale from lossy to lossless containers
2026-05-15 04:37:25 +07:00
zarzet 8b7cecc1c5 refactor: extract download progress label formatting
- Extract _formatDownloadProgressLabel() for cleaner code
- Show received/total size when bytesTotal is available
- Estimate total size from progress when only bytesReceived is known
- Add text overflow handling with ellipsis
2026-05-15 01:29:02 +07:00
zarzet 012dcdc2dd fix: native FLAC handling and extension API optimizations
Native FLAC handling:
- Properly detect and publish native FLAC payloads inside MP4 containers
- Rename to .flac extension and embed metadata instead of skipping
- Fix all code paths: SAF, non-SAF, and native worker finalizer

Extension API optimizations:
- Enable response compression for API/search calls (faster metadata loads)
- Keep downloads uncompressed for accurate progress/streaming
- Add separate extensionAPITransport with compression enabled

Platform bridge caching:
- Cache handleURLWithExtension results (5 min TTL)
- Cache customSearchWithExtension results (2 min TTL)
- Prevent duplicate in-flight requests for same URL/query

Dependency cleanup:
- Remove unused sqflite_common_ffi and sqlite3 packages
2026-05-15 00:54:58 +07:00
zarzet 629eb66595 chore: bump version to 4.5.5 (build 132) 2026-05-14 20:48:29 +07:00
zarzet 36749a40d3 Revert "feat: add library scroll-to-top and scroll-to-bottom quick buttons"
This reverts commit f84a33bbf2.
2026-05-14 20:47:24 +07:00
zarzet 4336e6dc78 feat: add 5 new lyrics providers
New lyrics providers using Paxsenix API:
- Spotify: Synced lyrics from Spotify
- Deezer: Synced lyrics from Deezer
- YouTube: Lyrics from YouTube
- Kugou: Lyrics from Kugou (Chinese service)
- Genius: Plain text lyrics from Genius

Implementation:
- Add lyrics client implementations for all providers
- Smart search result scoring based on track name, artist, and duration
- Support for both synced (LRC) and unsynced lyrics formats
- Fallback search with simplified track names and primary artist

UI updates:
- Add provider entries to lyrics priority settings page
- Add display names for new providers in settings
2026-05-14 20:42:14 +07:00
zarzet 3e3e87e73e fix: MP3 lyrics embedding via ID3v2.3 USLT frame
FFmpeg doesn't always embed lyrics correctly to MP3 files. This adds
manual ID3v2.3 USLT (Unsynchronized Lyrics) frame writing after FFmpeg
metadata embedding to ensure lyrics are properly stored.

Implementation:
- Extract lyrics from metadata (UNSYNCEDLYRICS or LYRICS key)
- Build ID3v2.3 compliant USLT frame with UTF-16LE encoding
- Insert or replace USLT frame in existing ID3v2.3 tag
- Create new ID3v2.3 tag if file has no ID3 header
- Skip gracefully for unsupported ID3 versions or flags

Also includes minor audio analysis improvements:
- Consistent dynamic range calculation (peak - rms)
- Filter out 'unknown' and 'n/a' labels
- Add -vn -sn -dn flags for more robust stream selection
2026-05-14 18:25:03 +07:00
zarzet 1b8d6ce7fa feat: enhanced audio analysis with loudness, clipping, and spectral cutoff
Audio Analysis Enhancements:
- Display codec name and container format
- Show decoded sample format (s16, s32, fltp, etc.)
- Add LUFS integrated loudness measurement (broadcast standard)
- Add true peak measurement (dBTP)
- Detect and count clipping samples per channel
- Estimate spectral cutoff frequency (helps detect fake upscales)
- Show per-channel statistics (Peak, RMS, DR, Clip count)

UI Improvements:
- MetricChip now handles long text with ellipsis
- Constrained max width for better layout

Cache version bumped to 4 to force rescan with new metrics.
2026-05-14 16:28:49 +07:00
zarzet 60f1df1488 refactor: use audio_conversion_utils in downloaded_album_screen
- Replace inline format detection with convertibleAudioSourceFormat()
- Replace inline conversion rules with canConvertAudioFormat()
- Add unit tests for Dolby format detection and conversion rules
2026-05-14 15:49:27 +07:00
zarzet ff86869c33 feat: audio analysis rescan and AAC conversion support
Audio Analysis:
- Add rescan capability by bumping cache version
- Display channel layout (stereo, 5.1, etc.) and bitrate
- Use astats filter for more accurate peak/RMS measurements
- Support more formats: mp4, ac3, eac3, mka, wv, ape, tta, aif
- Only report bit depth for codecs that store it (FLAC, ALAC, WAV)
- Validate cache for SAF content:// URIs

Conversion:
- Add AAC as conversion target format
- Recognize ALAC as lossless source
- Prevent accidental deletion when source and target URI match
- Store format and bitrate in database after conversion

Utilities:
- Add audio_conversion_utils.dart for centralized conversion logic
- Add isSameContentUri() helper for safe URI comparison
2026-05-14 15:46:55 +07:00
zarzet 2a2d817314 feat: add AAC lossy target and toggle for Apple Music eLRC word sync
The HIGH-quality lossy format picker can now produce an AAC/M4A 320 kbps output alongside MP3 and Opus. FFmpegService.convertM4aToLossy/convertAudioFormat, the Dart queue pipeline, the Kotlin finalizer, and the library database format helper all route .m4a through a unified aac codec path and tag the resulting file with the M4A metadata writer. The Lossy Format setting gains a new option, and the track metadata convert dialog lists AAC next to the other targets.

Apple Music lyrics gain a 'eLRC word sync' switch (default off). When disabled the pax-to-LRC formatter strips inline word timestamps, producing line-synced LRC that is safer for players that choke on eLRC; enabling it restores the previous word-by-word behaviour. The change propagates through SetLyricsFetchOptions and invalidates the global lyrics cache on toggle.

Broad l10n migration: roughly 400 previously hardcoded English strings across queue, settings, track metadata, repo, audio analysis, setup and extension screens now live in the ARB catalog, with matching plural/placeholder forms. No behaviour change beyond localisation. Existing and new unit tests (lyrics eLRC toggle and Dart settings round-trip) pass.
2026-05-12 02:23:04 +07:00
zarzet 7845ac8be5 feat: show remote-config launch announcement on app start
Introduce AppRemoteConfigService which fetches a platform/version/locale-aware JSON payload from api.zarz.moe/v1/spotiflac-mobile/config and caches it in SharedPreferences. main_shell shows a one-shot announcement dialog (respecting dismissible, CTA, time window and version gates) when no update prompt is pending; dismissed IDs are persisted so each announcement surfaces only once.

Tweaks bundled in: the service health dot loses its blur halo in favour of solid Material 3 tones, and AppInfo gains the remote config endpoint constant. The share listener and SAF migration hook stay synchronous inside the post-frame callback so share-intent URLs never race the network-bound checks.

New unit tests cover the announcement CTA/active-window rules.
2026-05-11 01:37:10 +07:00
zarzet 81547013f9 fix: gate M4A to FLAC conversion on a codec probe in every branch
The SAF and local post-download branches used to rush an ffmpeg 'M4A to FLAC' remux whenever the output extension was .flac, which silently upscaled AAC or EAC3 streams into a lossless container. Each branch now mirrors the native worker by probing the primary audio codec before converting: lossless sources (and true FLAC-in-MP4 files) stay in their native container with the right extension, while genuine ALAC/WAV payloads still get remuxed.

Add an outputExt field to DownloadRequestPayload so the Go backend always knows the user-requested container, and use it together with _shouldRequestContainerConversion to pick the right behaviour for shouldPreserveNativeM4a and the Kotlin finalizer. Decryption descriptors no longer force M4A preservation on their own; the codec probe already makes that call correctly.
2026-05-11 00:52:02 +07:00
zarzet 8e605cbd0f feat: persist codec format and bitrate in download history
Bump the history schema on both the Kotlin finalizer and the Dart database to v9, adding bitrate (kbps) and format (codec label) columns, and let the download flow fill them from backend/probe metadata so lossy downloads keep a 'AAC 256kbps' label instead of falling back to the stored placeholder. Library filtering and the track metadata screen now read format/bitrate directly from those columns, which also fixes mis-tagged quality badges after re-downloading a track at a different format.

Additional fixes bundled in: EditFileMetadata now routes ReplayGain writes through the M4A path whenever the file starts with ftyp (fixing .flac files that actually hold MP4 containers); GetM4AQuality falls back to the first trak/mdia/mdhd duration when mvhd is zero so EAC3 streams no longer report 0s; and both Kotlin and Dart reject bitrate values below 16 kbps to prevent probe noise from surfacing as '0 kbps' labels. New unit tests cover the EAC3 mdhd fallback and the mis-named M4A replaygain path.
2026-05-10 23:18:32 +07:00
zarzet d664d46ca4 feat: detect FLAC/ALAC/EAC3/AC3/AC4 codecs inside MP4 containers
GetM4AQuality now recognizes fLaC, alac, ec-3, ac-3, and ac-4 sample entries and parses the MP4 FLACSpecificBox so library entries carry the real codec rather than the container extension. The AudioQuality struct exposes Codec and Bitrate fields (with an estimator for compressed streams), and ReadFileMetadata publishes format + audio_codec so Flutter and Kotlin can make format decisions based on the actual stream.

Downstream: library_scan labels M4A-family items as flac/alac/eac3/ac3/ac4/m4a, zeroes the bitrate for lossless formats, and the filter UI + quality badges use the codec-derived format instead of only the file extension. Scans and SAF importers also accept .mp4 and .aac file extensions. New unit tests cover codec name mapping and MP4 FLACSpecificBox decoding.
2026-05-10 22:14:47 +07:00
zarzet b4031936a0 feat: allow re-running audio quality analysis after cached result
The audio analysis card used to read from a persistent cache but offered no way to refresh the result when the underlying file had been re-downloaded at a different quality (for example, re-downloading a track as FLAC after capturing it as AAC). Add an explicit rescan control that clears the cached JSON + spectrogram, reruns the FFmpeg probe and analysis pipeline, and swaps in the fresh data while keeping the loading copy distinct from first-run analysis. A retry button is also exposed in the error card so transient failures do not require navigating away.

All audio_analysis strings now have a Re-analyze / Re-analyzing pair in the ARB catalog so every locale can translate them independently.
2026-05-10 21:27:54 +07:00
zarzet f84a33bbf2 feat: add library scroll-to-top and scroll-to-bottom quick buttons
Add a pair of floating quick-scroll buttons on the library tab so long lists become easier to navigate. The buttons sit above the bottom navigation (or the selection toolbar in selection mode), fade in and out based on the active page's scroll metrics, and share their scroll-target keys per filter mode so switching filters does not carry over the previous page's scroll state.
2026-05-10 19:09:38 +07:00
zarzet 8f5c59683a fix: force native FLAC muxer when decrypting to .flac output
Downloads from providers that stream FLAC inside an fMP4 container (e.g. Amazon Music) were being written to disk with a .flac extension while the payload still carried ISO-BMFF atoms. The container-conversion guard then saw codec=flac and skipped the remux, leaving native FLAC tag writers to fail with 'fLaC head incorrect'.

Force '-f flac' on the decryption command whenever the target extension is .flac so FFmpeg emits a real FLAC stream, and add an 'fLaC' magic-byte probe on both the Dart and Kotlin container-conversion guards so a FLAC-in-MP4 source is remuxed rather than silently passed through as a tag-writer hazard.
2026-05-10 18:50:49 +07:00
zarzet 4b7146afe4 fix: report zero bit depth for non-ALAC M4A containers
GetM4AQuality previously defaulted to 16-bit whenever the audio sample entry was not ALAC, which silently labeled lossy AAC downloads as CD quality in the library and in extension APIs. Only fill BitDepth when the atom is ALAC (including the ALACSpecificConfig refinement), and leave it as zero for AAC/mp4a, matching how the MP3 and Opus probes already report lossy sources. Tests cover both the ALAC and AAC branches.
2026-05-10 18:31:19 +07:00
zarzet 939407675b fix: probe codec to avoid fake FLAC upscale from lossy sources
The native-worker container conversion used to remux any .m4a download to .flac whenever the user requested a FLAC output, which silently upgraded lossy AAC streams to a FLAC container without adding any information. Guard the remux with an FFmpeg/FFprobe codec probe on both the Dart and Kotlin finalization paths so only genuinely lossless sources (ALAC, WavPack, PCM, etc.) are converted, and expose a requires_container_conversion capability so extensions can force conversion when they know the source is lossless.
2026-05-09 20:51:40 +07:00
zarzet 20ac6b2cd4 fix(native-worker): preserve requested output container in finalizer
When the native worker result advertises a requested non-FLAC output extension (for example '.m4a'), skip the m4a-to-flac container conversion in both the Dart and Kotlin finalizers so the native output container is preserved end-to-end.

- ffmpeg_service: propagate the top-level 'output_extension' hint into the download-result descriptor for both the map-backed and legacy paths; expose a normalized getter for consistent comparisons.

- download_queue_provider: short-circuit the native-worker container-conversion step when the descriptor's requested extension is not '.flac', with a debug log describing the skip.

- NativeDownloadFinalizer: mirror the guard on the Kotlin side so the finalizer does not force a container conversion that would clobber the requested native output.
2026-05-09 01:23:38 +07:00
zarzet 904b45e8f6 chore: housekeeping cleanup and code deduplication
- Remove stray tracked files (root AndroidManifest.xml, build.gradle.bak, temp_project template)
- Move README-only images out of app asset bundle to reduce APK/IPA size (~1.68MB)
- Fix logo filename typo (transparant -> transparent)
- Deduplicate _readPositiveInt into shared int_utils.dart
- Deduplicate _themeModeFromString (reuse from theme_settings.dart)
- Remove deprecated LocalLibraryState.items getter
- Remove unused sqflite_common_ffi dependency
- Update apps.json version to 4.5.1
- Fix Flutter version in CONTRIBUTING.md (3.38.1 -> 3.41.5)
- Improve .gitignore patterns (NUL, *.bak, root AndroidManifest.xml)
2026-05-08 21:37:56 +07:00
zarzet 1bd54c530b fix(saf): use extension-agnostic .partial staged filename
Staged SAF outputs and library-scan partials now share a single naming pattern: '<name>.partial' regardless of the audio extension. The previous '<name>.partial.<ext>' form caused SAF / media-scanner to surface half-written files as valid audio.

- SafDownloadHandler: force 'application/octet-stream' MIME for staged docs and collapse buildStagedSafFileName to '<name>.partial'. Keep the legacy form behind buildLegacyStagedSafFileName and sweep both via deleteStaleStagedFiles so upgrades clean old residue.

- library_scan: add isLibraryStagingFile that skips both the new and legacy partial patterns during collectLibraryAudioFiles so residual staging files never show up in the library.

- library_scan_supplement_test: seed both legacy and new partial files and assert they are ignored by the scanner.
2026-05-08 20:35:41 +07:00
116 changed files with 11977 additions and 1255 deletions
+4 -1
View File
@@ -44,6 +44,7 @@ go_backend/*.xcframework/
# Android
android/.gradle/
android/app/libs/gobackend.aar
android/app/libs/gobackend-sources.jar
android/local.properties
android/*.iml
android/key.properties
@@ -57,7 +58,6 @@ ios/Pods/
ios/.symlinks/
ios/Flutter/Flutter.framework/
ios/Flutter/Flutter.podspec
android/app/libs/gobackend-sources.jar
# Extension folder
extension/
@@ -67,7 +67,10 @@ AGENTS.md
# Temp/misc
nul
NUL
network_requests.txt
*.bak
/AndroidManifest.xml
# Log files
*.log
Binary file not shown.
+1 -1
View File
@@ -86,7 +86,7 @@ Translation files are located in `lib/l10n/arb/`.
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
```
3. **Use FVM (Flutter Version: 3.38.1)**
3. **Use FVM (Flutter Version: 3.41.5)**
```bash
fvm use
```
+7 -7
View File
@@ -1,9 +1,9 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/images/banner-readme-dark.png">
<source media="(prefers-color-scheme: light)" srcset="assets/images/banner-readme-light.png">
<img alt="SpotiFLAC Mobile" src="assets/images/banner-readme-light.png" width="650" height="auto">
<source media="(prefers-color-scheme: dark)" srcset="assets/readme/banner-readme-dark.png">
<source media="(prefers-color-scheme: light)" srcset="assets/readme/banner-readme-light.png">
<img alt="SpotiFLAC Mobile" src="assets/readme/banner-readme-light.png" width="650" height="auto">
</picture>
<p align="center">
@@ -28,10 +28,10 @@
## Screenshots
<p align="center">
<img src="assets/images/1.jpg?v=2" width="200" />
<img src="assets/images/2.jpg?v=2" width="200" />
<img src="assets/images/3.jpg?v=2" width="200" />
<img src="assets/images/4.jpg?v=2" width="200" />
<img src="assets/readme/1.jpg?v=2" width="200" />
<img src="assets/readme/2.jpg?v=2" width="200" />
<img src="assets/readme/3.jpg?v=2" width="200" />
<img src="assets/readme/4.jpg?v=2" width="200" />
</p>
---
-71
View File
@@ -1,71 +0,0 @@
plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
}
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader('UTF-8') { reader ->
localProperties.load(reader)
}
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
flutterVersionCode = '1'
}
def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
android {
namespace "com.zarz.spotiflac"
compileSdk flutter.compileSdkVersion
ndkVersion flutter.ndkVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
defaultConfig {
applicationId "com.zarz.spotiflac"
minSdkVersion flutter.minSdkVersion
targetSdk flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
buildTypes {
release {
signingConfig signingConfigs.debug
minifyEnabled false
shrinkResources false
}
}
}
flutter {
source '../..'
}
dependencies {
// Go backend library (gomobile generated)
implementation fileTree(dir: 'libs', include: ['*.aar'])
// Kotlin coroutines for async Go backend calls
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
}
@@ -1,5 +0,0 @@
package com.example.temp_project
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -772,6 +772,7 @@ class MainActivity: FlutterFragmentActivity() {
return when {
name.endsWith(".m4a") -> ".m4a"
name.endsWith(".mp4") -> ".mp4"
name.endsWith(".aac") -> ".aac"
name.endsWith(".mp3") -> ".mp3"
name.endsWith(".opus") -> ".opus"
name.endsWith(".flac") -> ".flac"
@@ -783,6 +784,10 @@ class MainActivity: FlutterFragmentActivity() {
private fun extFromMimeType(mime: String?): String {
return when (mime) {
"audio/mp4" -> ".m4a"
"audio/aac" -> ".aac"
"audio/eac3" -> ".m4a"
"audio/ac3" -> ".m4a"
"audio/ac4" -> ".m4a"
"audio/mpeg" -> ".mp3"
"audio/ogg" -> ".opus"
"audio/flac" -> ".flac"
@@ -1063,7 +1068,7 @@ class MainActivity: FlutterFragmentActivity() {
}
private val cueSiblingAudioExtensions = listOf(
".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"
".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a", ".mp4", ".aac"
)
private fun getSafChildFileLookup(
@@ -1135,7 +1140,7 @@ class MainActivity: FlutterFragmentActivity() {
it.currentFile = "Scanning folders..."
}
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
val supportedAudioExt = setOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg")
val audioFiles = mutableListOf<Pair<DocumentFile, String>>()
val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
val visitedDirUris = mutableSetOf<String>()
@@ -1435,7 +1440,7 @@ class MainActivity: FlutterFragmentActivity() {
it.currentFile = "Scanning folders..."
}
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
val supportedAudioExt = setOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg")
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>()
val cueFilesToScan = mutableListOf<Triple<DocumentFile, DocumentFile, Long>>()
val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
@@ -3475,7 +3480,7 @@ class MainActivity: FlutterFragmentActivity() {
} catch (_: Exception) { "" }
val cueBaseName = cueName.substringBeforeLast('.')
if (cueBaseName.isNotBlank()) {
val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a")
val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a", ".mp4", ".aac")
for (ext in commonExts) {
audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null }
if (audioDoc != null) break
@@ -16,6 +16,7 @@ import com.antonkarpenko.ffmpegkit.ReturnCode
import gobackend.Gobackend
import org.json.JSONObject
import java.io.File
import java.io.RandomAccessFile
import java.nio.ByteBuffer
import java.util.Locale
import java.util.concurrent.CancellationException
@@ -29,7 +30,7 @@ object NativeDownloadFinalizer {
const val NATIVE_WORKER_CONTRACT_VERSION = 1
// Native finalizer owns background-safe history writes while Flutter may be suspended.
// Keep this schema contract in sync with Dart HistoryDatabase before bumping either side.
private const val HISTORY_SCHEMA_VERSION = 8
private const val HISTORY_SCHEMA_VERSION = 9
private val activeFFmpegSessionIds = mutableSetOf<Long>()
private val nativeFFmpegSessionIds = mutableSetOf<Long>()
private val activeFFmpegSessionLock = Any()
@@ -72,6 +73,8 @@ object NativeDownloadFinalizer {
"quality",
"bit_depth",
"sample_rate",
"bitrate",
"format",
"genre",
"composer",
"label",
@@ -95,6 +98,7 @@ object NativeDownloadFinalizer {
".ogg",
".wav",
".aac",
".mp4",
)
private data class FinalizeInput(
@@ -112,6 +116,7 @@ object NativeDownloadFinalizer {
var bitDepth: Int?,
var sampleRate: Int?,
var bitrateKbps: Int? = null,
var audioCodec: String? = null,
var pendingExternalLrc: String? = null,
var pendingExternalLrcFileName: String? = null,
)
@@ -174,6 +179,9 @@ object NativeDownloadFinalizer {
sampleRate = optPositiveInt(result, "actual_sample_rate"),
bitrateKbps = optPositiveBitrateKbps(result, "bitrate")
?: optPositiveBitrateKbps(result, "actual_bitrate"),
audioCodec = normalizeAudioCodec(
result.optString("audio_codec", "").ifBlank { result.optString("format", "") },
),
)
try {
@@ -214,6 +222,7 @@ object NativeDownloadFinalizer {
result.put("file_path", state.filePath)
if (state.fileName.isNotBlank()) result.put("file_name", state.fileName)
if (state.quality.isNotBlank()) result.put("quality", state.quality)
result.put("native_finalized", true)
result.put("history_written", true)
result.put("history_item", historyToJson(history))
@@ -419,7 +428,13 @@ object NativeDownloadFinalizer {
for ((candidateOutput, mapAudioOnly) in attempts) {
try {
val audioMap = if (mapAudioOnly) "-map 0:a " else ""
val command = "-v error -decryption_key ${q(candidate)} -f $inputFormat -i ${q(localInput)} ${audioMap}-c copy ${q(candidateOutput)} -y"
// Force the flac muxer when the target extension is
// .flac. Without this override FFmpeg keeps the ISO-BMFF
// stream layout, producing FLAC-in-MP4 under a .flac
// filename which downstream native FLAC tag writers
// cannot read.
val muxerOverride = if (candidateOutput.lowercase(Locale.ROOT).endsWith(".flac")) "-f flac " else ""
val command = "-v error -decryption_key ${q(candidate)} -f $inputFormat -i ${q(localInput)} ${audioMap}-c copy ${muxerOverride}${q(candidateOutput)} -y"
val result = runFFmpeg(command, shouldCancel)
lastOutput = result.second
if (result.first && File(candidateOutput).exists()) {
@@ -461,13 +476,23 @@ object NativeDownloadFinalizer {
if (!looksLikeM4a(state.filePath, state.fileName)) return
val tidalHighFormat = input.request.optString("tidal_high_format", "").ifBlank { "mp3_320" }
val format = if (tidalHighFormat.startsWith("opus")) "opus" else "mp3"
val format = when {
tidalHighFormat.startsWith("opus") -> "opus"
tidalHighFormat.startsWith("aac") || tidalHighFormat.startsWith("m4a") -> "aac"
else -> "mp3"
}
val metadataFormat = if (format == "aac") "m4a" else format
val displayFormat = if (format == "aac") "AAC" else format.uppercase(Locale.ROOT)
val bitrate = if (tidalHighFormat.contains("_")) {
"${tidalHighFormat.substringAfterLast("_")}k"
} else {
if (format == "opus") "128k" else "320k"
}
val ext = if (format == "opus") ".opus" else ".mp3"
val ext = when (format) {
"opus" -> ".opus"
"aac" -> ".m4a"
else -> ".mp3"
}
val localInput = materializeForFFmpeg(context, input, state)
val deleteLocalInput = state.filePath.startsWith("content://")
val output = buildOutputPath(localInput, ext)
@@ -475,6 +500,8 @@ object NativeDownloadFinalizer {
try {
val command = if (format == "opus") {
"-v error -hide_banner -i ${q(localInput)} -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a ${q(output)} -y"
} else if (format == "aac") {
"-v error -hide_banner -i ${q(localInput)} -codec:a aac -b:a $bitrate -map 0:a -f mp4 ${q(output)} -y"
} else {
"-v error -hide_banner -i ${q(localInput)} -codec:a libmp3lame -b:a $bitrate -map 0:a -id3v2_version 3 ${q(output)} -y"
}
@@ -482,14 +509,14 @@ object NativeDownloadFinalizer {
if (!result.first || !File(output).exists()) {
throw IllegalStateException("HIGH conversion failed: ${result.second}")
}
embedBasicMetadata(context, output, input, format)
embedBasicMetadata(context, output, input, metadataFormat)
replaceStatePath(context, input, state, output, deleteOld = true)
adoptedOutput = true
} finally {
if (!adoptedOutput) File(output).delete()
if (deleteLocalInput) File(localInput).delete()
}
state.quality = "${format.uppercase(Locale.ROOT)} ${bitrate.removeSuffix("k")}kbps"
state.quality = "$displayFormat ${bitrate.removeSuffix("k")}kbps"
state.bitDepth = null
state.sampleRate = null
}
@@ -501,13 +528,37 @@ object NativeDownloadFinalizer {
shouldCancel: () -> Boolean,
) {
if (requestQuality(input) == "HIGH" || outputExt(input) != ".flac") return
if (!looksLikeM4a(state.filePath, state.fileName) && !shouldForceContainerConversion(input, state)) return
val requestedDecryptionExt = requestedDecryptionOutputExt(input)
val forceContainerConversion = shouldForceContainerConversion(input, state)
if (!forceContainerConversion && requestedDecryptionExt.isNotBlank() && requestedDecryptionExt != ".flac") return
val mayNeedContainerConversion = forceContainerConversion ||
looksLikeM4a(state.filePath, state.fileName) ||
state.filePath.startsWith("content://")
if (!mayNeedContainerConversion) return
val localInput = materializeForFFmpeg(context, input, state)
val deleteLocalInput = state.filePath.startsWith("content://")
val output = buildOutputPath(localInput, ".flac")
var adoptedOutput = false
try {
val codec = probePrimaryAudioCodec(localInput, shouldCancel)
val isAlreadyNativeFlac = codec == "flac" && isNativeFlacFile(localInput)
if (!isLosslessAudioCodec(codec)) {
Log.d(TAG, "Preserving native container; audio codec is ${codec.ifBlank { "unknown" }}")
return
}
if (isAlreadyNativeFlac) {
Log.d(TAG, "Native FLAC payload detected; publishing as FLAC and embedding metadata")
val nativeFlacOutput = if (localInput.lowercase(Locale.ROOT).endsWith(".flac")) {
localInput
} else {
File(localInput).copyTo(File(output), overwrite = true).absolutePath
}
embedBasicMetadata(context, nativeFlacOutput, input, "flac")
replaceStatePath(context, input, state, nativeFlacOutput, deleteOld = true)
adoptedOutput = true
return
}
val result = runFFmpeg(
"-v error -xerror -i ${q(localInput)} -c:a flac -compression_level 8 ${q(output)} -y",
shouldCancel,
@@ -633,6 +684,17 @@ object NativeDownloadFinalizer {
val bitDepth = optPositiveInt(metadata, "bit_depth")
val sampleRate = optPositiveInt(metadata, "sample_rate")
val probedCodec = normalizeAudioCodec(
metadata.optString("audio_codec", "").ifBlank {
metadata.optString("codec", "").ifBlank {
metadata.optString("format", "")
}
}
)
if (probedCodec != null) {
state.audioCodec = probedCodec
result.put("audio_codec", probedCodec)
}
if (bitDepth != null) {
state.bitDepth = bitDepth
result.put("actual_bit_depth", bitDepth)
@@ -643,7 +705,7 @@ object NativeDownloadFinalizer {
}
val bitrateKbps = optPositiveBitrateKbps(metadata, "bitrate")
?: optPositiveBitrateKbps(metadata, "bit_rate")
if (bitrateKbps != null) {
if (bitrateKbps != null && isLossyAudioCodec(state.audioCodec)) {
state.bitrateKbps = bitrateKbps
result.put("bitrate", bitrateKbps)
}
@@ -654,6 +716,7 @@ object NativeDownloadFinalizer {
bitDepth = state.bitDepth,
sampleRate = state.sampleRate,
bitrateKbps = state.bitrateKbps,
audioCodec = state.audioCodec,
storedQuality = state.quality,
)
if (displayQuality != null) {
@@ -691,15 +754,19 @@ object NativeDownloadFinalizer {
bitDepth: Int?,
sampleRate: Int?,
bitrateKbps: Int?,
audioCodec: String? = null,
storedQuality: String?,
): String? {
val format = audioFormatForPath(filePath, fileName)
val format = audioFormatForCodec(audioCodec) ?: audioFormatForPath(filePath, fileName)
if (format == "OPUS" ||
format == "MP3" ||
format == "AAC" ||
format == "EAC3" ||
format == "AC3" ||
format == "AC4" ||
(format == "M4A" && (bitDepth == null || bitDepth <= 0))
) {
return if (bitrateKbps != null && bitrateKbps > 0) {
return if (bitrateKbps != null && bitrateKbps >= 16) {
"$format ${bitrateKbps}kbps"
} else {
nonPlaceholderQuality(storedQuality) ?: format
@@ -715,6 +782,43 @@ object NativeDownloadFinalizer {
return nonPlaceholderQuality(storedQuality) ?: normalizeOptional(storedQuality)
}
private fun audioFormatForCodec(codec: String?): String? {
return when (normalizeAudioCodec(codec)) {
"flac" -> "FLAC"
"alac" -> "ALAC"
"aac" -> "AAC"
"eac3" -> "EAC3"
"ac3" -> "AC3"
"ac4" -> "AC4"
"mp3" -> "MP3"
"opus" -> "OPUS"
else -> null
}
}
private fun isLossyAudioCodec(codec: String?): Boolean {
return when (normalizeAudioCodec(codec)) {
"aac", "eac3", "ac3", "ac4", "mp3", "opus", "m4a" -> true
else -> false
}
}
private fun normalizeAudioCodec(codec: String?): String? {
val normalized = normalizeOptional(codec)
?.lowercase(Locale.ROOT)
?.replace('-', '_')
?: return null
return when (normalized) {
"mp4a" -> "aac"
"ec_3" -> "eac3"
"ac_3" -> "ac3"
"ac_4" -> "ac4"
"mp4" -> "m4a"
"ogg" -> "opus"
else -> normalized
}
}
private fun audioFormatForPath(filePath: String, fileName: String): String? {
for (candidate in listOf(filePath, fileName)) {
val lower = candidate.trim().lowercase(Locale.ROOT)
@@ -730,6 +834,11 @@ object NativeDownloadFinalizer {
private fun nonPlaceholderQuality(quality: String?): String? {
val normalized = normalizeOptional(quality) ?: return null
val bitrateMatch = Regex("\\b(\\d+)\\s*kbps\\b", RegexOption.IGNORE_CASE).find(normalized)
if (bitrateMatch != null) {
val bitrate = bitrateMatch.groupValues.getOrNull(1)?.toIntOrNull()
if (bitrate != null && bitrate < 16) return null
}
val key = normalized.lowercase(Locale.ROOT).replace(Regex("[^a-z0-9]+"), "_").trim('_')
val placeholders = setOf(
"best",
@@ -1146,7 +1255,7 @@ object NativeDownloadFinalizer {
return when (normalizeExt(File(path).extension)) {
".mp3" -> "mp3"
".opus", ".ogg" -> "opus"
".m4a", ".mp4" -> "m4a"
".m4a", ".mp4", ".aac" -> "m4a"
else -> "flac"
}
}
@@ -1294,7 +1403,7 @@ object NativeDownloadFinalizer {
val rawName = input.request.optString("saf_file_name", "")
.ifBlank { state.fileName }
.ifBlank { "${trackString(input, "artistName", input.request.optString("artist_name", "Artist"))} - ${trackString(input, "name", input.request.optString("track_name", "Track"))}" }
val knownExts = listOf(".flac", ".m4a", ".mp4", ".mp3", ".opus", ".ogg", ".lrc")
val knownExts = listOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg", ".lrc")
var base = rawName.trim()
val lower = base.lowercase(Locale.ROOT)
for (knownExt in knownExts) {
@@ -1315,19 +1424,66 @@ object NativeDownloadFinalizer {
private fun shouldForceContainerConversion(input: FinalizeInput, state: FinalizeState): Boolean {
if (input.result.optBoolean("requires_container_conversion", false)) return true
if (input.request.optBoolean("requires_container_conversion", false)) return true
return false
}
val actualExt = normalizeExt(
input.result.optString("actual_extension", "")
.ifBlank { input.result.optString("output_extension", "") }
private fun probePrimaryAudioCodec(path: String, shouldCancel: () -> Boolean = { false }): String {
val result = runFFmpeg("-hide_banner -nostdin -i ${q(path)} -map 0:a:0 -frames:a 1 -f null -", shouldCancel)
val output = result.second
val match = Regex("Audio:\\s*([^,\\s]+)", RegexOption.IGNORE_CASE).find(output)
return match?.groupValues?.getOrNull(1)
?.trim()
?.lowercase(Locale.ROOT)
?.replace('-', '_')
.orEmpty()
}
/**
* Returns true when the file on [path] starts with the native FLAC magic
* bytes (`fLaC`). A file may contain a FLAC audio stream yet live inside
* an MP4/fMP4 container (e.g. some Amazon Music downloads); native FLAC
* tag writers require the raw fLaC header, so we must detect that mismatch
* before skipping the container conversion step.
*/
private fun isNativeFlacFile(path: String): Boolean {
return try {
RandomAccessFile(path, "r").use { raf ->
if (raf.length() < 4L) return false
val header = ByteArray(4)
raf.readFully(header)
header[0] == 0x66.toByte() && // 'f'
header[1] == 0x4C.toByte() && // 'L'
header[2] == 0x61.toByte() && // 'a'
header[3] == 0x43.toByte() // 'C'
}
} catch (e: Exception) {
Log.w(TAG, "Native FLAC magic probe failed for $path: ${e.message}")
false
}
}
private fun isLosslessAudioCodec(codec: String): Boolean {
val normalized = codec.trim().lowercase(Locale.ROOT).replace('-', '_')
if (normalized.isBlank()) return false
if (normalized.startsWith("pcm_")) return true
return normalized in setOf(
"alac",
"flac",
"wavpack",
"ape",
"tta",
"mlp",
"truehd",
"shorten"
)
if (actualExt == ".m4a" || actualExt == ".mp4") return true
}
val container = input.result.optString("actual_container", "")
.ifBlank { input.result.optString("container", "") }
.trim()
.lowercase(Locale.ROOT)
.removePrefix(".")
return container == "m4a" || container == "mp4" || container == "mov" || container == "aac"
private fun requestedDecryptionOutputExt(input: FinalizeInput): String {
val descriptor = input.result.optJSONObject("decryption")
return normalizeExt(
descriptor?.optString("output_extension", "")
?.ifBlank { input.result.optString("output_extension", "") }
)
}
private fun validateRequestContract(request: JSONObject) {
@@ -1541,6 +1697,10 @@ object NativeDownloadFinalizer {
values.put("quality", state.quality)
state.bitDepth?.let { values.put("bit_depth", it) }
state.sampleRate?.let { values.put("sample_rate", it) }
state.bitrateKbps?.takeIf { it >= 16 && isLossyAudioCodec(state.audioCodec) }?.let {
values.put("bitrate", it)
}
normalizeAudioCodec(state.audioCodec)?.let { values.put("format", it) }
values.put("genre", normalizeOptional(result.optString("genre", "").ifBlank { input.request.optString("genre", "") }))
values.put("composer", normalizeOptional(resultString(input, "composer").ifBlank { trackString(input, "composer", requestString(input, "composer")) }))
values.put("label", normalizeOptional(result.optString("label", "").ifBlank { input.request.optString("label", "") }))
@@ -1597,6 +1757,8 @@ object NativeDownloadFinalizer {
quality TEXT,
bit_depth INTEGER,
sample_rate INTEGER,
bitrate INTEGER,
format TEXT,
genre TEXT,
composer TEXT,
label TEXT,
@@ -1612,6 +1774,8 @@ object NativeDownloadFinalizer {
ensureHistoryColumn(db, "composer", "ALTER TABLE history ADD COLUMN composer TEXT")
ensureHistoryColumn(db, "total_tracks", "ALTER TABLE history ADD COLUMN total_tracks INTEGER")
ensureHistoryColumn(db, "total_discs", "ALTER TABLE history ADD COLUMN total_discs INTEGER")
ensureHistoryColumn(db, "bitrate", "ALTER TABLE history ADD COLUMN bitrate INTEGER")
ensureHistoryColumn(db, "format", "ALTER TABLE history ADD COLUMN format TEXT")
ensureHistoryColumn(db, "spotify_id_norm", "ALTER TABLE history ADD COLUMN spotify_id_norm TEXT")
ensureHistoryColumn(db, "isrc_norm", "ALTER TABLE history ADD COLUMN isrc_norm TEXT")
ensureHistoryColumn(db, "match_key", "ALTER TABLE history ADD COLUMN match_key TEXT")
@@ -1983,6 +2147,8 @@ object NativeDownloadFinalizer {
putCamel("quality", "quality")
putCamel("bit_depth", "bitDepth")
putCamel("sample_rate", "sampleRate")
putCamel("bitrate", "bitrate")
putCamel("format", "format")
putCamel("genre", "genre")
putCamel("composer", "composer")
putCamel("label", "label")
@@ -2014,11 +2180,12 @@ object NativeDownloadFinalizer {
private fun optPositiveBitrateKbps(obj: JSONObject, key: String): Int? {
val value = optPositiveInt(obj, key) ?: return null
return if (value >= 10000) {
val kbps = if (value >= 10000) {
Math.round(value / 1000.0).toInt()
} else {
value
}
return if (kbps >= 16) kbps else null
}
private fun positiveOrNull(primary: Int, fallback: Int): Int? {
@@ -15,6 +15,7 @@ import java.util.Locale
object SafDownloadHandler {
private val safDirLock = Any()
private const val MAX_SAF_DISPLAY_NAME_UTF8_BYTES = 180
private const val STAGED_SAF_MIME_TYPE = "application/octet-stream"
fun handle(context: Context, requestJson: String, downloader: (String) -> String): String {
val req = JSONObject(requestJson)
@@ -31,15 +32,15 @@ object SafDownloadHandler {
val fileName = buildSafFileName(req, outputExt)
val deferSafPublish = req.optBoolean("defer_saf_publish", false)
val useStagedOutput = req.optBoolean("stage_saf_output", false) && !deferSafPublish
val stagedFileName = if (useStagedOutput) buildStagedSafFileName(fileName, outputExt) else fileName
val staleStagedFileName = buildStagedSafFileName(fileName, outputExt)
val stagedFileName = if (useStagedOutput) buildStagedSafFileName(fileName) else fileName
val stagedMimeType = if (useStagedOutput) STAGED_SAF_MIME_TYPE else mimeType
val existingDir = findDocumentDir(context, treeUri, relativeDir)
if (existingDir != null) {
val existing = existingDir.findFile(fileName)
if (existing != null && existing.isFile && existing.length() > 0) {
if (useStagedOutput || deferSafPublish) {
existingDir.findFile(staleStagedFileName)?.delete()
deleteStaleStagedFiles(existingDir, fileName, outputExt)
}
val obj = JSONObject()
obj.put("success", true)
@@ -55,7 +56,7 @@ object SafDownloadHandler {
?: return errorJson("Failed to access SAF directory")
if (deferSafPublish) {
targetDir.findFile(staleStagedFileName)?.delete()
deleteStaleStagedFiles(targetDir, fileName, outputExt)
val workingExt = outputExt.ifBlank { ".tmp" }
val workingFile = File.createTempFile("native_saf_work_", workingExt, context.cacheDir)
Log.i("SpotiFLAC", "SAF deferred native output: target=$fileName working=${workingFile.name}")
@@ -89,7 +90,7 @@ object SafDownloadHandler {
}
}
var document = createOrReuseDocumentFile(targetDir, mimeType, stagedFileName)
var document = createOrReuseDocumentFile(targetDir, stagedMimeType, stagedFileName)
?: return errorJson("Failed to create SAF file")
val pfd = context.contentResolver.openFileDescriptor(document.uri, "rw")
@@ -121,14 +122,14 @@ object SafDownloadHandler {
if (actualExt.isNotBlank() && actualExt != outputExt) {
val actualFileName = buildSafFileName(req, actualExt)
val actualStagedFileName = if (useStagedOutput) {
buildStagedSafFileName(actualFileName, actualExt)
buildStagedSafFileName(actualFileName)
} else {
actualFileName
}
val actualMimeType = mimeTypeForExt(actualExt)
val replacement = createOrReuseDocumentFile(
targetDir,
actualMimeType,
if (useStagedOutput) STAGED_SAF_MIME_TYPE else actualMimeType,
actualStagedFileName
) ?: throw IllegalStateException(
"failed to create SAF output with actual extension"
@@ -212,8 +213,9 @@ object SafDownloadHandler {
val targetDir = ensureDocumentDir(context, treeUri, relativeDir) ?: return null
val finalName = sanitizeFilename(fileName)
val ext = normalizeExt(finalName.substringAfterLast('.', ""))
val stagedName = buildStagedSafFileName(finalName, ext)
val document = createOrReuseDocumentFile(targetDir, mimeType, stagedName)
val stagedName = buildStagedSafFileName(finalName)
deleteStaleStagedFiles(targetDir, finalName, ext)
val document = createOrReuseDocumentFile(targetDir, STAGED_SAF_MIME_TYPE, stagedName)
?: return null
stagedDocument = document
val outputStream = context.contentResolver.openOutputStream(document.uri, "wt")
@@ -288,13 +290,17 @@ object SafDownloadHandler {
return safeName + normalizedExt
}
private fun buildStagedSafFileName(fileName: String, outputExt: String): String {
private fun buildStagedSafFileName(fileName: String): String {
val safeName = sanitizeFilename(fileName)
return "$safeName.partial"
}
private fun buildLegacyStagedSafFileName(fileName: String, outputExt: String): String {
val safeName = sanitizeFilename(fileName)
val ext = normalizeExt(outputExt)
if (ext.isNotBlank() && safeName.lowercase(Locale.ROOT).endsWith(ext)) {
return safeName.dropLast(ext.length).trimEnd('.', ' ') + ".partial$ext"
}
val dot = safeName.lastIndexOf('.')
if (dot > 0 && dot < safeName.lastIndex) {
return safeName.substring(0, dot).trimEnd('.', ' ') +
@@ -304,6 +310,19 @@ object SafDownloadHandler {
return "$safeName.partial"
}
private fun deleteStaleStagedFiles(parent: DocumentFile, fileName: String, outputExt: String) {
val stagedNames = linkedSetOf(
buildStagedSafFileName(fileName),
buildLegacyStagedSafFileName(fileName, outputExt)
)
for (stagedName in stagedNames) {
try {
parent.findFile(stagedName)?.delete()
} catch (_: Exception) {
}
}
}
private fun sanitizeFilename(name: String): String {
var sanitized = name
.replace("/", " ")
+3 -3
View File
@@ -7,9 +7,9 @@
"name": "SpotiFLAC Mobile",
"bundleIdentifier": "com.zarzet.spotiflac",
"developerName": "zarzet",
"version": "4.5.0",
"versionDate": "2026-05-06",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.5.0/SpotiFLAC-v4.5.0-ios-unsigned.ipa",
"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",
"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": 37191956

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Before

Width:  |  Height:  |  Size: 539 KiB

After

Width:  |  Height:  |  Size: 539 KiB

Before

Width:  |  Height:  |  Size: 811 KiB

After

Width:  |  Height:  |  Size: 811 KiB

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

+38 -3
View File
@@ -308,16 +308,20 @@ func TestM4AMetadataAtomHelpers(t *testing.T) {
t.Fatalf("ReplayGain fields = %#v", fields)
}
qualityPath := filepath.Join(dir, "quality.m4a")
qualityPath := filepath.Join(dir, "quality-alac.m4a")
mvhd := make([]byte, 20)
binary.BigEndian.PutUint32(mvhd[12:16], 1000)
binary.BigEndian.PutUint32(mvhd[16:20], 180000)
sampleEntry := make([]byte, 32)
copy(sampleEntry[0:4], "mp4a")
copy(sampleEntry[0:4], "alac")
binary.BigEndian.PutUint16(sampleEntry[22:24], 24)
sampleEntry[28] = 0xAC
sampleEntry[29] = 0x44
qualityFile := append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", append(buildM4AAtom("mvhd", mvhd), sampleEntry...))...)
alacConfig := make([]byte, 24)
alacConfig[5] = 24
binary.BigEndian.PutUint32(alacConfig[20:24], 44100)
alacEntryPayload := append(append([]byte{}, sampleEntry[4:]...), buildM4AAtom("alac", alacConfig)...)
qualityFile := append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", append(buildM4AAtom("mvhd", mvhd), buildM4AAtom("alac", alacEntryPayload)...))...)
if err := os.WriteFile(qualityPath, qualityFile, 0600); err != nil {
t.Fatal(err)
}
@@ -327,6 +331,37 @@ func TestM4AMetadataAtomHelpers(t *testing.T) {
if quality, err := GetAudioQuality(qualityPath); err != nil || quality.SampleRate != 44100 {
t.Fatalf("GetAudioQuality M4A = %#v/%v", quality, err)
}
aacQualityPath := filepath.Join(dir, "quality-aac.m4a")
copy(sampleEntry[0:4], "mp4a")
aacQualityFile := append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", append(buildM4AAtom("mvhd", mvhd), sampleEntry...))...)
if err := os.WriteFile(aacQualityPath, aacQualityFile, 0600); err != nil {
t.Fatal(err)
}
if quality, err := GetM4AQuality(aacQualityPath); err != nil || quality.BitDepth != 0 || quality.SampleRate != 44100 || quality.Duration != 180 {
t.Fatalf("GetM4AQuality AAC = %#v/%v", quality, err)
}
eac3QualityPath := filepath.Join(dir, "quality-eac3.m4a")
zeroMvhd := make([]byte, 20)
eac3SampleEntry := make([]byte, 32)
copy(eac3SampleEntry[0:4], "ec-3")
eac3SampleEntry[28] = 0xBB
eac3SampleEntry[29] = 0x80
mdhd := make([]byte, 20)
binary.BigEndian.PutUint32(mdhd[12:16], 48000)
binary.BigEndian.PutUint32(mdhd[16:20], 48000*123)
eac3QualityFile := append(
buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")),
buildM4AAtom("moov", append(
append(buildM4AAtom("mvhd", zeroMvhd), buildM4AAtom("trak", buildM4AAtom("mdia", buildM4AAtom("mdhd", mdhd)))...),
eac3SampleEntry...,
))...,
)
if err := os.WriteFile(eac3QualityPath, eac3QualityFile, 0600); err != nil {
t.Fatal(err)
}
if quality, err := GetM4AQuality(eac3QualityPath); err != nil || quality.Codec != "eac3" || quality.Duration != 123 {
t.Fatalf("GetM4AQuality EAC3 mdhd fallback = %#v/%v", quality, err)
}
if _, _, ok := parseALACSpecificConfig(make([]byte, 4)); ok {
t.Fatal("short ALAC config should not parse")
}
+61 -15
View File
@@ -313,6 +313,7 @@ type DownloadResponse struct {
AlreadyExists bool `json:"already_exists,omitempty"`
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
AudioCodec string `json:"audio_codec,omitempty"`
ActualExtension string `json:"actual_extension,omitempty"`
ActualContainer string `json:"actual_container,omitempty"`
RequiresContainerConversion bool `json:"requires_container_conversion,omitempty"`
@@ -342,6 +343,7 @@ type DownloadResult struct {
FilePath string
BitDepth int
SampleRate int
AudioCodec string
Title string
Artist string
Album string
@@ -863,6 +865,7 @@ func buildDownloadSuccessResponse(
AlreadyExists: alreadyExists,
ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate,
AudioCodec: result.AudioCodec,
ActualExtension: result.ActualExtension,
ActualContainer: result.ActualContainer,
RequiresContainerConversion: result.RequiresContainerConversion,
@@ -920,7 +923,12 @@ func enrichResultQualityFromFile(result *DownloadResult) {
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
result.AudioCodec = quality.Codec
if quality.Codec != "" {
GoLog("[Download] Actual quality from file: %s %d-bit/%dHz\n", quality.Codec, quality.BitDepth, quality.SampleRate)
} else {
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
}
return
}
@@ -1101,7 +1109,7 @@ func CleanupConnections() {
func ReadFileMetadata(filePath string) (string, error) {
lower := strings.ToLower(filePath)
isFlac := strings.HasSuffix(lower, ".flac")
isM4A := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac")
isM4A := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".aac")
isMp3 := strings.HasSuffix(lower, ".mp3")
isOgg := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg")
isApe := strings.HasSuffix(lower, ".ape")
@@ -1126,9 +1134,13 @@ func ReadFileMetadata(filePath string) (string, error) {
"composer": "",
"comment": "",
"duration": 0,
"format": "",
"audio_codec": "",
}
if isFlac {
result["format"] = "flac"
result["audio_codec"] = "flac"
metadata, err := ReadMetadata(filePath)
if err != nil {
// File may have wrong extension (e.g. opus saved as .flac).
@@ -1161,6 +1173,8 @@ func ReadFileMetadata(filePath string) (string, error) {
result["bitrate"] = quality.Bitrate / 1000
}
}
result["format"] = "opus"
result["audio_codec"] = "opus"
} else {
return "", fmt.Errorf("failed to read metadata: %w", err)
}
@@ -1190,12 +1204,16 @@ func ReadFileMetadata(filePath string) (string, error) {
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
if quality.Codec != "" {
result["audio_codec"] = quality.Codec
}
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
}
}
}
} else if isM4A {
result["format"] = "m4a"
meta, err := ReadM4ATags(filePath)
if err == nil && meta != nil {
result["title"] = meta.Title
@@ -1227,8 +1245,17 @@ func ReadFileMetadata(filePath string) (string, error) {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
result["duration"] = quality.Duration
result["audio_codec"] = quality.Codec
if format := libraryFormatForM4ACodec(quality.Codec); format != "" {
result["format"] = format
}
if quality.Bitrate > 0 && !isLosslessLibraryFormat(fmt.Sprint(result["format"])) {
result["bitrate"] = quality.Bitrate
}
}
} else if isMp3 {
result["format"] = "mp3"
result["audio_codec"] = "mp3"
meta, err := ReadID3Tags(filePath)
if err == nil && meta != nil {
result["title"] = meta.Title
@@ -1265,6 +1292,8 @@ func ReadFileMetadata(filePath string) (string, error) {
}
}
} else if isOgg {
result["format"] = "opus"
result["audio_codec"] = "opus"
meta, err := ReadOggVorbisComments(filePath)
if err == nil && meta != nil {
result["title"] = meta.Title
@@ -1300,6 +1329,8 @@ func ReadFileMetadata(filePath string) (string, error) {
}
}
} else if isApe || isWv || isMpc {
result["format"] = strings.TrimPrefix(filepath.Ext(filePath), ".")
result["audio_codec"] = result["format"]
// APE, WavPack, Musepack: read APEv2 tags
apeTag, apeErr := ReadAPETags(filePath)
if apeErr == nil && apeTag != nil {
@@ -1400,6 +1431,19 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
isM4AFile := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".m4b")
coverPath := strings.TrimSpace(fields["cover_path"])
if hasOnlyM4AReplayGainFields(fields) && (isM4AFile || isMP4ContainerFile(filePath)) {
if err := EditM4AReplayGain(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write M4A metadata: %w", err)
}
resp := map[string]any{
"success": true,
"method": "native_m4a_replaygain",
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
if isFlac {
if err := EditFlacFields(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write FLAC metadata: %w", err)
@@ -1510,19 +1554,6 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
return string(jsonBytes), nil
}
if isM4AFile && hasOnlyM4AReplayGainFields(fields) {
if err := EditM4AReplayGain(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write M4A metadata: %w", err)
}
resp := map[string]any{
"success": true,
"method": "native_m4a_replaygain",
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
resp := map[string]any{
"success": true,
"method": "ffmpeg",
@@ -1532,6 +1563,21 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
return string(jsonBytes), nil
}
func isMP4ContainerFile(filePath string) bool {
f, err := os.Open(filePath)
if err != nil {
return false
}
defer f.Close()
header := make([]byte, 12)
n, err := f.Read(header)
if err != nil || n < 8 {
return false
}
return string(header[4:8]) == "ftyp"
}
func hasOnlyM4AReplayGainFields(fields map[string]string) bool {
allowed := map[string]struct{}{
"replaygain_track_gain": {},
+8
View File
@@ -85,6 +85,14 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
if response, err := EditFileMetadata(filepath.Join(dir, "edit.mp3"), editJSON); err != nil || !strings.Contains(response, "ffmpeg") {
t.Fatalf("EditFileMetadata ffmpeg = %q/%v", response, err)
}
misnamedM4APath := filepath.Join(dir, "misnamed.flac")
if err := os.WriteFile(misnamedM4APath, buildM4AFileWithIlst(buildM4ATextTag("\xa9nam", "Misnamed"), true), 0600); err != nil {
t.Fatal(err)
}
replayGainJSON := `{"replaygain_track_gain":"-1 dB","replaygain_track_peak":"0.9"}`
if response, err := EditFileMetadata(misnamedM4APath, replayGainJSON); err != nil || !strings.Contains(response, "native_m4a_replaygain") {
t.Fatalf("EditFileMetadata misnamed m4a replaygain = %q/%v", response, err)
}
if _, err := EditFileMetadata(apePath, `not-json`); err == nil {
t.Fatal("expected invalid metadata JSON")
}
+2 -2
View File
@@ -367,8 +367,8 @@ func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensio
jar, _ := newSimpleCookieJar()
runtime.cookieJar = jar
}
runtime.httpClient = newExtensionHTTPClient(ext, runtime.cookieJar, extensionHTTPTimeout(ext, 30*time.Second))
runtime.downloadClient = newExtensionHTTPClient(ext, runtime.cookieJar, DownloadTimeout)
runtime.httpClient = newExtensionHTTPClient(ext, runtime.cookieJar, extensionHTTPTimeout(ext, 30*time.Second), true)
runtime.downloadClient = newExtensionHTTPClient(ext, runtime.cookieJar, DownloadTimeout, false)
runtime.RegisterAPIs(vm)
runtime.RegisterGoBackendAPIs(vm)
+3
View File
@@ -236,6 +236,7 @@ func normalizeExtensionDownloadResult(result *ExtDownloadResult) (DownloadResult
FilePath: strings.TrimSpace(result.FilePath),
BitDepth: result.BitDepth,
SampleRate: result.SampleRate,
AudioCodec: strings.TrimSpace(result.AudioCodec),
Title: result.Title,
Artist: result.Artist,
Album: result.Album,
@@ -420,6 +421,7 @@ type ExtDownloadResult struct {
AlreadyExists bool `json:"already_exists,omitempty"`
BitDepth int `json:"bit_depth,omitempty"`
SampleRate int `json:"sample_rate,omitempty"`
AudioCodec string `json:"audio_codec,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
ErrorType string `json:"error_type,omitempty"`
@@ -873,6 +875,7 @@ func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDo
AlreadyExists: gojaObjectBool(obj, "already_exists", "alreadyExists"),
BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"),
SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"),
AudioCodec: gojaObjectString(obj, "audio_codec", "audioCodec", "codec"),
ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"),
ErrorType: gojaObjectString(obj, "error_type", "errorType"),
Title: gojaObjectString(obj, "title"),
+10 -5
View File
@@ -140,8 +140,8 @@ func newExtensionRuntime(ext *loadedExtension) *extensionRuntime {
storageFlushDelay: defaultStorageFlushDelay,
}
runtime.httpClient = newExtensionHTTPClient(ext, jar, extensionHTTPTimeout(ext, 30*time.Second))
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout)
runtime.httpClient = newExtensionHTTPClient(ext, jar, extensionHTTPTimeout(ext, 30*time.Second), true)
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout, false)
return runtime
}
@@ -247,13 +247,18 @@ func (r *extensionRuntime) bindDownloadCancelContext(req *http.Request) *http.Re
return req.WithContext(initDownloadCancel(itemID))
}
func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration, compressResponses bool) *http.Client {
// Extension sandbox enforces HTTPS-only domains. Do not apply global
// allow_http scheme downgrade here, because some extension APIs (e.g.
// spotify-web) will redirect http -> https and can end up in 301 loops.
// We still reuse sharedTransport so insecure TLS compatibility mode remains effective.
// API calls can use response compression for faster metadata/search loads,
// while media downloads keep identity transfer semantics for progress/streaming.
transport := sharedTransport
if compressResponses {
transport = extensionAPITransport
}
client := &http.Client{
Transport: sharedTransport,
Transport: transport,
Timeout: timeout,
Jar: jar,
}
+1
View File
@@ -131,6 +131,7 @@ func (r *extensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
"sample_rate": quality.SampleRate,
"total_samples": quality.TotalSamples,
"duration": float64(quality.TotalSamples) / float64(quality.SampleRate),
"codec": quality.Codec,
})
}
+30 -4
View File
@@ -136,6 +136,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
var onProgress goja.Callable
var headers map[string]string
var chunkedDownload bool
trackItemBytes := true
var chunkSize int64
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
optionsObj := call.Arguments[2].Export()
@@ -151,6 +152,15 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
onProgress = callable
}
}
if trackBytes, ok := opts["trackItemBytes"]; ok {
if v, ok := trackBytes.(bool); ok {
trackItemBytes = v
}
} else if trackBytes, ok := opts["track_item_bytes"]; ok {
if v, ok := trackBytes.(bool); ok {
trackItemBytes = v
}
}
if chunked, ok := opts["chunked"]; ok {
switch v := chunked.(type) {
case bool:
@@ -194,7 +204,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}
if chunkedDownload {
return r.fileDownloadChunked(client, urlStr, fullPath, headers, ua, chunkSize, onProgress)
return r.fileDownloadChunked(client, urlStr, fullPath, headers, ua, chunkSize, onProgress, trackItemBytes)
}
req, err := http.NewRequest("GET", urlStr, nil)
@@ -244,7 +254,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}
contentLength := resp.ContentLength
shouldTrackItemBytes := activeItemID != "" && onProgress == nil
shouldTrackItemBytes := activeItemID != "" && trackItemBytes
if shouldTrackItemBytes && contentLength > 0 {
SetItemBytesTotal(activeItemID, contentLength)
}
@@ -301,6 +311,14 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}
}
if shouldTrackItemBytes {
if contentLength > 0 {
SetItemProgress(activeItemID, float64(written)/float64(contentLength), written, contentLength)
} else if written > 0 {
SetItemBytesReceived(activeItemID, written)
}
}
GoLog("[Extension:%s] Downloaded %d bytes to %s\n", r.extensionID, written, fullPath)
return r.vm.ToValue(map[string]interface{}{
@@ -313,7 +331,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
// fileDownloadChunked downloads a URL using sequential Range requests.
// This is needed for servers (like YouTube's googlevideo CDN) that reject
// non-ranged or large-range requests with 403 and require small chunk downloads.
func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, fullPath string, headers map[string]string, ua string, chunkSize int64, onProgress goja.Callable) goja.Value {
func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, fullPath string, headers map[string]string, ua string, chunkSize int64, onProgress goja.Callable, trackItemBytes bool) goja.Value {
// First, get the total content length with a small probe request
probeReq, err := http.NewRequest("GET", urlStr, nil)
if err != nil {
@@ -383,7 +401,7 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
SetItemDownloading(activeItemID)
}
shouldTrackItemBytes := activeItemID != "" && onProgress == nil
shouldTrackItemBytes := activeItemID != "" && trackItemBytes
if shouldTrackItemBytes && totalSize > 0 {
SetItemBytesTotal(activeItemID, totalSize)
}
@@ -526,6 +544,14 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
}
}
if shouldTrackItemBytes {
if totalSize > 0 {
SetItemProgress(activeItemID, float64(totalWritten)/float64(totalSize), totalWritten, totalSize)
} else if totalWritten > 0 {
SetItemBytesReceived(activeItemID, totalWritten)
}
}
GoLog("[Extension:%s] Chunked download complete: %d bytes to %s\n", r.extensionID, totalWritten, fullPath)
return r.vm.ToValue(map[string]interface{}{
+1
View File
@@ -415,6 +415,7 @@ func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
"sampleRate": quality.SampleRate,
"totalSamples": quality.TotalSamples,
"duration": quality.Duration,
"codec": quality.Codec,
})
})
+20
View File
@@ -79,6 +79,24 @@ var sharedTransport = &http.Transport{
DisableCompression: true,
}
var extensionAPITransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
MaxConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false,
ForceAttemptHTTP2: true,
WriteBufferSize: 64 * 1024,
ReadBufferSize: 64 * 1024,
DisableCompression: false,
}
var metadataTransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
@@ -131,6 +149,7 @@ func GetDownloadClient() *http.Client {
func CloseIdleConnections() {
sharedTransport.CloseIdleConnections()
extensionAPITransport.CloseIdleConnections()
metadataTransport.CloseIdleConnections()
}
@@ -143,6 +162,7 @@ func SetNetworkCompatibilityOptions(allowHTTP, insecureTLS bool) {
networkCompatibilityMu.Unlock()
applyTLSCompatibility(sharedTransport, insecureTLS)
applyTLSCompatibility(extensionAPITransport, insecureTLS)
applyTLSCompatibility(metadataTransport, insecureTLS)
CloseIdleConnections()
+61 -2
View File
@@ -68,6 +68,8 @@ var (
var supportedAudioFormats = map[string]bool{
".flac": true,
".m4a": true,
".mp4": true,
".aac": true,
".mp3": true,
".opus": true,
".ogg": true,
@@ -87,6 +89,19 @@ type scannedCueFileInfo struct {
audioPath string
}
func isLibraryStagingFile(path string) bool {
name := strings.ToLower(filepath.Base(path))
if strings.HasSuffix(name, ".partial") {
return true
}
for ext := range supportedAudioFormats {
if strings.HasSuffix(name, ".partial"+ext) {
return true
}
}
return false
}
func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) {
var files []libraryAudioFileInfo
@@ -104,6 +119,9 @@ func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]li
if entry.IsDir() {
return nil
}
if isLibraryStagingFile(path) {
return nil
}
ext := strings.ToLower(filepath.Ext(path))
if !supportedAudioFormats[ext] {
@@ -314,7 +332,7 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ
switch ext {
case ".flac":
return scanFLACFile(filePath, result, displayNameHint)
case ".m4a":
case ".m4a", ".mp4", ".aac":
return scanM4AFile(filePath, result, displayNameHint)
case ".mp3":
return scanMP3File(filePath, result, displayNameHint)
@@ -394,7 +412,6 @@ func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint str
metadata, err := ReadM4ATags(filePath)
if err != nil {
GoLog("[LibraryScan] M4A read error for %s: %v\n", filePath, err)
return scanFromFilename(filePath, displayNameHint, result)
}
if metadata != nil {
@@ -421,12 +438,54 @@ func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint str
if err == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
result.Duration = quality.Duration
if quality.Bitrate > 0 {
result.Bitrate = quality.Bitrate
}
if format := libraryFormatForM4ACodec(quality.Codec); format != "" {
result.Format = format
if isLosslessLibraryFormat(format) {
result.Bitrate = 0
}
}
}
if metadata == nil {
return scanFromFilename(filePath, displayNameHint, result)
}
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil
}
func libraryFormatForM4ACodec(codec string) string {
switch strings.ToLower(strings.TrimSpace(codec)) {
case "flac":
return "flac"
case "alac":
return "alac"
case "eac3", "ec-3":
return "eac3"
case "ac3", "ac-3":
return "ac3"
case "ac4", "ac-4":
return "ac4"
case "aac", "mp4a":
return "m4a"
default:
return ""
}
}
func isLosslessLibraryFormat(format string) bool {
switch strings.ToLower(strings.TrimSpace(format)) {
case "flac", "alac":
return true
default:
return false
}
}
func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
metadata, err := ReadID3Tags(filePath)
if err != nil {
@@ -42,6 +42,14 @@ func TestLibraryScanFullIncrementalAndMetadataFallbacks(t *testing.T) {
if err := os.WriteFile(filepath.Join(albumDir, "ignored.txt"), []byte("ignore"), 0600); err != nil {
t.Fatal(err)
}
legacyPartialPath := filepath.Join(albumDir, "Artist - Song.partial.flac")
if err := os.WriteFile(legacyPartialPath, []byte("partial flac"), 0600); err != nil {
t.Fatal(err)
}
newPartialPath := filepath.Join(albumDir, "Artist - Song.flac.partial")
if err := os.WriteFile(newPartialPath, []byte("partial flac"), 0600); err != nil {
t.Fatal(err)
}
files, err := collectLibraryAudioFiles(dir, make(chan struct{}))
if err != nil {
@@ -50,6 +58,11 @@ func TestLibraryScanFullIncrementalAndMetadataFallbacks(t *testing.T) {
if len(files) < 4 {
t.Fatalf("files = %#v", files)
}
for _, file := range files {
if file.path == legacyPartialPath || file.path == newPartialPath {
t.Fatalf("staging file should be ignored: %#v", files)
}
}
cancelCh := make(chan struct{})
close(cancelCh)
if _, err := collectLibraryAudioFiles(dir, cancelCh); err == nil {
+77 -7
View File
@@ -26,6 +26,11 @@ const (
LyricsProviderMusixmatch = "musixmatch"
LyricsProviderAppleMusic = "apple_music"
LyricsProviderQQMusic = "qqmusic"
LyricsProviderSpotify = "spotify"
LyricsProviderDeezer = "deezer"
LyricsProviderYouTube = "youtube"
LyricsProviderKugou = "kugou"
LyricsProviderGenius = "genius"
)
var DefaultLyricsProviders = []string{
@@ -68,6 +73,7 @@ type LyricsFetchOptions struct {
IncludeTranslationNetease bool `json:"include_translation_netease"`
IncludeRomanizationNetease bool `json:"include_romanization_netease"`
MultiPersonWordByWord bool `json:"multi_person_word_by_word"`
AppleElrcWordSync bool `json:"apple_elrc_word_sync"`
MusixmatchLanguage string `json:"musixmatch_language,omitempty"`
}
@@ -75,6 +81,7 @@ var defaultLyricsFetchOptions = LyricsFetchOptions{
IncludeTranslationNetease: false,
IncludeRomanizationNetease: false,
MultiPersonWordByWord: true,
AppleElrcWordSync: false,
MusixmatchLanguage: "",
}
@@ -100,6 +107,11 @@ func SetLyricsProviderOrder(providers []string) {
LyricsProviderMusixmatch: true,
LyricsProviderAppleMusic: true,
LyricsProviderQQMusic: true,
LyricsProviderSpotify: true,
LyricsProviderDeezer: true,
LyricsProviderYouTube: true,
LyricsProviderKugou: true,
LyricsProviderGenius: true,
}
var valid []string
@@ -130,10 +142,15 @@ func GetLyricsProviderOrder() []string {
func GetAvailableLyricsProviders() []map[string]interface{} {
return []map[string]interface{}{
{"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"},
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics via Paxsenix"},
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics via Paxsenix"},
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics via Paxsenix"},
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics via Paxsenix"},
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics"},
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics"},
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics"},
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics"},
{"id": LyricsProviderSpotify, "name": "Spotify", "has_proxy_dependency": true, "description": "Spotify synced lyrics"},
{"id": LyricsProviderDeezer, "name": "Deezer", "has_proxy_dependency": true, "description": "Deezer lyrics"},
{"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"},
}
}
@@ -151,12 +168,18 @@ func SetLyricsFetchOptions(opts LyricsFetchOptions) {
lyricsFetchOptionsMu.Lock()
defer lyricsFetchOptionsMu.Unlock()
changed := lyricsFetchOptions != normalized
lyricsFetchOptions = normalized
GoLog("[Lyrics] Fetch options set: translation=%v romanization=%v multi_person=%v musixmatch_lang=%q\n",
if changed {
globalLyricsCache.ClearAll()
}
GoLog("[Lyrics] Fetch options set: translation=%v romanization=%v multi_person=%v apple_elrc=%v musixmatch_lang=%q\n",
normalized.IncludeTranslationNetease,
normalized.IncludeRomanizationNetease,
normalized.MultiPersonWordByWord,
normalized.AppleElrcWordSync,
normalized.MusixmatchLanguage,
)
}
@@ -530,9 +553,9 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
case LyricsProviderAppleMusic:
appleClient := NewAppleMusicClient()
lyrics, err = appleClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord)
lyrics, err = appleClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord, fetchOptions.AppleElrcWordSync)
if err != nil && primaryArtist != artistName {
lyrics, err = appleClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord)
lyrics, err = appleClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord, fetchOptions.AppleElrcWordSync)
}
case LyricsProviderQQMusic:
@@ -542,6 +565,53 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
lyrics, err = qqClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord)
}
case LyricsProviderSpotify:
spotifyClient := NewSpotifyLyricsClient()
lyrics, err = spotifyClient.FetchLyrics(spotifyID, trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = spotifyClient.FetchLyrics(spotifyID, trackName, artistName, durationSec)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = spotifyClient.FetchLyrics("", simplifiedTrack, primaryArtist, durationSec)
}
case LyricsProviderDeezer:
deezerClient := NewDeezerLyricsClient()
lyrics, err = deezerClient.FetchLyrics(spotifyID, trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = deezerClient.FetchLyrics(spotifyID, trackName, artistName, durationSec)
}
case LyricsProviderYouTube:
youtubeClient := NewYouTubeLyricsClient()
lyrics, err = youtubeClient.FetchLyrics(trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = youtubeClient.FetchLyrics(trackName, artistName, durationSec)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = youtubeClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
}
case LyricsProviderKugou:
kugouClient := NewKugouLyricsClient()
lyrics, err = kugouClient.FetchLyrics(trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = kugouClient.FetchLyrics(trackName, artistName, durationSec)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = kugouClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
}
case LyricsProviderGenius:
geniusClient := NewGeniusLyricsClient()
lyrics, err = geniusClient.FetchLyrics(trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = geniusClient.FetchLyrics(trackName, artistName, durationSec)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = geniusClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
}
default:
GoLog("[Lyrics] Unknown provider: %s, skipping\n", providerName)
continue
+11 -10
View File
@@ -173,25 +173,25 @@ func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
return bodyStr, nil
}
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool, preserveWordTiming bool) (string, error) {
var paxResp paxResponse
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil && paxResp.Content != nil {
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord), nil
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord, preserveWordTiming), nil
}
var directLyrics []paxLyrics
if err := json.Unmarshal([]byte(rawJSON), &directLyrics); err == nil && len(directLyrics) > 0 {
return formatPaxContent("Syllable", directLyrics, multiPersonWordByWord), nil
return formatPaxContent("Syllable", directLyrics, multiPersonWordByWord, preserveWordTiming), nil
}
return "", fmt.Errorf("failed to parse pax lyrics response")
}
func appendPaxLyricDetail(builder *strings.Builder, details []paxLyricDetail) {
func appendPaxLyricDetail(builder *strings.Builder, details []paxLyricDetail, preserveWordTiming bool) {
lastStart := ""
for _, syllable := range details {
if syllable.Timestamp != nil {
if preserveWordTiming && syllable.Timestamp != nil {
start := fmt.Sprintf("<%s>", msToLRCTimestampInline(int64(*syllable.Timestamp)))
if start != lastStart {
builder.WriteString(start)
@@ -204,13 +204,13 @@ func appendPaxLyricDetail(builder *strings.Builder, details []paxLyricDetail) {
builder.WriteString(" ")
}
if syllable.EndTime != nil {
if preserveWordTiming && syllable.EndTime != nil {
builder.WriteString(fmt.Sprintf("<%s>", msToLRCTimestampInline(int64(*syllable.EndTime))))
}
}
}
func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByWord bool) string {
func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByWord bool, preserveWordTiming bool) string {
var sb strings.Builder
for i, line := range content {
@@ -230,11 +230,11 @@ func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByW
}
}
appendPaxLyricDetail(&sb, line.Text)
appendPaxLyricDetail(&sb, line.Text, preserveWordTiming)
if line.Background && multiPersonWordByWord && len(line.BackgroundText) > 0 {
sb.WriteString("\n[bg:")
appendPaxLyricDetail(&sb, line.BackgroundText)
appendPaxLyricDetail(&sb, line.BackgroundText, preserveWordTiming)
sb.WriteString("]")
}
} else {
@@ -253,6 +253,7 @@ func (c *AppleMusicClient) FetchLyrics(
artistName string,
durationSec float64,
multiPersonWordByWord bool,
preserveWordTiming bool,
) (*LyricsResponse, error) {
songID, err := c.SearchSong(trackName, artistName, durationSec)
if err != nil {
@@ -267,7 +268,7 @@ func (c *AppleMusicClient) FetchLyrics(
return nil, fmt.Errorf("apple music proxy returned non-lyric payload: %s", errMsg)
}
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord, preserveWordTiming)
if err != nil {
lrcText = rawLyrics
}
+565
View File
@@ -0,0 +1,565 @@
package gobackend
import (
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
)
type SpotifyLyricsClient struct {
httpClient *http.Client
}
type DeezerLyricsClient struct {
httpClient *http.Client
}
type YouTubeLyricsClient struct {
httpClient *http.Client
}
type KugouLyricsClient struct {
httpClient *http.Client
}
type GeniusLyricsClient struct {
httpClient *http.Client
}
type spotifyLyricsSearchResult struct {
TrackID string `json:"trackId"`
Name string `json:"name"`
ArtistName string `json:"artistName"`
Duration string `json:"duration"`
}
type youtubeLyricsSearchResult struct {
VideoID string `json:"videoId"`
Title string `json:"title"`
Author string `json:"author"`
Duration string `json:"duration"`
}
type kugouLyricsSearchResult struct {
Hash string `json:"hash"`
Title string `json:"title"`
Artist string `json:"artist"`
Duration float64 `json:"duration"`
}
type geniusSearchResponse struct {
Response struct {
Sections []struct {
Hits []struct {
Type string `json:"type"`
Result struct {
Title string `json:"title"`
ArtistNames string `json:"artist_names"`
PrimaryArtistNames string `json:"primary_artist_names"`
URL string `json:"url"`
} `json:"result"`
} `json:"hits"`
} `json:"sections"`
} `json:"response"`
}
type paxsenixLyricsObject struct {
Type string `json:"type"`
Content []paxLyrics `json:"content"`
Lyrics []paxLyrics `json:"lyrics"`
LyricsText string `json:"lyrics_text"`
PlainLyrics string `json:"plain_lyrics"`
}
func NewSpotifyLyricsClient() *SpotifyLyricsClient {
return &SpotifyLyricsClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
}
func NewDeezerLyricsClient() *DeezerLyricsClient {
return &DeezerLyricsClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
}
func NewYouTubeLyricsClient() *YouTubeLyricsClient {
return &YouTubeLyricsClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
}
func NewKugouLyricsClient() *KugouLyricsClient {
return &KugouLyricsClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
}
func NewGeniusLyricsClient() *GeniusLyricsClient {
return &GeniusLyricsClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
}
func fetchPaxsenixBody(httpClient *http.Client, endpoint string, params url.Values) (string, error) {
fullURL := endpoint
if len(params) > 0 {
fullURL += "?" + params.Encode()
}
req, err := http.NewRequest("GET", fullURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", appUserAgent())
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
trimmed := strings.TrimSpace(string(body))
if resp.StatusCode != http.StatusOK {
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, errMsg)
}
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
return "", fmt.Errorf("%s", errMsg)
}
if trimmed == "" {
return "", fmt.Errorf("empty response")
}
return trimmed, nil
}
func parsePaxsenixLyricsPayload(raw, provider string, multiPersonWordByWord bool) (*LyricsResponse, error) {
var lrcPayload string
if err := json.Unmarshal([]byte(raw), &lrcPayload); err == nil {
lrcPayload = strings.TrimSpace(lrcPayload)
if lrcPayload == "" {
return nil, fmt.Errorf("%s returned empty lyrics", provider)
}
return lyricsResponseFromText(lrcPayload, provider), nil
}
var rawObject map[string]json.RawMessage
if err := json.Unmarshal([]byte(raw), &rawObject); err == nil {
for _, key := range []string{"lyrics", "lyric", "lyrics_text", "plain_lyrics"} {
var value string
if rawValue, ok := rawObject[key]; ok && json.Unmarshal(rawValue, &value) == nil {
value = strings.TrimSpace(value)
if value != "" {
return lyricsResponseFromText(value, provider), nil
}
}
}
}
var payload paxsenixLyricsObject
if err := json.Unmarshal([]byte(raw), &payload); err == nil {
switch {
case strings.TrimSpace(payload.LyricsText) != "":
return lyricsResponseFromText(payload.LyricsText, provider), nil
case len(payload.Lyrics) > 0:
return lyricsResponseFromText(formatPaxContent("Syllable", payload.Lyrics, multiPersonWordByWord, true), provider), nil
case len(payload.Content) > 0:
lyricsType := payload.Type
if lyricsType == "" {
lyricsType = "Syllable"
}
return lyricsResponseFromText(formatPaxContent(lyricsType, payload.Content, multiPersonWordByWord, true), provider), nil
case strings.TrimSpace(payload.PlainLyrics) != "":
return lyricsResponseFromText(payload.PlainLyrics, provider), nil
}
}
trimmed := strings.TrimSpace(raw)
if trimmed != "" && !strings.HasPrefix(trimmed, "{") && !strings.HasPrefix(trimmed, "[") {
return lyricsResponseFromText(trimmed, provider), nil
}
return nil, fmt.Errorf("failed to decode %s lyrics response", provider)
}
func lyricsResponseFromText(text, provider string) *LyricsResponse {
lines := parseSyncedLyrics(text)
if len(lines) > 0 {
return &LyricsResponse{
Lines: lines,
SyncType: "LINE_SYNCED",
PlainLyrics: plainLyricsFromTimedLines(lines),
Provider: provider,
Source: provider,
}
}
plainLines := plainTextLyricsLines(text)
if len(plainLines) > 0 {
return &LyricsResponse{
Lines: plainLines,
SyncType: "UNSYNCED",
PlainLyrics: text,
Provider: provider,
Source: provider,
}
}
return &LyricsResponse{Provider: provider, Source: provider}
}
func normalizeSpotifyLyricsID(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" || strings.HasPrefix(strings.ToLower(raw), "deezer:") {
return ""
}
if strings.HasPrefix(strings.ToLower(raw), "spotify:") {
parts := strings.Split(raw, ":")
raw = parts[len(parts)-1]
}
if strings.Contains(raw, "spotify.com/track/") {
raw = extractSpotifyIDFromURL(raw)
}
raw = strings.TrimSpace(strings.Split(raw, "?")[0])
if regexpSpotifyTrackID.MatchString(raw) {
return raw
}
return ""
}
var regexpSpotifyTrackID = regexp.MustCompile(`^[A-Za-z0-9]{22}$`)
func (c *SpotifyLyricsClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
query := strings.TrimSpace(trackName + " " + artistName)
if query == "" {
return "", fmt.Errorf("empty search query")
}
params := url.Values{}
params.Set("q", query)
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/spotify/search", params)
if err != nil {
return "", fmt.Errorf("spotify search failed: %w", err)
}
var results []spotifyLyricsSearchResult
if err := json.Unmarshal([]byte(raw), &results); err != nil {
return "", fmt.Errorf("failed to decode spotify search: %w", err)
}
best := selectBestSpotifyLyricsSearchResult(results, trackName, artistName, durationSec)
if best == nil || strings.TrimSpace(best.TrackID) == "" {
return "", fmt.Errorf("no songs found on spotify")
}
return strings.TrimSpace(best.TrackID), nil
}
func selectBestSpotifyLyricsSearchResult(results []spotifyLyricsSearchResult, trackName, artistName string, durationSec float64) *spotifyLyricsSearchResult {
if len(results) == 0 {
return nil
}
bestIndex := 0
bestScore := -1
for i := range results {
result := &results[i]
score := scoreLyricsSearchCandidate(result.Name, result.ArtistName, parseClockDuration(result.Duration), trackName, artistName, durationSec)
if score > bestScore {
bestIndex = i
bestScore = score
}
}
return &results[bestIndex]
}
func (c *SpotifyLyricsClient) FetchLyricsByID(trackID string) (*LyricsResponse, error) {
params := url.Values{}
params.Set("id", trackID)
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/spotify/lyrics", params)
if err != nil {
return nil, fmt.Errorf("spotify lyrics fetch failed: %w", err)
}
return parsePaxsenixLyricsPayload(raw, "Spotify", false)
}
func (c *SpotifyLyricsClient) FetchLyrics(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
trackID := normalizeSpotifyLyricsID(spotifyID)
if trackID == "" {
var err error
trackID, err = c.SearchSong(trackName, artistName, durationSec)
if err != nil {
return nil, err
}
}
return c.FetchLyricsByID(trackID)
}
func normalizeDeezerLyricsID(raw string) string {
raw = strings.TrimSpace(raw)
if strings.HasPrefix(strings.ToLower(raw), "deezer:") {
raw = strings.TrimSpace(raw[len("deezer:"):])
}
if strings.Contains(raw, "deezer.com/") {
raw = extractDeezerIDFromURL(raw)
}
raw = strings.TrimSpace(strings.Split(raw, "?")[0])
if _, err := strconv.ParseInt(raw, 10, 64); err == nil {
return raw
}
return ""
}
func (c *DeezerLyricsClient) FetchLyricsByID(trackID string, multiPersonWordByWord bool) (*LyricsResponse, error) {
params := url.Values{}
params.Set("id", trackID)
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/deezer/lyrics", params)
if err != nil {
return nil, fmt.Errorf("deezer lyrics fetch failed: %w", err)
}
return parsePaxsenixLyricsPayload(raw, "Deezer", multiPersonWordByWord)
}
func (c *DeezerLyricsClient) FetchLyrics(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
deezerID := normalizeDeezerLyricsID(spotifyID)
if deezerID == "" {
spotifyTrackID := normalizeSpotifyLyricsID(spotifyID)
if spotifyTrackID == "" {
return nil, fmt.Errorf("deezer provider needs a deezer id or spotify id")
}
resolvedID, err := NewSongLinkClient().GetDeezerIDFromSpotify(spotifyTrackID)
if err != nil {
return nil, fmt.Errorf("failed to resolve deezer id: %w", err)
}
deezerID = normalizeDeezerLyricsID(resolvedID)
}
if deezerID == "" {
return nil, fmt.Errorf("deezer id unavailable")
}
return c.FetchLyricsByID(deezerID, true)
}
func (c *YouTubeLyricsClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
query := strings.TrimSpace(trackName + " " + artistName)
if query == "" {
return "", fmt.Errorf("empty search query")
}
params := url.Values{}
params.Set("q", query)
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/youtube/search", params)
if err != nil {
return "", fmt.Errorf("youtube search failed: %w", err)
}
var results []youtubeLyricsSearchResult
if err := json.Unmarshal([]byte(raw), &results); err != nil {
return "", fmt.Errorf("failed to decode youtube search: %w", err)
}
best := selectBestYouTubeLyricsSearchResult(results, trackName, artistName, durationSec)
if best == nil || strings.TrimSpace(best.VideoID) == "" {
return "", fmt.Errorf("no songs found on youtube")
}
return strings.TrimSpace(best.VideoID), nil
}
func selectBestYouTubeLyricsSearchResult(results []youtubeLyricsSearchResult, trackName, artistName string, durationSec float64) *youtubeLyricsSearchResult {
if len(results) == 0 {
return nil
}
bestIndex := 0
bestScore := -1
for i := range results {
result := &results[i]
score := scoreLyricsSearchCandidate(result.Title, result.Author, parseClockDuration(result.Duration), trackName, artistName, durationSec)
if score > bestScore {
bestIndex = i
bestScore = score
}
}
return &results[bestIndex]
}
func (c *YouTubeLyricsClient) FetchLyrics(trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
videoID, err := c.SearchSong(trackName, artistName, durationSec)
if err != nil {
return nil, err
}
params := url.Values{}
params.Set("id", videoID)
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/youtube/lyrics", params)
if err != nil {
return nil, fmt.Errorf("youtube lyrics fetch failed: %w", err)
}
return parsePaxsenixLyricsPayload(raw, "YouTube", false)
}
func (c *KugouLyricsClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
query := strings.TrimSpace(trackName + " " + artistName)
if query == "" {
return "", fmt.Errorf("empty search query")
}
params := url.Values{}
params.Set("q", query)
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/kugou/search", params)
if err != nil {
return "", fmt.Errorf("kugou search failed: %w", err)
}
var results []kugouLyricsSearchResult
if err := json.Unmarshal([]byte(raw), &results); err != nil {
return "", fmt.Errorf("failed to decode kugou search: %w", err)
}
best := selectBestKugouLyricsSearchResult(results, trackName, artistName, durationSec)
if best == nil || strings.TrimSpace(best.Hash) == "" {
return "", fmt.Errorf("no songs found on kugou")
}
return strings.TrimSpace(best.Hash), nil
}
func selectBestKugouLyricsSearchResult(results []kugouLyricsSearchResult, trackName, artistName string, durationSec float64) *kugouLyricsSearchResult {
if len(results) == 0 {
return nil
}
bestIndex := 0
bestScore := -1
for i := range results {
result := &results[i]
score := scoreLyricsSearchCandidate(result.Title, result.Artist, result.Duration, trackName, artistName, durationSec)
if score > bestScore {
bestIndex = i
bestScore = score
}
}
return &results[bestIndex]
}
func (c *KugouLyricsClient) FetchLyrics(trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
hash, err := c.SearchSong(trackName, artistName, durationSec)
if err != nil {
return nil, err
}
params := url.Values{}
params.Set("id", hash)
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/kugou/lyrics", params)
if err != nil {
return nil, fmt.Errorf("kugou lyrics fetch failed: %w", err)
}
return parsePaxsenixLyricsPayload(raw, "Kugou", false)
}
func (c *GeniusLyricsClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
query := strings.TrimSpace(trackName + " " + artistName)
if query == "" {
return "", fmt.Errorf("empty search query")
}
params := url.Values{}
params.Set("q", query)
params.Set("per_page", "10")
raw, err := fetchPaxsenixBody(c.httpClient, "https://genius.com/api/search/multi", params)
if err != nil {
return "", fmt.Errorf("genius search failed: %w", err)
}
var results geniusSearchResponse
if err := json.Unmarshal([]byte(raw), &results); err != nil {
return "", fmt.Errorf("failed to decode genius search: %w", err)
}
bestURL := ""
bestScore := -1
for _, section := range results.Response.Sections {
for _, hit := range section.Hits {
if hit.Type != "song" || strings.TrimSpace(hit.Result.URL) == "" {
continue
}
artist := hit.Result.PrimaryArtistNames
if strings.TrimSpace(artist) == "" {
artist = hit.Result.ArtistNames
}
score := scoreLyricsSearchCandidate(hit.Result.Title, artist, 0, trackName, artistName, durationSec)
if score > bestScore {
bestScore = score
bestURL = strings.TrimSpace(hit.Result.URL)
}
}
}
if bestURL == "" {
return "", fmt.Errorf("no songs found on genius")
}
return bestURL, nil
}
func (c *GeniusLyricsClient) FetchLyrics(trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
geniusURL, err := c.SearchSong(trackName, artistName, durationSec)
if err != nil {
return nil, err
}
params := url.Values{}
params.Set("url", geniusURL)
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/genius/lyrics", params)
if err != nil {
return nil, fmt.Errorf("genius lyrics fetch failed: %w", err)
}
return parsePaxsenixLyricsPayload(raw, "Genius", false)
}
func scoreLyricsSearchCandidate(candidateTrack, candidateArtist string, candidateDuration float64, trackName, artistName string, durationSec float64) int {
normalizedTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(trackName)))
normalizedArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(artistName)))
candidateTrack = strings.ToLower(strings.TrimSpace(simplifyTrackName(candidateTrack)))
candidateArtist = strings.ToLower(strings.TrimSpace(normalizeArtistName(candidateArtist)))
score := 0
switch {
case candidateTrack == normalizedTrack:
score += 50
case strings.Contains(candidateTrack, normalizedTrack) || strings.Contains(normalizedTrack, candidateTrack):
score += 25
}
switch {
case candidateArtist == normalizedArtist:
score += 60
case strings.Contains(candidateArtist, normalizedArtist) || strings.Contains(normalizedArtist, candidateArtist):
score += 30
}
if durationSec > 0 && candidateDuration > 0 {
diff := math.Abs(candidateDuration - durationSec)
if diff <= durationToleranceSec {
score += 20
}
}
return score
}
func parseClockDuration(value string) float64 {
value = strings.TrimSpace(value)
if value == "" {
return 0
}
parts := strings.Split(value, ":")
total := 0
for _, part := range parts {
n, err := strconv.Atoi(strings.TrimSpace(part))
if err != nil {
return 0
}
total = total*60 + n
}
return float64(total)
}
+2 -2
View File
@@ -87,7 +87,7 @@ func formatQQLyricsMetadataToLRC(rawJSON string, multiPersonWordByWord bool) (st
if len(response.Lyrics) == 0 {
return "", fmt.Errorf("qq metadata lyrics response was empty")
}
return formatPaxContent("Syllable", response.Lyrics, multiPersonWordByWord), nil
return formatPaxContent("Syllable", response.Lyrics, multiPersonWordByWord, true), nil
}
func (c *QQMusicClient) FetchLyrics(
@@ -106,7 +106,7 @@ func (c *QQMusicClient) FetchLyrics(
lrcText, err := formatQQLyricsMetadataToLRC(rawLyrics, multiPersonWordByWord)
if err != nil {
if fallback, fallbackErr := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord); fallbackErr == nil {
if fallback, fallbackErr := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord, true); fallbackErr == nil {
lrcText = fallback
} else {
lrcText = rawLyrics
+84 -2
View File
@@ -156,13 +156,27 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
if err != nil || !strings.Contains(rawApple, "Syllable") {
t.Fatalf("apple raw = %q/%v", rawApple, err)
}
appleLyrics, err := apple.FetchLyrics("Song", "Artist", 180, true)
appleLyrics, err := apple.FetchLyrics("Song", "Artist", 180, true, true)
if err != nil || appleLyrics.SyncType != "LINE_SYNCED" || appleLyrics.Provider != "Apple Music" {
t.Fatalf("apple lyrics = %#v/%v", appleLyrics, err)
}
if plain, err := formatPaxLyricsToLRC(`[{"timestamp":2000,"text":[{"text":"Plain","part":false}]}]`, false); err != nil || !strings.Contains(plain, "Plain") {
if plain, err := formatPaxLyricsToLRC(`[{"timestamp":2000,"text":[{"text":"Plain","part":false}]}]`, false, false); err != nil || !strings.Contains(plain, "Plain") {
t.Fatalf("direct pax = %q/%v", plain, err)
}
lineOnly, err := formatPaxLyricsToLRC(paxJSON, true, false)
if err != nil {
t.Fatalf("line-only pax = %v", err)
}
if strings.Contains(lineOnly, "<00:") {
t.Fatalf("line-only pax should not include inline word timing: %q", lineOnly)
}
elrc, err := formatPaxLyricsToLRC(paxJSON, true, true)
if err != nil {
t.Fatalf("elrc pax = %v", err)
}
if !strings.Contains(elrc, "<00:") {
t.Fatalf("elrc pax should include inline word timing: %q", elrc)
}
if _, err := apple.SearchSong("", "", 0); err == nil {
t.Fatal("expected empty apple search error")
}
@@ -233,4 +247,72 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
if _, err := formatQQLyricsMetadataToLRC(`{"lyrics":[]}`, false); err == nil {
t.Fatal("expected empty QQ metadata error")
}
spotify := &SpotifyLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/spotify/search"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[{"trackId":"spotify-1","name":"Song","artistName":"Artist","duration":"03:00"}]`)), Request: req}, nil
case strings.Contains(req.URL.Path, "/spotify/lyrics"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`"[00:01.00]Spotify"`)), Request: req}, nil
default:
return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
}
})}}
spotifyLyrics, err := spotify.FetchLyrics("", "Song", "Artist", 180)
if err != nil || spotifyLyrics.Provider != "Spotify" || spotifyLyrics.SyncType != "LINE_SYNCED" {
t.Fatalf("spotify lyrics = %#v/%v", spotifyLyrics, err)
}
deezer := &DeezerLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"lyrics":[{"timestamp":1000,"text":[{"text":"Deezer","part":false}]}]}`)), Request: req}, nil
})}}
deezerLyrics, err := deezer.FetchLyricsByID("123", false)
if err != nil || deezerLyrics.Provider != "Deezer" || deezerLyrics.SyncType != "LINE_SYNCED" {
t.Fatalf("deezer lyrics = %#v/%v", deezerLyrics, err)
}
youtube := &YouTubeLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/youtube/search"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[{"videoId":"yt-1","title":"Song","author":"Artist","duration":"3:00"}]`)), Request: req}, nil
case strings.Contains(req.URL.Path, "/youtube/lyrics"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`"[00:01.00]YouTube"`)), Request: req}, nil
default:
return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
}
})}}
youtubeLyrics, err := youtube.FetchLyrics("Song", "Artist", 180)
if err != nil || youtubeLyrics.Provider != "YouTube" || youtubeLyrics.SyncType != "LINE_SYNCED" {
t.Fatalf("youtube lyrics = %#v/%v", youtubeLyrics, err)
}
kugou := &KugouLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/kugou/search"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[{"hash":"kg-1","title":"Song","artist":"Artist","duration":180}]`)), Request: req}, nil
case strings.Contains(req.URL.Path, "/kugou/lyrics"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"lyrics_text":"[00:01.00]Kugou"}`)), Request: req}, nil
default:
return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
}
})}}
kugouLyrics, err := kugou.FetchLyrics("Song", "Artist", 180)
if err != nil || kugouLyrics.Provider != "Kugou" || kugouLyrics.SyncType != "LINE_SYNCED" {
t.Fatalf("kugou lyrics = %#v/%v", kugouLyrics, err)
}
genius := &GeniusLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/api/search/multi"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"response":{"sections":[{"hits":[{"type":"song","result":{"title":"Song","primary_artist_names":"Artist","url":"https://genius.com/artist-song-lyrics"}}]}]}}`)), Request: req}, nil
case strings.Contains(req.URL.Path, "/genius/lyrics"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"error":false,"lyrics":"Genius line"}`)), Request: req}, nil
default:
return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
}
})}}
geniusLyrics, err := genius.FetchLyrics("Song", "Artist", 180)
if err != nil || geniusLyrics.Provider != "Genius" || geniusLyrics.SyncType != "UNSYNCED" {
t.Fatalf("genius lyrics = %#v/%v", geniusLyrics, err)
}
}
+201 -31
View File
@@ -872,7 +872,7 @@ func ExtractLyrics(filePath string) (string, error) {
return extractLyricsFromSidecarLRC(filePath)
}
if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") {
if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".aac") {
lyrics, err := extractLyricsFromM4A(filePath)
if err == nil && strings.TrimSpace(lyrics) != "" {
return lyrics, nil
@@ -1578,10 +1578,12 @@ func looksLikeEmbeddedLyrics(value string) bool {
}
type AudioQuality struct {
BitDepth int `json:"bit_depth"`
SampleRate int `json:"sample_rate"`
TotalSamples int64 `json:"total_samples"`
Duration int `json:"duration"`
BitDepth int `json:"bit_depth"`
SampleRate int `json:"sample_rate"`
TotalSamples int64 `json:"total_samples"`
Duration int `json:"duration"`
Bitrate int `json:"bitrate,omitempty"` // kbps, estimated for compressed MP4-family streams
Codec string `json:"codec,omitempty"`
}
func GetAudioQuality(filePath string) (AudioQuality, error) {
@@ -1632,6 +1634,7 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
SampleRate: sampleRate,
TotalSamples: totalSamples,
Duration: duration,
Codec: "flac",
}, nil
}
@@ -1695,9 +1698,11 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
// [26:28] reserved
// [28:32] samplerate (16.16 fixed-point)
sampleRate := int(buf[28])<<8 | int(buf[29])
bitDepth := int(buf[22])<<8 | int(buf[23])
bitDepth := 0
codec := normalizeM4AAudioCodec(atomType)
if atomType == "alac" {
bitDepth = int(buf[22])<<8 | int(buf[23])
if alacBitDepth, alacSampleRate, ok := readALACSpecificConfig(f, sampleOffset, fileSize); ok {
if alacBitDepth > 0 {
bitDepth = alacBitDepth
@@ -1706,24 +1711,75 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
sampleRate = alacSampleRate
}
}
} else if atomType == "fLaC" {
bitDepth = int(buf[22])<<8 | int(buf[23])
if flacBitDepth, flacSampleRate, flacTotalSamples, ok := readMP4FLACSpecificConfig(f, sampleOffset, fileSize); ok {
if flacBitDepth > 0 {
bitDepth = flacBitDepth
}
if flacSampleRate > 0 {
sampleRate = flacSampleRate
}
if flacTotalSamples > 0 && sampleRate > 0 && duration <= 0 {
duration = int(flacTotalSamples / int64(sampleRate))
}
}
}
if bitDepth <= 0 {
bitDepth = 16
bitrate := estimateAudioBitrateKbps(fileSize, duration)
if bitrate > 0 && bitrate < 16 {
bitrate = 0
}
return AudioQuality{
BitDepth: bitDepth,
SampleRate: sampleRate,
Duration: duration,
Bitrate: bitrate,
Codec: codec,
}, nil
}
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate, Duration: duration}, nil
func normalizeM4AAudioCodec(atomType string) string {
switch atomType {
case "mp4a":
return "aac"
case "alac":
return "alac"
case "fLaC":
return "flac"
case "ec-3":
return "eac3"
case "ac-3":
return "ac3"
case "ac-4":
return "ac4"
default:
return strings.TrimSpace(atomType)
}
}
func estimateAudioBitrateKbps(fileSize int64, durationSeconds int) int {
if fileSize <= 0 || durationSeconds <= 0 {
return 0
}
return int(math.Round(float64(fileSize*8) / float64(durationSeconds) / 1000.0))
}
func readM4ADurationSeconds(f *os.File, moovHeader atomHeader, fileSize int64) int {
childStart := moovHeader.offset + moovHeader.headerSize
childSize := moovHeader.size - moovHeader.headerSize
mvhdHeader, found, err := findAtomInRange(f, childStart, childSize, "mvhd", fileSize)
if err != nil || !found {
return 0
if err == nil && found {
if duration := readMP4DurationAtomSeconds(f, mvhdHeader, fileSize); duration > 0 {
return duration
}
}
payloadOffset := mvhdHeader.offset + mvhdHeader.headerSize
return readM4ATrackDurationSeconds(f, moovHeader, fileSize)
}
func readMP4DurationAtomSeconds(f *os.File, header atomHeader, fileSize int64) int {
payloadOffset := header.offset + header.headerSize
versionBuf := make([]byte, 1)
if _, err := f.ReadAt(versionBuf, payloadOffset); err != nil {
return 0
@@ -1754,6 +1810,53 @@ func readM4ADurationSeconds(f *os.File, moovHeader atomHeader, fileSize int64) i
return int(math.Round(float64(duration) / float64(timescale)))
}
func readM4ATrackDurationSeconds(f *os.File, moovHeader atomHeader, fileSize int64) int {
childStart := moovHeader.offset + moovHeader.headerSize
childSize := moovHeader.size - moovHeader.headerSize
bestDuration := 0
_ = walkMP4AtomsInRange(f, childStart, childSize, fileSize, func(header atomHeader) bool {
if header.typ == "mdhd" {
if duration := readMP4DurationAtomSeconds(f, header, fileSize); duration > bestDuration {
bestDuration = duration
}
return false
}
return header.typ == "trak" || header.typ == "mdia"
})
return bestDuration
}
func walkMP4AtomsInRange(f *os.File, start, size, fileSize int64, visit func(atomHeader) bool) error {
if size <= 0 {
return nil
}
end := start + size
for pos := start; pos+8 <= end; {
header, err := readAtomHeaderAt(f, pos, fileSize)
if err != nil {
return err
}
atomSize := header.size
if atomSize == 0 {
atomSize = end - pos
}
if atomSize < header.headerSize {
return fmt.Errorf("invalid atom size for %s", header.typ)
}
header.size = atomSize
if visit(header) {
childStart := header.offset + header.headerSize
childSize := header.size - header.headerSize
if err := walkMP4AtomsInRange(f, childStart, childSize, fileSize, visit); err != nil {
return err
}
}
pos += atomSize
}
return nil
}
func readALACSpecificConfig(f *os.File, sampleOffset, fileSize int64) (int, int, bool) {
if sampleOffset < 4 {
return 0, 0, false
@@ -1788,6 +1891,79 @@ func readALACSpecificConfig(f *os.File, sampleOffset, fileSize int64) (int, int,
return parseALACSpecificConfig(payload)
}
func readMP4FLACSpecificConfig(f *os.File, sampleOffset, fileSize int64) (int, int, int64, bool) {
if sampleOffset < 4 {
return 0, 0, 0, false
}
sampleEntryHeader, err := readAtomHeaderAt(f, sampleOffset-4, fileSize)
if err != nil {
return 0, 0, 0, false
}
childStart := sampleOffset + 32
childEnd := sampleEntryHeader.offset + sampleEntryHeader.size
if childStart >= childEnd {
return 0, 0, 0, false
}
configHeader, found, err := findAtomInRange(f, childStart, childEnd-childStart, "dfLa", fileSize)
if err != nil || !found {
return 0, 0, 0, false
}
payloadSize := configHeader.size - configHeader.headerSize
if payloadSize <= 0 {
return 0, 0, 0, false
}
payload := make([]byte, payloadSize)
if _, err := f.ReadAt(payload, configHeader.offset+configHeader.headerSize); err != nil {
return 0, 0, 0, false
}
return parseMP4FLACSpecificConfig(payload)
}
func parseMP4FLACSpecificConfig(payload []byte) (int, int, int64, bool) {
if len(payload) >= 4 && string(payload[:4]) == "fLaC" {
payload = payload[4:]
} else if len(payload) >= 4 {
// FLACSpecificBox starts with a full-box version/flags field.
payload = payload[4:]
}
for len(payload) >= 4 {
blockType := payload[0] & 0x7F
blockLen := int(payload[1])<<16 | int(payload[2])<<8 | int(payload[3])
if blockLen < 0 || len(payload) < 4+blockLen {
return 0, 0, 0, false
}
block := payload[4 : 4+blockLen]
if blockType == 0 && len(block) >= 34 {
bitDepth, sampleRate, totalSamples := parseFLACStreamInfoQuality(block[:34])
return bitDepth, sampleRate, totalSamples, bitDepth > 0 || sampleRate > 0
}
payload = payload[4+blockLen:]
}
return 0, 0, 0, false
}
func parseFLACStreamInfoQuality(streamInfo []byte) (int, int, int64) {
if len(streamInfo) < 18 {
return 0, 0, 0
}
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
bitsPerSample := (((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4)) + 1
totalSamples := int64(streamInfo[13]&0x0F)<<32 |
int64(streamInfo[14])<<24 |
int64(streamInfo[15])<<16 |
int64(streamInfo[16])<<8 |
int64(streamInfo[17])
return bitsPerSample, sampleRate, totalSamples
}
func parseALACSpecificConfig(payload []byte) (int, int, bool) {
if len(payload) < 24 {
return 0, 0, false
@@ -1882,8 +2058,14 @@ func findAtomInRange(f *os.File, start, size int64, target string, fileSize int6
func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string, error) {
const chunkSize = 64 * 1024
patternMP4A := []byte("mp4a")
patternALAC := []byte("alac")
patterns := [][]byte{
[]byte("mp4a"),
[]byte("alac"),
[]byte("fLaC"),
[]byte("ec-3"),
[]byte("ac-3"),
[]byte("ac-4"),
}
var tail []byte
readPos := start
@@ -1904,26 +2086,14 @@ func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string
}
data := append(tail, buf[:n]...)
mp4aIdx := bytes.Index(data, patternMP4A)
alacIdx := bytes.Index(data, patternALAC)
bestIdx := -1
bestType := ""
switch {
case mp4aIdx >= 0 && alacIdx >= 0:
if mp4aIdx <= alacIdx {
bestIdx = mp4aIdx
bestType = "mp4a"
} else {
bestIdx = alacIdx
bestType = "alac"
for _, pattern := range patterns {
idx := bytes.Index(data, pattern)
if idx >= 0 && (bestIdx < 0 || idx < bestIdx) {
bestIdx = idx
bestType = string(pattern)
}
case mp4aIdx >= 0:
bestIdx = mp4aIdx
bestType = "mp4a"
case alacIdx >= 0:
bestIdx = alacIdx
bestType = "alac"
}
if bestIdx >= 0 {
+50
View File
@@ -47,3 +47,53 @@ func TestParseALACSpecificConfigRejectsShortPayload(t *testing.T) {
t.Fatal("expected short ALAC payload to be rejected")
}
}
func TestM4ACodecFormatMapping(t *testing.T) {
cases := map[string]string{
"mp4a": "aac",
"alac": "alac",
"fLaC": "flac",
"ec-3": "eac3",
"ac-3": "ac3",
"ac-4": "ac4",
}
for atomType, want := range cases {
if got := normalizeM4AAudioCodec(atomType); got != want {
t.Fatalf("normalizeM4AAudioCodec(%q) = %q, want %q", atomType, got, want)
}
}
if got := libraryFormatForM4ACodec("flac"); got != "flac" {
t.Fatalf("libraryFormatForM4ACodec(flac) = %q", got)
}
if got := libraryFormatForM4ACodec("eac3"); got != "eac3" {
t.Fatalf("libraryFormatForM4ACodec(eac3) = %q", got)
}
if got := libraryFormatForM4ACodec("aac"); got != "m4a" {
t.Fatalf("libraryFormatForM4ACodec(aac) = %q", got)
}
}
func TestParseMP4FLACSpecificConfig(t *testing.T) {
streamInfo := make([]byte, 34)
sampleRate := 48000
bitsPerSample := 24
totalSamples := int64(48000 * 180)
streamInfo[10] = byte(sampleRate >> 12)
streamInfo[11] = byte(sampleRate >> 4)
streamInfo[12] = byte((sampleRate&0x0F)<<4 | ((bitsPerSample-1)>>4)&0x01)
streamInfo[13] = byte(((bitsPerSample-1)&0x0F)<<4 | int((totalSamples>>32)&0x0F))
streamInfo[14] = byte(totalSamples >> 24)
streamInfo[15] = byte(totalSamples >> 16)
streamInfo[16] = byte(totalSamples >> 8)
streamInfo[17] = byte(totalSamples)
payload := append([]byte{0, 0, 0, 0, 0, 0, 0, 34}, streamInfo...)
bitDepth, parsedRate, parsedSamples, ok := parseMP4FLACSpecificConfig(payload)
if !ok {
t.Fatal("expected MP4 FLAC config to parse")
}
if bitDepth != bitsPerSample || parsedRate != sampleRate || parsedSamples != totalSamples {
t.Fatalf("FLAC config = %d/%d/%d", bitDepth, parsedRate, parsedSamples)
}
}
+4 -2
View File
@@ -1,8 +1,8 @@
import 'package:flutter/foundation.dart';
class AppInfo {
static const String version = '4.5.1';
static const String buildNumber = '128';
static const String version = '4.5.5';
static const String buildNumber = '132';
static const String fullVersion = '$version+$buildNumber';
static String get displayVersion => kDebugMode ? 'Internal' : version;
@@ -17,6 +17,8 @@ class AppInfo {
static const String githubUrl = 'https://github.com/$githubRepo';
static const String originalGithubUrl =
'https://github.com/afkarxyz/SpotiFLAC';
static const String remoteConfigApiUrl =
'https://api.zarz.moe/v1/spotiflac-mobile/config';
static const String kofiUrl = 'https://ko-fi.com/zarzet';
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
+568 -2
View File
@@ -837,7 +837,7 @@ abstract class AppLocalizations {
/// App description in header card
///
/// In en, this message translates to:
/// **'Download Spotify tracks in lossless quality from Tidal and Qobuz.'**
/// **'Search music metadata, manage extensions, and organize your library.'**
String get aboutAppDescription;
/// Section header for artist albums
@@ -2286,6 +2286,12 @@ abstract class AppLocalizations {
/// **'Copy lyrics'**
String get trackCopyLyrics;
/// Label showing the lyrics source/provider
///
/// In en, this message translates to:
/// **'Source: {source}'**
String trackLyricsSource(String source);
/// Message when lyrics not found
///
/// In en, this message translates to:
@@ -2838,6 +2844,18 @@ abstract class AppLocalizations {
/// **'Best compatibility, ~10MB per track'**
String get downloadLossyMp3Subtitle;
/// Tidal 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
///
/// In en, this message translates to:
/// **'Best mobile compatibility, M4A container'**
String get downloadLossyAacSubtitle;
/// Tidal lossy format option - Opus 256kbps
///
/// In en, this message translates to:
@@ -4299,7 +4317,7 @@ abstract class AppLocalizations {
/// Subtitle for convert format menu item
///
/// In en, this message translates to:
/// **'Convert to MP3, Opus, ALAC, or FLAC'**
/// **'Convert to AAC/M4A, MP3, Opus, ALAC, or FLAC'**
String get trackConvertFormatSubtitle;
/// Title of convert bottom sheet
@@ -5209,6 +5227,24 @@ abstract class AppLocalizations {
/// **'Standard lyrics without speaker labels'**
String get downloadAppleQqMultiPersonDisabled;
/// Setting for preserving Apple Music word-by-word eLRC timestamps
///
/// In en, this message translates to:
/// **'Apple Music eLRC Word Sync'**
String get downloadAppleElrcWordSync;
/// Subtitle when Apple Music eLRC word sync is enabled
///
/// In en, this message translates to:
/// **'Raw word-by-word timestamps preserved'**
String get downloadAppleElrcWordSyncEnabled;
/// Subtitle when Apple Music eLRC word sync is disabled
///
/// In en, this message translates to:
/// **'Safer line-by-line Apple Music lyrics'**
String get downloadAppleElrcWordSyncDisabled;
/// Setting for Musixmatch lyrics translation language
///
/// In en, this message translates to:
@@ -5563,6 +5599,24 @@ abstract class AppLocalizations {
/// **'Sample Rate'**
String get audioAnalysisSampleRate;
/// Audio codec metric label
///
/// In en, this message translates to:
/// **'Codec'**
String get audioAnalysisCodec;
/// Audio container metric label
///
/// In en, this message translates to:
/// **'Container'**
String get audioAnalysisContainer;
/// Decoded sample format metric label
///
/// In en, this message translates to:
/// **'Decoded Format'**
String get audioAnalysisDecodedFormat;
/// Bit depth metric label
///
/// In en, this message translates to:
@@ -5611,12 +5665,60 @@ abstract class AppLocalizations {
/// **'RMS'**
String get audioAnalysisRms;
/// Integrated loudness metric label
///
/// In en, this message translates to:
/// **'LUFS'**
String get audioAnalysisLufs;
/// True peak metric label
///
/// In en, this message translates to:
/// **'True Peak'**
String get audioAnalysisTruePeak;
/// Clipping metric label
///
/// In en, this message translates to:
/// **'Clipping'**
String get audioAnalysisClipping;
/// Displayed when no clipped samples were detected
///
/// In en, this message translates to:
/// **'No clipping'**
String get audioAnalysisNoClipping;
/// Estimated spectral cutoff metric label
///
/// In en, this message translates to:
/// **'Spectral Cutoff'**
String get audioAnalysisSpectralCutoff;
/// Per-channel audio analysis section label
///
/// In en, this message translates to:
/// **'Per-channel Stats'**
String get audioAnalysisChannelStats;
/// Total samples metric label
///
/// In en, this message translates to:
/// **'Samples'**
String get audioAnalysisSamples;
/// Tooltip/label for the button that re-runs the audio analysis, discarding cached results
///
/// In en, this message translates to:
/// **'Re-analyze'**
String get audioAnalysisRescan;
/// Loading text while audio is being re-analyzed after an explicit refresh
///
/// In en, this message translates to:
/// **'Re-analyzing audio...'**
String get audioAnalysisRescanning;
/// Extensions page - subtitle for built-in search provider option
///
/// In en, this message translates to:
@@ -6416,6 +6518,470 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Choose which extensions can be used as fallback'**
String get downloadFallbackExtensionsSubtitle;
/// Hint text for the edit metadata date field
///
/// In en, this message translates to:
/// **'YYYY-MM-DD or YYYY'**
String get editMetadataFieldDateHint;
/// Label for total tracks field in the edit metadata sheet
///
/// In en, this message translates to:
/// **'Track Total'**
String get editMetadataFieldTrackTotal;
/// Label for total discs field in the edit metadata sheet
///
/// In en, this message translates to:
/// **'Disc Total'**
String get editMetadataFieldDiscTotal;
/// Label for composer field in the edit metadata sheet
///
/// In en, this message translates to:
/// **'Composer'**
String get editMetadataFieldComposer;
/// Label for comment field in the edit metadata sheet
///
/// In en, this message translates to:
/// **'Comment'**
String get editMetadataFieldComment;
/// Expandable section label for advanced metadata fields
///
/// In en, this message translates to:
/// **'Advanced'**
String get editMetadataAdvanced;
/// Filter option - items missing track number
///
/// In en, this message translates to:
/// **'Missing track number'**
String get libraryFilterMetadataMissingTrackNumber;
/// Filter option - items missing disc number
///
/// In en, this message translates to:
/// **'Missing disc number'**
String get libraryFilterMetadataMissingDiscNumber;
/// Filter option - items missing artist
///
/// In en, this message translates to:
/// **'Missing artist'**
String get libraryFilterMetadataMissingArtist;
/// Filter option - items with an invalid ISRC format
///
/// In en, this message translates to:
/// **'Incorrect ISRC format'**
String get libraryFilterMetadataIncorrectIsrcFormat;
/// Filter option - items missing record label
///
/// In en, this message translates to:
/// **'Missing label'**
String get libraryFilterMetadataMissingLabel;
/// Confirmation message for deleting selected playlists
///
/// In en, this message translates to:
/// **'Delete {count} {count, plural, =1{playlist} other{playlists}}?'**
String collectionDeletePlaylistsMessage(int count);
/// Snackbar after deleting selected playlists
///
/// In en, this message translates to:
/// **'{count} {count, plural, =1{playlist} other{playlists}} deleted'**
String collectionPlaylistsDeleted(int count);
/// Snackbar after adding multiple tracks to a playlist
///
/// In en, this message translates to:
/// **'Added {count} {count, plural, =1{track} other{tracks}} to {playlistName}'**
String collectionAddedTracksToPlaylist(int count, String playlistName);
/// Snackbar after adding multiple tracks to a playlist when some were already present
///
/// In en, this message translates to:
/// **'Added {count} {count, plural, =1{track} other{tracks}} to {playlistName} ({alreadyCount} already in playlist)'**
String collectionAddedTracksToPlaylistWithExisting(
int count,
String playlistName,
int alreadyCount,
);
/// Generic item count label
///
/// In en, this message translates to:
/// **'{count} {count, plural, =1{item} other{items}}'**
String itemCount(int count);
/// Snackbar summary after batch metadata re-enrichment finishes with failures
///
/// In en, this message translates to:
/// **'Metadata re-enriched successfully ({successCount}/{total}) - Failed: {failedCount}'**
String trackReEnrichSuccessWithFailures(
int successCount,
int total,
int failedCount,
);
/// Button label for deleting selected tracks
///
/// In en, this message translates to:
/// **'Delete {count} {count, plural, =1{track} other{tracks}}'**
String selectionDeleteTracksCount(int count);
/// Queue status while downloading with speed
///
/// In en, this message translates to:
/// **'Downloading - {speed} MB/s'**
String queueDownloadSpeedStatus(String speed);
/// Queue status before download progress is available
///
/// In en, this message translates to:
/// **'Starting...'**
String get queueDownloadStarting;
/// Accessibility label for selecting a track
///
/// In en, this message translates to:
/// **'Select track'**
String get a11ySelectTrack;
/// Accessibility label for deselecting a track
///
/// In en, this message translates to:
/// **'Deselect track'**
String get a11yDeselectTrack;
/// Accessibility label for playing a local library track
///
/// In en, this message translates to:
/// **'Play {trackName} by {artistName}'**
String a11yPlayTrackByArtist(String trackName, String artistName);
/// Store extension result count
///
/// In en, this message translates to:
/// **'{count} {count, plural, =1{extension} other{extensions}}'**
String storeExtensionsCount(int count);
/// Store compatibility badge for minimum app version
///
/// In en, this message translates to:
/// **'Requires v{version}+'**
String storeRequiresVersion(String version);
/// Generic action button label
///
/// In en, this message translates to:
/// **'Go'**
String get actionGo;
/// Header for log issue analysis summary
///
/// In en, this message translates to:
/// **'Issue Summary'**
String get logIssueSummary;
/// Total error count in log issue analysis
///
/// In en, this message translates to:
/// **'Total errors: {count}'**
String logTotalErrors(int count);
/// Affected domains in log issue analysis
///
/// In en, this message translates to:
/// **'Affected: {domains}'**
String logAffectedDomains(String domains);
/// Library scan status when a scan was cancelled
///
/// In en, this message translates to:
/// **'Scan cancelled'**
String get libraryScanCancelled;
/// Library scan status subtitle after cancellation
///
/// In en, this message translates to:
/// **'You can retry the scan when ready.'**
String get libraryScanCancelledSubtitle;
/// Library count note for downloaded history items excluded from the local list
///
/// In en, this message translates to:
/// **'{count} from Downloads history (excluded from list)'**
String libraryDownloadsHistoryExcluded(int count);
/// Setting title for Android native download worker
///
/// In en, this message translates to:
/// **'Native download worker'**
String get downloadNativeWorker;
/// Setting subtitle for Android native download worker
///
/// In en, this message translates to:
/// **'Beta Android service worker for extension downloads'**
String get downloadNativeWorkerSubtitle;
/// Badge label for beta features
///
/// In en, this message translates to:
/// **'BETA'**
String get badgeBeta;
/// Extension detail section header for service status
///
/// In en, this message translates to:
/// **'Service Status'**
String get extensionServiceStatus;
/// Extension capability label for service health checks
///
/// In en, this message translates to:
/// **'Service health'**
String get extensionServiceHealth;
/// Extension service health check count
///
/// In en, this message translates to:
/// **'{count} {count, plural, =1{check} other{checks}} configured'**
String extensionHealthChecksConfigured(int count);
/// Hint for an OAuth login link field before connecting Spotify
///
/// In en, this message translates to:
/// **'Tap Connect to Spotify to fill this field.'**
String get extensionOauthConnectHint;
/// Timestamp for the latest extension service health check
///
/// In en, this message translates to:
/// **'Last checked {time}'**
String extensionLastChecked(String time);
/// Tooltip for refreshing extension service health status
///
/// In en, this message translates to:
/// **'Refresh status'**
String get extensionRefreshStatus;
/// Extension detail section title for custom URL handling
///
/// In en, this message translates to:
/// **'Custom URL Handling'**
String get extensionCustomUrlHandling;
/// Extension detail subtitle for custom URL handling
///
/// In en, this message translates to:
/// **'This extension can handle links from these sites'**
String get extensionCustomUrlHandlingSubtitle;
/// Extension detail hint explaining share-to-app URL handling
///
/// In en, this message translates to:
/// **'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.'**
String get extensionCustomUrlHandlingShareHint;
/// Count of settings exposed by an extension quality option
///
/// In en, this message translates to:
/// **'{count} {count, plural, =1{setting} other{settings}}'**
String extensionSettingsCount(int count);
/// Extension service health status - online
///
/// In en, this message translates to:
/// **'Online'**
String get extensionHealthOnline;
/// Extension service health status - degraded
///
/// In en, this message translates to:
/// **'Degraded'**
String get extensionHealthDegraded;
/// Extension service health status - offline
///
/// In en, this message translates to:
/// **'Offline'**
String get extensionHealthOffline;
/// Extension service health status - not configured
///
/// In en, this message translates to:
/// **'Not configured'**
String get extensionHealthNotConfigured;
/// Extension service health status - unknown
///
/// In en, this message translates to:
/// **'Unknown'**
String get extensionHealthUnknown;
/// Label for a required extension service health check
///
/// In en, this message translates to:
/// **'required'**
String get extensionHealthRequired;
/// Value shown when an extension setting has no value
///
/// In en, this message translates to:
/// **'Not set'**
String get extensionSettingNotSet;
/// Fallback error when an extension action fails without details
///
/// In en, this message translates to:
/// **'Action failed'**
String get extensionActionFailed;
/// Hint for editing an extension setting value
///
/// In en, this message translates to:
/// **'Enter value'**
String get extensionEnterValue;
/// Tooltip for online extension service
///
/// In en, this message translates to:
/// **'Service online'**
String get extensionHealthServiceOnline;
/// Tooltip for degraded extension service
///
/// In en, this message translates to:
/// **'Service degraded'**
String get extensionHealthServiceDegraded;
/// Tooltip for offline extension service
///
/// In en, this message translates to:
/// **'Service offline'**
String get extensionHealthServiceOffline;
/// Tooltip for unknown extension service health
///
/// In en, this message translates to:
/// **'Service status unknown'**
String get extensionHealthServiceUnknown;
/// Audio channel layout label - stereo
///
/// In en, this message translates to:
/// **'Stereo'**
String get audioAnalysisStereo;
/// Audio channel layout label - mono
///
/// In en, this message translates to:
/// **'Mono'**
String get audioAnalysisMono;
/// Button label to open a track in a named music service
///
/// In en, this message translates to:
/// **'Open in {serviceName}'**
String trackOpenInService(String serviceName);
/// Lyrics source label for embedded lyrics
///
/// In en, this message translates to:
/// **'Embedded'**
String get trackLyricsEmbeddedSource;
/// Fallback album name when metadata is missing
///
/// In en, this message translates to:
/// **'Unknown Album'**
String get unknownAlbum;
/// Fallback artist name when metadata is missing
///
/// In en, this message translates to:
/// **'Unknown Artist'**
String get unknownArtist;
/// Audio permission type label
///
/// In en, this message translates to:
/// **'Audio'**
String get permissionAudio;
/// Storage permission type label
///
/// In en, this message translates to:
/// **'Storage'**
String get permissionStorage;
/// Notification permission type label
///
/// In en, this message translates to:
/// **'Notification'**
String get permissionNotification;
/// Error when the selected folder is invalid
///
/// In en, this message translates to:
/// **'Invalid folder selected'**
String get errorInvalidFolderSelected;
/// Error when persistent folder access cannot be saved
///
/// In en, this message translates to:
/// **'Could not keep access to the selected folder'**
String get errorCouldNotKeepFolderAccess;
/// Store detail value when any app version is accepted
///
/// In en, this message translates to:
/// **'Any'**
String get storeAnyVersion;
/// Store extension category - metadata
///
/// In en, this message translates to:
/// **'Metadata'**
String get storeCategoryMetadata;
/// Store extension category - download
///
/// In en, this message translates to:
/// **'Download'**
String get storeCategoryDownload;
/// Store extension category - utility
///
/// In en, this message translates to:
/// **'Utility'**
String get storeCategoryUtility;
/// Store extension category - lyrics
///
/// In en, this message translates to:
/// **'Lyrics'**
String get storeCategoryLyrics;
/// Store extension category - integration
///
/// In en, this message translates to:
/// **'Integration'**
String get storeCategoryIntegration;
/// Section header for all artist releases
///
/// In en, this message translates to:
/// **'Releases'**
String get artistReleases;
}
class _AppLocalizationsDelegate
+390 -1
View File
@@ -405,7 +405,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get aboutAppDescription =>
'Lade Spotify-Titel in verlustfreier Qualität von Tidal und Qobuz herunter.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Alben';
@@ -1237,6 +1237,11 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get trackCopyLyrics => 'Lyrics kopieren';
@override
String trackLyricsSource(String source) {
return 'Source: $source';
}
@override
String get trackLyricsNotAvailable =>
'Lyrics sind für diesen Titel nicht verfügbar';
@@ -1547,6 +1552,13 @@ class AppLocalizationsDe extends AppLocalizations {
String get downloadLossyMp3Subtitle =>
'Beste Kompatibilität, ~10MB pro Titel';
@override
String get downloadLossyAac => 'AAC/M4A 320kbps';
@override
String get downloadLossyAacSubtitle =>
'Best mobile compatibility, M4A container';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@@ -3049,6 +3061,17 @@ class AppLocalizationsDe extends AppLocalizations {
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@override
String get downloadAppleElrcWordSyncEnabled =>
'Raw word-by-word timestamps preserved';
@override
String get downloadAppleElrcWordSyncDisabled =>
'Safer line-by-line Apple Music lyrics';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@@ -3295,6 +3318,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisCodec => 'Codec';
@override
String get audioAnalysisContainer => 'Container';
@override
String get audioAnalysisDecodedFormat => 'Decoded Format';
@override
String get audioAnalysisBitDepth => 'Bit-Tiefe';
@@ -3319,9 +3351,33 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisLufs => 'LUFS';
@override
String get audioAnalysisTruePeak => 'True Peak';
@override
String get audioAnalysisClipping => 'Clipping';
@override
String get audioAnalysisNoClipping => 'No clipping';
@override
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
@override
String get audioAnalysisChannelStats => 'Per-channel Stats';
@override
String get audioAnalysisSamples => 'Proben';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
@@ -3833,4 +3889,337 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get downloadFallbackExtensionsSubtitle =>
'Choose which extensions can be used as fallback';
@override
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
@override
String get editMetadataFieldTrackTotal => 'Track Total';
@override
String get editMetadataFieldDiscTotal => 'Disc Total';
@override
String get editMetadataFieldComposer => 'Composer';
@override
String get editMetadataFieldComment => 'Comment';
@override
String get editMetadataAdvanced => 'Advanced';
@override
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
@override
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
@override
String get libraryFilterMetadataMissingArtist => 'Missing artist';
@override
String get libraryFilterMetadataIncorrectIsrcFormat =>
'Incorrect ISRC format';
@override
String get libraryFilterMetadataMissingLabel => 'Missing label';
@override
String collectionDeletePlaylistsMessage(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0?';
}
@override
String collectionPlaylistsDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return '$count $_temp0 deleted';
}
@override
String collectionAddedTracksToPlaylist(int count, String playlistName) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName';
}
@override
String collectionAddedTracksToPlaylistWithExisting(
int count,
String playlistName,
int alreadyCount,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
}
@override
String itemCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count $_temp0';
}
@override
String trackReEnrichSuccessWithFailures(
int successCount,
int total,
int failedCount,
) {
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
}
@override
String selectionDeleteTracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Delete $count $_temp0';
}
@override
String queueDownloadSpeedStatus(String speed) {
return 'Downloading - $speed MB/s';
}
@override
String get queueDownloadStarting => 'Starting...';
@override
String get a11ySelectTrack => 'Select track';
@override
String get a11yDeselectTrack => 'Deselect track';
@override
String a11yPlayTrackByArtist(String trackName, String artistName) {
return 'Play $trackName by $artistName';
}
@override
String storeExtensionsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0';
}
@override
String storeRequiresVersion(String version) {
return 'Requires v$version+';
}
@override
String get actionGo => 'Go';
@override
String get logIssueSummary => 'Issue Summary';
@override
String logTotalErrors(int count) {
return 'Total errors: $count';
}
@override
String logAffectedDomains(String domains) {
return 'Affected: $domains';
}
@override
String get libraryScanCancelled => 'Scan cancelled';
@override
String get libraryScanCancelledSubtitle =>
'You can retry the scan when ready.';
@override
String libraryDownloadsHistoryExcluded(int count) {
return '$count from Downloads history (excluded from list)';
}
@override
String get downloadNativeWorker => 'Native download worker';
@override
String get downloadNativeWorkerSubtitle =>
'Beta Android service worker for extension downloads';
@override
String get badgeBeta => 'BETA';
@override
String get extensionServiceStatus => 'Service Status';
@override
String get extensionServiceHealth => 'Service health';
@override
String extensionHealthChecksConfigured(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'checks',
one: 'check',
);
return '$count $_temp0 configured';
}
@override
String get extensionOauthConnectHint =>
'Tap Connect to Spotify to fill this field.';
@override
String extensionLastChecked(String time) {
return 'Last checked $time';
}
@override
String get extensionRefreshStatus => 'Refresh status';
@override
String get extensionCustomUrlHandling => 'Custom URL Handling';
@override
String get extensionCustomUrlHandlingSubtitle =>
'This extension can handle links from these sites';
@override
String get extensionCustomUrlHandlingShareHint =>
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
@override
String extensionSettingsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'settings',
one: 'setting',
);
return '$count $_temp0';
}
@override
String get extensionHealthOnline => 'Online';
@override
String get extensionHealthDegraded => 'Degraded';
@override
String get extensionHealthOffline => 'Offline';
@override
String get extensionHealthNotConfigured => 'Not configured';
@override
String get extensionHealthUnknown => 'Unknown';
@override
String get extensionHealthRequired => 'required';
@override
String get extensionSettingNotSet => 'Not set';
@override
String get extensionActionFailed => 'Action failed';
@override
String get extensionEnterValue => 'Enter value';
@override
String get extensionHealthServiceOnline => 'Service online';
@override
String get extensionHealthServiceDegraded => 'Service degraded';
@override
String get extensionHealthServiceOffline => 'Service offline';
@override
String get extensionHealthServiceUnknown => 'Service status unknown';
@override
String get audioAnalysisStereo => 'Stereo';
@override
String get audioAnalysisMono => 'Mono';
@override
String trackOpenInService(String serviceName) {
return 'Open in $serviceName';
}
@override
String get trackLyricsEmbeddedSource => 'Embedded';
@override
String get unknownAlbum => 'Unknown Album';
@override
String get unknownArtist => 'Unknown Artist';
@override
String get permissionAudio => 'Audio';
@override
String get permissionStorage => 'Storage';
@override
String get permissionNotification => 'Notification';
@override
String get errorInvalidFolderSelected => 'Invalid folder selected';
@override
String get errorCouldNotKeepFolderAccess =>
'Could not keep access to the selected folder';
@override
String get storeAnyVersion => 'Any';
@override
String get storeCategoryMetadata => 'Metadata';
@override
String get storeCategoryDownload => 'Download';
@override
String get storeCategoryUtility => 'Utility';
@override
String get storeCategoryLyrics => 'Lyrics';
@override
String get storeCategoryIntegration => 'Integration';
@override
String get artistReleases => 'Releases';
}
+391 -2
View File
@@ -397,7 +397,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Albums';
@@ -1220,6 +1220,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get trackCopyLyrics => 'Copy lyrics';
@override
String trackLyricsSource(String source) {
return 'Source: $source';
}
@override
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@@ -1523,6 +1528,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyAac => 'AAC/M4A 320kbps';
@override
String get downloadLossyAacSubtitle =>
'Best mobile compatibility, M4A container';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@@ -2408,7 +2420,7 @@ class AppLocalizationsEn 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';
@@ -3015,6 +3027,17 @@ class AppLocalizationsEn extends AppLocalizations {
String get downloadAppleQqMultiPersonDisabled =>
'Standard lyrics without speaker labels';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@override
String get downloadAppleElrcWordSyncEnabled =>
'Raw word-by-word timestamps preserved';
@override
String get downloadAppleElrcWordSyncDisabled =>
'Safer line-by-line Apple Music lyrics';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@@ -3260,6 +3283,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisCodec => 'Codec';
@override
String get audioAnalysisContainer => 'Container';
@override
String get audioAnalysisDecodedFormat => 'Decoded Format';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@@ -3284,9 +3316,33 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisLufs => 'LUFS';
@override
String get audioAnalysisTruePeak => 'True Peak';
@override
String get audioAnalysisClipping => 'Clipping';
@override
String get audioAnalysisNoClipping => 'No clipping';
@override
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
@override
String get audioAnalysisChannelStats => 'Per-channel Stats';
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
@@ -3804,4 +3860,337 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get downloadFallbackExtensionsSubtitle =>
'Choose which extensions can be used as fallback';
@override
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
@override
String get editMetadataFieldTrackTotal => 'Track Total';
@override
String get editMetadataFieldDiscTotal => 'Disc Total';
@override
String get editMetadataFieldComposer => 'Composer';
@override
String get editMetadataFieldComment => 'Comment';
@override
String get editMetadataAdvanced => 'Advanced';
@override
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
@override
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
@override
String get libraryFilterMetadataMissingArtist => 'Missing artist';
@override
String get libraryFilterMetadataIncorrectIsrcFormat =>
'Incorrect ISRC format';
@override
String get libraryFilterMetadataMissingLabel => 'Missing label';
@override
String collectionDeletePlaylistsMessage(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0?';
}
@override
String collectionPlaylistsDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return '$count $_temp0 deleted';
}
@override
String collectionAddedTracksToPlaylist(int count, String playlistName) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName';
}
@override
String collectionAddedTracksToPlaylistWithExisting(
int count,
String playlistName,
int alreadyCount,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
}
@override
String itemCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count $_temp0';
}
@override
String trackReEnrichSuccessWithFailures(
int successCount,
int total,
int failedCount,
) {
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
}
@override
String selectionDeleteTracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Delete $count $_temp0';
}
@override
String queueDownloadSpeedStatus(String speed) {
return 'Downloading - $speed MB/s';
}
@override
String get queueDownloadStarting => 'Starting...';
@override
String get a11ySelectTrack => 'Select track';
@override
String get a11yDeselectTrack => 'Deselect track';
@override
String a11yPlayTrackByArtist(String trackName, String artistName) {
return 'Play $trackName by $artistName';
}
@override
String storeExtensionsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0';
}
@override
String storeRequiresVersion(String version) {
return 'Requires v$version+';
}
@override
String get actionGo => 'Go';
@override
String get logIssueSummary => 'Issue Summary';
@override
String logTotalErrors(int count) {
return 'Total errors: $count';
}
@override
String logAffectedDomains(String domains) {
return 'Affected: $domains';
}
@override
String get libraryScanCancelled => 'Scan cancelled';
@override
String get libraryScanCancelledSubtitle =>
'You can retry the scan when ready.';
@override
String libraryDownloadsHistoryExcluded(int count) {
return '$count from Downloads history (excluded from list)';
}
@override
String get downloadNativeWorker => 'Native download worker';
@override
String get downloadNativeWorkerSubtitle =>
'Beta Android service worker for extension downloads';
@override
String get badgeBeta => 'BETA';
@override
String get extensionServiceStatus => 'Service Status';
@override
String get extensionServiceHealth => 'Service health';
@override
String extensionHealthChecksConfigured(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'checks',
one: 'check',
);
return '$count $_temp0 configured';
}
@override
String get extensionOauthConnectHint =>
'Tap Connect to Spotify to fill this field.';
@override
String extensionLastChecked(String time) {
return 'Last checked $time';
}
@override
String get extensionRefreshStatus => 'Refresh status';
@override
String get extensionCustomUrlHandling => 'Custom URL Handling';
@override
String get extensionCustomUrlHandlingSubtitle =>
'This extension can handle links from these sites';
@override
String get extensionCustomUrlHandlingShareHint =>
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
@override
String extensionSettingsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'settings',
one: 'setting',
);
return '$count $_temp0';
}
@override
String get extensionHealthOnline => 'Online';
@override
String get extensionHealthDegraded => 'Degraded';
@override
String get extensionHealthOffline => 'Offline';
@override
String get extensionHealthNotConfigured => 'Not configured';
@override
String get extensionHealthUnknown => 'Unknown';
@override
String get extensionHealthRequired => 'required';
@override
String get extensionSettingNotSet => 'Not set';
@override
String get extensionActionFailed => 'Action failed';
@override
String get extensionEnterValue => 'Enter value';
@override
String get extensionHealthServiceOnline => 'Service online';
@override
String get extensionHealthServiceDegraded => 'Service degraded';
@override
String get extensionHealthServiceOffline => 'Service offline';
@override
String get extensionHealthServiceUnknown => 'Service status unknown';
@override
String get audioAnalysisStereo => 'Stereo';
@override
String get audioAnalysisMono => 'Mono';
@override
String trackOpenInService(String serviceName) {
return 'Open in $serviceName';
}
@override
String get trackLyricsEmbeddedSource => 'Embedded';
@override
String get unknownAlbum => 'Unknown Album';
@override
String get unknownArtist => 'Unknown Artist';
@override
String get permissionAudio => 'Audio';
@override
String get permissionStorage => 'Storage';
@override
String get permissionNotification => 'Notification';
@override
String get errorInvalidFolderSelected => 'Invalid folder selected';
@override
String get errorCouldNotKeepFolderAccess =>
'Could not keep access to the selected folder';
@override
String get storeAnyVersion => 'Any';
@override
String get storeCategoryMetadata => 'Metadata';
@override
String get storeCategoryDownload => 'Download';
@override
String get storeCategoryUtility => 'Utility';
@override
String get storeCategoryLyrics => 'Lyrics';
@override
String get storeCategoryIntegration => 'Integration';
@override
String get artistReleases => 'Releases';
}
+397 -2
View File
@@ -397,7 +397,7 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Albums';
@@ -1220,6 +1220,11 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get trackCopyLyrics => 'Copy lyrics';
@override
String trackLyricsSource(String source) {
return 'Source: $source';
}
@override
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@@ -1523,6 +1528,13 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyAac => 'AAC/M4A 320kbps';
@override
String get downloadLossyAacSubtitle =>
'Best mobile compatibility, M4A container';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@@ -3015,6 +3027,17 @@ class AppLocalizationsEs extends AppLocalizations {
String get downloadAppleQqMultiPersonDisabled =>
'Standard lyrics without speaker labels';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@override
String get downloadAppleElrcWordSyncEnabled =>
'Raw word-by-word timestamps preserved';
@override
String get downloadAppleElrcWordSyncDisabled =>
'Safer line-by-line Apple Music lyrics';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@@ -3260,6 +3283,15 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisCodec => 'Codec';
@override
String get audioAnalysisContainer => 'Container';
@override
String get audioAnalysisDecodedFormat => 'Decoded Format';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@@ -3284,9 +3316,33 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisLufs => 'LUFS';
@override
String get audioAnalysisTruePeak => 'True Peak';
@override
String get audioAnalysisClipping => 'Clipping';
@override
String get audioAnalysisNoClipping => 'No clipping';
@override
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
@override
String get audioAnalysisChannelStats => 'Per-channel Stats';
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
@@ -3798,6 +3854,339 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get downloadFallbackExtensionsSubtitle =>
'Choose which extensions can be used as fallback';
@override
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
@override
String get editMetadataFieldTrackTotal => 'Track Total';
@override
String get editMetadataFieldDiscTotal => 'Disc Total';
@override
String get editMetadataFieldComposer => 'Composer';
@override
String get editMetadataFieldComment => 'Comment';
@override
String get editMetadataAdvanced => 'Advanced';
@override
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
@override
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
@override
String get libraryFilterMetadataMissingArtist => 'Missing artist';
@override
String get libraryFilterMetadataIncorrectIsrcFormat =>
'Incorrect ISRC format';
@override
String get libraryFilterMetadataMissingLabel => 'Missing label';
@override
String collectionDeletePlaylistsMessage(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0?';
}
@override
String collectionPlaylistsDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return '$count $_temp0 deleted';
}
@override
String collectionAddedTracksToPlaylist(int count, String playlistName) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName';
}
@override
String collectionAddedTracksToPlaylistWithExisting(
int count,
String playlistName,
int alreadyCount,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
}
@override
String itemCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count $_temp0';
}
@override
String trackReEnrichSuccessWithFailures(
int successCount,
int total,
int failedCount,
) {
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
}
@override
String selectionDeleteTracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Delete $count $_temp0';
}
@override
String queueDownloadSpeedStatus(String speed) {
return 'Downloading - $speed MB/s';
}
@override
String get queueDownloadStarting => 'Starting...';
@override
String get a11ySelectTrack => 'Select track';
@override
String get a11yDeselectTrack => 'Deselect track';
@override
String a11yPlayTrackByArtist(String trackName, String artistName) {
return 'Play $trackName by $artistName';
}
@override
String storeExtensionsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0';
}
@override
String storeRequiresVersion(String version) {
return 'Requires v$version+';
}
@override
String get actionGo => 'Go';
@override
String get logIssueSummary => 'Issue Summary';
@override
String logTotalErrors(int count) {
return 'Total errors: $count';
}
@override
String logAffectedDomains(String domains) {
return 'Affected: $domains';
}
@override
String get libraryScanCancelled => 'Scan cancelled';
@override
String get libraryScanCancelledSubtitle =>
'You can retry the scan when ready.';
@override
String libraryDownloadsHistoryExcluded(int count) {
return '$count from Downloads history (excluded from list)';
}
@override
String get downloadNativeWorker => 'Native download worker';
@override
String get downloadNativeWorkerSubtitle =>
'Beta Android service worker for extension downloads';
@override
String get badgeBeta => 'BETA';
@override
String get extensionServiceStatus => 'Service Status';
@override
String get extensionServiceHealth => 'Service health';
@override
String extensionHealthChecksConfigured(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'checks',
one: 'check',
);
return '$count $_temp0 configured';
}
@override
String get extensionOauthConnectHint =>
'Tap Connect to Spotify to fill this field.';
@override
String extensionLastChecked(String time) {
return 'Last checked $time';
}
@override
String get extensionRefreshStatus => 'Refresh status';
@override
String get extensionCustomUrlHandling => 'Custom URL Handling';
@override
String get extensionCustomUrlHandlingSubtitle =>
'This extension can handle links from these sites';
@override
String get extensionCustomUrlHandlingShareHint =>
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
@override
String extensionSettingsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'settings',
one: 'setting',
);
return '$count $_temp0';
}
@override
String get extensionHealthOnline => 'Online';
@override
String get extensionHealthDegraded => 'Degraded';
@override
String get extensionHealthOffline => 'Offline';
@override
String get extensionHealthNotConfigured => 'Not configured';
@override
String get extensionHealthUnknown => 'Unknown';
@override
String get extensionHealthRequired => 'required';
@override
String get extensionSettingNotSet => 'Not set';
@override
String get extensionActionFailed => 'Action failed';
@override
String get extensionEnterValue => 'Enter value';
@override
String get extensionHealthServiceOnline => 'Service online';
@override
String get extensionHealthServiceDegraded => 'Service degraded';
@override
String get extensionHealthServiceOffline => 'Service offline';
@override
String get extensionHealthServiceUnknown => 'Service status unknown';
@override
String get audioAnalysisStereo => 'Stereo';
@override
String get audioAnalysisMono => 'Mono';
@override
String trackOpenInService(String serviceName) {
return 'Open in $serviceName';
}
@override
String get trackLyricsEmbeddedSource => 'Embedded';
@override
String get unknownAlbum => 'Unknown Album';
@override
String get unknownArtist => 'Unknown Artist';
@override
String get permissionAudio => 'Audio';
@override
String get permissionStorage => 'Storage';
@override
String get permissionNotification => 'Notification';
@override
String get errorInvalidFolderSelected => 'Invalid folder selected';
@override
String get errorCouldNotKeepFolderAccess =>
'Could not keep access to the selected folder';
@override
String get storeAnyVersion => 'Any';
@override
String get storeCategoryMetadata => 'Metadata';
@override
String get storeCategoryDownload => 'Download';
@override
String get storeCategoryUtility => 'Utility';
@override
String get storeCategoryLyrics => 'Lyrics';
@override
String get storeCategoryIntegration => 'Integration';
@override
String get artistReleases => 'Releases';
}
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
@@ -4196,7 +4585,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
@override
String get aboutAppDescription =>
'Descargar pistas de Spotify en alta calidad (sin pérdida) de Tidal y Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Álbumes';
@@ -7049,6 +7438,12 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
+390 -1
View File
@@ -399,7 +399,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Albums';
@@ -1223,6 +1223,11 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get trackCopyLyrics => 'Copy lyrics';
@override
String trackLyricsSource(String source) {
return 'Source: $source';
}
@override
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@@ -1526,6 +1531,13 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyAac => 'AAC/M4A 320kbps';
@override
String get downloadLossyAacSubtitle =>
'Best mobile compatibility, M4A container';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@@ -3018,6 +3030,17 @@ class AppLocalizationsFr extends AppLocalizations {
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@override
String get downloadAppleElrcWordSyncEnabled =>
'Raw word-by-word timestamps preserved';
@override
String get downloadAppleElrcWordSyncDisabled =>
'Safer line-by-line Apple Music lyrics';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@@ -3264,6 +3287,15 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisCodec => 'Codec';
@override
String get audioAnalysisContainer => 'Container';
@override
String get audioAnalysisDecodedFormat => 'Decoded Format';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@@ -3288,9 +3320,33 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisLufs => 'LUFS';
@override
String get audioAnalysisTruePeak => 'True Peak';
@override
String get audioAnalysisClipping => 'Clipping';
@override
String get audioAnalysisNoClipping => 'No clipping';
@override
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
@override
String get audioAnalysisChannelStats => 'Per-channel Stats';
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
@@ -3802,4 +3858,337 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get downloadFallbackExtensionsSubtitle =>
'Choose which extensions can be used as fallback';
@override
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
@override
String get editMetadataFieldTrackTotal => 'Track Total';
@override
String get editMetadataFieldDiscTotal => 'Disc Total';
@override
String get editMetadataFieldComposer => 'Composer';
@override
String get editMetadataFieldComment => 'Comment';
@override
String get editMetadataAdvanced => 'Advanced';
@override
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
@override
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
@override
String get libraryFilterMetadataMissingArtist => 'Missing artist';
@override
String get libraryFilterMetadataIncorrectIsrcFormat =>
'Incorrect ISRC format';
@override
String get libraryFilterMetadataMissingLabel => 'Missing label';
@override
String collectionDeletePlaylistsMessage(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0?';
}
@override
String collectionPlaylistsDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return '$count $_temp0 deleted';
}
@override
String collectionAddedTracksToPlaylist(int count, String playlistName) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName';
}
@override
String collectionAddedTracksToPlaylistWithExisting(
int count,
String playlistName,
int alreadyCount,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
}
@override
String itemCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count $_temp0';
}
@override
String trackReEnrichSuccessWithFailures(
int successCount,
int total,
int failedCount,
) {
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
}
@override
String selectionDeleteTracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Delete $count $_temp0';
}
@override
String queueDownloadSpeedStatus(String speed) {
return 'Downloading - $speed MB/s';
}
@override
String get queueDownloadStarting => 'Starting...';
@override
String get a11ySelectTrack => 'Select track';
@override
String get a11yDeselectTrack => 'Deselect track';
@override
String a11yPlayTrackByArtist(String trackName, String artistName) {
return 'Play $trackName by $artistName';
}
@override
String storeExtensionsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0';
}
@override
String storeRequiresVersion(String version) {
return 'Requires v$version+';
}
@override
String get actionGo => 'Go';
@override
String get logIssueSummary => 'Issue Summary';
@override
String logTotalErrors(int count) {
return 'Total errors: $count';
}
@override
String logAffectedDomains(String domains) {
return 'Affected: $domains';
}
@override
String get libraryScanCancelled => 'Scan cancelled';
@override
String get libraryScanCancelledSubtitle =>
'You can retry the scan when ready.';
@override
String libraryDownloadsHistoryExcluded(int count) {
return '$count from Downloads history (excluded from list)';
}
@override
String get downloadNativeWorker => 'Native download worker';
@override
String get downloadNativeWorkerSubtitle =>
'Beta Android service worker for extension downloads';
@override
String get badgeBeta => 'BETA';
@override
String get extensionServiceStatus => 'Service Status';
@override
String get extensionServiceHealth => 'Service health';
@override
String extensionHealthChecksConfigured(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'checks',
one: 'check',
);
return '$count $_temp0 configured';
}
@override
String get extensionOauthConnectHint =>
'Tap Connect to Spotify to fill this field.';
@override
String extensionLastChecked(String time) {
return 'Last checked $time';
}
@override
String get extensionRefreshStatus => 'Refresh status';
@override
String get extensionCustomUrlHandling => 'Custom URL Handling';
@override
String get extensionCustomUrlHandlingSubtitle =>
'This extension can handle links from these sites';
@override
String get extensionCustomUrlHandlingShareHint =>
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
@override
String extensionSettingsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'settings',
one: 'setting',
);
return '$count $_temp0';
}
@override
String get extensionHealthOnline => 'Online';
@override
String get extensionHealthDegraded => 'Degraded';
@override
String get extensionHealthOffline => 'Offline';
@override
String get extensionHealthNotConfigured => 'Not configured';
@override
String get extensionHealthUnknown => 'Unknown';
@override
String get extensionHealthRequired => 'required';
@override
String get extensionSettingNotSet => 'Not set';
@override
String get extensionActionFailed => 'Action failed';
@override
String get extensionEnterValue => 'Enter value';
@override
String get extensionHealthServiceOnline => 'Service online';
@override
String get extensionHealthServiceDegraded => 'Service degraded';
@override
String get extensionHealthServiceOffline => 'Service offline';
@override
String get extensionHealthServiceUnknown => 'Service status unknown';
@override
String get audioAnalysisStereo => 'Stereo';
@override
String get audioAnalysisMono => 'Mono';
@override
String trackOpenInService(String serviceName) {
return 'Open in $serviceName';
}
@override
String get trackLyricsEmbeddedSource => 'Embedded';
@override
String get unknownAlbum => 'Unknown Album';
@override
String get unknownArtist => 'Unknown Artist';
@override
String get permissionAudio => 'Audio';
@override
String get permissionStorage => 'Storage';
@override
String get permissionNotification => 'Notification';
@override
String get errorInvalidFolderSelected => 'Invalid folder selected';
@override
String get errorCouldNotKeepFolderAccess =>
'Could not keep access to the selected folder';
@override
String get storeAnyVersion => 'Any';
@override
String get storeCategoryMetadata => 'Metadata';
@override
String get storeCategoryDownload => 'Download';
@override
String get storeCategoryUtility => 'Utility';
@override
String get storeCategoryLyrics => 'Lyrics';
@override
String get storeCategoryIntegration => 'Integration';
@override
String get artistReleases => 'Releases';
}
+390 -1
View File
@@ -397,7 +397,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Albums';
@@ -1220,6 +1220,11 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get trackCopyLyrics => 'Copy lyrics';
@override
String trackLyricsSource(String source) {
return 'Source: $source';
}
@override
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@@ -1523,6 +1528,13 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyAac => 'AAC/M4A 320kbps';
@override
String get downloadLossyAacSubtitle =>
'Best mobile compatibility, M4A container';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@@ -3015,6 +3027,17 @@ class AppLocalizationsHi extends AppLocalizations {
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@override
String get downloadAppleElrcWordSyncEnabled =>
'Raw word-by-word timestamps preserved';
@override
String get downloadAppleElrcWordSyncDisabled =>
'Safer line-by-line Apple Music lyrics';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@@ -3261,6 +3284,15 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisCodec => 'Codec';
@override
String get audioAnalysisContainer => 'Container';
@override
String get audioAnalysisDecodedFormat => 'Decoded Format';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@@ -3285,9 +3317,33 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisLufs => 'LUFS';
@override
String get audioAnalysisTruePeak => 'True Peak';
@override
String get audioAnalysisClipping => 'Clipping';
@override
String get audioAnalysisNoClipping => 'No clipping';
@override
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
@override
String get audioAnalysisChannelStats => 'Per-channel Stats';
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
@@ -3799,4 +3855,337 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get downloadFallbackExtensionsSubtitle =>
'Choose which extensions can be used as fallback';
@override
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
@override
String get editMetadataFieldTrackTotal => 'Track Total';
@override
String get editMetadataFieldDiscTotal => 'Disc Total';
@override
String get editMetadataFieldComposer => 'Composer';
@override
String get editMetadataFieldComment => 'Comment';
@override
String get editMetadataAdvanced => 'Advanced';
@override
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
@override
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
@override
String get libraryFilterMetadataMissingArtist => 'Missing artist';
@override
String get libraryFilterMetadataIncorrectIsrcFormat =>
'Incorrect ISRC format';
@override
String get libraryFilterMetadataMissingLabel => 'Missing label';
@override
String collectionDeletePlaylistsMessage(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0?';
}
@override
String collectionPlaylistsDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return '$count $_temp0 deleted';
}
@override
String collectionAddedTracksToPlaylist(int count, String playlistName) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName';
}
@override
String collectionAddedTracksToPlaylistWithExisting(
int count,
String playlistName,
int alreadyCount,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
}
@override
String itemCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count $_temp0';
}
@override
String trackReEnrichSuccessWithFailures(
int successCount,
int total,
int failedCount,
) {
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
}
@override
String selectionDeleteTracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Delete $count $_temp0';
}
@override
String queueDownloadSpeedStatus(String speed) {
return 'Downloading - $speed MB/s';
}
@override
String get queueDownloadStarting => 'Starting...';
@override
String get a11ySelectTrack => 'Select track';
@override
String get a11yDeselectTrack => 'Deselect track';
@override
String a11yPlayTrackByArtist(String trackName, String artistName) {
return 'Play $trackName by $artistName';
}
@override
String storeExtensionsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0';
}
@override
String storeRequiresVersion(String version) {
return 'Requires v$version+';
}
@override
String get actionGo => 'Go';
@override
String get logIssueSummary => 'Issue Summary';
@override
String logTotalErrors(int count) {
return 'Total errors: $count';
}
@override
String logAffectedDomains(String domains) {
return 'Affected: $domains';
}
@override
String get libraryScanCancelled => 'Scan cancelled';
@override
String get libraryScanCancelledSubtitle =>
'You can retry the scan when ready.';
@override
String libraryDownloadsHistoryExcluded(int count) {
return '$count from Downloads history (excluded from list)';
}
@override
String get downloadNativeWorker => 'Native download worker';
@override
String get downloadNativeWorkerSubtitle =>
'Beta Android service worker for extension downloads';
@override
String get badgeBeta => 'BETA';
@override
String get extensionServiceStatus => 'Service Status';
@override
String get extensionServiceHealth => 'Service health';
@override
String extensionHealthChecksConfigured(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'checks',
one: 'check',
);
return '$count $_temp0 configured';
}
@override
String get extensionOauthConnectHint =>
'Tap Connect to Spotify to fill this field.';
@override
String extensionLastChecked(String time) {
return 'Last checked $time';
}
@override
String get extensionRefreshStatus => 'Refresh status';
@override
String get extensionCustomUrlHandling => 'Custom URL Handling';
@override
String get extensionCustomUrlHandlingSubtitle =>
'This extension can handle links from these sites';
@override
String get extensionCustomUrlHandlingShareHint =>
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
@override
String extensionSettingsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'settings',
one: 'setting',
);
return '$count $_temp0';
}
@override
String get extensionHealthOnline => 'Online';
@override
String get extensionHealthDegraded => 'Degraded';
@override
String get extensionHealthOffline => 'Offline';
@override
String get extensionHealthNotConfigured => 'Not configured';
@override
String get extensionHealthUnknown => 'Unknown';
@override
String get extensionHealthRequired => 'required';
@override
String get extensionSettingNotSet => 'Not set';
@override
String get extensionActionFailed => 'Action failed';
@override
String get extensionEnterValue => 'Enter value';
@override
String get extensionHealthServiceOnline => 'Service online';
@override
String get extensionHealthServiceDegraded => 'Service degraded';
@override
String get extensionHealthServiceOffline => 'Service offline';
@override
String get extensionHealthServiceUnknown => 'Service status unknown';
@override
String get audioAnalysisStereo => 'Stereo';
@override
String get audioAnalysisMono => 'Mono';
@override
String trackOpenInService(String serviceName) {
return 'Open in $serviceName';
}
@override
String get trackLyricsEmbeddedSource => 'Embedded';
@override
String get unknownAlbum => 'Unknown Album';
@override
String get unknownArtist => 'Unknown Artist';
@override
String get permissionAudio => 'Audio';
@override
String get permissionStorage => 'Storage';
@override
String get permissionNotification => 'Notification';
@override
String get errorInvalidFolderSelected => 'Invalid folder selected';
@override
String get errorCouldNotKeepFolderAccess =>
'Could not keep access to the selected folder';
@override
String get storeAnyVersion => 'Any';
@override
String get storeCategoryMetadata => 'Metadata';
@override
String get storeCategoryDownload => 'Download';
@override
String get storeCategoryUtility => 'Utility';
@override
String get storeCategoryLyrics => 'Lyrics';
@override
String get storeCategoryIntegration => 'Integration';
@override
String get artistReleases => 'Releases';
}
+390 -1
View File
@@ -400,7 +400,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get aboutAppDescription =>
'Unduh lagu-lagu Spotify dalam kualitas lossless dari Tidal dan Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Album';
@@ -1226,6 +1226,11 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get trackCopyLyrics => 'Salin lirik';
@override
String trackLyricsSource(String source) {
return 'Source: $source';
}
@override
String get trackLyricsNotAvailable => 'Lirik tidak tersedia untuk lagu ini';
@@ -1531,6 +1536,13 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyAac => 'AAC/M4A 320kbps';
@override
String get downloadLossyAacSubtitle =>
'Best mobile compatibility, M4A container';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@@ -3024,6 +3036,17 @@ class AppLocalizationsId extends AppLocalizations {
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@override
String get downloadAppleElrcWordSyncEnabled =>
'Raw word-by-word timestamps preserved';
@override
String get downloadAppleElrcWordSyncDisabled =>
'Safer line-by-line Apple Music lyrics';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@@ -3270,6 +3293,15 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisCodec => 'Codec';
@override
String get audioAnalysisContainer => 'Container';
@override
String get audioAnalysisDecodedFormat => 'Decoded Format';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@@ -3294,9 +3326,33 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisLufs => 'LUFS';
@override
String get audioAnalysisTruePeak => 'True Peak';
@override
String get audioAnalysisClipping => 'Clipping';
@override
String get audioAnalysisNoClipping => 'No clipping';
@override
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
@override
String get audioAnalysisChannelStats => 'Per-channel Stats';
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
@@ -3790,4 +3846,337 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get downloadFallbackExtensionsSubtitle =>
'Choose which extensions can be used as fallback';
@override
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
@override
String get editMetadataFieldTrackTotal => 'Track Total';
@override
String get editMetadataFieldDiscTotal => 'Disc Total';
@override
String get editMetadataFieldComposer => 'Composer';
@override
String get editMetadataFieldComment => 'Comment';
@override
String get editMetadataAdvanced => 'Advanced';
@override
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
@override
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
@override
String get libraryFilterMetadataMissingArtist => 'Missing artist';
@override
String get libraryFilterMetadataIncorrectIsrcFormat =>
'Incorrect ISRC format';
@override
String get libraryFilterMetadataMissingLabel => 'Missing label';
@override
String collectionDeletePlaylistsMessage(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0?';
}
@override
String collectionPlaylistsDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return '$count $_temp0 deleted';
}
@override
String collectionAddedTracksToPlaylist(int count, String playlistName) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName';
}
@override
String collectionAddedTracksToPlaylistWithExisting(
int count,
String playlistName,
int alreadyCount,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
}
@override
String itemCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count $_temp0';
}
@override
String trackReEnrichSuccessWithFailures(
int successCount,
int total,
int failedCount,
) {
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
}
@override
String selectionDeleteTracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Delete $count $_temp0';
}
@override
String queueDownloadSpeedStatus(String speed) {
return 'Downloading - $speed MB/s';
}
@override
String get queueDownloadStarting => 'Starting...';
@override
String get a11ySelectTrack => 'Select track';
@override
String get a11yDeselectTrack => 'Deselect track';
@override
String a11yPlayTrackByArtist(String trackName, String artistName) {
return 'Play $trackName by $artistName';
}
@override
String storeExtensionsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0';
}
@override
String storeRequiresVersion(String version) {
return 'Requires v$version+';
}
@override
String get actionGo => 'Go';
@override
String get logIssueSummary => 'Issue Summary';
@override
String logTotalErrors(int count) {
return 'Total errors: $count';
}
@override
String logAffectedDomains(String domains) {
return 'Affected: $domains';
}
@override
String get libraryScanCancelled => 'Scan cancelled';
@override
String get libraryScanCancelledSubtitle =>
'You can retry the scan when ready.';
@override
String libraryDownloadsHistoryExcluded(int count) {
return '$count from Downloads history (excluded from list)';
}
@override
String get downloadNativeWorker => 'Native download worker';
@override
String get downloadNativeWorkerSubtitle =>
'Beta Android service worker for extension downloads';
@override
String get badgeBeta => 'BETA';
@override
String get extensionServiceStatus => 'Service Status';
@override
String get extensionServiceHealth => 'Service health';
@override
String extensionHealthChecksConfigured(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'checks',
one: 'check',
);
return '$count $_temp0 configured';
}
@override
String get extensionOauthConnectHint =>
'Tap Connect to Spotify to fill this field.';
@override
String extensionLastChecked(String time) {
return 'Last checked $time';
}
@override
String get extensionRefreshStatus => 'Refresh status';
@override
String get extensionCustomUrlHandling => 'Custom URL Handling';
@override
String get extensionCustomUrlHandlingSubtitle =>
'This extension can handle links from these sites';
@override
String get extensionCustomUrlHandlingShareHint =>
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
@override
String extensionSettingsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'settings',
one: 'setting',
);
return '$count $_temp0';
}
@override
String get extensionHealthOnline => 'Online';
@override
String get extensionHealthDegraded => 'Degraded';
@override
String get extensionHealthOffline => 'Offline';
@override
String get extensionHealthNotConfigured => 'Not configured';
@override
String get extensionHealthUnknown => 'Unknown';
@override
String get extensionHealthRequired => 'required';
@override
String get extensionSettingNotSet => 'Not set';
@override
String get extensionActionFailed => 'Action failed';
@override
String get extensionEnterValue => 'Enter value';
@override
String get extensionHealthServiceOnline => 'Service online';
@override
String get extensionHealthServiceDegraded => 'Service degraded';
@override
String get extensionHealthServiceOffline => 'Service offline';
@override
String get extensionHealthServiceUnknown => 'Service status unknown';
@override
String get audioAnalysisStereo => 'Stereo';
@override
String get audioAnalysisMono => 'Mono';
@override
String trackOpenInService(String serviceName) {
return 'Open in $serviceName';
}
@override
String get trackLyricsEmbeddedSource => 'Embedded';
@override
String get unknownAlbum => 'Unknown Album';
@override
String get unknownArtist => 'Unknown Artist';
@override
String get permissionAudio => 'Audio';
@override
String get permissionStorage => 'Storage';
@override
String get permissionNotification => 'Notification';
@override
String get errorInvalidFolderSelected => 'Invalid folder selected';
@override
String get errorCouldNotKeepFolderAccess =>
'Could not keep access to the selected folder';
@override
String get storeAnyVersion => 'Any';
@override
String get storeCategoryMetadata => 'Metadata';
@override
String get storeCategoryDownload => 'Download';
@override
String get storeCategoryUtility => 'Utility';
@override
String get storeCategoryLyrics => 'Lyrics';
@override
String get storeCategoryIntegration => 'Integration';
@override
String get artistReleases => 'Releases';
}
+390 -1
View File
@@ -393,7 +393,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'アルバム';
@@ -1214,6 +1214,11 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get trackCopyLyrics => '歌詞をコピー';
@override
String trackLyricsSource(String source) {
return 'Source: $source';
}
@override
String get trackLyricsNotAvailable => 'このトラックの歌詞は利用できません';
@@ -1513,6 +1518,13 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyAac => 'AAC/M4A 320kbps';
@override
String get downloadLossyAacSubtitle =>
'Best mobile compatibility, M4A container';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@@ -3002,6 +3014,17 @@ class AppLocalizationsJa extends AppLocalizations {
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@override
String get downloadAppleElrcWordSyncEnabled =>
'Raw word-by-word timestamps preserved';
@override
String get downloadAppleElrcWordSyncDisabled =>
'Safer line-by-line Apple Music lyrics';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@@ -3248,6 +3271,15 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisCodec => 'Codec';
@override
String get audioAnalysisContainer => 'Container';
@override
String get audioAnalysisDecodedFormat => 'Decoded Format';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@@ -3272,9 +3304,33 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisLufs => 'LUFS';
@override
String get audioAnalysisTruePeak => 'True Peak';
@override
String get audioAnalysisClipping => 'Clipping';
@override
String get audioAnalysisNoClipping => 'No clipping';
@override
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
@override
String get audioAnalysisChannelStats => 'Per-channel Stats';
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
@@ -3786,4 +3842,337 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get downloadFallbackExtensionsSubtitle =>
'Choose which extensions can be used as fallback';
@override
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
@override
String get editMetadataFieldTrackTotal => 'Track Total';
@override
String get editMetadataFieldDiscTotal => 'Disc Total';
@override
String get editMetadataFieldComposer => 'Composer';
@override
String get editMetadataFieldComment => 'Comment';
@override
String get editMetadataAdvanced => 'Advanced';
@override
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
@override
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
@override
String get libraryFilterMetadataMissingArtist => 'Missing artist';
@override
String get libraryFilterMetadataIncorrectIsrcFormat =>
'Incorrect ISRC format';
@override
String get libraryFilterMetadataMissingLabel => 'Missing label';
@override
String collectionDeletePlaylistsMessage(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0?';
}
@override
String collectionPlaylistsDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return '$count $_temp0 deleted';
}
@override
String collectionAddedTracksToPlaylist(int count, String playlistName) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName';
}
@override
String collectionAddedTracksToPlaylistWithExisting(
int count,
String playlistName,
int alreadyCount,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
}
@override
String itemCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count $_temp0';
}
@override
String trackReEnrichSuccessWithFailures(
int successCount,
int total,
int failedCount,
) {
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
}
@override
String selectionDeleteTracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Delete $count $_temp0';
}
@override
String queueDownloadSpeedStatus(String speed) {
return 'Downloading - $speed MB/s';
}
@override
String get queueDownloadStarting => 'Starting...';
@override
String get a11ySelectTrack => 'Select track';
@override
String get a11yDeselectTrack => 'Deselect track';
@override
String a11yPlayTrackByArtist(String trackName, String artistName) {
return 'Play $trackName by $artistName';
}
@override
String storeExtensionsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0';
}
@override
String storeRequiresVersion(String version) {
return 'Requires v$version+';
}
@override
String get actionGo => 'Go';
@override
String get logIssueSummary => 'Issue Summary';
@override
String logTotalErrors(int count) {
return 'Total errors: $count';
}
@override
String logAffectedDomains(String domains) {
return 'Affected: $domains';
}
@override
String get libraryScanCancelled => 'Scan cancelled';
@override
String get libraryScanCancelledSubtitle =>
'You can retry the scan when ready.';
@override
String libraryDownloadsHistoryExcluded(int count) {
return '$count from Downloads history (excluded from list)';
}
@override
String get downloadNativeWorker => 'Native download worker';
@override
String get downloadNativeWorkerSubtitle =>
'Beta Android service worker for extension downloads';
@override
String get badgeBeta => 'BETA';
@override
String get extensionServiceStatus => 'Service Status';
@override
String get extensionServiceHealth => 'Service health';
@override
String extensionHealthChecksConfigured(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'checks',
one: 'check',
);
return '$count $_temp0 configured';
}
@override
String get extensionOauthConnectHint =>
'Tap Connect to Spotify to fill this field.';
@override
String extensionLastChecked(String time) {
return 'Last checked $time';
}
@override
String get extensionRefreshStatus => 'Refresh status';
@override
String get extensionCustomUrlHandling => 'Custom URL Handling';
@override
String get extensionCustomUrlHandlingSubtitle =>
'This extension can handle links from these sites';
@override
String get extensionCustomUrlHandlingShareHint =>
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
@override
String extensionSettingsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'settings',
one: 'setting',
);
return '$count $_temp0';
}
@override
String get extensionHealthOnline => 'Online';
@override
String get extensionHealthDegraded => 'Degraded';
@override
String get extensionHealthOffline => 'Offline';
@override
String get extensionHealthNotConfigured => 'Not configured';
@override
String get extensionHealthUnknown => 'Unknown';
@override
String get extensionHealthRequired => 'required';
@override
String get extensionSettingNotSet => 'Not set';
@override
String get extensionActionFailed => 'Action failed';
@override
String get extensionEnterValue => 'Enter value';
@override
String get extensionHealthServiceOnline => 'Service online';
@override
String get extensionHealthServiceDegraded => 'Service degraded';
@override
String get extensionHealthServiceOffline => 'Service offline';
@override
String get extensionHealthServiceUnknown => 'Service status unknown';
@override
String get audioAnalysisStereo => 'Stereo';
@override
String get audioAnalysisMono => 'Mono';
@override
String trackOpenInService(String serviceName) {
return 'Open in $serviceName';
}
@override
String get trackLyricsEmbeddedSource => 'Embedded';
@override
String get unknownAlbum => 'Unknown Album';
@override
String get unknownArtist => 'Unknown Artist';
@override
String get permissionAudio => 'Audio';
@override
String get permissionStorage => 'Storage';
@override
String get permissionNotification => 'Notification';
@override
String get errorInvalidFolderSelected => 'Invalid folder selected';
@override
String get errorCouldNotKeepFolderAccess =>
'Could not keep access to the selected folder';
@override
String get storeAnyVersion => 'Any';
@override
String get storeCategoryMetadata => 'Metadata';
@override
String get storeCategoryDownload => 'Download';
@override
String get storeCategoryUtility => 'Utility';
@override
String get storeCategoryLyrics => 'Lyrics';
@override
String get storeCategoryIntegration => 'Integration';
@override
String get artistReleases => 'Releases';
}
+390 -1
View File
@@ -385,7 +385,7 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => '앨범';
@@ -1200,6 +1200,11 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get trackCopyLyrics => 'Copy lyrics';
@override
String trackLyricsSource(String source) {
return 'Source: $source';
}
@override
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@@ -1503,6 +1508,13 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyAac => 'AAC/M4A 320kbps';
@override
String get downloadLossyAacSubtitle =>
'Best mobile compatibility, M4A container';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@@ -2995,6 +3007,17 @@ class AppLocalizationsKo extends AppLocalizations {
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@override
String get downloadAppleElrcWordSyncEnabled =>
'Raw word-by-word timestamps preserved';
@override
String get downloadAppleElrcWordSyncDisabled =>
'Safer line-by-line Apple Music lyrics';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@@ -3241,6 +3264,15 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisCodec => 'Codec';
@override
String get audioAnalysisContainer => 'Container';
@override
String get audioAnalysisDecodedFormat => 'Decoded Format';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@@ -3265,9 +3297,33 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisLufs => 'LUFS';
@override
String get audioAnalysisTruePeak => 'True Peak';
@override
String get audioAnalysisClipping => 'Clipping';
@override
String get audioAnalysisNoClipping => 'No clipping';
@override
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
@override
String get audioAnalysisChannelStats => 'Per-channel Stats';
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
@@ -3779,4 +3835,337 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get downloadFallbackExtensionsSubtitle =>
'Choose which extensions can be used as fallback';
@override
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
@override
String get editMetadataFieldTrackTotal => 'Track Total';
@override
String get editMetadataFieldDiscTotal => 'Disc Total';
@override
String get editMetadataFieldComposer => 'Composer';
@override
String get editMetadataFieldComment => 'Comment';
@override
String get editMetadataAdvanced => 'Advanced';
@override
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
@override
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
@override
String get libraryFilterMetadataMissingArtist => 'Missing artist';
@override
String get libraryFilterMetadataIncorrectIsrcFormat =>
'Incorrect ISRC format';
@override
String get libraryFilterMetadataMissingLabel => 'Missing label';
@override
String collectionDeletePlaylistsMessage(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0?';
}
@override
String collectionPlaylistsDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return '$count $_temp0 deleted';
}
@override
String collectionAddedTracksToPlaylist(int count, String playlistName) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName';
}
@override
String collectionAddedTracksToPlaylistWithExisting(
int count,
String playlistName,
int alreadyCount,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
}
@override
String itemCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count $_temp0';
}
@override
String trackReEnrichSuccessWithFailures(
int successCount,
int total,
int failedCount,
) {
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
}
@override
String selectionDeleteTracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Delete $count $_temp0';
}
@override
String queueDownloadSpeedStatus(String speed) {
return 'Downloading - $speed MB/s';
}
@override
String get queueDownloadStarting => 'Starting...';
@override
String get a11ySelectTrack => 'Select track';
@override
String get a11yDeselectTrack => 'Deselect track';
@override
String a11yPlayTrackByArtist(String trackName, String artistName) {
return 'Play $trackName by $artistName';
}
@override
String storeExtensionsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0';
}
@override
String storeRequiresVersion(String version) {
return 'Requires v$version+';
}
@override
String get actionGo => 'Go';
@override
String get logIssueSummary => 'Issue Summary';
@override
String logTotalErrors(int count) {
return 'Total errors: $count';
}
@override
String logAffectedDomains(String domains) {
return 'Affected: $domains';
}
@override
String get libraryScanCancelled => 'Scan cancelled';
@override
String get libraryScanCancelledSubtitle =>
'You can retry the scan when ready.';
@override
String libraryDownloadsHistoryExcluded(int count) {
return '$count from Downloads history (excluded from list)';
}
@override
String get downloadNativeWorker => 'Native download worker';
@override
String get downloadNativeWorkerSubtitle =>
'Beta Android service worker for extension downloads';
@override
String get badgeBeta => 'BETA';
@override
String get extensionServiceStatus => 'Service Status';
@override
String get extensionServiceHealth => 'Service health';
@override
String extensionHealthChecksConfigured(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'checks',
one: 'check',
);
return '$count $_temp0 configured';
}
@override
String get extensionOauthConnectHint =>
'Tap Connect to Spotify to fill this field.';
@override
String extensionLastChecked(String time) {
return 'Last checked $time';
}
@override
String get extensionRefreshStatus => 'Refresh status';
@override
String get extensionCustomUrlHandling => 'Custom URL Handling';
@override
String get extensionCustomUrlHandlingSubtitle =>
'This extension can handle links from these sites';
@override
String get extensionCustomUrlHandlingShareHint =>
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
@override
String extensionSettingsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'settings',
one: 'setting',
);
return '$count $_temp0';
}
@override
String get extensionHealthOnline => 'Online';
@override
String get extensionHealthDegraded => 'Degraded';
@override
String get extensionHealthOffline => 'Offline';
@override
String get extensionHealthNotConfigured => 'Not configured';
@override
String get extensionHealthUnknown => 'Unknown';
@override
String get extensionHealthRequired => 'required';
@override
String get extensionSettingNotSet => 'Not set';
@override
String get extensionActionFailed => 'Action failed';
@override
String get extensionEnterValue => 'Enter value';
@override
String get extensionHealthServiceOnline => 'Service online';
@override
String get extensionHealthServiceDegraded => 'Service degraded';
@override
String get extensionHealthServiceOffline => 'Service offline';
@override
String get extensionHealthServiceUnknown => 'Service status unknown';
@override
String get audioAnalysisStereo => 'Stereo';
@override
String get audioAnalysisMono => 'Mono';
@override
String trackOpenInService(String serviceName) {
return 'Open in $serviceName';
}
@override
String get trackLyricsEmbeddedSource => 'Embedded';
@override
String get unknownAlbum => 'Unknown Album';
@override
String get unknownArtist => 'Unknown Artist';
@override
String get permissionAudio => 'Audio';
@override
String get permissionStorage => 'Storage';
@override
String get permissionNotification => 'Notification';
@override
String get errorInvalidFolderSelected => 'Invalid folder selected';
@override
String get errorCouldNotKeepFolderAccess =>
'Could not keep access to the selected folder';
@override
String get storeAnyVersion => 'Any';
@override
String get storeCategoryMetadata => 'Metadata';
@override
String get storeCategoryDownload => 'Download';
@override
String get storeCategoryUtility => 'Utility';
@override
String get storeCategoryLyrics => 'Lyrics';
@override
String get storeCategoryIntegration => 'Integration';
@override
String get artistReleases => 'Releases';
}
+390 -1
View File
@@ -397,7 +397,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Albums';
@@ -1220,6 +1220,11 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get trackCopyLyrics => 'Copy lyrics';
@override
String trackLyricsSource(String source) {
return 'Source: $source';
}
@override
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@@ -1523,6 +1528,13 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyAac => 'AAC/M4A 320kbps';
@override
String get downloadLossyAacSubtitle =>
'Best mobile compatibility, M4A container';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@@ -3015,6 +3027,17 @@ class AppLocalizationsNl extends AppLocalizations {
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@override
String get downloadAppleElrcWordSyncEnabled =>
'Raw word-by-word timestamps preserved';
@override
String get downloadAppleElrcWordSyncDisabled =>
'Safer line-by-line Apple Music lyrics';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@@ -3261,6 +3284,15 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisCodec => 'Codec';
@override
String get audioAnalysisContainer => 'Container';
@override
String get audioAnalysisDecodedFormat => 'Decoded Format';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@@ -3285,9 +3317,33 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisLufs => 'LUFS';
@override
String get audioAnalysisTruePeak => 'True Peak';
@override
String get audioAnalysisClipping => 'Clipping';
@override
String get audioAnalysisNoClipping => 'No clipping';
@override
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
@override
String get audioAnalysisChannelStats => 'Per-channel Stats';
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
@@ -3799,4 +3855,337 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get downloadFallbackExtensionsSubtitle =>
'Choose which extensions can be used as fallback';
@override
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
@override
String get editMetadataFieldTrackTotal => 'Track Total';
@override
String get editMetadataFieldDiscTotal => 'Disc Total';
@override
String get editMetadataFieldComposer => 'Composer';
@override
String get editMetadataFieldComment => 'Comment';
@override
String get editMetadataAdvanced => 'Advanced';
@override
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
@override
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
@override
String get libraryFilterMetadataMissingArtist => 'Missing artist';
@override
String get libraryFilterMetadataIncorrectIsrcFormat =>
'Incorrect ISRC format';
@override
String get libraryFilterMetadataMissingLabel => 'Missing label';
@override
String collectionDeletePlaylistsMessage(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0?';
}
@override
String collectionPlaylistsDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return '$count $_temp0 deleted';
}
@override
String collectionAddedTracksToPlaylist(int count, String playlistName) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName';
}
@override
String collectionAddedTracksToPlaylistWithExisting(
int count,
String playlistName,
int alreadyCount,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
}
@override
String itemCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count $_temp0';
}
@override
String trackReEnrichSuccessWithFailures(
int successCount,
int total,
int failedCount,
) {
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
}
@override
String selectionDeleteTracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Delete $count $_temp0';
}
@override
String queueDownloadSpeedStatus(String speed) {
return 'Downloading - $speed MB/s';
}
@override
String get queueDownloadStarting => 'Starting...';
@override
String get a11ySelectTrack => 'Select track';
@override
String get a11yDeselectTrack => 'Deselect track';
@override
String a11yPlayTrackByArtist(String trackName, String artistName) {
return 'Play $trackName by $artistName';
}
@override
String storeExtensionsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0';
}
@override
String storeRequiresVersion(String version) {
return 'Requires v$version+';
}
@override
String get actionGo => 'Go';
@override
String get logIssueSummary => 'Issue Summary';
@override
String logTotalErrors(int count) {
return 'Total errors: $count';
}
@override
String logAffectedDomains(String domains) {
return 'Affected: $domains';
}
@override
String get libraryScanCancelled => 'Scan cancelled';
@override
String get libraryScanCancelledSubtitle =>
'You can retry the scan when ready.';
@override
String libraryDownloadsHistoryExcluded(int count) {
return '$count from Downloads history (excluded from list)';
}
@override
String get downloadNativeWorker => 'Native download worker';
@override
String get downloadNativeWorkerSubtitle =>
'Beta Android service worker for extension downloads';
@override
String get badgeBeta => 'BETA';
@override
String get extensionServiceStatus => 'Service Status';
@override
String get extensionServiceHealth => 'Service health';
@override
String extensionHealthChecksConfigured(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'checks',
one: 'check',
);
return '$count $_temp0 configured';
}
@override
String get extensionOauthConnectHint =>
'Tap Connect to Spotify to fill this field.';
@override
String extensionLastChecked(String time) {
return 'Last checked $time';
}
@override
String get extensionRefreshStatus => 'Refresh status';
@override
String get extensionCustomUrlHandling => 'Custom URL Handling';
@override
String get extensionCustomUrlHandlingSubtitle =>
'This extension can handle links from these sites';
@override
String get extensionCustomUrlHandlingShareHint =>
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
@override
String extensionSettingsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'settings',
one: 'setting',
);
return '$count $_temp0';
}
@override
String get extensionHealthOnline => 'Online';
@override
String get extensionHealthDegraded => 'Degraded';
@override
String get extensionHealthOffline => 'Offline';
@override
String get extensionHealthNotConfigured => 'Not configured';
@override
String get extensionHealthUnknown => 'Unknown';
@override
String get extensionHealthRequired => 'required';
@override
String get extensionSettingNotSet => 'Not set';
@override
String get extensionActionFailed => 'Action failed';
@override
String get extensionEnterValue => 'Enter value';
@override
String get extensionHealthServiceOnline => 'Service online';
@override
String get extensionHealthServiceDegraded => 'Service degraded';
@override
String get extensionHealthServiceOffline => 'Service offline';
@override
String get extensionHealthServiceUnknown => 'Service status unknown';
@override
String get audioAnalysisStereo => 'Stereo';
@override
String get audioAnalysisMono => 'Mono';
@override
String trackOpenInService(String serviceName) {
return 'Open in $serviceName';
}
@override
String get trackLyricsEmbeddedSource => 'Embedded';
@override
String get unknownAlbum => 'Unknown Album';
@override
String get unknownArtist => 'Unknown Artist';
@override
String get permissionAudio => 'Audio';
@override
String get permissionStorage => 'Storage';
@override
String get permissionNotification => 'Notification';
@override
String get errorInvalidFolderSelected => 'Invalid folder selected';
@override
String get errorCouldNotKeepFolderAccess =>
'Could not keep access to the selected folder';
@override
String get storeAnyVersion => 'Any';
@override
String get storeCategoryMetadata => 'Metadata';
@override
String get storeCategoryDownload => 'Download';
@override
String get storeCategoryUtility => 'Utility';
@override
String get storeCategoryLyrics => 'Lyrics';
@override
String get storeCategoryIntegration => 'Integration';
@override
String get artistReleases => 'Releases';
}
+397 -2
View File
@@ -397,7 +397,7 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Albums';
@@ -1220,6 +1220,11 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get trackCopyLyrics => 'Copy lyrics';
@override
String trackLyricsSource(String source) {
return 'Source: $source';
}
@override
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@@ -1523,6 +1528,13 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyAac => 'AAC/M4A 320kbps';
@override
String get downloadLossyAacSubtitle =>
'Best mobile compatibility, M4A container';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@@ -3015,6 +3027,17 @@ class AppLocalizationsPt extends AppLocalizations {
String get downloadAppleQqMultiPersonDisabled =>
'Standard lyrics without speaker labels';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@override
String get downloadAppleElrcWordSyncEnabled =>
'Raw word-by-word timestamps preserved';
@override
String get downloadAppleElrcWordSyncDisabled =>
'Safer line-by-line Apple Music lyrics';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@@ -3260,6 +3283,15 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisCodec => 'Codec';
@override
String get audioAnalysisContainer => 'Container';
@override
String get audioAnalysisDecodedFormat => 'Decoded Format';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@@ -3284,9 +3316,33 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisLufs => 'LUFS';
@override
String get audioAnalysisTruePeak => 'True Peak';
@override
String get audioAnalysisClipping => 'Clipping';
@override
String get audioAnalysisNoClipping => 'No clipping';
@override
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
@override
String get audioAnalysisChannelStats => 'Per-channel Stats';
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
@@ -3798,6 +3854,339 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get downloadFallbackExtensionsSubtitle =>
'Choose which extensions can be used as fallback';
@override
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
@override
String get editMetadataFieldTrackTotal => 'Track Total';
@override
String get editMetadataFieldDiscTotal => 'Disc Total';
@override
String get editMetadataFieldComposer => 'Composer';
@override
String get editMetadataFieldComment => 'Comment';
@override
String get editMetadataAdvanced => 'Advanced';
@override
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
@override
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
@override
String get libraryFilterMetadataMissingArtist => 'Missing artist';
@override
String get libraryFilterMetadataIncorrectIsrcFormat =>
'Incorrect ISRC format';
@override
String get libraryFilterMetadataMissingLabel => 'Missing label';
@override
String collectionDeletePlaylistsMessage(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0?';
}
@override
String collectionPlaylistsDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return '$count $_temp0 deleted';
}
@override
String collectionAddedTracksToPlaylist(int count, String playlistName) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName';
}
@override
String collectionAddedTracksToPlaylistWithExisting(
int count,
String playlistName,
int alreadyCount,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
}
@override
String itemCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count $_temp0';
}
@override
String trackReEnrichSuccessWithFailures(
int successCount,
int total,
int failedCount,
) {
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
}
@override
String selectionDeleteTracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Delete $count $_temp0';
}
@override
String queueDownloadSpeedStatus(String speed) {
return 'Downloading - $speed MB/s';
}
@override
String get queueDownloadStarting => 'Starting...';
@override
String get a11ySelectTrack => 'Select track';
@override
String get a11yDeselectTrack => 'Deselect track';
@override
String a11yPlayTrackByArtist(String trackName, String artistName) {
return 'Play $trackName by $artistName';
}
@override
String storeExtensionsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0';
}
@override
String storeRequiresVersion(String version) {
return 'Requires v$version+';
}
@override
String get actionGo => 'Go';
@override
String get logIssueSummary => 'Issue Summary';
@override
String logTotalErrors(int count) {
return 'Total errors: $count';
}
@override
String logAffectedDomains(String domains) {
return 'Affected: $domains';
}
@override
String get libraryScanCancelled => 'Scan cancelled';
@override
String get libraryScanCancelledSubtitle =>
'You can retry the scan when ready.';
@override
String libraryDownloadsHistoryExcluded(int count) {
return '$count from Downloads history (excluded from list)';
}
@override
String get downloadNativeWorker => 'Native download worker';
@override
String get downloadNativeWorkerSubtitle =>
'Beta Android service worker for extension downloads';
@override
String get badgeBeta => 'BETA';
@override
String get extensionServiceStatus => 'Service Status';
@override
String get extensionServiceHealth => 'Service health';
@override
String extensionHealthChecksConfigured(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'checks',
one: 'check',
);
return '$count $_temp0 configured';
}
@override
String get extensionOauthConnectHint =>
'Tap Connect to Spotify to fill this field.';
@override
String extensionLastChecked(String time) {
return 'Last checked $time';
}
@override
String get extensionRefreshStatus => 'Refresh status';
@override
String get extensionCustomUrlHandling => 'Custom URL Handling';
@override
String get extensionCustomUrlHandlingSubtitle =>
'This extension can handle links from these sites';
@override
String get extensionCustomUrlHandlingShareHint =>
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
@override
String extensionSettingsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'settings',
one: 'setting',
);
return '$count $_temp0';
}
@override
String get extensionHealthOnline => 'Online';
@override
String get extensionHealthDegraded => 'Degraded';
@override
String get extensionHealthOffline => 'Offline';
@override
String get extensionHealthNotConfigured => 'Not configured';
@override
String get extensionHealthUnknown => 'Unknown';
@override
String get extensionHealthRequired => 'required';
@override
String get extensionSettingNotSet => 'Not set';
@override
String get extensionActionFailed => 'Action failed';
@override
String get extensionEnterValue => 'Enter value';
@override
String get extensionHealthServiceOnline => 'Service online';
@override
String get extensionHealthServiceDegraded => 'Service degraded';
@override
String get extensionHealthServiceOffline => 'Service offline';
@override
String get extensionHealthServiceUnknown => 'Service status unknown';
@override
String get audioAnalysisStereo => 'Stereo';
@override
String get audioAnalysisMono => 'Mono';
@override
String trackOpenInService(String serviceName) {
return 'Open in $serviceName';
}
@override
String get trackLyricsEmbeddedSource => 'Embedded';
@override
String get unknownAlbum => 'Unknown Album';
@override
String get unknownArtist => 'Unknown Artist';
@override
String get permissionAudio => 'Audio';
@override
String get permissionStorage => 'Storage';
@override
String get permissionNotification => 'Notification';
@override
String get errorInvalidFolderSelected => 'Invalid folder selected';
@override
String get errorCouldNotKeepFolderAccess =>
'Could not keep access to the selected folder';
@override
String get storeAnyVersion => 'Any';
@override
String get storeCategoryMetadata => 'Metadata';
@override
String get storeCategoryDownload => 'Download';
@override
String get storeCategoryUtility => 'Utility';
@override
String get storeCategoryLyrics => 'Lyrics';
@override
String get storeCategoryIntegration => 'Integration';
@override
String get artistReleases => 'Releases';
}
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
@@ -4195,7 +4584,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Álbuns';
@@ -7042,6 +7431,12 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
+390 -1
View File
@@ -403,7 +403,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get aboutAppDescription =>
'Скачивайте треки Spotify в lossless качестве с Tidal и Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Альбомы';
@@ -1238,6 +1238,11 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get trackCopyLyrics => 'Копировать текст';
@override
String trackLyricsSource(String source) {
return 'Source: $source';
}
@override
String get trackLyricsNotAvailable =>
'Текст песни недоступен для этого трека';
@@ -1547,6 +1552,13 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyAac => 'AAC/M4A 320kbps';
@override
String get downloadLossyAacSubtitle =>
'Best mobile compatibility, M4A container';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@@ -3074,6 +3086,17 @@ class AppLocalizationsRu extends AppLocalizations {
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@override
String get downloadAppleElrcWordSyncEnabled =>
'Raw word-by-word timestamps preserved';
@override
String get downloadAppleElrcWordSyncDisabled =>
'Safer line-by-line Apple Music lyrics';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@@ -3320,6 +3343,15 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisCodec => 'Codec';
@override
String get audioAnalysisContainer => 'Container';
@override
String get audioAnalysisDecodedFormat => 'Decoded Format';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@@ -3344,9 +3376,33 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisLufs => 'LUFS';
@override
String get audioAnalysisTruePeak => 'True Peak';
@override
String get audioAnalysisClipping => 'Clipping';
@override
String get audioAnalysisNoClipping => 'No clipping';
@override
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
@override
String get audioAnalysisChannelStats => 'Per-channel Stats';
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
@@ -3858,4 +3914,337 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get downloadFallbackExtensionsSubtitle =>
'Choose which extensions can be used as fallback';
@override
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
@override
String get editMetadataFieldTrackTotal => 'Track Total';
@override
String get editMetadataFieldDiscTotal => 'Disc Total';
@override
String get editMetadataFieldComposer => 'Composer';
@override
String get editMetadataFieldComment => 'Comment';
@override
String get editMetadataAdvanced => 'Advanced';
@override
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
@override
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
@override
String get libraryFilterMetadataMissingArtist => 'Missing artist';
@override
String get libraryFilterMetadataIncorrectIsrcFormat =>
'Incorrect ISRC format';
@override
String get libraryFilterMetadataMissingLabel => 'Missing label';
@override
String collectionDeletePlaylistsMessage(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0?';
}
@override
String collectionPlaylistsDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return '$count $_temp0 deleted';
}
@override
String collectionAddedTracksToPlaylist(int count, String playlistName) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName';
}
@override
String collectionAddedTracksToPlaylistWithExisting(
int count,
String playlistName,
int alreadyCount,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
}
@override
String itemCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count $_temp0';
}
@override
String trackReEnrichSuccessWithFailures(
int successCount,
int total,
int failedCount,
) {
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
}
@override
String selectionDeleteTracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Delete $count $_temp0';
}
@override
String queueDownloadSpeedStatus(String speed) {
return 'Downloading - $speed MB/s';
}
@override
String get queueDownloadStarting => 'Starting...';
@override
String get a11ySelectTrack => 'Select track';
@override
String get a11yDeselectTrack => 'Deselect track';
@override
String a11yPlayTrackByArtist(String trackName, String artistName) {
return 'Play $trackName by $artistName';
}
@override
String storeExtensionsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0';
}
@override
String storeRequiresVersion(String version) {
return 'Requires v$version+';
}
@override
String get actionGo => 'Go';
@override
String get logIssueSummary => 'Issue Summary';
@override
String logTotalErrors(int count) {
return 'Total errors: $count';
}
@override
String logAffectedDomains(String domains) {
return 'Affected: $domains';
}
@override
String get libraryScanCancelled => 'Scan cancelled';
@override
String get libraryScanCancelledSubtitle =>
'You can retry the scan when ready.';
@override
String libraryDownloadsHistoryExcluded(int count) {
return '$count from Downloads history (excluded from list)';
}
@override
String get downloadNativeWorker => 'Native download worker';
@override
String get downloadNativeWorkerSubtitle =>
'Beta Android service worker for extension downloads';
@override
String get badgeBeta => 'BETA';
@override
String get extensionServiceStatus => 'Service Status';
@override
String get extensionServiceHealth => 'Service health';
@override
String extensionHealthChecksConfigured(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'checks',
one: 'check',
);
return '$count $_temp0 configured';
}
@override
String get extensionOauthConnectHint =>
'Tap Connect to Spotify to fill this field.';
@override
String extensionLastChecked(String time) {
return 'Last checked $time';
}
@override
String get extensionRefreshStatus => 'Refresh status';
@override
String get extensionCustomUrlHandling => 'Custom URL Handling';
@override
String get extensionCustomUrlHandlingSubtitle =>
'This extension can handle links from these sites';
@override
String get extensionCustomUrlHandlingShareHint =>
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
@override
String extensionSettingsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'settings',
one: 'setting',
);
return '$count $_temp0';
}
@override
String get extensionHealthOnline => 'Online';
@override
String get extensionHealthDegraded => 'Degraded';
@override
String get extensionHealthOffline => 'Offline';
@override
String get extensionHealthNotConfigured => 'Not configured';
@override
String get extensionHealthUnknown => 'Unknown';
@override
String get extensionHealthRequired => 'required';
@override
String get extensionSettingNotSet => 'Not set';
@override
String get extensionActionFailed => 'Action failed';
@override
String get extensionEnterValue => 'Enter value';
@override
String get extensionHealthServiceOnline => 'Service online';
@override
String get extensionHealthServiceDegraded => 'Service degraded';
@override
String get extensionHealthServiceOffline => 'Service offline';
@override
String get extensionHealthServiceUnknown => 'Service status unknown';
@override
String get audioAnalysisStereo => 'Stereo';
@override
String get audioAnalysisMono => 'Mono';
@override
String trackOpenInService(String serviceName) {
return 'Open in $serviceName';
}
@override
String get trackLyricsEmbeddedSource => 'Embedded';
@override
String get unknownAlbum => 'Unknown Album';
@override
String get unknownArtist => 'Unknown Artist';
@override
String get permissionAudio => 'Audio';
@override
String get permissionStorage => 'Storage';
@override
String get permissionNotification => 'Notification';
@override
String get errorInvalidFolderSelected => 'Invalid folder selected';
@override
String get errorCouldNotKeepFolderAccess =>
'Could not keep access to the selected folder';
@override
String get storeAnyVersion => 'Any';
@override
String get storeCategoryMetadata => 'Metadata';
@override
String get storeCategoryDownload => 'Download';
@override
String get storeCategoryUtility => 'Utility';
@override
String get storeCategoryLyrics => 'Lyrics';
@override
String get storeCategoryIntegration => 'Integration';
@override
String get artistReleases => 'Releases';
}
+390 -1
View File
@@ -405,7 +405,7 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get aboutAppDescription =>
'Spotify parçalarını Tidal ve Qobuz aracılığıyla kayıpsız kalitede indirin.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Albümler';
@@ -1234,6 +1234,11 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get trackCopyLyrics => 'Şarkı sözlerini kopyala';
@override
String trackLyricsSource(String source) {
return 'Source: $source';
}
@override
String get trackLyricsNotAvailable => 'Bu parça için şarkı sözü mevcut değil';
@@ -1540,6 +1545,13 @@ class AppLocalizationsTr extends AppLocalizations {
String get downloadLossyMp3Subtitle =>
'En iyi uyumluluk, parça başına ~10 Mb';
@override
String get downloadLossyAac => 'AAC/M4A 320kbps';
@override
String get downloadLossyAacSubtitle =>
'Best mobile compatibility, M4A container';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@@ -3041,6 +3053,17 @@ class AppLocalizationsTr extends AppLocalizations {
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@override
String get downloadAppleElrcWordSyncEnabled =>
'Raw word-by-word timestamps preserved';
@override
String get downloadAppleElrcWordSyncDisabled =>
'Safer line-by-line Apple Music lyrics';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@@ -3287,6 +3310,15 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisCodec => 'Codec';
@override
String get audioAnalysisContainer => 'Container';
@override
String get audioAnalysisDecodedFormat => 'Decoded Format';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@@ -3311,9 +3343,33 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisLufs => 'LUFS';
@override
String get audioAnalysisTruePeak => 'True Peak';
@override
String get audioAnalysisClipping => 'Clipping';
@override
String get audioAnalysisNoClipping => 'No clipping';
@override
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
@override
String get audioAnalysisChannelStats => 'Per-channel Stats';
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
@@ -3825,4 +3881,337 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get downloadFallbackExtensionsSubtitle =>
'Choose which extensions can be used as fallback';
@override
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
@override
String get editMetadataFieldTrackTotal => 'Track Total';
@override
String get editMetadataFieldDiscTotal => 'Disc Total';
@override
String get editMetadataFieldComposer => 'Composer';
@override
String get editMetadataFieldComment => 'Comment';
@override
String get editMetadataAdvanced => 'Advanced';
@override
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
@override
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
@override
String get libraryFilterMetadataMissingArtist => 'Missing artist';
@override
String get libraryFilterMetadataIncorrectIsrcFormat =>
'Incorrect ISRC format';
@override
String get libraryFilterMetadataMissingLabel => 'Missing label';
@override
String collectionDeletePlaylistsMessage(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0?';
}
@override
String collectionPlaylistsDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return '$count $_temp0 deleted';
}
@override
String collectionAddedTracksToPlaylist(int count, String playlistName) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName';
}
@override
String collectionAddedTracksToPlaylistWithExisting(
int count,
String playlistName,
int alreadyCount,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
}
@override
String itemCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count $_temp0';
}
@override
String trackReEnrichSuccessWithFailures(
int successCount,
int total,
int failedCount,
) {
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
}
@override
String selectionDeleteTracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Delete $count $_temp0';
}
@override
String queueDownloadSpeedStatus(String speed) {
return 'Downloading - $speed MB/s';
}
@override
String get queueDownloadStarting => 'Starting...';
@override
String get a11ySelectTrack => 'Select track';
@override
String get a11yDeselectTrack => 'Deselect track';
@override
String a11yPlayTrackByArtist(String trackName, String artistName) {
return 'Play $trackName by $artistName';
}
@override
String storeExtensionsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0';
}
@override
String storeRequiresVersion(String version) {
return 'Requires v$version+';
}
@override
String get actionGo => 'Go';
@override
String get logIssueSummary => 'Issue Summary';
@override
String logTotalErrors(int count) {
return 'Total errors: $count';
}
@override
String logAffectedDomains(String domains) {
return 'Affected: $domains';
}
@override
String get libraryScanCancelled => 'Scan cancelled';
@override
String get libraryScanCancelledSubtitle =>
'You can retry the scan when ready.';
@override
String libraryDownloadsHistoryExcluded(int count) {
return '$count from Downloads history (excluded from list)';
}
@override
String get downloadNativeWorker => 'Native download worker';
@override
String get downloadNativeWorkerSubtitle =>
'Beta Android service worker for extension downloads';
@override
String get badgeBeta => 'BETA';
@override
String get extensionServiceStatus => 'Service Status';
@override
String get extensionServiceHealth => 'Service health';
@override
String extensionHealthChecksConfigured(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'checks',
one: 'check',
);
return '$count $_temp0 configured';
}
@override
String get extensionOauthConnectHint =>
'Tap Connect to Spotify to fill this field.';
@override
String extensionLastChecked(String time) {
return 'Last checked $time';
}
@override
String get extensionRefreshStatus => 'Refresh status';
@override
String get extensionCustomUrlHandling => 'Custom URL Handling';
@override
String get extensionCustomUrlHandlingSubtitle =>
'This extension can handle links from these sites';
@override
String get extensionCustomUrlHandlingShareHint =>
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
@override
String extensionSettingsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'settings',
one: 'setting',
);
return '$count $_temp0';
}
@override
String get extensionHealthOnline => 'Online';
@override
String get extensionHealthDegraded => 'Degraded';
@override
String get extensionHealthOffline => 'Offline';
@override
String get extensionHealthNotConfigured => 'Not configured';
@override
String get extensionHealthUnknown => 'Unknown';
@override
String get extensionHealthRequired => 'required';
@override
String get extensionSettingNotSet => 'Not set';
@override
String get extensionActionFailed => 'Action failed';
@override
String get extensionEnterValue => 'Enter value';
@override
String get extensionHealthServiceOnline => 'Service online';
@override
String get extensionHealthServiceDegraded => 'Service degraded';
@override
String get extensionHealthServiceOffline => 'Service offline';
@override
String get extensionHealthServiceUnknown => 'Service status unknown';
@override
String get audioAnalysisStereo => 'Stereo';
@override
String get audioAnalysisMono => 'Mono';
@override
String trackOpenInService(String serviceName) {
return 'Open in $serviceName';
}
@override
String get trackLyricsEmbeddedSource => 'Embedded';
@override
String get unknownAlbum => 'Unknown Album';
@override
String get unknownArtist => 'Unknown Artist';
@override
String get permissionAudio => 'Audio';
@override
String get permissionStorage => 'Storage';
@override
String get permissionNotification => 'Notification';
@override
String get errorInvalidFolderSelected => 'Invalid folder selected';
@override
String get errorCouldNotKeepFolderAccess =>
'Could not keep access to the selected folder';
@override
String get storeAnyVersion => 'Any';
@override
String get storeCategoryMetadata => 'Metadata';
@override
String get storeCategoryDownload => 'Download';
@override
String get storeCategoryUtility => 'Utility';
@override
String get storeCategoryLyrics => 'Lyrics';
@override
String get storeCategoryIntegration => 'Integration';
@override
String get artistReleases => 'Releases';
}
+390 -1
View File
@@ -407,7 +407,7 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get aboutAppDescription =>
'Кінцеві точки потокового передавання Tidal Hi-Res FLAC. Ключовий елемент пазлу музики без втрат.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Альбоми';
@@ -1240,6 +1240,11 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get trackCopyLyrics => 'Скопіювати тексти пісень';
@override
String trackLyricsSource(String source) {
return 'Source: $source';
}
@override
String get trackLyricsNotAvailable =>
'Текст пісні для цього треку недоступний';
@@ -1548,6 +1553,13 @@ class AppLocalizationsUk extends AppLocalizations {
String get downloadLossyMp3Subtitle =>
'Найкраща сумісність, ~10 МБ на доріжку';
@override
String get downloadLossyAac => 'AAC/M4A 320kbps';
@override
String get downloadLossyAacSubtitle =>
'Best mobile compatibility, M4A container';
@override
String get downloadLossyOpus256 => 'Opus 256 кбіт/с';
@@ -3067,6 +3079,17 @@ class AppLocalizationsUk extends AppLocalizations {
String get downloadAppleQqMultiPersonDisabled =>
'Спрощене послівне форматування';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@override
String get downloadAppleElrcWordSyncEnabled =>
'Raw word-by-word timestamps preserved';
@override
String get downloadAppleElrcWordSyncDisabled =>
'Safer line-by-line Apple Music lyrics';
@override
String get downloadMusixmatchLanguage => 'Мова Musixmatch';
@@ -3316,6 +3339,15 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get audioAnalysisSampleRate => 'Частота дискретизації';
@override
String get audioAnalysisCodec => 'Codec';
@override
String get audioAnalysisContainer => 'Container';
@override
String get audioAnalysisDecodedFormat => 'Decoded Format';
@override
String get audioAnalysisBitDepth => 'Глибина бітів';
@@ -3340,9 +3372,33 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisLufs => 'LUFS';
@override
String get audioAnalysisTruePeak => 'True Peak';
@override
String get audioAnalysisClipping => 'Clipping';
@override
String get audioAnalysisNoClipping => 'No clipping';
@override
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
@override
String get audioAnalysisChannelStats => 'Per-channel Stats';
@override
String get audioAnalysisSamples => 'Семпли';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Пошук за допомогою$providerName';
@@ -3858,4 +3914,337 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get downloadFallbackExtensionsSubtitle =>
'Choose which extensions can be used as fallback';
@override
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
@override
String get editMetadataFieldTrackTotal => 'Track Total';
@override
String get editMetadataFieldDiscTotal => 'Disc Total';
@override
String get editMetadataFieldComposer => 'Composer';
@override
String get editMetadataFieldComment => 'Comment';
@override
String get editMetadataAdvanced => 'Advanced';
@override
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
@override
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
@override
String get libraryFilterMetadataMissingArtist => 'Missing artist';
@override
String get libraryFilterMetadataIncorrectIsrcFormat =>
'Incorrect ISRC format';
@override
String get libraryFilterMetadataMissingLabel => 'Missing label';
@override
String collectionDeletePlaylistsMessage(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0?';
}
@override
String collectionPlaylistsDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return '$count $_temp0 deleted';
}
@override
String collectionAddedTracksToPlaylist(int count, String playlistName) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName';
}
@override
String collectionAddedTracksToPlaylistWithExisting(
int count,
String playlistName,
int alreadyCount,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
}
@override
String itemCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count $_temp0';
}
@override
String trackReEnrichSuccessWithFailures(
int successCount,
int total,
int failedCount,
) {
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
}
@override
String selectionDeleteTracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Delete $count $_temp0';
}
@override
String queueDownloadSpeedStatus(String speed) {
return 'Downloading - $speed MB/s';
}
@override
String get queueDownloadStarting => 'Starting...';
@override
String get a11ySelectTrack => 'Select track';
@override
String get a11yDeselectTrack => 'Deselect track';
@override
String a11yPlayTrackByArtist(String trackName, String artistName) {
return 'Play $trackName by $artistName';
}
@override
String storeExtensionsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0';
}
@override
String storeRequiresVersion(String version) {
return 'Requires v$version+';
}
@override
String get actionGo => 'Go';
@override
String get logIssueSummary => 'Issue Summary';
@override
String logTotalErrors(int count) {
return 'Total errors: $count';
}
@override
String logAffectedDomains(String domains) {
return 'Affected: $domains';
}
@override
String get libraryScanCancelled => 'Scan cancelled';
@override
String get libraryScanCancelledSubtitle =>
'You can retry the scan when ready.';
@override
String libraryDownloadsHistoryExcluded(int count) {
return '$count from Downloads history (excluded from list)';
}
@override
String get downloadNativeWorker => 'Native download worker';
@override
String get downloadNativeWorkerSubtitle =>
'Beta Android service worker for extension downloads';
@override
String get badgeBeta => 'BETA';
@override
String get extensionServiceStatus => 'Service Status';
@override
String get extensionServiceHealth => 'Service health';
@override
String extensionHealthChecksConfigured(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'checks',
one: 'check',
);
return '$count $_temp0 configured';
}
@override
String get extensionOauthConnectHint =>
'Tap Connect to Spotify to fill this field.';
@override
String extensionLastChecked(String time) {
return 'Last checked $time';
}
@override
String get extensionRefreshStatus => 'Refresh status';
@override
String get extensionCustomUrlHandling => 'Custom URL Handling';
@override
String get extensionCustomUrlHandlingSubtitle =>
'This extension can handle links from these sites';
@override
String get extensionCustomUrlHandlingShareHint =>
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
@override
String extensionSettingsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'settings',
one: 'setting',
);
return '$count $_temp0';
}
@override
String get extensionHealthOnline => 'Online';
@override
String get extensionHealthDegraded => 'Degraded';
@override
String get extensionHealthOffline => 'Offline';
@override
String get extensionHealthNotConfigured => 'Not configured';
@override
String get extensionHealthUnknown => 'Unknown';
@override
String get extensionHealthRequired => 'required';
@override
String get extensionSettingNotSet => 'Not set';
@override
String get extensionActionFailed => 'Action failed';
@override
String get extensionEnterValue => 'Enter value';
@override
String get extensionHealthServiceOnline => 'Service online';
@override
String get extensionHealthServiceDegraded => 'Service degraded';
@override
String get extensionHealthServiceOffline => 'Service offline';
@override
String get extensionHealthServiceUnknown => 'Service status unknown';
@override
String get audioAnalysisStereo => 'Stereo';
@override
String get audioAnalysisMono => 'Mono';
@override
String trackOpenInService(String serviceName) {
return 'Open in $serviceName';
}
@override
String get trackLyricsEmbeddedSource => 'Embedded';
@override
String get unknownAlbum => 'Unknown Album';
@override
String get unknownArtist => 'Unknown Artist';
@override
String get permissionAudio => 'Audio';
@override
String get permissionStorage => 'Storage';
@override
String get permissionNotification => 'Notification';
@override
String get errorInvalidFolderSelected => 'Invalid folder selected';
@override
String get errorCouldNotKeepFolderAccess =>
'Could not keep access to the selected folder';
@override
String get storeAnyVersion => 'Any';
@override
String get storeCategoryMetadata => 'Metadata';
@override
String get storeCategoryDownload => 'Download';
@override
String get storeCategoryUtility => 'Utility';
@override
String get storeCategoryLyrics => 'Lyrics';
@override
String get storeCategoryIntegration => 'Integration';
@override
String get artistReleases => 'Releases';
}
+404 -3
View File
@@ -397,7 +397,7 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Albums';
@@ -1220,6 +1220,11 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get trackCopyLyrics => 'Copy lyrics';
@override
String trackLyricsSource(String source) {
return 'Source: $source';
}
@override
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@@ -1523,6 +1528,13 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyAac => 'AAC/M4A 320kbps';
@override
String get downloadLossyAacSubtitle =>
'Best mobile compatibility, M4A container';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@@ -3015,6 +3027,17 @@ class AppLocalizationsZh extends AppLocalizations {
String get downloadAppleQqMultiPersonDisabled =>
'Standard lyrics without speaker labels';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@override
String get downloadAppleElrcWordSyncEnabled =>
'Raw word-by-word timestamps preserved';
@override
String get downloadAppleElrcWordSyncDisabled =>
'Safer line-by-line Apple Music lyrics';
@override
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@@ -3260,6 +3283,15 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisCodec => 'Codec';
@override
String get audioAnalysisContainer => 'Container';
@override
String get audioAnalysisDecodedFormat => 'Decoded Format';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@@ -3284,9 +3316,33 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisLufs => 'LUFS';
@override
String get audioAnalysisTruePeak => 'True Peak';
@override
String get audioAnalysisClipping => 'Clipping';
@override
String get audioAnalysisNoClipping => 'No clipping';
@override
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
@override
String get audioAnalysisChannelStats => 'Per-channel Stats';
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
@@ -3798,6 +3854,339 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get downloadFallbackExtensionsSubtitle =>
'Choose which extensions can be used as fallback';
@override
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
@override
String get editMetadataFieldTrackTotal => 'Track Total';
@override
String get editMetadataFieldDiscTotal => 'Disc Total';
@override
String get editMetadataFieldComposer => 'Composer';
@override
String get editMetadataFieldComment => 'Comment';
@override
String get editMetadataAdvanced => 'Advanced';
@override
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
@override
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
@override
String get libraryFilterMetadataMissingArtist => 'Missing artist';
@override
String get libraryFilterMetadataIncorrectIsrcFormat =>
'Incorrect ISRC format';
@override
String get libraryFilterMetadataMissingLabel => 'Missing label';
@override
String collectionDeletePlaylistsMessage(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0?';
}
@override
String collectionPlaylistsDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return '$count $_temp0 deleted';
}
@override
String collectionAddedTracksToPlaylist(int count, String playlistName) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName';
}
@override
String collectionAddedTracksToPlaylistWithExisting(
int count,
String playlistName,
int alreadyCount,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
}
@override
String itemCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count $_temp0';
}
@override
String trackReEnrichSuccessWithFailures(
int successCount,
int total,
int failedCount,
) {
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
}
@override
String selectionDeleteTracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Delete $count $_temp0';
}
@override
String queueDownloadSpeedStatus(String speed) {
return 'Downloading - $speed MB/s';
}
@override
String get queueDownloadStarting => 'Starting...';
@override
String get a11ySelectTrack => 'Select track';
@override
String get a11yDeselectTrack => 'Deselect track';
@override
String a11yPlayTrackByArtist(String trackName, String artistName) {
return 'Play $trackName by $artistName';
}
@override
String storeExtensionsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0';
}
@override
String storeRequiresVersion(String version) {
return 'Requires v$version+';
}
@override
String get actionGo => 'Go';
@override
String get logIssueSummary => 'Issue Summary';
@override
String logTotalErrors(int count) {
return 'Total errors: $count';
}
@override
String logAffectedDomains(String domains) {
return 'Affected: $domains';
}
@override
String get libraryScanCancelled => 'Scan cancelled';
@override
String get libraryScanCancelledSubtitle =>
'You can retry the scan when ready.';
@override
String libraryDownloadsHistoryExcluded(int count) {
return '$count from Downloads history (excluded from list)';
}
@override
String get downloadNativeWorker => 'Native download worker';
@override
String get downloadNativeWorkerSubtitle =>
'Beta Android service worker for extension downloads';
@override
String get badgeBeta => 'BETA';
@override
String get extensionServiceStatus => 'Service Status';
@override
String get extensionServiceHealth => 'Service health';
@override
String extensionHealthChecksConfigured(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'checks',
one: 'check',
);
return '$count $_temp0 configured';
}
@override
String get extensionOauthConnectHint =>
'Tap Connect to Spotify to fill this field.';
@override
String extensionLastChecked(String time) {
return 'Last checked $time';
}
@override
String get extensionRefreshStatus => 'Refresh status';
@override
String get extensionCustomUrlHandling => 'Custom URL Handling';
@override
String get extensionCustomUrlHandlingSubtitle =>
'This extension can handle links from these sites';
@override
String get extensionCustomUrlHandlingShareHint =>
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
@override
String extensionSettingsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'settings',
one: 'setting',
);
return '$count $_temp0';
}
@override
String get extensionHealthOnline => 'Online';
@override
String get extensionHealthDegraded => 'Degraded';
@override
String get extensionHealthOffline => 'Offline';
@override
String get extensionHealthNotConfigured => 'Not configured';
@override
String get extensionHealthUnknown => 'Unknown';
@override
String get extensionHealthRequired => 'required';
@override
String get extensionSettingNotSet => 'Not set';
@override
String get extensionActionFailed => 'Action failed';
@override
String get extensionEnterValue => 'Enter value';
@override
String get extensionHealthServiceOnline => 'Service online';
@override
String get extensionHealthServiceDegraded => 'Service degraded';
@override
String get extensionHealthServiceOffline => 'Service offline';
@override
String get extensionHealthServiceUnknown => 'Service status unknown';
@override
String get audioAnalysisStereo => 'Stereo';
@override
String get audioAnalysisMono => 'Mono';
@override
String trackOpenInService(String serviceName) {
return 'Open in $serviceName';
}
@override
String get trackLyricsEmbeddedSource => 'Embedded';
@override
String get unknownAlbum => 'Unknown Album';
@override
String get unknownArtist => 'Unknown Artist';
@override
String get permissionAudio => 'Audio';
@override
String get permissionStorage => 'Storage';
@override
String get permissionNotification => 'Notification';
@override
String get errorInvalidFolderSelected => 'Invalid folder selected';
@override
String get errorCouldNotKeepFolderAccess =>
'Could not keep access to the selected folder';
@override
String get storeAnyVersion => 'Any';
@override
String get storeCategoryMetadata => 'Metadata';
@override
String get storeCategoryDownload => 'Download';
@override
String get storeCategoryUtility => 'Utility';
@override
String get storeCategoryLyrics => 'Lyrics';
@override
String get storeCategoryIntegration => 'Integration';
@override
String get artistReleases => 'Releases';
}
/// The translations for Chinese, as used in China (`zh_CN`).
@@ -4176,7 +4565,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Albums';
@@ -7008,6 +7397,12 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
@@ -7651,7 +8046,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Albums';
@@ -10485,6 +10880,12 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String get audioAnalysisSamples => 'Samples';
@override
String get audioAnalysisRescan => 'Re-analyze';
@override
String get audioAnalysisRescanning => 'Re-analyzing audio...';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
+9 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Lade Spotify-Titel in verlustfreier Qualität von Tidal und Qobuz herunter.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4196,6 +4196,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
+487 -2
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -1587,6 +1587,15 @@
"@trackCopyLyrics": {
"description": "Action - copy lyrics to clipboard"
},
"trackLyricsSource": "Source: {source}",
"@trackLyricsSource": {
"description": "Label showing the lyrics source/provider",
"placeholders": {
"source": {
"type": "String"
}
}
},
"trackLyricsNotAvailable": "Lyrics not available for this track",
"@trackLyricsNotAvailable": {
"description": "Message when lyrics not found"
@@ -2001,6 +2010,14 @@
"@downloadLossyMp3Subtitle": {
"description": "Subtitle for MP3 320kbps Tidal lossy option"
},
"downloadLossyAac": "AAC/M4A 320kbps",
"@downloadLossyAac": {
"description": "Tidal 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"
},
"downloadLossyOpus256": "Opus 256kbps",
"@downloadLossyOpus256": {
"description": "Tidal lossy format option - Opus 256kbps"
@@ -3170,7 +3187,7 @@
"@trackConvertFormat": {
"description": "Menu item - convert audio format"
},
"trackConvertFormatSubtitle": "Convert to MP3, Opus, ALAC, or FLAC",
"trackConvertFormatSubtitle": "Convert to AAC/M4A, MP3, Opus, ALAC, or FLAC",
"@trackConvertFormatSubtitle": {
"description": "Subtitle for convert format menu item"
},
@@ -3969,6 +3986,18 @@
"@downloadAppleQqMultiPersonDisabled": {
"description": "Subtitle when multi-person lyrics is off"
},
"downloadAppleElrcWordSync": "Apple Music eLRC Word Sync",
"@downloadAppleElrcWordSync": {
"description": "Setting for preserving Apple Music word-by-word eLRC timestamps"
},
"downloadAppleElrcWordSyncEnabled": "Raw word-by-word timestamps preserved",
"@downloadAppleElrcWordSyncEnabled": {
"description": "Subtitle when Apple Music eLRC word sync is enabled"
},
"downloadAppleElrcWordSyncDisabled": "Safer line-by-line Apple Music lyrics",
"@downloadAppleElrcWordSyncDisabled": {
"description": "Subtitle when Apple Music eLRC word sync is disabled"
},
"downloadMusixmatchLanguage": "Musixmatch Language",
"@downloadMusixmatchLanguage": {
"description": "Setting for Musixmatch lyrics translation language"
@@ -4243,6 +4272,18 @@
"@audioAnalysisSampleRate": {
"description": "Sample rate metric label"
},
"audioAnalysisCodec": "Codec",
"@audioAnalysisCodec": {
"description": "Audio codec metric label"
},
"audioAnalysisContainer": "Container",
"@audioAnalysisContainer": {
"description": "Audio container metric label"
},
"audioAnalysisDecodedFormat": "Decoded Format",
"@audioAnalysisDecodedFormat": {
"description": "Decoded sample format metric label"
},
"audioAnalysisBitDepth": "Bit Depth",
"@audioAnalysisBitDepth": {
"description": "Bit depth metric label"
@@ -4275,10 +4316,42 @@
"@audioAnalysisRms": {
"description": "RMS level metric label"
},
"audioAnalysisLufs": "LUFS",
"@audioAnalysisLufs": {
"description": "Integrated loudness metric label"
},
"audioAnalysisTruePeak": "True Peak",
"@audioAnalysisTruePeak": {
"description": "True peak metric label"
},
"audioAnalysisClipping": "Clipping",
"@audioAnalysisClipping": {
"description": "Clipping metric label"
},
"audioAnalysisNoClipping": "No clipping",
"@audioAnalysisNoClipping": {
"description": "Displayed when no clipped samples were detected"
},
"audioAnalysisSpectralCutoff": "Spectral Cutoff",
"@audioAnalysisSpectralCutoff": {
"description": "Estimated spectral cutoff metric label"
},
"audioAnalysisChannelStats": "Per-channel Stats",
"@audioAnalysisChannelStats": {
"description": "Per-channel audio analysis section label"
},
"audioAnalysisSamples": "Samples",
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
@@ -5001,5 +5074,417 @@
"downloadFallbackExtensionsSubtitle": "Choose which extensions can be used as fallback",
"@downloadFallbackExtensionsSubtitle": {
"description": "Subtitle for fallback extensions item"
},
"editMetadataFieldDateHint": "YYYY-MM-DD or YYYY",
"@editMetadataFieldDateHint": {
"description": "Hint text for the edit metadata date field"
},
"editMetadataFieldTrackTotal": "Track Total",
"@editMetadataFieldTrackTotal": {
"description": "Label for total tracks field in the edit metadata sheet"
},
"editMetadataFieldDiscTotal": "Disc Total",
"@editMetadataFieldDiscTotal": {
"description": "Label for total discs field in the edit metadata sheet"
},
"editMetadataFieldComposer": "Composer",
"@editMetadataFieldComposer": {
"description": "Label for composer field in the edit metadata sheet"
},
"editMetadataFieldComment": "Comment",
"@editMetadataFieldComment": {
"description": "Label for comment field in the edit metadata sheet"
},
"editMetadataAdvanced": "Advanced",
"@editMetadataAdvanced": {
"description": "Expandable section label for advanced metadata fields"
},
"libraryFilterMetadataMissingTrackNumber": "Missing track number",
"@libraryFilterMetadataMissingTrackNumber": {
"description": "Filter option - items missing track number"
},
"libraryFilterMetadataMissingDiscNumber": "Missing disc number",
"@libraryFilterMetadataMissingDiscNumber": {
"description": "Filter option - items missing disc number"
},
"libraryFilterMetadataMissingArtist": "Missing artist",
"@libraryFilterMetadataMissingArtist": {
"description": "Filter option - items missing artist"
},
"libraryFilterMetadataIncorrectIsrcFormat": "Incorrect ISRC format",
"@libraryFilterMetadataIncorrectIsrcFormat": {
"description": "Filter option - items with an invalid ISRC format"
},
"libraryFilterMetadataMissingLabel": "Missing label",
"@libraryFilterMetadataMissingLabel": {
"description": "Filter option - items missing record label"
},
"collectionDeletePlaylistsMessage": "Delete {count} {count, plural, =1{playlist} other{playlists}}?",
"@collectionDeletePlaylistsMessage": {
"description": "Confirmation message for deleting selected playlists",
"placeholders": {
"count": {
"type": "int"
}
}
},
"collectionPlaylistsDeleted": "{count} {count, plural, =1{playlist} other{playlists}} deleted",
"@collectionPlaylistsDeleted": {
"description": "Snackbar after deleting selected playlists",
"placeholders": {
"count": {
"type": "int"
}
}
},
"collectionAddedTracksToPlaylist": "Added {count} {count, plural, =1{track} other{tracks}} to {playlistName}",
"@collectionAddedTracksToPlaylist": {
"description": "Snackbar after adding multiple tracks to a playlist",
"placeholders": {
"count": {
"type": "int"
},
"playlistName": {
"type": "String"
}
}
},
"collectionAddedTracksToPlaylistWithExisting": "Added {count} {count, plural, =1{track} other{tracks}} to {playlistName} ({alreadyCount} already in playlist)",
"@collectionAddedTracksToPlaylistWithExisting": {
"description": "Snackbar after adding multiple tracks to a playlist when some were already present",
"placeholders": {
"count": {
"type": "int"
},
"playlistName": {
"type": "String"
},
"alreadyCount": {
"type": "int"
}
}
},
"itemCount": "{count} {count, plural, =1{item} other{items}}",
"@itemCount": {
"description": "Generic item count label",
"placeholders": {
"count": {
"type": "int"
}
}
},
"trackReEnrichSuccessWithFailures": "Metadata re-enriched successfully ({successCount}/{total}) - Failed: {failedCount}",
"@trackReEnrichSuccessWithFailures": {
"description": "Snackbar summary after batch metadata re-enrichment finishes with failures",
"placeholders": {
"successCount": {
"type": "int"
},
"total": {
"type": "int"
},
"failedCount": {
"type": "int"
}
}
},
"selectionDeleteTracksCount": "Delete {count} {count, plural, =1{track} other{tracks}}",
"@selectionDeleteTracksCount": {
"description": "Button label for deleting selected tracks",
"placeholders": {
"count": {
"type": "int"
}
}
},
"queueDownloadSpeedStatus": "Downloading - {speed} MB/s",
"@queueDownloadSpeedStatus": {
"description": "Queue status while downloading with speed",
"placeholders": {
"speed": {
"type": "String"
}
}
},
"queueDownloadStarting": "Starting...",
"@queueDownloadStarting": {
"description": "Queue status before download progress is available"
},
"a11ySelectTrack": "Select track",
"@a11ySelectTrack": {
"description": "Accessibility label for selecting a track"
},
"a11yDeselectTrack": "Deselect track",
"@a11yDeselectTrack": {
"description": "Accessibility label for deselecting a track"
},
"a11yPlayTrackByArtist": "Play {trackName} by {artistName}",
"@a11yPlayTrackByArtist": {
"description": "Accessibility label for playing a local library track",
"placeholders": {
"trackName": {
"type": "String"
},
"artistName": {
"type": "String"
}
}
},
"storeExtensionsCount": "{count} {count, plural, =1{extension} other{extensions}}",
"@storeExtensionsCount": {
"description": "Store extension result count",
"placeholders": {
"count": {
"type": "int"
}
}
},
"storeRequiresVersion": "Requires v{version}+",
"@storeRequiresVersion": {
"description": "Store compatibility badge for minimum app version",
"placeholders": {
"version": {
"type": "String"
}
}
},
"actionGo": "Go",
"@actionGo": {
"description": "Generic action button label"
},
"logIssueSummary": "Issue Summary",
"@logIssueSummary": {
"description": "Header for log issue analysis summary"
},
"logTotalErrors": "Total errors: {count}",
"@logTotalErrors": {
"description": "Total error count in log issue analysis",
"placeholders": {
"count": {
"type": "int"
}
}
},
"logAffectedDomains": "Affected: {domains}",
"@logAffectedDomains": {
"description": "Affected domains in log issue analysis",
"placeholders": {
"domains": {
"type": "String"
}
}
},
"libraryScanCancelled": "Scan cancelled",
"@libraryScanCancelled": {
"description": "Library scan status when a scan was cancelled"
},
"libraryScanCancelledSubtitle": "You can retry the scan when ready.",
"@libraryScanCancelledSubtitle": {
"description": "Library scan status subtitle after cancellation"
},
"libraryDownloadsHistoryExcluded": "{count} from Downloads history (excluded from list)",
"@libraryDownloadsHistoryExcluded": {
"description": "Library count note for downloaded history items excluded from the local list",
"placeholders": {
"count": {
"type": "int"
}
}
},
"downloadNativeWorker": "Native download worker",
"@downloadNativeWorker": {
"description": "Setting title for Android native download worker"
},
"downloadNativeWorkerSubtitle": "Beta Android service worker for extension downloads",
"@downloadNativeWorkerSubtitle": {
"description": "Setting subtitle for Android native download worker"
},
"badgeBeta": "BETA",
"@badgeBeta": {
"description": "Badge label for beta features"
},
"extensionServiceStatus": "Service Status",
"@extensionServiceStatus": {
"description": "Extension detail section header for service status"
},
"extensionServiceHealth": "Service health",
"@extensionServiceHealth": {
"description": "Extension capability label for service health checks"
},
"extensionHealthChecksConfigured": "{count} {count, plural, =1{check} other{checks}} configured",
"@extensionHealthChecksConfigured": {
"description": "Extension service health check count",
"placeholders": {
"count": {
"type": "int"
}
}
},
"extensionOauthConnectHint": "Tap Connect to Spotify to fill this field.",
"@extensionOauthConnectHint": {
"description": "Hint for an OAuth login link field before connecting Spotify"
},
"extensionLastChecked": "Last checked {time}",
"@extensionLastChecked": {
"description": "Timestamp for the latest extension service health check",
"placeholders": {
"time": {
"type": "String"
}
}
},
"extensionRefreshStatus": "Refresh status",
"@extensionRefreshStatus": {
"description": "Tooltip for refreshing extension service health status"
},
"extensionCustomUrlHandling": "Custom URL Handling",
"@extensionCustomUrlHandling": {
"description": "Extension detail section title for custom URL handling"
},
"extensionCustomUrlHandlingSubtitle": "This extension can handle links from these sites",
"@extensionCustomUrlHandlingSubtitle": {
"description": "Extension detail subtitle for custom URL handling"
},
"extensionCustomUrlHandlingShareHint": "Share links from these sites to SpotiFLAC Mobile and this extension will handle them.",
"@extensionCustomUrlHandlingShareHint": {
"description": "Extension detail hint explaining share-to-app URL handling"
},
"extensionSettingsCount": "{count} {count, plural, =1{setting} other{settings}}",
"@extensionSettingsCount": {
"description": "Count of settings exposed by an extension quality option",
"placeholders": {
"count": {
"type": "int"
}
}
},
"extensionHealthOnline": "Online",
"@extensionHealthOnline": {
"description": "Extension service health status - online"
},
"extensionHealthDegraded": "Degraded",
"@extensionHealthDegraded": {
"description": "Extension service health status - degraded"
},
"extensionHealthOffline": "Offline",
"@extensionHealthOffline": {
"description": "Extension service health status - offline"
},
"extensionHealthNotConfigured": "Not configured",
"@extensionHealthNotConfigured": {
"description": "Extension service health status - not configured"
},
"extensionHealthUnknown": "Unknown",
"@extensionHealthUnknown": {
"description": "Extension service health status - unknown"
},
"extensionHealthRequired": "required",
"@extensionHealthRequired": {
"description": "Label for a required extension service health check"
},
"extensionSettingNotSet": "Not set",
"@extensionSettingNotSet": {
"description": "Value shown when an extension setting has no value"
},
"extensionActionFailed": "Action failed",
"@extensionActionFailed": {
"description": "Fallback error when an extension action fails without details"
},
"extensionEnterValue": "Enter value",
"@extensionEnterValue": {
"description": "Hint for editing an extension setting value"
},
"extensionHealthServiceOnline": "Service online",
"@extensionHealthServiceOnline": {
"description": "Tooltip for online extension service"
},
"extensionHealthServiceDegraded": "Service degraded",
"@extensionHealthServiceDegraded": {
"description": "Tooltip for degraded extension service"
},
"extensionHealthServiceOffline": "Service offline",
"@extensionHealthServiceOffline": {
"description": "Tooltip for offline extension service"
},
"extensionHealthServiceUnknown": "Service status unknown",
"@extensionHealthServiceUnknown": {
"description": "Tooltip for unknown extension service health"
},
"audioAnalysisStereo": "Stereo",
"@audioAnalysisStereo": {
"description": "Audio channel layout label - stereo"
},
"audioAnalysisMono": "Mono",
"@audioAnalysisMono": {
"description": "Audio channel layout label - mono"
},
"trackOpenInService": "Open in {serviceName}",
"@trackOpenInService": {
"description": "Button label to open a track in a named music service",
"placeholders": {
"serviceName": {
"type": "String"
}
}
},
"trackLyricsEmbeddedSource": "Embedded",
"@trackLyricsEmbeddedSource": {
"description": "Lyrics source label for embedded lyrics"
},
"unknownAlbum": "Unknown Album",
"@unknownAlbum": {
"description": "Fallback album name when metadata is missing"
},
"unknownArtist": "Unknown Artist",
"@unknownArtist": {
"description": "Fallback artist name when metadata is missing"
},
"permissionAudio": "Audio",
"@permissionAudio": {
"description": "Audio permission type label"
},
"permissionStorage": "Storage",
"@permissionStorage": {
"description": "Storage permission type label"
},
"permissionNotification": "Notification",
"@permissionNotification": {
"description": "Notification permission type label"
},
"errorInvalidFolderSelected": "Invalid folder selected",
"@errorInvalidFolderSelected": {
"description": "Error when the selected folder is invalid"
},
"errorCouldNotKeepFolderAccess": "Could not keep access to the selected folder",
"@errorCouldNotKeepFolderAccess": {
"description": "Error when persistent folder access cannot be saved"
},
"storeAnyVersion": "Any",
"@storeAnyVersion": {
"description": "Store detail value when any app version is accepted"
},
"storeCategoryMetadata": "Metadata",
"@storeCategoryMetadata": {
"description": "Store extension category - metadata"
},
"storeCategoryDownload": "Download",
"@storeCategoryDownload": {
"description": "Store extension category - download"
},
"storeCategoryUtility": "Utility",
"@storeCategoryUtility": {
"description": "Store extension category - utility"
},
"storeCategoryLyrics": "Lyrics",
"@storeCategoryLyrics": {
"description": "Store extension category - lyrics"
},
"storeCategoryIntegration": "Integration",
"@storeCategoryIntegration": {
"description": "Store extension category - integration"
},
"artistReleases": "Releases",
"@artistReleases": {
"description": "Section header for all artist releases"
}
}
+9 -1
View File
@@ -398,7 +398,7 @@
"@aboutSachinsenalDesc": {
"description": "Credit description for sachinsenal0x64"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4196,6 +4196,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
+9 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Descargar pistas de Spotify en alta calidad (sin pérdida) de Tidal y Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4196,6 +4196,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
+9 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4196,6 +4196,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
+9 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4196,6 +4196,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
+9 -1
View File
@@ -458,7 +458,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Unduh lagu-lagu Spotify dalam kualitas lossless dari Tidal dan Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4204,6 +4204,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
+9 -1
View File
@@ -438,7 +438,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4196,6 +4196,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
+9 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4196,6 +4196,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
+9 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4196,6 +4196,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
+9 -1
View File
@@ -398,7 +398,7 @@
"@aboutSachinsenalDesc": {
"description": "Credit description for sachinsenal0x64"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4196,6 +4196,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
+9 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4196,6 +4196,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
+9 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Скачивайте треки Spotify в lossless качестве с Tidal и Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4196,6 +4196,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
+9 -1
View File
@@ -438,7 +438,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Spotify parçalarını Tidal ve Qobuz aracılığıyla kayıpsız kalitede indirin.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4027,6 +4027,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"downloadSingleFilenameFormat": "Single Dosya Adı Formatı",
"@downloadSingleFilenameFormat": {
"description": "Setting for output filename pattern for singles/EPs"
+9 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Кінцеві точки потокового передавання Tidal Hi-Res FLAC. Ключовий елемент пазлу музики без втрат.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4196,6 +4196,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Пошук за допомогою{providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
+9 -1
View File
@@ -398,7 +398,7 @@
"@aboutSachinsenalDesc": {
"description": "Credit description for sachinsenal0x64"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4196,6 +4196,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
+9 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4196,6 +4196,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
+9 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -4196,6 +4196,14 @@
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"audioAnalysisRescan": "Re-analyze",
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
+7 -1
View File
@@ -47,7 +47,7 @@ class AppSettings {
final String locale;
final String lyricsMode;
final String
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'aac_320', 'opus_256', or 'opus_128'
final bool
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
final bool
@@ -81,6 +81,8 @@ class AppSettings {
lyricsIncludeRomanizationNetease; // Append romanized lyrics (Netease)
final bool
lyricsMultiPersonWordByWord; // Enable v1/v2 + [bg:] tags for Apple/QQ syllable lyrics
final bool
lyricsAppleElrcWordSync; // Preserve Apple Music inline word timestamps for eLRC-capable players
final String
musixmatchLanguage; // Optional ISO language code for Musixmatch localized lyrics
@@ -146,6 +148,7 @@ class AppSettings {
this.lyricsIncludeTranslationNetease = false,
this.lyricsIncludeRomanizationNetease = false,
this.lyricsMultiPersonWordByWord = false,
this.lyricsAppleElrcWordSync = false,
this.musixmatchLanguage = '',
this.lastSeenVersion = '',
this.deduplicateDownloads = true,
@@ -210,6 +213,7 @@ class AppSettings {
bool? lyricsIncludeTranslationNetease,
bool? lyricsIncludeRomanizationNetease,
bool? lyricsMultiPersonWordByWord,
bool? lyricsAppleElrcWordSync,
String? musixmatchLanguage,
String? lastSeenVersion,
bool? deduplicateDownloads,
@@ -291,6 +295,8 @@ class AppSettings {
this.lyricsIncludeRomanizationNetease,
lyricsMultiPersonWordByWord:
lyricsMultiPersonWordByWord ?? this.lyricsMultiPersonWordByWord,
lyricsAppleElrcWordSync:
lyricsAppleElrcWordSync ?? this.lyricsAppleElrcWordSync,
musixmatchLanguage: musixmatchLanguage ?? this.musixmatchLanguage,
lastSeenVersion: lastSeenVersion ?? this.lastSeenVersion,
deduplicateDownloads: deduplicateDownloads ?? this.deduplicateDownloads,
+2
View File
@@ -78,6 +78,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
json['lyricsIncludeRomanizationNetease'] as bool? ?? false,
lyricsMultiPersonWordByWord:
json['lyricsMultiPersonWordByWord'] as bool? ?? false,
lyricsAppleElrcWordSync: json['lyricsAppleElrcWordSync'] as bool? ?? false,
musixmatchLanguage: json['musixmatchLanguage'] as String? ?? '',
lastSeenVersion: json['lastSeenVersion'] as String? ?? '',
deduplicateDownloads: json['deduplicateDownloads'] as bool? ?? true,
@@ -142,6 +143,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'lyricsIncludeTranslationNetease': instance.lyricsIncludeTranslationNetease,
'lyricsIncludeRomanizationNetease': instance.lyricsIncludeRomanizationNetease,
'lyricsMultiPersonWordByWord': instance.lyricsMultiPersonWordByWord,
'lyricsAppleElrcWordSync': instance.lyricsAppleElrcWordSync,
'musixmatchLanguage': instance.musixmatchLanguage,
'lastSeenVersion': instance.lastSeenVersion,
'deduplicateDownloads': instance.deduplicateDownloads,
+2 -2
View File
@@ -46,7 +46,7 @@ class ThemeSettings {
factory ThemeSettings.fromJson(Map<String, dynamic> json) {
return ThemeSettings(
themeMode: _themeModeFromString(json[kThemeModeKey] as String?),
themeMode: themeModeFromString(json[kThemeModeKey] as String?),
useDynamicColor: json[kUseDynamicColorKey] as bool? ?? true,
seedColorValue: json[kSeedColorKey] as int? ?? kDefaultSeedColor,
useAmoled: json[kUseAmoledKey] as bool? ?? false,
@@ -68,7 +68,7 @@ class ThemeSettings {
themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode ^ useAmoled.hashCode;
}
ThemeMode _themeModeFromString(String? value) {
ThemeMode themeModeFromString(String? value) {
if (value == null) return ThemeMode.system;
return ThemeMode.values.firstWhere(
(e) => e.name == value,
File diff suppressed because it is too large Load Diff
@@ -54,11 +54,6 @@ class LocalLibraryState {
_isrcSet = isrcSet ?? const <String>{},
_filePathById = filePathById ?? const <String, String>{};
@Deprecated(
'LocalLibraryState no longer owns full track rows. Use DB-backed page providers.',
)
List<LocalLibraryItem> get items => const <LocalLibraryItem>[];
bool hasIsrc(String isrc) => _isrcSet.contains(isrc);
bool hasTrack(String trackName, String artistName) {
+7
View File
@@ -108,6 +108,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
'include_translation_netease': state.lyricsIncludeTranslationNetease,
'include_romanization_netease': state.lyricsIncludeRomanizationNetease,
'multi_person_word_by_word': state.lyricsMultiPersonWordByWord,
'apple_elrc_word_sync': state.lyricsAppleElrcWordSync,
'musixmatch_language': state.musixmatchLanguage,
}).catchError((Object e) {
_log.w('Failed to sync lyrics fetch options to backend: $e');
@@ -367,6 +368,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
_syncLyricsSettingsToBackend();
}
void setLyricsAppleElrcWordSync(bool enabled) {
state = state.copyWith(lyricsAppleElrcWordSync: enabled);
_saveSettings();
_syncLyricsSettingsToBackend();
}
void setMusixmatchLanguage(String languageCode) {
state = state.copyWith(
musixmatchLanguage: languageCode.trim().toLowerCase(),
+1 -9
View File
@@ -25,7 +25,7 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
final useAmoled = prefs.getBool(kUseAmoledKey);
state = ThemeSettings(
themeMode: _themeModeFromString(modeString),
themeMode: themeModeFromString(modeString),
useDynamicColor: useDynamic ?? true,
seedColorValue: seedColor ?? kDefaultSeedColor,
useAmoled: useAmoled ?? false,
@@ -71,12 +71,4 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
state = state.copyWith(useAmoled: value);
await _saveToStorage();
}
ThemeMode _themeModeFromString(String? value) {
if (value == null) return ThemeMode.system;
return ThemeMode.values.firstWhere(
(e) => e.name == value,
orElse: () => ThemeMode.system,
);
}
}
+1 -1
View File
@@ -507,7 +507,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
if (releases.isNotEmpty)
SliverToBoxAdapter(
child: _buildAlbumSection(
'Releases',
context.l10n.artistReleases,
releases,
colorScheme,
),
+55 -47
View File
@@ -10,6 +10,7 @@ import 'package:spotiflac_android/services/ffmpeg_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';
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';
@@ -944,40 +945,39 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
for (final id in _selectedIds) {
final item = tracksById[id];
if (item == null) continue;
final nameToCheck =
(item.safFileName != null && item.safFileName!.isNotEmpty)
? item.safFileName!.toLowerCase()
: item.filePath.toLowerCase();
final ext = nameToCheck.endsWith('.flac')
? 'FLAC'
: nameToCheck.endsWith('.m4a')
? 'M4A'
: nameToCheck.endsWith('.mp3')
? 'MP3'
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
? 'Opus'
: null;
if (ext != null) sourceFormats.add(ext);
final sourceFormat = convertibleAudioSourceFormat(
storedFormat: item.format,
filePath: item.filePath,
fileName: item.safFileName,
);
if (sourceFormat != null) sourceFormats.add(sourceFormat);
}
final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) {
return sourceFormats.any((src) {
if (src == target) return false;
final isLosslessTarget = target == 'ALAC' || target == 'FLAC';
final isLosslessSource = src == 'FLAC' || src == 'M4A';
if (isLosslessTarget && !isLosslessSource) return false;
return true;
});
}).toList();
final formats = audioConversionTargetFormats
.where(
(target) => sourceFormats.any(
(source) => canConvertAudioFormat(
sourceFormat: source,
targetFormat: target,
),
),
)
.toList();
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'
: (selectedFormat == 'Opus' ? '128k' : '320k');
: defaultBitrateForFormat(selectedFormat);
showModalBottomSheet<void>(
context: context,
@@ -1039,9 +1039,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
if (!isLosslessTarget) {
selectedBitrate = format == 'Opus'
? '128k'
: '320k';
selectedBitrate = defaultBitrateForFormat(
format,
);
}
});
}
@@ -1137,23 +1137,18 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
for (final id in _selectedIds) {
final item = tracksById[id];
if (item == null) continue;
final nameToCheck =
(item.safFileName != null && item.safFileName!.isNotEmpty)
? item.safFileName!.toLowerCase()
: item.filePath.toLowerCase();
final ext = nameToCheck.endsWith('.flac')
? 'FLAC'
: nameToCheck.endsWith('.m4a')
? 'M4A'
: nameToCheck.endsWith('.mp3')
? 'MP3'
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
? 'Opus'
: null;
if (ext == null || ext == targetFormat) continue;
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final isLosslessSource = ext == 'FLAC' || ext == 'M4A';
if (isLosslessTarget && !isLosslessSource) continue;
final sourceFormat = convertibleAudioSourceFormat(
storedFormat: item.format,
filePath: item.filePath,
fileName: item.safFileName,
);
if (sourceFormat == null ||
!canConvertAudioFormat(
sourceFormat: sourceFormat,
targetFormat: targetFormat,
)) {
continue;
}
selected.add(item);
}
@@ -1316,6 +1311,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
mimeType = 'audio/opus';
break;
case 'alac':
case 'aac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
@@ -1350,14 +1346,21 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
continue;
}
try {
await PlatformBridge.safDelete(item.filePath);
} catch (_) {}
if (!isSameContentUri(item.filePath, safUri)) {
try {
await PlatformBridge.safDelete(item.filePath);
} catch (_) {}
}
await historyDb.updateFilePath(
item.id,
safUri,
newSafFileName: newFileName,
newQuality: newQuality,
newFormat: normalizedConvertedAudioFormat(targetFormat),
newBitrate: convertedAudioBitrateKbps(
targetFormat: targetFormat,
bitrate: bitrate,
),
clearAudioSpecs: true,
);
}
@@ -1374,6 +1377,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
item.id,
newPath,
newQuality: newQuality,
newFormat: normalizedConvertedAudioFormat(targetFormat),
newBitrate: convertedAudioBitrateKbps(
targetFormat: targetFormat,
bitrate: bitrate,
),
clearAudioSpecs: true,
);
}
+1 -1
View File
@@ -1549,7 +1549,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
shape: BoxShape.circle,
),
child: Image.asset(
'assets/images/logo-transparant.png',
'assets/images/logo-transparent.png',
color: colorScheme.onPrimary,
fit: BoxFit.contain,
errorBuilder: (_, _, _) => ClipRRect(
+9 -5
View File
@@ -508,8 +508,10 @@ class _CollectionItemWidget extends StatelessWidget {
item.artistName.isNotEmpty
? item.artistName
: (isPlaylist
? 'Playlist'
: (isArtist ? 'Artist' : 'Album')),
? context.l10n.recentTypePlaylist
: (isArtist
? context.l10n.recentTypeArtist
: context.l10n.recentTypeAlbum)),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -607,7 +609,7 @@ class _SearchArtistItemWidget extends StatelessWidget {
),
const SizedBox(height: 2),
Text(
'Artist',
context.l10n.recentTypeArtist,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -707,7 +709,7 @@ class _SearchAlbumItemWidget extends StatelessWidget {
ClickableArtistName(
artistName: album.artists.isNotEmpty
? album.artists
: 'Album',
: context.l10n.recentTypeAlbum,
coverUrl: album.imageUrl,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
@@ -806,7 +808,9 @@ class _SearchPlaylistItemWidget extends StatelessWidget {
),
const SizedBox(height: 2),
Text(
playlist.owner.isNotEmpty ? playlist.owner : 'Playlist',
playlist.owner.isNotEmpty
? playlist.owner
: context.l10n.recentTypePlaylist,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
+52 -78
View File
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
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';
@@ -440,8 +441,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
color: Colors.white,
),
const SizedBox(width: 4),
const Text(
'Local',
Text(
context.l10n.librarySourceLocal,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
@@ -470,7 +471,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
),
const SizedBox(width: 4),
Text(
'${_sortedTracksCache.length} tracks',
context.l10n.queueTrackCount(
_sortedTracksCache.length,
),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
@@ -1155,7 +1158,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final failedCount = total - successCount;
final summary = failedCount <= 0
? '${context.l10n.trackReEnrichSuccess} ($successCount/$total)'
: '${context.l10n.trackReEnrichSuccess} ($successCount/$total) • Failed: $failedCount';
: context.l10n.trackReEnrichSuccessWithFailures(
successCount,
total,
failedCount,
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(summary)));
@@ -1170,52 +1177,38 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
for (final id in _selectedIds) {
final item = tracksById[id];
if (item == null) continue;
String? ext;
if (item.format != null && item.format!.isNotEmpty) {
final fmt = item.format!.toLowerCase();
if (fmt == 'flac') {
ext = 'FLAC';
} else if (fmt == 'm4a') {
ext = 'M4A';
} else if (fmt == 'mp3') {
ext = 'MP3';
} else if (fmt == 'opus' || fmt == 'ogg') {
ext = 'Opus';
}
}
if (ext == null) {
final lower = item.filePath.toLowerCase();
if (lower.endsWith('.flac')) {
ext = 'FLAC';
} else if (lower.endsWith('.m4a')) {
ext = 'M4A';
} else if (lower.endsWith('.mp3')) {
ext = 'MP3';
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
ext = 'Opus';
}
}
if (ext != null) sourceFormats.add(ext);
final sourceFormat = convertibleAudioSourceFormat(
storedFormat: item.format,
filePath: item.filePath,
);
if (sourceFormat != null) sourceFormats.add(sourceFormat);
}
final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) {
return sourceFormats.any((src) {
if (src == target) return false;
final isLosslessTarget = target == 'ALAC' || target == 'FLAC';
final isLosslessSource = src == 'FLAC' || src == 'M4A';
if (isLosslessTarget && !isLosslessSource) return false;
return true;
});
}).toList();
final formats = audioConversionTargetFormats
.where(
(target) => sourceFormats.any(
(source) => canConvertAudioFormat(
sourceFormat: source,
targetFormat: target,
),
),
)
.toList();
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'
: (selectedFormat == 'Opus' ? '128k' : '320k');
: defaultBitrateForFormat(selectedFormat);
showModalBottomSheet<void>(
context: context,
@@ -1277,9 +1270,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
if (!isLosslessTarget) {
selectedBitrate = format == 'Opus'
? '128k'
: '320k';
selectedBitrate = defaultBitrateForFormat(
format,
);
}
});
}
@@ -1375,39 +1368,17 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
for (final id in _selectedIds) {
final item = tracksById[id];
if (item == null) continue;
// Detect current format: prefer item.format field (works for SAF too),
// fall back to file extension for regular paths
String? currentFormat;
if (item.format != null && item.format!.isNotEmpty) {
final fmt = item.format!.toLowerCase();
if (fmt == 'flac') {
currentFormat = 'FLAC';
} else if (fmt == 'm4a') {
currentFormat = 'M4A';
} else if (fmt == 'mp3') {
currentFormat = 'MP3';
} else if (fmt == 'opus' || fmt == 'ogg') {
currentFormat = 'Opus';
}
final currentFormat = convertibleAudioSourceFormat(
storedFormat: item.format,
filePath: item.filePath,
);
if (currentFormat == null ||
!canConvertAudioFormat(
sourceFormat: currentFormat,
targetFormat: targetFormat,
)) {
continue;
}
if (currentFormat == null) {
// Fallback: try file extension (works for regular paths)
final lower = item.filePath.toLowerCase();
if (lower.endsWith('.flac')) {
currentFormat = 'FLAC';
} else if (lower.endsWith('.m4a')) {
currentFormat = 'M4A';
} else if (lower.endsWith('.mp3')) {
currentFormat = 'MP3';
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
currentFormat = 'Opus';
}
}
if (currentFormat == null || currentFormat == targetFormat) continue;
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final isLosslessSource =
currentFormat == 'FLAC' || currentFormat == 'M4A';
if (isLosslessTarget && !isLosslessSource) continue;
selected.add(item);
}
@@ -1602,6 +1573,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
mimeType = 'audio/opus';
break;
case 'alac':
case 'aac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
@@ -1636,9 +1608,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
continue;
}
try {
await PlatformBridge.safDelete(item.filePath);
} catch (_) {}
if (!isSameContentUri(item.filePath, safUri)) {
try {
await PlatformBridge.safDelete(item.filePath);
} catch (_) {}
}
await localDb.replaceWithConvertedItem(
item: item,
newFilePath: safUri,
+34 -5
View File
@@ -18,7 +18,9 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/shell_navigation_service.dart';
import 'package:spotiflac_android/services/share_intent_service.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/services/app_remote_config_service.dart';
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/utils/logger.dart';
@@ -38,6 +40,7 @@ class _MainShellState extends ConsumerState<MainShell>
late final PageController _pageController;
late final AnimationController _tabJumpTransitionController;
bool _hasCheckedUpdate = false;
bool _hasCheckedAppAnnouncement = false;
StreamSubscription<String>? _shareSubscription;
DateTime? _lastBackPress;
final GlobalKey<NavigatorState> _homeTabNavigatorKey =
@@ -66,10 +69,13 @@ class _MainShellState extends ConsumerState<MainShell>
currentTabIndex: _currentIndex,
showRepoTab: false,
);
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkForUpdates();
WidgetsBinding.instance.addPostFrameCallback((_) async {
_setupShareListener();
_checkSafMigration();
final updateDialogShown = await _checkForUpdates();
if (!updateDialogShown) {
await _checkAppAnnouncement();
}
});
}
@@ -127,12 +133,12 @@ class _MainShellState extends ConsumerState<MainShell>
}
}
Future<void> _checkForUpdates() async {
if (_hasCheckedUpdate) return;
Future<bool> _checkForUpdates() async {
if (_hasCheckedUpdate) return false;
_hasCheckedUpdate = true;
final settings = ref.read(settingsProvider);
if (!settings.checkForUpdates) return;
if (!settings.checkForUpdates) return false;
final updateInfo = await UpdateChecker.checkForUpdate(
channel: settings.updateChannel,
@@ -145,7 +151,30 @@ class _MainShellState extends ConsumerState<MainShell>
ref.read(settingsProvider.notifier).setCheckForUpdates(false);
},
);
return true;
}
return false;
}
Future<void> _checkAppAnnouncement() async {
if (_hasCheckedAppAnnouncement) return;
_hasCheckedAppAnnouncement = true;
final locale = Localizations.localeOf(context).toLanguageTag();
final remoteConfigService = AppRemoteConfigService();
final announcement = await remoteConfigService.fetchActiveAnnouncement(
locale: locale,
);
if (announcement == null || !mounted) return;
showAppAnnouncementDialog(
context,
announcement: announcement,
onDismiss: () {
remoteConfigService.markAnnouncementDismissed(announcement.id);
},
);
}
static const _safMigrationShownKey = 'saf_migration_prompt_shown';
+205 -120
View File
@@ -10,6 +10,7 @@ import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/models/download_item.dart';
@@ -41,6 +42,49 @@ import 'package:spotiflac_android/widgets/animation_utils.dart';
part 'queue_tab_helpers.dart';
part 'queue_tab_widgets.dart';
String _formatDownloadSizeMB(num bytes) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
String _formatDownloadProgressLabel(BuildContext context, DownloadItem item) {
final progress = item.progress.clamp(0.0, 1.0);
final speedSuffix = item.speedMBps > 0
? '${item.speedMBps.toStringAsFixed(1)} MB/s'
: '';
if (item.bytesTotal > 0) {
final received = item.bytesReceived > 0
? item.bytesReceived
: item.bytesTotal * progress;
final percent = (progress * 100).toStringAsFixed(0);
return '${_formatDownloadSizeMB(received)} / ${_formatDownloadSizeMB(item.bytesTotal)}$percent%$speedSuffix';
}
if (item.bytesReceived > 0) {
final canEstimateTotal = progress > 0.01 && progress < 0.995;
if (canEstimateTotal) {
final estimatedTotal = item.bytesReceived / progress;
if (estimatedTotal > item.bytesReceived) {
return '${_formatDownloadSizeMB(item.bytesReceived)} / ~${_formatDownloadSizeMB(estimatedTotal)}$speedSuffix';
}
}
return '${_formatDownloadSizeMB(item.bytesReceived)}$speedSuffix';
}
if (progress > 0) {
final percent = (progress * 100).toStringAsFixed(0);
return '$percent%$speedSuffix';
}
if (item.speedMBps > 0) {
return context.l10n.queueDownloadSpeedStatus(
item.speedMBps.toStringAsFixed(1),
);
}
return context.l10n.queueDownloadStarting;
}
class QueueTab extends ConsumerStatefulWidget {
final PageController? parentPageController;
final int parentPageIndex;
@@ -1001,9 +1045,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
context: context,
builder: (ctx) => AlertDialog(
title: Text(ctx.l10n.collectionDeletePlaylist),
content: Text(
'Delete $count ${count == 1 ? 'playlist' : 'playlists'}?',
),
content: Text(ctx.l10n.collectionDeletePlaylistsMessage(count)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
@@ -1030,11 +1072,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
if (!context.mounted) return;
_exitPlaylistSelectionMode();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'$count ${count == 1 ? 'playlist' : 'playlists'} deleted',
),
),
SnackBar(content: Text(context.l10n.collectionPlaylistsDeleted(count))),
);
}
@@ -1363,6 +1401,18 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return filePath.substring(dotIndex + 1).toLowerCase();
}
String _itemFormatLower(UnifiedLibraryItem item) {
final localFormat = normalizeOptionalString(item.localItem?.format);
if (localFormat != null) {
return localFormat.toLowerCase().replaceAll('-', '_');
}
final historyFormat = normalizeOptionalString(item.historyItem?.format);
if (historyFormat != null) {
return historyFormat.toLowerCase().replaceAll('-', '_');
}
return _fileExtLower(item.filePath);
}
List<UnifiedLibraryItem> _applyAdvancedFilters(
List<UnifiedLibraryItem> items,
) {
@@ -1400,7 +1450,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
if (_filterFormat != null) {
final ext = _fileExtLower(item.filePath);
final ext = _itemFormatLower(item);
if (ext != _filterFormat) return false;
}
@@ -1533,8 +1583,21 @@ class _QueueTabState extends ConsumerState<QueueTab> {
Set<String> _getAvailableFormats(List<UnifiedLibraryItem> items) {
final formats = <String>{};
for (final item in items) {
final ext = _fileExtLower(item.filePath);
if (['flac', 'mp3', 'm4a', 'opus', 'ogg', 'wav', 'aiff'].contains(ext)) {
final ext = _itemFormatLower(item);
if ([
'flac',
'alac',
'mp3',
'm4a',
'aac',
'eac3',
'ac3',
'ac4',
'opus',
'ogg',
'wav',
'aiff',
].contains(ext)) {
formats.add(ext);
}
}
@@ -1785,7 +1848,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
FilterChip(
label: const Text('Missing track number'),
label: Text(
context
.l10n
.libraryFilterMetadataMissingTrackNumber,
),
selected:
tempMetadata == 'missing-track-number',
onSelected: (_) => setSheetState(
@@ -1793,21 +1860,33 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
FilterChip(
label: const Text('Missing disc number'),
label: Text(
context
.l10n
.libraryFilterMetadataMissingDiscNumber,
),
selected: tempMetadata == 'missing-disc-number',
onSelected: (_) => setSheetState(
() => tempMetadata = 'missing-disc-number',
),
),
FilterChip(
label: const Text('Missing artist'),
label: Text(
context
.l10n
.libraryFilterMetadataMissingArtist,
),
selected: tempMetadata == 'missing-artist',
onSelected: (_) => setSheetState(
() => tempMetadata = 'missing-artist',
),
),
FilterChip(
label: const Text('Incorrect ISRC format'),
label: Text(
context
.l10n
.libraryFilterMetadataIncorrectIsrcFormat,
),
selected:
tempMetadata == 'incorrect-isrc-format',
onSelected: (_) => setSheetState(
@@ -1815,7 +1894,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
FilterChip(
label: const Text('Missing label'),
label: Text(
context
.l10n
.libraryFilterMetadataMissingLabel,
),
selected: tempMetadata == 'missing-label',
onSelected: (_) => setSheetState(
() => tempMetadata = 'missing-label',
@@ -2401,8 +2484,16 @@ class _QueueTabState extends ConsumerState<QueueTab> {
if (!context.mounted) return;
final message = addedCount > 0
? 'Added $addedCount ${addedCount == 1 ? 'track' : 'tracks'} to $playlistName'
'${alreadyCount > 0 ? ' ($alreadyCount already in playlist)' : ''}'
? alreadyCount > 0
? context.l10n.collectionAddedTracksToPlaylistWithExisting(
addedCount,
playlistName,
alreadyCount,
)
: context.l10n.collectionAddedTracksToPlaylist(
addedCount,
playlistName,
)
: context.l10n.collectionAlreadyInPlaylist(playlistName);
ScaffoldMessenger.of(
context,
@@ -3107,7 +3198,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
),
Text(
'$count ${count == 1 ? 'item' : 'items'}',
context.l10n.itemCount(count),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
@@ -4601,7 +4692,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final failedCount = total - successCount;
final summary = failedCount <= 0
? '${context.l10n.trackReEnrichSuccess} ($successCount/$total)'
: '${context.l10n.trackReEnrichSuccess} ($successCount/$total) • Failed: $failedCount';
: context.l10n.trackReEnrichSuccessWithFailures(
successCount,
total,
failedCount,
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(summary)));
@@ -4657,46 +4752,39 @@ class _QueueTabState extends ConsumerState<QueueTab> {
for (final id in _selectedIds) {
final item = itemsById[id];
if (item == null) continue;
String nameToCheck;
if (item.historyItem?.safFileName != null &&
item.historyItem!.safFileName!.isNotEmpty) {
nameToCheck = item.historyItem!.safFileName!.toLowerCase();
} else if (item.localItem?.format != null &&
item.localItem!.format!.isNotEmpty) {
nameToCheck = '.${item.localItem!.format!.toLowerCase()}';
} else {
nameToCheck = item.filePath.toLowerCase();
}
final ext = nameToCheck.endsWith('.flac')
? 'FLAC'
: nameToCheck.endsWith('.m4a')
? 'M4A'
: nameToCheck.endsWith('.mp3')
? 'MP3'
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
? 'Opus'
: null;
if (ext != null) sourceFormats.add(ext);
final sourceFormat = convertibleAudioSourceFormat(
storedFormat: item.localItem?.format ?? item.historyItem?.format,
filePath: item.filePath,
fileName: item.historyItem?.safFileName,
);
if (sourceFormat != null) sourceFormats.add(sourceFormat);
}
final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) {
return sourceFormats.any((src) {
if (src == target) return false;
final isLosslessTarget = target == 'ALAC' || target == 'FLAC';
final isLosslessSource = src == 'FLAC' || src == 'M4A';
if (isLosslessTarget && !isLosslessSource) return false;
return true;
});
}).toList();
final formats = audioConversionTargetFormats
.where(
(target) => sourceFormats.any(
(source) => canConvertAudioFormat(
sourceFormat: source,
targetFormat: target,
),
),
)
.toList();
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'
: (selectedFormat == 'Opus' ? '128k' : '320k');
: defaultBitrateForFormat(selectedFormat);
var didStartConversion = false;
_hideSelectionOverlay();
@@ -4762,9 +4850,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
if (!isLosslessTarget) {
selectedBitrate = format == 'Opus'
? '128k'
: '320k';
selectedBitrate = defaultBitrateForFormat(
format,
);
}
});
}
@@ -4875,29 +4963,18 @@ class _QueueTabState extends ConsumerState<QueueTab> {
for (final id in _selectedIds) {
final item = itemsById[id];
if (item == null) continue;
String nameToCheck;
if (item.historyItem?.safFileName != null &&
item.historyItem!.safFileName!.isNotEmpty) {
nameToCheck = item.historyItem!.safFileName!.toLowerCase();
} else if (item.localItem?.format != null &&
item.localItem!.format!.isNotEmpty) {
nameToCheck = '.${item.localItem!.format!.toLowerCase()}';
} else {
nameToCheck = item.filePath.toLowerCase();
final sourceFormat = convertibleAudioSourceFormat(
storedFormat: item.localItem?.format ?? item.historyItem?.format,
filePath: item.filePath,
fileName: item.historyItem?.safFileName,
);
if (sourceFormat == null ||
!canConvertAudioFormat(
sourceFormat: sourceFormat,
targetFormat: targetFormat,
)) {
continue;
}
final ext = nameToCheck.endsWith('.flac')
? 'FLAC'
: nameToCheck.endsWith('.m4a')
? 'M4A'
: nameToCheck.endsWith('.mp3')
? 'MP3'
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
? 'Opus'
: null;
if (ext == null || ext == targetFormat) continue;
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final isLosslessSource = ext == 'FLAC' || ext == 'M4A';
if (isLosslessTarget && !isLosslessSource) continue;
selectedItems.add(item);
}
@@ -5065,6 +5142,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
mimeType = 'audio/opus';
break;
case 'alac':
case 'aac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
@@ -5099,15 +5177,22 @@ class _QueueTabState extends ConsumerState<QueueTab> {
continue;
}
try {
await PlatformBridge.safDelete(item.filePath);
} catch (_) {}
if (!isSameContentUri(item.filePath, safUri)) {
try {
await PlatformBridge.safDelete(item.filePath);
} catch (_) {}
}
await historyDb.updateFilePath(
hi.id,
safUri,
newSafFileName: newFileName,
newQuality: newQuality,
newFormat: normalizedConvertedAudioFormat(targetFormat),
newBitrate: convertedAudioBitrateKbps(
targetFormat: targetFormat,
bitrate: bitrate,
),
clearAudioSpecs: true,
);
}
@@ -5170,6 +5255,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
mimeType = 'audio/opus';
break;
case 'alac':
case 'aac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
@@ -5204,9 +5290,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
continue;
}
try {
await PlatformBridge.safDelete(item.filePath);
} catch (_) {}
if (!isSameContentUri(item.filePath, safUri)) {
try {
await PlatformBridge.safDelete(item.filePath);
} catch (_) {}
}
await LibraryDatabase.instance.replaceWithConvertedItem(
item: item.localItem!,
newFilePath: safUri,
@@ -5228,6 +5316,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
item.historyItem!.id,
newPath,
newQuality: newQuality,
newFormat: normalizedConvertedAudioFormat(targetFormat),
newBitrate: convertedAudioBitrateKbps(
targetFormat: targetFormat,
bitrate: bitrate,
),
clearAudioSpecs: true,
);
} else if (item.localItem != null) {
@@ -5426,7 +5519,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
icon: const Icon(Icons.delete_outline),
label: Text(
selectedCount > 0
? 'Delete $selectedCount ${selectedCount == 1 ? 'track' : 'tracks'}'
? context.l10n.selectionDeleteTracksCount(selectedCount)
: context.l10n.selectionSelectToDelete,
),
style: FilledButton.styleFrom(
@@ -5541,43 +5634,31 @@ class _QueueTabState extends ConsumerState<QueueTab> {
?.copyWith(color: colorScheme.onSurfaceVariant),
),
if (item.status == DownloadStatus.downloading) ...[
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: item.progress > 0
? item.progress
: null,
backgroundColor:
colorScheme.surfaceContainerHighest,
const SizedBox(height: 6),
Align(
alignment: Alignment.centerRight,
child: Text(
_formatDownloadProgressLabel(context, item),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: colorScheme.primary,
minHeight: 6,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 8),
Text(
item.bytesTotal > 0
? '${(item.progress * 100).toStringAsFixed(0)}%'
: (item.bytesReceived > 0
? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB${item.speedMBps > 0 ? '${item.speedMBps.toStringAsFixed(1)} MB/s' : ''}'
: (item.progress > 0
? (item.speedMBps > 0
? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s'
: '${(item.progress * 100).toStringAsFixed(0)}%')
: (item.speedMBps > 0
? 'Downloading • ${item.speedMBps.toStringAsFixed(1)} MB/s'
: 'Starting...'))),
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
],
),
),
const SizedBox(height: 3),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: item.progress > 0 ? item.progress : null,
backgroundColor:
colorScheme.surfaceContainerHighest,
color: colorScheme.primary,
minHeight: 6,
),
),
],
if (item.status == DownloadStatus.failed) ...[
@@ -5998,7 +6079,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
if (_isSelectionMode) ...[
Semantics(
checked: isSelected,
label: isSelected ? 'Deselect track' : 'Select track',
label: isSelected
? context.l10n.a11yDeselectTrack
: context.l10n.a11ySelectTrack,
child: AnimatedSelectionCheckbox(
visible: true,
selected: isSelected,
@@ -6261,8 +6344,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return fileExists
? Semantics(
button: true,
label:
'Play ${item.trackName} by ${item.artistName}',
label: context.l10n.a11yPlayTrackByArtist(
item.trackName,
item.artistName,
),
child: GestureDetector(
onTap: () => _openFile(
item.filePath,
+16 -5
View File
@@ -33,6 +33,21 @@ class UnifiedLibraryItem {
});
factory UnifiedLibraryItem.fromDownloadHistory(DownloadHistoryItem item) {
String? quality;
if (item.bitrate != null && item.bitrate! > 0) {
quality = buildDisplayAudioQuality(
bitrateKbps: item.bitrate,
format: item.format,
);
} else if (item.bitDepth != null &&
item.bitDepth! > 0 &&
item.sampleRate != null) {
quality = buildDisplayAudioQuality(
bitDepth: item.bitDepth,
sampleRate: item.sampleRate,
);
}
quality ??= item.quality;
return UnifiedLibraryItem(
id: 'dl_${item.id}',
trackName: item.trackName,
@@ -40,11 +55,7 @@ class UnifiedLibraryItem {
albumName: item.albumName,
coverUrl: item.coverUrl,
filePath: item.filePath,
quality: buildDisplayAudioQuality(
bitDepth: item.bitDepth,
sampleRate: item.sampleRate,
storedQuality: item.quality,
),
quality: quality,
addedAt: item.downloadedAt,
source: LibraryItemSource.downloaded,
historyItem: item,
+7 -3
View File
@@ -281,7 +281,9 @@ class _RepoTabState extends ConsumerState<RepoTab> {
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
'${filteredExtensions.length} ${filteredExtensions.length == 1 ? 'extension' : 'extensions'}',
context.l10n.storeExtensionsCount(
filteredExtensions.length,
),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -810,7 +812,9 @@ class _ExtensionItem extends StatelessWidget {
),
const SizedBox(width: 4),
Text(
'Requires v${extension.minAppVersion}+',
context.l10n.storeRequiresVersion(
extension.minAppVersion ?? '',
),
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: colorScheme.onErrorContainer,
@@ -862,7 +866,7 @@ class _ExtensionItem extends StatelessWidget {
Icon(Icons.check, size: 16, color: colorScheme.outline),
const SizedBox(width: 4),
Text(
'Installed',
context.l10n.storeInstalled,
style: TextStyle(color: colorScheme.outline),
),
],
+3 -2
View File
@@ -141,7 +141,8 @@ class AboutPage extends StatelessWidget {
icon: Icons.lyrics_outlined,
title: 'Paxsenix',
subtitle:
'Partner lyrics proxy for Apple Music and QQ Music sources',
'Lyrics proxy for Musixmatch, Netease, Apple Music, '
'QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius',
onTap: () => _launchUrl('https://lyrics.paxsenix.org'),
showDivider: false,
),
@@ -302,7 +303,7 @@ class _AppHeaderCard extends StatelessWidget {
shape: BoxShape.circle,
),
child: Image.asset(
'assets/images/logo-transparant.png',
'assets/images/logo-transparent.png',
color: colorScheme.onPrimary,
fit: BoxFit.contain,
errorBuilder: (_, _, _) => ClipRRect(
+266 -245
View File
@@ -1,13 +1,58 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/services/app_remote_config_service.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/donate_icons.dart';
class DonatePage extends StatelessWidget {
class DonatePage extends StatefulWidget {
const DonatePage({super.key});
@override
State<DonatePage> createState() => _DonatePageState();
}
class _DonatePageState extends State<DonatePage> {
DonateConfig _config = DonateConfig.fallback();
bool _hasRequestedConfig = false;
String? _activeRemoteJson;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_hasRequestedConfig) return;
_hasRequestedConfig = true;
_loadConfig(Localizations.localeOf(context).toLanguageTag());
}
Future<void> _loadConfig(String locale) async {
final service = AppRemoteConfigService();
final cached = await service.readCachedConfig();
if (!mounted) return;
if (cached != null) {
_applyRemoteConfig(cached);
}
unawaited(_refreshConfigCache(locale));
}
Future<void> _refreshConfigCache(String locale) async {
await AppRemoteConfigService().fetchConfigSnapshot(locale: locale);
}
void _applyRemoteConfig(RemoteConfigSnapshot snapshot) {
if (_activeRemoteJson == snapshot.rawJson) return;
setState(() {
_activeRemoteJson = snapshot.rawJson;
_config = snapshot.config.donate;
});
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
@@ -57,94 +102,16 @@ class DonatePage extends StatelessWidget {
padding: const EdgeInsets.all(16),
child: Column(
children: [
_DonateLinksCard(colorScheme: colorScheme),
_DonateLinksCard(colorScheme: colorScheme, config: _config),
const SizedBox(height: 24),
_RecentDonorsCard(colorScheme: colorScheme),
_RecentDonorsCard(
colorScheme: colorScheme,
supporters: _config.supporters,
),
const SizedBox(height: 16),
Card(
elevation: 0,
color: colorScheme.secondaryContainer.withValues(
alpha: 0.3,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.volunteer_activism_rounded,
size: 20,
color: colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'Good to Know',
style: Theme.of(context).textTheme.titleSmall
?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
],
),
const SizedBox(height: 10),
_NoticeLine(
icon: Icons.block,
text:
'Not selling early access, premium features, or paywalls',
colorScheme: colorScheme,
),
const SizedBox(height: 6),
_NoticeLine(
icon: Icons.build_outlined,
text: 'Funds go to dev tools & testing devices',
colorScheme: colorScheme,
),
const SizedBox(height: 6),
_NoticeLine(
icon: Icons.favorite_border,
text:
'Your support is the only way to keep this project alive',
colorScheme: colorScheme,
),
Divider(
height: 24,
color: colorScheme.outlineVariant.withValues(
alpha: 0.3,
),
),
_NoticeLine(
icon: Icons.history,
text:
'Your name stays permanently in every version it was included in',
colorScheme: colorScheme,
),
const SizedBox(height: 6),
_NoticeLine(
icon: Icons.update,
text:
'Supporter list is updated monthly and embedded in the app',
colorScheme: colorScheme,
),
const SizedBox(height: 6),
_NoticeLine(
icon: Icons.cloud_off,
text:
'No remote server -- everything is stored locally',
colorScheme: colorScheme,
),
],
),
),
_DonateNoticeCard(
colorScheme: colorScheme,
notices: _config.notices,
),
],
),
@@ -156,16 +123,112 @@ class DonatePage extends StatelessWidget {
}
}
class _RecentDonorsCard extends StatelessWidget {
class _DonateLinksCard extends StatelessWidget {
final ColorScheme colorScheme;
final DonateConfig config;
const _RecentDonorsCard({required this.colorScheme});
const _DonateLinksCard({required this.colorScheme, required this.config});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
const donorNames = <String>[];
final cardColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: Color.alphaBlend(
Colors.black.withValues(alpha: 0.04),
colorScheme.surface,
);
return Card(
elevation: 0,
color: cardColor,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
config.title,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(
fontWeight: FontWeight.w700,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 4),
Text(
config.message,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.35,
),
),
],
),
),
],
),
),
Divider(
height: 1,
thickness: 1,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
if (!config.enabled)
Padding(
padding: const EdgeInsets.all(20),
child: Text(
'Donation links are currently unavailable.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
)
else
for (var index = 0; index < config.methods.length; index++) ...[
_DonateMethodItem(
method: config.methods[index],
colorScheme: colorScheme,
),
if (index < config.methods.length - 1)
Divider(
height: 1,
thickness: 1,
indent: 74,
endIndent: 16,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
],
],
),
);
}
}
class _RecentDonorsCard extends StatelessWidget {
final ColorScheme colorScheme;
final List<String> supporters;
const _RecentDonorsCard({
required this.colorScheme,
required this.supporters,
});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final cardColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
@@ -206,7 +269,7 @@ class _RecentDonorsCard extends StatelessWidget {
),
),
const SizedBox(height: 16),
if (donorNames.isEmpty)
if (supporters.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
@@ -221,7 +284,7 @@ class _RecentDonorsCard extends StatelessWidget {
),
const SizedBox(height: 8),
Text(
'No supporters yet be the first!',
'No supporters yet - be the first!',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.6,
@@ -236,7 +299,7 @@ class _RecentDonorsCard extends StatelessWidget {
Wrap(
spacing: 8,
runSpacing: 8,
children: donorNames
children: supporters
.map(
(name) =>
_SupporterChip(name: name, colorScheme: colorScheme),
@@ -250,135 +313,49 @@ class _RecentDonorsCard extends StatelessWidget {
}
}
class _DonateLinksCard extends StatelessWidget {
class _DonateNoticeCard extends StatelessWidget {
final ColorScheme colorScheme;
final List<String> notices;
const _DonateLinksCard({required this.colorScheme});
const _DonateNoticeCard({required this.colorScheme, required this.notices});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final cardColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: Color.alphaBlend(
Colors.black.withValues(alpha: 0.04),
colorScheme.surface,
);
return Card(
elevation: 0,
color: cardColor,
color: colorScheme.secondaryContainer.withValues(alpha: 0.3),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
_DonateCardItem(
title: 'Ko-fi',
subtitle: 'ko-fi.com/zarzet',
customIcon: const KofiIcon(size: 22, color: Colors.white),
color: const Color(0xFFFF5E5B),
url: AppInfo.kofiUrl,
colorScheme: colorScheme,
),
Divider(
height: 1,
thickness: 1,
indent: 74,
endIndent: 16,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
_DonateCardItem(
title: 'GitHub Sponsors',
subtitle: 'github.com/sponsors/zarzet',
customIcon: const GitHubIcon(size: 22, color: Colors.white),
color: const Color(0xFF2D333B),
url: AppInfo.githubSponsorsUrl,
colorScheme: colorScheme,
),
Divider(
height: 1,
thickness: 1,
indent: 74,
endIndent: 16,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
_CryptoWalletItem(
title: 'USDT (TRC20)',
walletAddress: 'TL7iAqjq9M8BwVMi9AtHvuAGHtdwEvsDta',
color: const Color(0xFF26A17B),
colorScheme: colorScheme,
),
],
),
);
}
}
class _DonateCardItem extends StatelessWidget {
final String title;
final String subtitle;
final Widget customIcon;
final Color color;
final String url;
final ColorScheme colorScheme;
const _DonateCardItem({
required this.title,
required this.subtitle,
required this.customIcon,
required this.color,
required this.url,
required this.colorScheme,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () =>
launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(12),
),
child: Center(child: customIcon),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
Row(
children: [
Icon(
Icons.volunteer_activism_rounded,
size: 20,
color: colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'Good to Know',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
const SizedBox(height: 2),
Text(
subtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
],
),
const SizedBox(height: 10),
for (var index = 0; index < notices.length; index++) ...[
_NoticeLine(
icon: _noticeIcon(index),
text: notices[index],
colorScheme: colorScheme,
),
),
Icon(
Icons.open_in_new,
size: 18,
color: colorScheme.onSurfaceVariant,
),
if (index < notices.length - 1) const SizedBox(height: 6),
],
],
),
),
@@ -386,32 +363,18 @@ class _DonateCardItem extends StatelessWidget {
}
}
class _CryptoWalletItem extends StatelessWidget {
final String title;
final String walletAddress;
final Color color;
class _DonateMethodItem extends StatelessWidget {
final DonateMethod method;
final ColorScheme colorScheme;
const _CryptoWalletItem({
required this.title,
required this.walletAddress,
required this.color,
required this.colorScheme,
});
const _DonateMethodItem({required this.method, required this.colorScheme});
@override
Widget build(BuildContext context) {
final color = Color(method.color);
return InkWell(
onTap: () {
Clipboard.setData(ClipboardData(text: walletAddress));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$title address copied to clipboard'),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
),
);
},
onTap: () => _handleTap(context),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
@@ -423,16 +386,7 @@ class _CryptoWalletItem extends StatelessWidget {
color: color,
borderRadius: BorderRadius.circular(12),
),
child: const Center(
child: Text(
'\$',
style: TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
),
child: Center(child: _methodIcon(method)),
),
const SizedBox(width: 14),
Expanded(
@@ -440,7 +394,7 @@ class _CryptoWalletItem extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
method.title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
@@ -448,10 +402,12 @@ class _CryptoWalletItem extends StatelessWidget {
),
const SizedBox(height: 2),
Text(
walletAddress,
method.subtitle.isEmpty
? method.walletAddress ?? method.url ?? ''
: method.subtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
fontSize: method.isWallet ? 11 : null,
),
overflow: TextOverflow.ellipsis,
),
@@ -459,7 +415,7 @@ class _CryptoWalletItem extends StatelessWidget {
),
),
Icon(
Icons.copy_rounded,
method.isWallet ? Icons.copy_rounded : Icons.open_in_new,
size: 18,
color: colorScheme.onSurfaceVariant,
),
@@ -468,6 +424,30 @@ class _CryptoWalletItem extends StatelessWidget {
),
);
}
Future<void> _handleTap(BuildContext context) async {
if (method.isWallet) {
await Clipboard.setData(ClipboardData(text: method.walletAddress!));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${method.title} address copied to clipboard'),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
),
);
}
return;
}
final url = method.url;
if (url == null || url.isEmpty) return;
final uri = Uri.tryParse(url);
if (uri == null) return;
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
class _SupporterChip extends StatelessWidget {
@@ -543,3 +523,44 @@ class _NoticeLine extends StatelessWidget {
);
}
}
Widget _methodIcon(DonateMethod method) {
switch (method.icon.toLowerCase()) {
case 'kofi':
case 'ko-fi':
return const KofiIcon(size: 22, color: Colors.white);
case 'github':
case 'github-sponsors':
return const GitHubIcon(size: 22, color: Colors.white);
case 'crypto':
case 'wallet':
return const Text(
'\$',
style: TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
),
);
case 'coffee':
return const Icon(
Icons.local_cafe_rounded,
color: Colors.white,
size: 22,
);
case 'heart':
default:
return const Icon(Icons.favorite_rounded, color: Colors.white, size: 22);
}
}
IconData _noticeIcon(int index) {
const icons = [
Icons.block,
Icons.build_outlined,
Icons.favorite_border,
Icons.history,
Icons.update,
];
return icons[index % icons.length];
}
@@ -199,10 +199,10 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
if (Platform.isAndroid)
SettingsSwitchItem(
icon: Icons.downloading_outlined,
title: 'Native download worker',
title: context.l10n.downloadNativeWorker,
titleTrailing: const _BetaBadge(),
subtitle: hasDownloadExtensions
? 'Beta Android service worker for extension downloads'
? context.l10n.downloadNativeWorkerSubtitle
: context.l10n.extensionsNoDownloadProvider,
value:
settings.nativeDownloadWorkerEnabled &&
@@ -382,6 +382,8 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
switch (format) {
case 'mp3_320':
return context.l10n.downloadLossyMp3;
case 'aac_320':
return context.l10n.downloadLossyAac;
case 'opus_256':
return context.l10n.downloadLossyOpus256;
case 'opus_128':
@@ -441,6 +443,20 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.album_outlined),
title: Text(context.l10n.downloadLossyAac),
subtitle: Text(context.l10n.downloadLossyAacSubtitle),
trailing: current == 'aac_320'
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setTidalHighFormat('aac_320');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.graphic_eq),
title: Text(context.l10n.downloadLossyOpus256),
@@ -829,7 +845,7 @@ class _BetaBadge extends StatelessWidget {
borderRadius: BorderRadius.circular(6),
),
child: Text(
'BETA',
context.l10n.badgeBeta,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w700,
+36 -24
View File
@@ -253,8 +253,10 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
if (extension.hasServiceHealth) ...[
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Service Status'),
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.extensionServiceStatus,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -339,10 +341,12 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
_CapabilityItem(
icon: Icons.monitor_heart_outlined,
title: 'Service health',
title: context.l10n.extensionServiceHealth,
enabled: extension.hasServiceHealth,
subtitle: extension.hasServiceHealth
? '${extension.serviceHealth.length} check${extension.serviceHealth.length == 1 ? '' : 's'} configured'
? context.l10n.extensionHealthChecksConfigured(
extension.serviceHealth.length,
)
: null,
showDivider: false,
),
@@ -570,7 +574,7 @@ class _OauthLoginLinkPreview extends StatelessWidget {
final text = value?.trim() ?? '';
if (text.isEmpty) {
return Text(
'Tap Connect to Spotify to fill this field.',
context.l10n.extensionOauthConnectHint,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
@@ -725,18 +729,18 @@ IconData _healthStatusIcon(String status) {
}
}
String _healthStatusLabel(String status) {
String _healthStatusLabel(BuildContext context, String status) {
switch (status) {
case 'online':
return 'Online';
return context.l10n.extensionHealthOnline;
case 'degraded':
return 'Degraded';
return context.l10n.extensionHealthDegraded;
case 'offline':
return 'Offline';
return context.l10n.extensionHealthOffline;
case 'unsupported':
return 'Not configured';
return context.l10n.extensionHealthNotConfigured;
default:
return 'Unknown';
return context.l10n.extensionHealthUnknown;
}
}
@@ -771,7 +775,7 @@ class _HealthSummaryItem extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_healthStatusLabel(statusValue),
_healthStatusLabel(context, statusValue),
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: color,
fontWeight: FontWeight.w600,
@@ -780,7 +784,11 @@ class _HealthSummaryItem extends StatelessWidget {
if (status?.checkedAt != null) ...[
const SizedBox(height: 2),
Text(
'Last checked ${TimeOfDay.fromDateTime(status!.checkedAt!.toLocal()).format(context)}',
context.l10n.extensionLastChecked(
TimeOfDay.fromDateTime(
status!.checkedAt!.toLocal(),
).format(context),
),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -790,7 +798,7 @@ class _HealthSummaryItem extends StatelessWidget {
),
),
IconButton(
tooltip: 'Refresh status',
tooltip: context.l10n.extensionRefreshStatus,
onPressed: isRefreshing ? null : onRefresh,
icon: isRefreshing
? SizedBox(
@@ -829,11 +837,11 @@ class _HealthCheckItem extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme;
final color = _healthStatusColor(colorScheme, check.status);
final detailParts = <String>[
_healthStatusLabel(check.status),
_healthStatusLabel(context, check.status),
if (check.httpStatus != null) 'HTTP ${check.httpStatus}',
if (check.serviceKey?.isNotEmpty == true) check.serviceKey!,
if (check.latencyMs > 0) '${check.latencyMs} ms',
if (check.required) 'required',
if (check.required) context.l10n.extensionHealthRequired,
];
final message = check.error?.trim().isNotEmpty == true
? check.error!
@@ -1090,7 +1098,8 @@ class _SettingItemState extends State<_SettingItem> {
)
else
Text(
widget.value?.toString() ?? 'Not set',
widget.value?.toString() ??
context.l10n.extensionSettingNotSet,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.primary),
),
@@ -1144,7 +1153,7 @@ class _SettingItemState extends State<_SettingItem> {
final error =
payload['error'] as String? ??
result['error'] as String? ??
'Action failed';
context.l10n.extensionActionFailed;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error)));
@@ -1206,7 +1215,8 @@ class _SettingItemState extends State<_SettingItem> {
? TextInputType.number
: TextInputType.text,
decoration: InputDecoration(
hintText: widget.setting.description ?? 'Enter value',
hintText:
widget.setting.description ?? context.l10n.extensionEnterValue,
filled: true,
fillColor: colorScheme.surfaceContainerHighest.withValues(
alpha: 0.3,
@@ -1327,7 +1337,7 @@ class _PostProcessingHookItem extends StatelessWidget {
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Auto',
context.l10n.extensionsHomeFeedAuto,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onPrimaryContainer,
),
@@ -1384,14 +1394,14 @@ class _URLHandlerInfo extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Custom URL Handling',
context.l10n.extensionCustomUrlHandling,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
'This extension can handle links from these sites',
context.l10n.extensionCustomUrlHandlingSubtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -1445,7 +1455,7 @@ class _URLHandlerInfo extends StatelessWidget {
const SizedBox(width: 12),
Expanded(
child: Text(
'Share links from these sites to SpotiFLAC and this extension will handle them.',
context.l10n.extensionCustomUrlHandlingShareHint,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -1533,7 +1543,9 @@ class _QualityOptionItem extends StatelessWidget {
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${quality.settings.length} setting${quality.settings.length > 1 ? 's' : ''}',
context.l10n.extensionSettingsCount(
quality.settings.length,
),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
+6 -6
View File
@@ -458,7 +458,7 @@ class _ExtensionItem extends StatelessWidget {
context.l10n.extensionsErrorLoading
: serviceHealthStatus == null
? 'v${extension.version}'
: 'v${extension.version} · ${_extensionHealthLabel(serviceHealthStatus)}',
: 'v${extension.version} · ${_extensionHealthLabel(context, serviceHealthStatus)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: hasError
? colorScheme.error
@@ -503,16 +503,16 @@ Color _extensionHealthColor(ColorScheme colorScheme, String status) {
}
}
String _extensionHealthLabel(String status) {
String _extensionHealthLabel(BuildContext context, String status) {
switch (status) {
case 'online':
return 'Online';
return context.l10n.extensionHealthOnline;
case 'degraded':
return 'Degraded';
return context.l10n.extensionHealthDegraded;
case 'offline':
return 'Offline';
return context.l10n.extensionHealthOffline;
default:
return 'Unknown';
return context.l10n.extensionHealthUnknown;
}
}
+16 -14
View File
@@ -221,6 +221,7 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
icon: Icons.folder_outlined,
title: context.l10n.downloadAlbumFolderStructure,
subtitle: _getAlbumFolderStructureLabel(
context,
settings.albumFolderStructure,
),
onTap: () => _showAlbumFolderStructurePicker(
@@ -234,6 +235,7 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
icon: Icons.create_new_folder_outlined,
title: context.l10n.downloadFolderOrganization,
subtitle: _getFolderOrganizationLabel(
context,
settings.folderOrganization,
),
onTap: () => _showFolderOrganizationPicker(
@@ -375,35 +377,35 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
);
}
String _getAlbumFolderStructureLabel(String structure) {
String _getAlbumFolderStructureLabel(BuildContext context, String structure) {
switch (structure) {
case 'album_only':
return 'Albums/Album Name/';
return context.l10n.albumFolderAlbumOnlySubtitle;
case 'artist_year_album':
return 'Albums/Artist/[Year] Album/';
return context.l10n.albumFolderArtistYearAlbumSubtitle;
case 'year_album':
return 'Albums/[Year] Album/';
return context.l10n.albumFolderYearAlbumSubtitle;
case 'artist_album_singles':
return 'Artist/Album/ + Artist/Singles/';
return context.l10n.albumFolderArtistAlbumSinglesSubtitle;
case 'artist_album_flat':
return 'Artist/Album/ + Artist/song.flac';
return context.l10n.albumFolderArtistAlbumFlatSubtitle;
default:
return 'Albums/Artist/Album Name/';
return context.l10n.albumFolderArtistAlbumSubtitle;
}
}
String _getFolderOrganizationLabel(String value) {
String _getFolderOrganizationLabel(BuildContext context, String value) {
switch (value) {
case 'playlist':
return 'By Playlist';
return context.l10n.folderOrganizationByPlaylist;
case 'artist':
return 'By Artist';
return context.l10n.folderOrganizationByArtist;
case 'album':
return 'By Album';
return context.l10n.folderOrganizationByAlbum;
case 'artist_album':
return 'Artist/Album';
return context.l10n.folderOrganizationByArtistAlbum;
default:
return 'None';
return context.l10n.folderOrganizationNone;
}
}
@@ -641,7 +643,7 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
SnackBar(
content: Text(
ctx.l10n.snackbarFolderPickerFailed(
'Could not keep access to the selected folder',
ctx.l10n.errorCouldNotKeepFolderAccess,
),
),
backgroundColor: Theme.of(ctx).colorScheme.error,
@@ -480,7 +480,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Scan cancelled',
context.l10n.libraryScanCancelled,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
fontWeight: FontWeight.w600,
@@ -489,7 +489,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
),
const SizedBox(height: 2),
Text(
'You can retry the scan when ready.',
context.l10n.libraryScanCancelledSubtitle,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onTertiaryContainer
@@ -760,7 +760,7 @@ class _LibraryHeroCard extends StatelessWidget {
),
const SizedBox(width: 8),
Text(
'Scanning...',
context.l10n.libraryScanning,
style: TextStyle(
color: colorScheme.onPrimary,
fontSize: 12,
@@ -801,8 +801,9 @@ class _LibraryHeroCard extends StatelessWidget {
if (!isScanning && excludedDownloadedCount > 0) ...[
const SizedBox(height: 4),
Text(
'$excludedDownloadedCount from Downloads history '
'(excluded from list)',
context.l10n.libraryDownloadsHistoryExcluded(
excludedDownloadedCount,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
+6 -6
View File
@@ -495,9 +495,9 @@ class _LogEntryTile extends StatelessWidget {
color: Colors.teal.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'Go',
style: TextStyle(
child: Text(
context.l10n.actionGo,
style: const TextStyle(
fontSize: 9,
fontWeight: FontWeight.bold,
color: Colors.teal,
@@ -597,7 +597,7 @@ class _LogSummaryCard extends StatelessWidget {
),
const SizedBox(width: 8),
Text(
'Issue Summary',
context.l10n.logIssueSummary,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
@@ -653,7 +653,7 @@ class _LogSummaryCard extends StatelessWidget {
const SizedBox(height: 12),
Text(
'Total errors: ${analysis.errorCount}',
context.l10n.logTotalErrors(analysis.errorCount),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -807,7 +807,7 @@ class _IssueBadge extends StatelessWidget {
if (domains != null && domains!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
'Affected: ${domains!.join(", ")}',
context.l10n.logAffectedDomains(domains!.join(', ')),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontFamily: 'monospace',
@@ -21,6 +21,11 @@ class _LyricsProviderPriorityPageState
'musixmatch',
'apple_music',
'qqmusic',
'spotify',
'deezer',
'youtube',
'kugou',
'genius',
];
late List<String> _enabledProviders;
@@ -211,6 +216,36 @@ class _LyricsProviderPriorityPageState
description: context.l10n.lyricsProviderQqMusicDesc,
icon: Icons.queue_music,
);
case 'spotify':
return _LyricsProviderInfo(
name: 'Spotify',
description: context.l10n.lyricsProviderExtensionDesc,
icon: Icons.graphic_eq,
);
case 'deezer':
return _LyricsProviderInfo(
name: 'Deezer',
description: context.l10n.lyricsProviderExtensionDesc,
icon: Icons.album_outlined,
);
case 'youtube':
return _LyricsProviderInfo(
name: 'YouTube',
description: context.l10n.lyricsProviderExtensionDesc,
icon: Icons.smart_display_outlined,
);
case 'kugou':
return _LyricsProviderInfo(
name: 'Kugou',
description: context.l10n.lyricsProviderExtensionDesc,
icon: Icons.library_music_outlined,
);
case 'genius':
return _LyricsProviderInfo(
name: 'Genius',
description: context.l10n.lyricsProviderExtensionDesc,
icon: Icons.auto_awesome_outlined,
);
default:
return _LyricsProviderInfo(
name: id,
+41 -17
View File
@@ -77,8 +77,7 @@ class LyricsSettingsPage extends ConsumerWidget {
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setEmbedLyrics(value),
showDivider:
settings.embedMetadata && settings.embedLyrics,
showDivider: settings.embedMetadata && settings.embedLyrics,
),
if (settings.embedMetadata && settings.embedLyrics) ...[
SettingsItem(
@@ -88,8 +87,11 @@ class LyricsSettingsPage extends ConsumerWidget {
context,
settings.lyricsMode,
),
onTap: () =>
_showLyricsModePicker(context, ref, settings.lyricsMode),
onTap: () => _showLyricsModePicker(
context,
ref,
settings.lyricsMode,
),
),
SettingsItem(
icon: Icons.source_outlined,
@@ -124,8 +126,12 @@ class LyricsSettingsPage extends ConsumerWidget {
icon: Icons.translate_outlined,
title: context.l10n.downloadNeteaseIncludeTranslation,
subtitle: settings.lyricsIncludeTranslationNetease
? context.l10n.downloadNeteaseIncludeTranslationEnabled
: context.l10n.downloadNeteaseIncludeTranslationDisabled,
? context
.l10n
.downloadNeteaseIncludeTranslationEnabled
: context
.l10n
.downloadNeteaseIncludeTranslationDisabled,
value: settings.lyricsIncludeTranslationNetease,
onChanged: (value) => ref
.read(settingsProvider.notifier)
@@ -157,6 +163,17 @@ class LyricsSettingsPage extends ConsumerWidget {
.read(settingsProvider.notifier)
.setLyricsMultiPersonWordByWord(value),
),
SettingsSwitchItem(
icon: Icons.graphic_eq_outlined,
title: context.l10n.downloadAppleElrcWordSync,
subtitle: settings.lyricsAppleElrcWordSync
? context.l10n.downloadAppleElrcWordSyncEnabled
: context.l10n.downloadAppleElrcWordSyncDisabled,
value: settings.lyricsAppleElrcWordSync,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setLyricsAppleElrcWordSync(value),
),
SettingsItem(
icon: Icons.language_outlined,
title: context.l10n.downloadMusixmatchLanguage,
@@ -199,6 +216,11 @@ class LyricsSettingsPage extends ConsumerWidget {
'musixmatch': 'Musixmatch',
'apple_music': 'Apple Music',
'qqmusic': 'QQ Music',
'spotify': 'Spotify',
'deezer': 'Deezer',
'youtube': 'YouTube',
'kugou': 'Kugou',
'genius': 'Genius',
};
String _getLyricsProvidersSubtitle(
@@ -206,9 +228,7 @@ class LyricsSettingsPage extends ConsumerWidget {
List<String> providers,
) {
if (providers.isEmpty) return context.l10n.downloadProvidersNoneEnabled;
return providers
.map((p) => _providerDisplayNames[p] ?? p)
.join(' > ');
return providers.map((p) => _providerDisplayNames[p] ?? p).join(' > ');
}
void _showLyricsModePicker(
@@ -233,16 +253,18 @@ class LyricsSettingsPage extends ConsumerWidget {
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
context.l10n.lyricsMode,
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
context.l10n.lyricsModeDescription,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: colorScheme.onSurfaceVariant),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
ListTile(
@@ -311,14 +333,16 @@ class LyricsSettingsPage extends ConsumerWidget {
children: [
Text(
context.l10n.downloadMusixmatchLanguage,
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
context.l10n.downloadMusixmatchLanguageDesc,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: colorScheme.onSurfaceVariant),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
TextField(
+11 -7
View File
@@ -106,6 +106,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
}
Future<void> _requestStoragePermission() async {
final permissionAudio = context.l10n.permissionAudio;
final permissionStorage = context.l10n.permissionStorage;
setState(() => _isLoading = true);
try {
if (Platform.isIOS) {
@@ -121,7 +123,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
allGranted = audioStatus.isGranted;
if (audioStatus.isPermanentlyDenied) {
await _showPermissionDeniedDialog('Audio');
await _showPermissionDeniedDialog(permissionAudio);
return;
}
} else if (_androidSdkVersion >= 30) {
@@ -139,7 +141,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
final status = await Permission.storage.request();
allGranted = status.isGranted;
if (status.isPermanentlyDenied) {
await _showPermissionDeniedDialog('Storage');
await _showPermissionDeniedDialog(permissionStorage);
return;
}
}
@@ -184,6 +186,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
}
Future<void> _requestNotificationPermission() async {
final permissionNotification = context.l10n.permissionNotification;
setState(() => _isLoading = true);
try {
if (Platform.isIOS) {
@@ -191,14 +194,14 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
if (status.isGranted || status.isProvisional) {
setState(() => _notificationPermissionGranted = true);
} else if (status.isPermanentlyDenied) {
await _showPermissionDeniedDialog('Notification');
await _showPermissionDeniedDialog(permissionNotification);
}
} else if (_androidSdkVersion >= 33) {
final status = await Permission.notification.request();
if (status.isGranted) {
setState(() => _notificationPermissionGranted = true);
} else if (status.isPermanentlyDenied) {
await _showPermissionDeniedDialog('Notification');
await _showPermissionDeniedDialog(permissionNotification);
}
} else {
setState(() => _notificationPermissionGranted = true);
@@ -392,7 +395,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
validation.errorReason ?? 'Invalid folder selected',
validation.errorReason ??
context.l10n.errorInvalidFolderSelected,
),
backgroundColor: Theme.of(context).colorScheme.error,
duration: const Duration(seconds: 4),
@@ -410,7 +414,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
SnackBar(
content: Text(
context.l10n.snackbarFolderPickerFailed(
'Could not keep access to the selected folder',
context.l10n.errorCouldNotKeepFolderAccess,
),
),
backgroundColor: Theme.of(context).colorScheme.error,
@@ -681,7 +685,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'assets/images/logo-transparant.png',
'assets/images/logo-transparent.png',
width: logoSize,
height: logoSize,
color: colorScheme.primary,
@@ -194,7 +194,7 @@ class _ExtensionDetailsScreenState
textColor: colorScheme.onSecondaryContainer,
),
_Badge(
label: _getCategoryName(ext.category),
label: _getCategoryName(context, ext.category),
color: colorScheme.tertiaryContainer,
textColor: colorScheme.onTertiaryContainer,
),
@@ -390,7 +390,7 @@ class _ExtensionDetailsScreenState
),
_MetadataRow(
label: context.l10n.extensionMinAppVersion,
value: ext.minAppVersion ?? 'Any',
value: ext.minAppVersion ?? context.l10n.storeAnyVersion,
colorScheme: colorScheme,
isLast: true,
),
@@ -496,18 +496,18 @@ class _ExtensionDetailsScreenState
}
}
String _getCategoryName(String category) {
String _getCategoryName(BuildContext context, String category) {
switch (category) {
case 'metadata':
return 'Metadata';
return context.l10n.storeCategoryMetadata;
case 'download':
return 'Download';
return context.l10n.storeCategoryDownload;
case 'utility':
return 'Utility';
return context.l10n.storeCategoryUtility;
case 'lyrics':
return 'Lyrics';
return context.l10n.storeCategoryLyrics;
case 'integration':
return 'Integration';
return context.l10n.storeCategoryIntegration;
default:
return category;
}
+37 -20
View File
@@ -265,11 +265,11 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
case 'track_number':
return l10n.editMetadataFieldTrackNum;
case 'total_tracks':
return 'Track Total';
return l10n.editMetadataFieldTrackTotal;
case 'disc_number':
return l10n.editMetadataFieldDiscNum;
case 'total_discs':
return 'Disc Total';
return l10n.editMetadataFieldDiscTotal;
case 'genre':
return l10n.editMetadataFieldGenre;
case 'isrc':
@@ -281,7 +281,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
case 'copyright':
return l10n.editMetadataFieldCopyright;
case 'composer':
return 'Composer';
return l10n.editMetadataFieldComposer;
case 'cover':
return l10n.editMetadataFieldCover;
default:
@@ -1224,16 +1224,23 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
const SizedBox(height: 6),
_buildCoverEditor(cs),
_buildAutoFillSection(cs),
_field('Title', _titleCtrl),
_field('Artist', _artistCtrl),
_field('Album', _albumCtrl),
_field('Album Artist', _albumArtistCtrl),
_field('Date', _dateCtrl, hint: 'YYYY-MM-DD or YYYY'),
_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(
children: [
Expanded(
child: _field(
'Track #',
context.l10n.editMetadataFieldTrackNum,
_trackNumCtrl,
keyboard: TextInputType.number,
),
@@ -1241,7 +1248,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
const SizedBox(width: 12),
Expanded(
child: _field(
'Track Total',
context.l10n.editMetadataFieldTrackTotal,
_trackTotalCtrl,
keyboard: TextInputType.number,
),
@@ -1253,7 +1260,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
children: [
Expanded(
child: _field(
'Disc #',
context.l10n.editMetadataFieldDiscNum,
_discNumCtrl,
keyboard: TextInputType.number,
),
@@ -1261,15 +1268,15 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
const SizedBox(width: 12),
Expanded(
child: _field(
'Disc Total',
context.l10n.editMetadataFieldDiscTotal,
_discTotalCtrl,
keyboard: TextInputType.number,
),
),
],
),
_field('Genre', _genreCtrl),
_field('ISRC', _isrcCtrl),
_field(context.l10n.editMetadataFieldGenre, _genreCtrl),
_field(context.l10n.editMetadataFieldIsrc, _isrcCtrl),
_field(
context.l10n.trackLyrics,
_lyricsCtrl,
@@ -1295,7 +1302,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
),
const SizedBox(width: 8),
Text(
'Advanced',
context.l10n.editMetadataAdvanced,
style: Theme.of(context).textTheme.labelLarge
?.copyWith(color: cs.onSurfaceVariant),
),
@@ -1305,10 +1312,20 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
),
),
if (_showAdvanced) ...[
_field('Label', _labelCtrl),
_field('Copyright', _copyrightCtrl),
_field('Composer', _composerCtrl),
_field('Comment', _commentCtrl, maxLines: 3),
_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),
],
@@ -1501,7 +1518,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Cover Art',
context.l10n.editMetadataFieldCover,
style: Theme.of(
context,
).textTheme.labelLarge?.copyWith(color: cs.onSurface),
+265 -69
View File
@@ -17,11 +17,13 @@ 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/l10n/l10n.dart';
import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:spotiflac_android/utils/image_cache_utils.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/utils/int_utils.dart';
import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
@@ -365,13 +367,23 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return;
}
final resolvedBitDepth = _readPositiveInt(metadata['bit_depth']);
final resolvedSampleRate = _readPositiveInt(metadata['sample_rate']);
final resolvedDuration = _readPositiveInt(metadata['duration']);
final resolvedBitDepth = readPositiveInt(metadata['bit_depth']);
final resolvedSampleRate = readPositiveInt(metadata['sample_rate']);
final resolvedFormat = _normalizeAudioFormatValue(
metadata['audio_codec']?.toString() ?? metadata['format']?.toString(),
);
final resolvedBitrate = _isBitrateFormatValue(resolvedFormat)
? _readPlausibleBitrateKbps(
metadata['bitrate'] ?? metadata['bit_rate'],
)
: null;
final resolvedDuration = readPositiveInt(metadata['duration']);
final resolvedAlbum = metadata['album']?.toString();
final resolvedQuality = buildDisplayAudioQuality(
final resolvedQuality = _displayQualityForValues(
format: resolvedFormat ?? _storedAudioFormat,
bitDepth: resolvedBitDepth ?? bitDepth,
sampleRate: resolvedSampleRate ?? sampleRate,
bitrateKbps: resolvedBitrate ?? _audioBitrate,
storedQuality: _quality,
);
@@ -386,10 +398,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
// Resolve label/copyright from file when the model doesn't carry them
// (e.g. local library items, or download history items without these fields).
final resolvedTrackNumber = _readPositiveInt(metadata['track_number']);
final resolvedTotalTracks = _readPositiveInt(metadata['total_tracks']);
final resolvedDiscNumber = _readPositiveInt(metadata['disc_number']);
final resolvedTotalDiscs = _readPositiveInt(metadata['total_discs']);
final resolvedTrackNumber = readPositiveInt(metadata['track_number']);
final resolvedTotalTracks = readPositiveInt(metadata['total_tracks']);
final resolvedDiscNumber = readPositiveInt(metadata['disc_number']);
final resolvedTotalDiscs = readPositiveInt(metadata['total_discs']);
final resolvedComposer = metadata['composer']?.toString();
final resolvedLabel = metadata['label']?.toString();
final resolvedCopyright = metadata['copyright']?.toString();
@@ -426,6 +438,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
!_isLocalItem &&
(resolvedBitDepth != null ||
resolvedSampleRate != null ||
resolvedBitrate != null ||
resolvedFormat != null ||
needsTrackNumber ||
needsTotalTracks ||
needsDiscNumber ||
@@ -475,6 +489,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
quality: resolvedQuality,
bitDepth: resolvedBitDepth,
sampleRate: resolvedSampleRate,
bitrate: resolvedBitrate,
format: resolvedFormat,
trackNumber: needsTrackNumber ? resolvedTrackNumber : null,
totalTracks: needsTotalTracks ? resolvedTotalTracks : null,
discNumber: needsDiscNumber ? resolvedDiscNumber : null,
@@ -482,6 +498,23 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
duration: needsDuration ? resolvedDuration : null,
composer: needsComposer ? resolvedComposer : null,
);
if (mounted && _downloadItem != null) {
setState(() {
_currentDownloadItem = _downloadItem!.copyWith(
quality: resolvedQuality,
bitDepth: resolvedBitDepth,
sampleRate: resolvedSampleRate,
bitrate: resolvedBitrate,
format: resolvedFormat,
trackNumber: needsTrackNumber ? resolvedTrackNumber : null,
totalTracks: needsTotalTracks ? resolvedTotalTracks : null,
discNumber: needsDiscNumber ? resolvedDiscNumber : null,
totalDiscs: needsTotalDiscs ? resolvedTotalDiscs : null,
duration: needsDuration ? resolvedDuration : null,
composer: needsComposer ? resolvedComposer : null,
);
});
}
} else if (_isLocalItem && needsDuration) {
await LibraryDatabase.instance.updateAudioMetadata(
_localLibraryItem!.id,
@@ -614,7 +647,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
int? get totalTracks =>
_readPositiveInt(_editedMetadata?['total_tracks']) ??
readPositiveInt(_editedMetadata?['total_tracks']) ??
(_isLocalItem
? _localLibraryItem!.totalTracks
: _downloadItem!.totalTracks);
@@ -631,7 +664,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
int? get totalDiscs =>
_readPositiveInt(_editedMetadata?['total_discs']) ??
readPositiveInt(_editedMetadata?['total_discs']) ??
(_isLocalItem
? _localLibraryItem!.totalDiscs
: _downloadItem!.totalDiscs);
@@ -670,17 +703,20 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_editedMetadata?['composer']?.toString() ??
(_isLocalItem ? _localLibraryItem!.composer : null);
int? get duration =>
_readPositiveInt(_editedMetadata?['duration']) ??
readPositiveInt(_editedMetadata?['duration']) ??
(_isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration);
int? get bitDepth =>
_readPositiveInt(_editedMetadata?['bit_depth']) ??
readPositiveInt(_editedMetadata?['bit_depth']) ??
(_isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth);
int? get sampleRate =>
_readPositiveInt(_editedMetadata?['sample_rate']) ??
readPositiveInt(_editedMetadata?['sample_rate']) ??
(_isLocalItem
? _localLibraryItem!.sampleRate
: _downloadItem!.sampleRate);
int? get _localBitrate => _isLocalItem ? _localLibraryItem!.bitrate : null;
int? get _audioBitrate =>
_isLocalItem ? _localLibraryItem!.bitrate : _downloadItem?.bitrate;
String? get _storedAudioFormat =>
_isLocalItem ? _localLibraryItem?.format : _downloadItem?.format;
String get _filePath =>
_isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath;
@@ -706,15 +742,83 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
String? get _quality => _isLocalItem ? null : _downloadItem!.quality;
int? _readPositiveInt(dynamic value) {
if (value == null) return null;
if (value is num) {
final asInt = value.toInt();
return asInt > 0 ? asInt : null;
String? _normalizeAudioFormatValue(String? value) {
final normalized = normalizeOptionalString(
value,
)?.toLowerCase().replaceAll('-', '_');
return switch (normalized) {
'flac' => 'flac',
'alac' => 'alac',
'aac' || 'mp4a' => 'aac',
'eac3' || 'ec_3' => 'eac3',
'ac3' || 'ac_3' => 'ac3',
'ac4' || 'ac_4' => 'ac4',
'mp3' => 'mp3',
'opus' || 'ogg' => 'opus',
'm4a' || 'mp4' => 'm4a',
_ => null,
};
}
int? _readPlausibleBitrateKbps(dynamic value) {
final parsed = readPositiveInt(value);
if (parsed == null) return null;
final kbps = parsed >= 10000 ? (parsed / 1000).round() : parsed;
return kbps >= 16 ? kbps : null;
}
bool _isBitrateFormatValue(String? value) {
return const {
'aac',
'eac3',
'ac3',
'ac4',
'mp3',
'opus',
'm4a',
}.contains(_normalizeAudioFormatValue(value));
}
String? _usableStoredQuality(String? quality) {
final normalized = normalizeOptionalString(quality);
if (normalized == null || isPlaceholderQualityLabel(normalized)) {
return null;
}
final parsed = int.tryParse(value.toString());
if (parsed == null || parsed <= 0) return null;
return parsed;
final bitrateMatch = RegExp(
r'\b(\d+)\s*kbps\b',
caseSensitive: false,
).firstMatch(normalized);
if (bitrateMatch != null) {
final bitrate = int.tryParse(bitrateMatch.group(1) ?? '');
if (bitrate != null && bitrate < 16) return null;
}
return normalized;
}
String? _displayQualityForValues({
required String? format,
int? bitDepth,
int? sampleRate,
int? bitrateKbps,
String? storedQuality,
}) {
final normalizedFormat = _normalizeAudioFormatValue(format);
final formatLabel = normalizedFormat == null
? normalizeOptionalString(format)?.toUpperCase()
: _formatLabelForRaw(normalizedFormat);
if (_isBitrateFormatValue(normalizedFormat)) {
return buildDisplayAudioQuality(
bitrateKbps: bitrateKbps,
format: formatLabel,
) ??
_usableStoredQuality(storedQuality) ??
formatLabel;
}
return buildDisplayAudioQuality(
bitDepth: bitDepth,
sampleRate: sampleRate,
storedQuality: _usableStoredQuality(storedQuality),
);
}
String _displayServiceTrackId(String value) {
@@ -776,11 +880,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
? fileName.split('.').last.toUpperCase()
: null;
return buildDisplayAudioQuality(
return _displayQualityForValues(
format: _storedAudioFormat ?? fileExt,
bitDepth: bitDepth,
sampleRate: sampleRate,
bitrateKbps: _isLocalItem ? _localBitrate : null,
format: _isLocalItem ? (_localLibraryItem!.format ?? fileExt) : fileExt,
bitrateKbps: _audioBitrate,
storedQuality: _quality,
);
}
@@ -1311,8 +1415,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
color: Colors.white,
),
const SizedBox(width: 4),
const Text(
'Local',
Text(
context.l10n.librarySourceLocal,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
@@ -1405,11 +1509,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (openService == 'deezer') {
buttonLabel = context.l10n.trackOpenInDeezer;
} else if (openService == 'amazon') {
buttonLabel = 'Open in Amazon Music';
buttonLabel = context.l10n.trackOpenInService(
'Amazon Music',
);
} else if (openService == 'tidal') {
buttonLabel = 'Open in Tidal';
buttonLabel = context.l10n.trackOpenInService('Tidal');
} else if (openService == 'qobuz') {
buttonLabel = 'Open in Qobuz';
buttonLabel = context.l10n.trackOpenInService('Qobuz');
} else {
buttonLabel = context.l10n.trackOpenInSpotify;
}
@@ -1514,11 +1620,17 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (trackNumber != null && trackNumber! > 0)
_MetadataItem(context.l10n.trackTrackNumber, trackNumber.toString()),
if (totalTracks != null && totalTracks! > 0)
_MetadataItem('Track Total', totalTracks.toString()),
_MetadataItem(
context.l10n.editMetadataFieldTrackTotal,
totalTracks.toString(),
),
if (discNumber != null && discNumber! > 0)
_MetadataItem(context.l10n.trackDiscNumber, discNumber.toString()),
if (totalDiscs != null && totalDiscs! > 0)
_MetadataItem('Disc Total', totalDiscs.toString()),
_MetadataItem(
context.l10n.editMetadataFieldDiscTotal,
totalDiscs.toString(),
),
if (duration != null)
_MetadataItem(context.l10n.trackDuration, _formatDuration(duration!)),
if (audioQualityStr != null)
@@ -1532,7 +1644,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (copyright != null && copyright!.isNotEmpty)
_MetadataItem(context.l10n.trackCopyright, copyright!),
if (composer != null && composer!.isNotEmpty)
_MetadataItem('Composer', composer!),
_MetadataItem(context.l10n.editMetadataFieldComposer, composer!),
if (isrc != null && isrc!.isNotEmpty) _MetadataItem('ISRC', isrc!),
];
@@ -1621,6 +1733,44 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return '$minutes:${secs.toString().padLeft(2, '0')}';
}
String _formatLabelForRaw(String raw) {
final normalized = raw.toLowerCase().replaceAll('-', '_');
return switch (normalized) {
'flac' => 'FLAC',
'alac' => 'ALAC',
'eac3' || 'ec_3' => 'EAC3',
'ac3' || 'ac_3' => 'AC3',
'ac4' || 'ac_4' => 'AC4',
'aac' || 'mp4a' => 'AAC',
'm4a' || 'mp4' => 'M4A',
'mp3' => 'MP3',
'opus' => 'Opus',
'ogg' => 'OGG',
_ => raw.toUpperCase(),
};
}
String _displayFormatLabelForFile(String fileName) {
final storedFormat = normalizeOptionalString(_storedAudioFormat);
final raw =
storedFormat ??
(fileName.contains('.') ? fileName.split('.').last : 'Unknown');
return _formatLabelForRaw(raw);
}
bool _isBitrateFormatLabel(String label) {
return const {
'MP3',
'OPUS',
'OGG',
'M4A',
'AAC',
'EAC3',
'AC3',
'AC4',
}.contains(label.toUpperCase());
}
Widget _buildFileInfoCard(
BuildContext context,
ColorScheme colorScheme,
@@ -1629,9 +1779,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
) {
final displayFilePath = _formatPathForDisplay(rawFilePath);
final fileName = _extractFileNameFromPathOrUri(rawFilePath);
final fileExtension = fileName.contains('.')
? fileName.split('.').last.toUpperCase()
: 'Unknown';
final fileExtension = _displayFormatLabelForFile(fileName);
final resolvedQuality = _displayAudioQuality;
final lossyBitrateLabel = _extractLossyBitrateLabel(resolvedQuality);
@@ -1704,9 +1852,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
),
),
if ((fileExtension == 'MP3' ||
fileExtension == 'OPUS' ||
fileExtension == 'OGG') &&
if (_isBitrateFormatLabel(fileExtension) &&
lossyBitrateLabel != null)
Container(
padding: const EdgeInsets.symmetric(
@@ -1726,12 +1872,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
),
)
else if (_isLocalItem &&
_localBitrate != null &&
_localBitrate! > 0 &&
(fileExtension == 'MP3' ||
fileExtension == 'OPUS' ||
fileExtension == 'OGG'))
else if (_audioBitrate != null &&
_audioBitrate! > 0 &&
_isBitrateFormatLabel(fileExtension))
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
@@ -1742,7 +1885,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
borderRadius: BorderRadius.circular(20),
),
child: Text(
'${_localBitrate}kbps',
'${_audioBitrate}kbps',
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w600,
@@ -1885,7 +2028,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'Source: ${_lyricsSource!}',
context.l10n.trackLyricsSource(_lyricsSource!),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -2085,7 +2228,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_rawLyrics = embeddedLyrics;
_lyricsSource = embeddedSource.isNotEmpty
? embeddedSource
: 'Embedded';
: context.l10n.trackLyricsEmbeddedSource;
_lyricsEmbedded = true;
_lyricsLoading = false;
_embeddedLyricsChecked = true;
@@ -2216,7 +2359,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_rawLyrics = embeddedLyrics;
_lyricsSource = embeddedSource.isNotEmpty
? embeddedSource
: 'Embedded';
: context.l10n.trackLyricsEmbeddedSource;
_lyricsEmbedded = true;
_lyricsLoading = false;
_embeddedLyricsChecked = true;
@@ -3233,6 +3376,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final lower = cleanFilePath.toLowerCase();
return lower.endsWith('.flac') ||
lower.endsWith('.m4a') ||
lower.endsWith('.aac') ||
lower.endsWith('.mp3') ||
lower.endsWith('.opus') ||
lower.endsWith('.ogg');
@@ -3259,9 +3403,31 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return 'CUE+$audioFmt';
}
}
if (_isLocalItem && _localLibraryItem != null) {
final format = normalizeOptionalString(
_localLibraryItem!.format,
)?.toLowerCase().replaceAll('-', '_');
switch (format) {
case 'flac':
return 'FLAC';
case 'alac':
return 'ALAC';
case 'm4a':
return 'M4A';
case 'aac':
case 'mp4a':
return 'AAC';
case 'mp3':
return 'MP3';
case 'opus':
case 'ogg':
return 'Opus';
}
}
final lower = cleanFilePath.toLowerCase();
if (lower.endsWith('.flac')) return 'FLAC';
if (lower.endsWith('.m4a')) return 'M4A';
if (lower.endsWith('.aac')) return 'AAC';
if (lower.endsWith('.mp3')) return 'MP3';
if (lower.endsWith('.opus') || lower.endsWith('.ogg')) return 'Opus';
if (lower.endsWith('.cue')) return 'CUE';
@@ -3394,19 +3560,29 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final formats = <String>[];
if (currentFormat == 'FLAC') {
formats.addAll(['ALAC', 'MP3', 'Opus']);
formats.addAll(['ALAC', 'AAC', 'MP3', 'Opus']);
} else if (currentFormat == 'ALAC') {
formats.addAll(['FLAC', 'AAC', 'MP3', 'Opus']);
} else if (currentFormat == 'M4A') {
formats.addAll(['FLAC', 'MP3', 'Opus']);
} else if (currentFormat == 'MP3') {
formats.add('Opus');
} else if (currentFormat == 'Opus') {
formats.add('MP3');
} else {
formats.addAll(['ALAC', 'FLAC', 'AAC', 'MP3', 'Opus']);
} else if (currentFormat == 'AAC') {
formats.addAll(['MP3', 'Opus']);
} else if (currentFormat == 'MP3') {
formats.addAll(['AAC', 'Opus']);
} else if (currentFormat == 'Opus') {
formats.addAll(['AAC', 'MP3']);
} else {
formats.addAll(['AAC', 'MP3', 'Opus']);
}
String selectedFormat = formats.first;
String selectedBitrate = selectedFormat == 'Opus' ? '128k' : '320k';
String defaultBitrateForFormat(String format) {
if (format == 'Opus') return '128k';
if (format == 'AAC') return '256k';
return '320k';
}
String selectedBitrate = defaultBitrateForFormat(selectedFormat);
bool isLosslessTarget =
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
@@ -3471,9 +3647,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
if (!isLosslessTarget) {
selectedBitrate = format == 'Opus'
? '128k'
: '320k';
selectedBitrate = defaultBitrateForFormat(
format,
);
}
});
}
@@ -3566,6 +3742,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
void _showCueSplitSheet(BuildContext context) async {
var cuePath = cleanFilePath;
final unknownAlbum = context.l10n.unknownAlbum;
final unknownArtist = context.l10n.unknownArtist;
final trackSuffix = RegExp(r'#track\d+$');
if (trackSuffix.hasMatch(cuePath)) {
cuePath = cuePath.replaceFirst(trackSuffix, '');
@@ -3586,8 +3764,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return;
}
final album = cueInfo['album'] as String? ?? 'Unknown Album';
final artist = cueInfo['artist'] as String? ?? 'Unknown Artist';
final album = cueInfo['album'] as String? ?? unknownAlbum;
final artist = cueInfo['artist'] as String? ?? unknownArtist;
final audioPath = cueInfo['audio_path'] as String? ?? '';
final genre = cueInfo['genre'] as String? ?? '';
final date = cueInfo['date'] as String? ?? '';
@@ -4236,6 +4414,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
newExt = '.opus';
mimeType = 'audio/opus';
break;
case 'aac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
case 'alac':
newExt = '.m4a';
mimeType = 'audio/mp4';
@@ -4277,11 +4459,15 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return;
}
final deletedOriginal = await PlatformBridge.safDelete(
cleanFilePath,
).catchError((_) => false);
if (deletedOriginal != true) {
_log.w('Converted SAF file created but failed deleting original URI');
if (!isSameContentUri(cleanFilePath, safUri)) {
final deletedOriginal = await PlatformBridge.safDelete(
cleanFilePath,
).catchError((_) => false);
if (deletedOriginal != true) {
_log.w(
'Converted SAF file created but failed deleting original URI',
);
}
}
if (!_isLocalItem) {
@@ -4290,6 +4476,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
safUri,
newSafFileName: newFileName,
newQuality: newQuality,
newFormat: normalizedConvertedAudioFormat(targetFormat),
newBitrate: convertedAudioBitrateKbps(
targetFormat: targetFormat,
bitrate: bitrate,
),
clearAudioSpecs: true,
);
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
@@ -4317,6 +4508,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_downloadItem!.id,
newPath,
newQuality: newQuality,
newFormat: normalizedConvertedAudioFormat(targetFormat),
newBitrate: convertedAudioBitrateKbps(
targetFormat: targetFormat,
bitrate: bitrate,
),
clearAudioSpecs: true,
);
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
@@ -4394,7 +4590,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
};
final initialDurationSeconds =
_readPositiveInt(fileMetadata?['duration']) ?? duration ?? 0;
readPositiveInt(fileMetadata?['duration']) ?? duration ?? 0;
if (!context.mounted) return;
@@ -4433,7 +4629,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (refreshedLyrics.isNotEmpty) {
_lyrics = _cleanLrcForDisplay(refreshedLyrics);
_rawLyrics = refreshedLyrics;
_lyricsSource = 'Embedded';
_lyricsSource = context.l10n.trackLyricsEmbeddedSource;
_lyricsEmbedded = true;
} else {
_lyrics = null;

Some files were not shown because too many files have changed in this diff Show More