Merge dev into main

This commit is contained in:
zarzet
2026-01-18 03:21:02 +07:00
30 changed files with 1487 additions and 905 deletions
-1
View File
@@ -1 +0,0 @@
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
+2
View File
@@ -6,6 +6,8 @@ Thumbs.db
.idea/
.vscode/
*.iml
.cursorignore
.cursorrules
# Kiro specs (development only)
.kiro/
+64 -14
View File
@@ -1,6 +1,56 @@
# Changelog
## [3.1.0] - 2026-01-19
## [3.1.1] - 2026-01-17
### Added
- **Lyrics Caching**: Lyrics are now cached for 24 hours to reduce API calls and improve performance
- Thread-safe cache with automatic expiration
- Cache key based on artist, track, and duration
- Log indicator shows "(cached)" when lyrics are served from cache
- **Lyrics Duration Matching**: Improved lyrics accuracy with duration-based matching
- Compares track duration with lrclib.net results
- 10-second tolerance to handle version differences (radio edit, remaster, etc.)
- Prioritizes synced lyrics over plain text when duration matches
- Falls back gracefully if no duration match found
- **Deezer Cover Art Upgrade**: Cover art from Deezer CDN now automatically upgraded to maximum quality
- Detects Deezer CDN URLs (`cdn-images.dzcdn.net`)
- Upgrades cover resolution to 1800x1800 (max available)
- Works alongside existing cover upgrade
- **Live Search for Extensions**: Search-as-you-type functionality for extension search
- 800ms debounce delay to prevent excessive API calls
- Minimum 3 characters required before searching
- Concurrency control to prevent race conditions in extension runtime
- Queues pending searches if a search is already in progress
- **Russian Language Support**: Added Russian (Русский) translation - 99% complete
- Translated via Crowdin community contributions
- Covers all UI elements, settings, and error messages
### Fixed
- **ISRC Index Race Condition**: Fixed repeated index rebuilding during parallel downloads
- Added per-directory build lock using `sync.Map` and `sync.Mutex`
- Double-check locking pattern ensures index is built only once
- Significantly improves performance during CSV import with many tracks
- **Queue Tab Scroll Exception**: Fixed Flutter rendering exception with NestedScrollView
- Disabled Material 3 stretch overscroll indicator that caused `_StretchController` assertion
- Wrapped NestedScrollView with ScrollConfiguration to prevent `setState during build` errors
- Issue was especially noticeable during rapid queue updates (CSV import)
- **CSV Import**: Fixed CSV export not being parsed correctly
- Added support for `Artist Name(s)` header (with parentheses)
- Added support for `Track URI` header for track IDs
- Added `artists` and `track_id` as alternative header names
- Now correctly parses "Liked Songs" and playlist exports
---
## [3.1.0] - 2026-01-16
### Added
@@ -105,17 +155,17 @@
- YT Music extension `getArtist()` now returns `top_tracks` array with up to 10 popular songs
- Go backend `GetArtistWithExtensionJSON` now forwards `top_tracks`, `header_image`, and `listeners` to Flutter
- `ExtensionArtistScreen` now parses and passes top tracks to `ArtistScreen`
- `ArtistScreen` with `extensionId` skips Spotify/Deezer fetch, uses extension data only (fixes "Rate Limited" errors)
- `ArtistScreen` with `extensionId` skips metadata fetch, uses extension data only (fixes "Rate Limited" errors)
- **Search Bar Unfocus**: Fixed search bar not unfocusing when tapping outside - now properly dismisses keyboard and unfocus when tapping anywhere outside the search field
- **Keyboard Appearing on Settings Navigation**: Fixed keyboard randomly appearing when returning from Settings sub-pages (e.g., Appearance) - now uses `FocusManager.instance.primaryFocus?.unfocus()` for more aggressive unfocus
- **Recent Access Artist Navigation**: Fixed opening artist from recent access using wrong screen - now correctly uses `ExtensionArtistScreen` for extension artists (YT Music, Spotify Web) instead of trying to fetch from Spotify API
- **Recent Access Artist Navigation**: Fixed opening artist from recent access using wrong screen - now correctly uses `ExtensionArtistScreen` for extension artists (YT Music, etc.) instead of trying to fetch from API
### Extensions
- **YouTube Music Extension**: Updated to v1.5.0
- `getArtist()` now returns `top_tracks` array with popular songs
- Added `header_image` and `listeners` to artist response
- **Spotify Web Extension**: Updated to v1.6.0
- **Web Extension**: Updated to v1.6.0
### Localization
@@ -148,12 +198,12 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
- One-tap install, update, and uninstall
- Offline cache for browsing without internet
#### Spotify Web Extension
#### Web Extension
- Available in Extension Store - install and enable in Settings > Extensions
- Metadata provider using Spotify's internal web player API
- Metadata provider using web player API
- Download tracks from Daily Mix, Discover Weekly, and other personalized playlists
- Useful when official Spotify API is rate-limited or unavailable
- Useful when official API is rate-limited or unavailable
#### Extension Capabilities
@@ -188,7 +238,7 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
- **Separate Singles Folder**: Organize downloads into Albums/ and Singles/ folders
- Based on `album_type` from Spotify/Deezer metadata
- Based on `album_type` from metadata
- Toggle in Settings > Download > Separate Singles Folder
- **Year in Album Folder Name**: New album folder structure options with release year
@@ -226,7 +276,7 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
- **Fixed Keyboard Appearing on Tab Switch**: Keyboard now auto-dismisses when swiping between tabs
- **Removed Search Source Badges**: Removed "Free" and "API Key" labels from Deezer/Spotify selector in Options
- **Removed Search Source Badges**: Removed "Free" and "API Key" labels from provider selector in Options
- **Back Gesture Freeze on Android 13+**: Fixed app freeze when using back gesture in settings
@@ -261,7 +311,7 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
- **Duplicate History Entries**: Fixed duplicate entries when re-downloading same track
- Detects existing entries by Spotify ID, Deezer ID, or ISRC
- Detects existing entries by track ID, Deezer ID, or ISRC
- **Permission Error Message**: Fixed download showing "Song not found" when actually permission error
@@ -330,7 +380,7 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
- **Extension Disabled Search Fallback**: Fixed error when extension is disabled but still called
- `_performSearch` now checks if extension is still enabled before calling custom search
- Automatically falls back to Deezer/Spotify search if extension was disabled
- Automatically falls back to Deezer search if extension was disabled
- Clears `searchProvider` setting if extension no longer available
- **Store Tab Unmount Crash**: Fixed "Using ref when widget is unmounted" error
@@ -450,7 +500,7 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
### Extensions
- **Spotify Web Extension** (example): New extension for Spotify metadata via web API
- **Web Extension** (example): New extension for metadata via web API
- Supports personalized playlists (Daily Mix, Discover Weekly, Release Radar, etc.)
- Search, album, playlist, track, and artist fetching
- Available in Extension Store (3.0.0-alpha.4)
@@ -462,7 +512,7 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
### Added
- **Separate Singles Folder**: Option to organize downloads into Albums/ and Singles/ folders
- Based on `album_type` from Spotify/Deezer metadata
- Based on `album_type` from metadata
- Toggle in Settings > Download > Separate Singles Folder
- Singles saved to `{output}/Singles/`, albums to `{output}/Albums/`
- **Browser-like Polyfills**: New global APIs for easier library porting
@@ -482,7 +532,7 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
### Fixed
- **Duplicate History Entries**: Fixed duplicate entries when re-downloading same track
- Detects existing entries by Spotify ID, Deezer ID, or ISRC
- Detects existing entries by track ID, Deezer ID, or ISRC
- Replaces existing entry and moves to top of list
- Auto-deduplicates existing history on app load
- **Extension Search Fallback**: Fixed error when extension is disabled but still called for search
+9 -10
View File
@@ -6,7 +6,7 @@
<img src="icon.png" width="128" />
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no account required.
![Android](https://img.shields.io/badge/Android-7.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white)
![iOS](https://img.shields.io/badge/iOS-14.0%2B-000000?style=for-the-badge&logo=apple&logoColor=white)
@@ -26,12 +26,12 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
## Search Source
SpotiFLAC supports two search sources:
SpotiFLAC supports multiple search sources for finding music metadata:
| Source | Setup |
|--------|-------|
| **Deezer** (Default) | No setup required |
| **Spotify** | Install **Spotify Web** extension from the Store, or use your own [Spotify Developer](https://developer.spotify.com) Client ID & Secret in Settings |
| **Extensions** | Install additional search providers from the Store |
## Extensions
@@ -50,7 +50,7 @@ Want to create your own extension? Check out the [Extension Development Guide](h
## Other project
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
## FAQ
@@ -60,15 +60,12 @@ A: The track may not be available on Tidal, Qobuz, or Amazon Music. Try enabling
**Q: Why are some tracks downloading in lower quality?**
A: Quality depends on what's available from the streaming service. Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Amazon up to 24-bit/48kHz. The app automatically selects the best available quality.
**Q: Can I download my Spotify playlists?**
A: Yes! Just paste the Spotify playlist URL in the search bar. The app will fetch all tracks and queue them for download.
**Q: Can I download playlists?**
A: Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
**Q: Why do I need to grant storage permission?**
A: The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant "All files access" in Settings > Apps > SpotiFLAC > Permissions.
**Q: How do I download Daily Mix or Discover Weekly?**
A: Install the **Spotify Web** extension from the Store. This extension can access personalized playlists that aren't available through the public API.
**Q: Why is the mobile app so large (~50MB) compared to the PC version (~3MB)?**
A: The mobile app includes FFmpeg libraries for audio processing and format conversion, which adds significant size. The PC version relies on system-installed FFmpeg, keeping the download smaller. We bundle FFmpeg to ensure compatibility across all Android devices without requiring users to install additional software.
@@ -81,7 +78,9 @@ A: Yes, the app is open source and you can verify the code yourself. Each releas
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Tidal, Qobuz, Amazon Music, Deezer, or any other streaming service.
The application is purely a user interface that facilitates communication between your device and existing third-party services.
You are solely responsible for:
1. Ensuring your use of this software complies with your local laws.
@@ -158,8 +158,9 @@ class MainActivity: FlutterActivity() {
val spotifyId = call.argument<String>("spotify_id") ?: ""
val trackName = call.argument<String>("track_name") ?: ""
val artistName = call.argument<String>("artist_name") ?: ""
val durationMs = call.argument<Int>("duration_ms")?.toLong() ?: 0L
val response = withContext(Dispatchers.IO) {
Gobackend.fetchLyrics(spotifyId, trackName, artistName)
Gobackend.fetchLyrics(spotifyId, trackName, artistName, durationMs)
}
result.success(response)
}
@@ -168,8 +169,9 @@ class MainActivity: FlutterActivity() {
val trackName = call.argument<String>("track_name") ?: ""
val artistName = call.argument<String>("artist_name") ?: ""
val filePath = call.argument<String>("file_path") ?: ""
val durationMs = call.argument<Int>("duration_ms")?.toLong() ?: 0L
val response = withContext(Dispatchers.IO) {
Gobackend.getLyricsLRC(spotifyId, trackName, artistName, filePath)
Gobackend.getLyricsLRC(spotifyId, trackName, artistName, filePath, durationMs)
}
result.success(response)
}
+17 -1
View File
@@ -1,3 +1,19 @@
files:
- source: /lib/l10n/arb/app_en.arb
translation: /lib/l10n/arb/app_%locale_with_underscore%.arb
translation: /lib/l10n/arb/app_%locale%.arb
languages_mapping:
locale:
# Short codes for single-variant languages
de: de
es: es
fr: fr
hi: hi
id: id
ja: ja
ko: ko
nl: nl
pt: pt
ru: ru
# Full codes for Chinese variants
zh-CN: zh_CN
zh-TW: zh_TW
+2 -1
View File
@@ -1,8 +1,8 @@
package gobackend
import (
"context"
"bufio"
"context"
"encoding/base64"
"encoding/json"
"errors"
@@ -512,6 +512,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
req.TrackName,
req.ArtistName,
req.EmbedLyrics,
int64(req.DurationMS),
)
}()
+32 -7
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"io"
"net/http"
"regexp"
"strings"
)
@@ -14,6 +15,9 @@ const (
spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000)
)
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
// convertSmallToMedium upgrades 300x300 cover URL to 640x640
// Same logic as PC version for consistency
func convertSmallToMedium(imageURL string) string {
@@ -41,9 +45,10 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
maxURL := upgradeToMaxQuality(downloadURL)
if maxURL != downloadURL {
downloadURL = maxURL
GoLog("[Cover] Upgraded to max resolution (~2000x2000)")
} else {
GoLog("[Cover] Max resolution not available, using 640x640")
// Log already printed by upgradeToMaxQuality for Deezer
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
}
}
}
@@ -85,18 +90,38 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
return data, nil
}
// upgradeToMaxQuality upgrades Spotify cover URL to maximum quality
// Same logic as PC version - directly replaces 640x640 size code with max resolution
// No HEAD verification needed - Spotify CDN always serves max resolution if available
// upgradeToMaxQuality upgrades cover URL to maximum quality
// Supports both Spotify and Deezer CDNs
func upgradeToMaxQuality(coverURL string) string {
// Spotify CDN upgrade
if strings.Contains(coverURL, spotifySize640) {
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
}
// Deezer CDN upgrade
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
return upgradeDeezerCover(coverURL)
}
return coverURL
}
// upgradeDeezerCover upgrades Deezer cover URL to maximum quality (1800x1800)
// Deezer CDN format: https://cdn-images.dzcdn.net/images/cover/{hash}/{size}x{size}-000000-80-0-0.jpg
// Available sizes: 56, 250, 500, 1000, 1400, 1800
func upgradeDeezerCover(coverURL string) string {
if !strings.Contains(coverURL, "cdn-images.dzcdn.net") {
return coverURL
}
// Replace any size pattern with 1800x1800
upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg")
if upgraded != coverURL {
GoLog("[Cover] Deezer: upgraded to 1800x1800")
}
return upgraded
}
// GetCoverFromSpotify gets cover URL from Spotify metadata
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
if imageURL == "" {
+19
View File
@@ -21,11 +21,14 @@ type ISRCIndex struct {
var (
isrcIndexCache = make(map[string]*ISRCIndex)
isrcIndexCacheMu sync.RWMutex
isrcBuildingMu sync.Map // Per-directory build lock to prevent concurrent builds
isrcIndexTTL = 5 * time.Minute
)
// GetISRCIndex returns or builds an ISRC index for the given directory
// Uses per-directory mutex to prevent concurrent builds (race condition fix)
func GetISRCIndex(outputDir string) *ISRCIndex {
// Fast path: check cache first
isrcIndexCacheMu.RLock()
idx, exists := isrcIndexCache[outputDir]
isrcIndexCacheMu.RUnlock()
@@ -34,6 +37,22 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
return idx
}
// Slow path: need to build index
// Use per-directory mutex to prevent multiple goroutines from building simultaneously
buildLock, _ := isrcBuildingMu.LoadOrStore(outputDir, &sync.Mutex{})
mu := buildLock.(*sync.Mutex)
mu.Lock()
defer mu.Unlock()
// Double-check cache after acquiring lock (another goroutine may have built it)
isrcIndexCacheMu.RLock()
idx, exists = isrcIndexCache[outputDir]
isrcIndexCacheMu.RUnlock()
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
return idx
}
return buildISRCIndex(outputDir)
}
+8 -4
View File
@@ -649,9 +649,11 @@ func SanitizeFilename(filename string) string {
// FetchLyrics fetches lyrics for a track from LRCLIB
// Returns JSON with lyrics data
func FetchLyrics(spotifyID, trackName, artistName string) (string, error) {
// durationMs: track duration in milliseconds for matching, use 0 to skip duration matching
func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (string, error) {
client := NewLyricsClient()
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
durationSec := float64(durationMs) / 1000.0
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
if err != nil {
return "", err
}
@@ -673,7 +675,8 @@ func FetchLyrics(spotifyID, trackName, artistName string) (string, error) {
// GetLyricsLRC fetches lyrics and converts to LRC format string with metadata headers
// First tries to extract from file, then falls back to fetching from internet
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (string, error) {
// durationMs: track duration in milliseconds for matching, use 0 to skip duration matching
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
if filePath != "" {
lyrics, err := ExtractLyrics(filePath)
if err == nil && lyrics != "" {
@@ -682,7 +685,8 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (str
}
client := NewLyricsClient()
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
durationSec := float64(durationMs) / 1000.0
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
if err != nil {
return "", err
}
+155 -5
View File
@@ -3,14 +3,100 @@ package gobackend
import (
"encoding/json"
"fmt"
"math"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
// ========================================
// Lyrics Cache with TTL
// ========================================
const (
lyricsCacheTTL = 24 * time.Hour // Cache lyrics for 24 hours
durationToleranceSec = 10.0 // Duration matching tolerance in seconds
)
type lyricsCacheEntry struct {
response *LyricsResponse
expiresAt time.Time
}
type lyricsCache struct {
mu sync.RWMutex
cache map[string]*lyricsCacheEntry
}
var globalLyricsCache = &lyricsCache{
cache: make(map[string]*lyricsCacheEntry),
}
func (c *lyricsCache) generateKey(artist, track string, durationSec float64) string {
// Normalize key: lowercase, trim spaces
normalizedArtist := strings.ToLower(strings.TrimSpace(artist))
normalizedTrack := strings.ToLower(strings.TrimSpace(track))
// Round duration to nearest 10 seconds for cache key
roundedDuration := math.Round(durationSec/10) * 10
return fmt.Sprintf("%s|%s|%.0f", normalizedArtist, normalizedTrack, roundedDuration)
}
func (c *lyricsCache) Get(artist, track string, durationSec float64) (*LyricsResponse, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
key := c.generateKey(artist, track, durationSec)
entry, exists := c.cache[key]
if !exists {
return nil, false
}
// Check if expired
if time.Now().After(entry.expiresAt) {
return nil, false
}
return entry.response, true
}
func (c *lyricsCache) Set(artist, track string, durationSec float64, response *LyricsResponse) {
c.mu.Lock()
defer c.mu.Unlock()
key := c.generateKey(artist, track, durationSec)
c.cache[key] = &lyricsCacheEntry{
response: response,
expiresAt: time.Now().Add(lyricsCacheTTL),
}
}
// CleanExpired removes expired entries from cache
func (c *lyricsCache) CleanExpired() int {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
cleaned := 0
for key, entry := range c.cache {
if now.After(entry.expiresAt) {
delete(c.cache, key)
cleaned++
}
}
return cleaned
}
// Size returns current cache size
func (c *lyricsCache) Size() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.cache)
}
type LRCLibResponse struct {
ID int `json:"id"`
Name string `json:"name"`
@@ -86,7 +172,9 @@ func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsRes
return c.parseLRCLibResponse(&lrcResp), nil
}
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsResponse, error) {
// FetchLyricsFromLRCLibSearch searches lyrics with optional duration matching
// durationSec: track duration in seconds, use 0 to skip duration matching
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec float64) (*LyricsResponse, error) {
baseURL := "https://lrclib.net/api/search"
params := url.Values{}
params.Set("q", query)
@@ -118,6 +206,13 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsRespons
return nil, fmt.Errorf("no lyrics found")
}
// Filter and score results based on duration matching and synced lyrics
bestMatch := c.findBestMatch(results, durationSec)
if bestMatch != nil {
return c.parseLRCLibResponse(bestMatch), nil
}
// Fallback: return first result with synced lyrics
for _, result := range results {
if result.SyncedLyrics != "" {
return c.parseLRCLibResponse(&result), nil
@@ -127,34 +222,89 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsRespons
return c.parseLRCLibResponse(&results[0]), nil
}
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string) (*LyricsResponse, error) {
lyrics, err := c.FetchLyricsWithMetadata(artistName, trackName)
// findBestMatch finds the best matching lyrics based on duration and sync status
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
var bestSynced *LRCLibResponse
var bestPlain *LRCLibResponse
for i := range results {
result := &results[i]
// Check duration match if target duration is provided
durationMatches := targetDurationSec == 0 || c.durationMatches(result.Duration, targetDurationSec)
if durationMatches {
// Prefer synced lyrics over plain
if result.SyncedLyrics != "" && bestSynced == nil {
bestSynced = result
} else if result.PlainLyrics != "" && bestPlain == nil {
bestPlain = result
}
}
}
// Return synced first, then plain
if bestSynced != nil {
return bestSynced
}
return bestPlain
}
// durationMatches checks if two durations are within tolerance
func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool {
diff := math.Abs(lrcDuration - targetDuration)
return diff <= durationToleranceSec
}
// FetchLyricsAllSources fetches lyrics from multiple sources with caching and duration matching
// durationSec: track duration in seconds for matching, use 0 to skip duration matching
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
// Check cache first
if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
cachedCopy := *cached
cachedCopy.Source = cached.Source + " (cached)"
return &cachedCopy, nil
}
var lyrics *LyricsResponse
var err error
// Try exact match first
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
lyrics.Source = "LRCLIB"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
// Try with simplified track name
simplifiedTrack := simplifyTrackName(trackName)
if simplifiedTrack != trackName {
lyrics, err = c.FetchLyricsWithMetadata(artistName, simplifiedTrack)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
lyrics.Source = "LRCLIB (simplified)"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
}
// Search with duration matching
query := artistName + " " + trackName
lyrics, err = c.FetchLyricsFromLRCLibSearch(query)
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
lyrics.Source = "LRCLIB Search"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
// Search with simplified name and duration matching
if simplifiedTrack != trackName {
query = artistName + " " + simplifiedTrack
lyrics, err = c.FetchLyricsFromLRCLibSearch(query)
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
lyrics.Source = "LRCLIB Search (simplified)"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
}
+4 -1
View File
@@ -124,6 +124,7 @@ type ParallelDownloadResult struct {
// FetchCoverAndLyricsParallel downloads cover and fetches lyrics in parallel
// This runs while the main audio download is happening
// durationMs: track duration in milliseconds for lyrics matching
func FetchCoverAndLyricsParallel(
coverURL string,
maxQualityCover bool,
@@ -131,6 +132,7 @@ func FetchCoverAndLyricsParallel(
trackName string,
artistName string,
embedLyrics bool,
durationMs int64,
) *ParallelDownloadResult {
result := &ParallelDownloadResult{}
var wg sync.WaitGroup
@@ -158,7 +160,8 @@ func FetchCoverAndLyricsParallel(
defer wg.Done()
fmt.Println("[Parallel] Starting lyrics fetch...")
client := NewLyricsClient()
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
durationSec := float64(durationMs) / 1000.0
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
if err != nil {
result.LyricsErr = err
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
+2 -1
View File
@@ -1,8 +1,8 @@
package gobackend
import (
"context"
"bufio"
"context"
"encoding/base64"
"encoding/json"
"errors"
@@ -1085,6 +1085,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
req.TrackName,
req.ArtistName,
req.EmbedLyrics,
int64(req.DurationMS),
)
}()
+2 -1
View File
@@ -1,8 +1,8 @@
package gobackend
import (
"context"
"bufio"
"context"
"encoding/base64"
"encoding/json"
"encoding/xml"
@@ -1666,6 +1666,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
req.TrackName,
req.ArtistName,
req.EmbedLyrics,
int64(req.DurationMS),
)
}()
+4 -2
View File
@@ -161,7 +161,8 @@ import Gobackend // Import Go framework
let spotifyId = args["spotify_id"] as! String
let trackName = args["track_name"] as! String
let artistName = args["artist_name"] as! String
let response = GobackendFetchLyrics(spotifyId, trackName, artistName, &error)
let durationMs = args["duration_ms"] as? Int64 ?? 0
let response = GobackendFetchLyrics(spotifyId, trackName, artistName, durationMs, &error)
if let error = error { throw error }
return response
@@ -171,7 +172,8 @@ import Gobackend // Import Go framework
let trackName = args["track_name"] as! String
let artistName = args["artist_name"] as! String
let filePath = args["file_path"] as? String ?? ""
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, &error)
let durationMs = args["duration_ms"] as? Int64 ?? 0
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, durationMs, &error)
if let error = error { throw error }
return response
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '3.1.0';
static const String buildNumber = '59';
static const String version = '3.1.1';
static const String buildNumber = '60';
static const String fullVersion = '$version+$buildNumber';
+106 -106
View File
@@ -1,23 +1,23 @@
{
"@@locale": "de",
"@@last_modified": "2026-01-16",
"@@last_modified": "2026-01-17",
"appName": "SpotiFLAC",
"@appName": {
"description": "App name - DO NOT TRANSLATE"
},
"appDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"appDescription": "Laden Sie Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.",
"@appDescription": {
"description": "App description shown in about page"
},
"navHome": "Home",
"navHome": "Startseite",
"@navHome": {
"description": "Bottom navigation - Home tab"
},
"navHistory": "History",
"navHistory": "Verlauf",
"@navHistory": {
"description": "Bottom navigation - History tab"
},
"navSettings": "Settings",
"navSettings": "Einstellungen",
"@navSettings": {
"description": "Bottom navigation - Settings tab"
},
@@ -25,15 +25,15 @@
"@navStore": {
"description": "Bottom navigation - Extension store tab"
},
"homeTitle": "Home",
"homeTitle": "Startseite",
"@homeTitle": {
"description": "Home screen title"
},
"homeSearchHint": "Paste Spotify URL or search...",
"homeSearchHint": "Spotify-URL einfügen oder suchen...",
"@homeSearchHint": {
"description": "Placeholder text in search box"
},
"homeSearchHintExtension": "Search with {extensionName}...",
"homeSearchHintExtension": "Mit {extensionName} suchen...",
"@homeSearchHintExtension": {
"description": "Placeholder when extension search is active",
"placeholders": {
@@ -43,23 +43,23 @@
}
}
},
"homeSubtitle": "Paste a Spotify link or search by name",
"homeSubtitle": "Spotify-Link einfügen oder nach Namen suchen",
"@homeSubtitle": {
"description": "Subtitle shown below search box"
},
"homeSupports": "Supports: Track, Album, Playlist, Artist URLs",
"homeSupports": "Unterstützt: Titel, Album, Playlist, Künstler-URLs",
"@homeSupports": {
"description": "Info text about supported URL types"
},
"homeRecent": "Recent",
"homeRecent": "Zuletzt",
"@homeRecent": {
"description": "Section header for recent searches"
},
"historyTitle": "History",
"historyTitle": "Verlauf",
"@historyTitle": {
"description": "History screen title"
},
"historyDownloading": "Downloading ({count})",
"historyDownloading": "Wird heruntergeladen ({count})",
"@historyDownloading": {
"description": "Tab showing active downloads count",
"placeholders": {
@@ -69,15 +69,15 @@
}
}
},
"historyDownloaded": "Downloaded",
"historyDownloaded": "Heruntergeladen",
"@historyDownloaded": {
"description": "Tab showing completed downloads"
},
"historyFilterAll": "All",
"historyFilterAll": "Alle",
"@historyFilterAll": {
"description": "Filter chip - show all items"
},
"historyFilterAlbums": "Albums",
"historyFilterAlbums": "Alben",
"@historyFilterAlbums": {
"description": "Filter chip - show albums only"
},
@@ -85,7 +85,7 @@
"@historyFilterSingles": {
"description": "Filter chip - show singles only"
},
"historyTracksCount": "{count, plural, =1{1 track} other{{count} tracks}}",
"historyTracksCount": "{count, plural, =1{1 Titel} other{{count} Titel}}",
"@historyTracksCount": {
"description": "Track count with plural form",
"placeholders": {
@@ -94,7 +94,7 @@
}
}
},
"historyAlbumsCount": "{count, plural, =1{1 album} other{{count} albums}}",
"historyAlbumsCount": "{count, plural, =1{1 Album} other{{count} Alben}}",
"@historyAlbumsCount": {
"description": "Album count with plural form",
"placeholders": {
@@ -103,31 +103,31 @@
}
}
},
"historyNoDownloads": "No download history",
"historyNoDownloads": "Kein Download-Verlauf",
"@historyNoDownloads": {
"description": "Empty state title"
},
"historyNoDownloadsSubtitle": "Downloaded tracks will appear here",
"historyNoDownloadsSubtitle": "Heruntergeladene Titel werden hier angezeigt",
"@historyNoDownloadsSubtitle": {
"description": "Empty state subtitle"
},
"historyNoAlbums": "No album downloads",
"historyNoAlbums": "Keine Album-Downloads",
"@historyNoAlbums": {
"description": "Empty state when filtering albums"
},
"historyNoAlbumsSubtitle": "Download multiple tracks from an album to see them here",
"historyNoAlbumsSubtitle": "Laden Sie mehrere Titel eines Albums herunter, um sie hier zu sehen",
"@historyNoAlbumsSubtitle": {
"description": "Empty state subtitle for albums filter"
},
"historyNoSingles": "No single downloads",
"historyNoSingles": "Keine Einzel-Downloads",
"@historyNoSingles": {
"description": "Empty state when filtering singles"
},
"historyNoSinglesSubtitle": "Single track downloads will appear here",
"historyNoSinglesSubtitle": "Einzelne Titel-Downloads werden hier angezeigt",
"@historyNoSinglesSubtitle": {
"description": "Empty state subtitle for singles filter"
},
"settingsTitle": "Settings",
"settingsTitle": "Einstellungen",
"@settingsTitle": {
"description": "Settings screen title"
},
@@ -135,19 +135,19 @@
"@settingsDownload": {
"description": "Settings section - download options"
},
"settingsAppearance": "Appearance",
"settingsAppearance": "Erscheinungsbild",
"@settingsAppearance": {
"description": "Settings section - visual customization"
},
"settingsOptions": "Options",
"settingsOptions": "Optionen",
"@settingsOptions": {
"description": "Settings section - app options"
},
"settingsExtensions": "Extensions",
"settingsExtensions": "Erweiterungen",
"@settingsExtensions": {
"description": "Settings section - extension management"
},
"settingsAbout": "About",
"settingsAbout": "Über",
"@settingsAbout": {
"description": "Settings section - app info"
},
@@ -155,55 +155,55 @@
"@downloadTitle": {
"description": "Download settings page title"
},
"downloadLocation": "Download Location",
"downloadLocation": "Download-Speicherort",
"@downloadLocation": {
"description": "Setting for download folder"
},
"downloadLocationSubtitle": "Choose where to save files",
"downloadLocationSubtitle": "Wählen Sie den Speicherort für Dateien",
"@downloadLocationSubtitle": {
"description": "Subtitle for download location"
},
"downloadLocationDefault": "Default location",
"downloadLocationDefault": "Standard-Speicherort",
"@downloadLocationDefault": {
"description": "Shown when using default folder"
},
"downloadDefaultService": "Default Service",
"downloadDefaultService": "Standard-Dienst",
"@downloadDefaultService": {
"description": "Setting for preferred download service (Tidal/Qobuz/Amazon)"
},
"downloadDefaultServiceSubtitle": "Service used for downloads",
"downloadDefaultServiceSubtitle": "Dienst für Downloads",
"@downloadDefaultServiceSubtitle": {
"description": "Subtitle for default service"
},
"downloadDefaultQuality": "Default Quality",
"downloadDefaultQuality": "Standard-Qualität",
"@downloadDefaultQuality": {
"description": "Setting for audio quality"
},
"downloadAskQuality": "Ask Quality Before Download",
"downloadAskQuality": "Qualität vor Download abfragen",
"@downloadAskQuality": {
"description": "Toggle to show quality picker"
},
"downloadAskQualitySubtitle": "Show quality picker for each download",
"downloadAskQualitySubtitle": "Qualitätsauswahl für jeden Download anzeigen",
"@downloadAskQualitySubtitle": {
"description": "Subtitle for ask quality toggle"
},
"downloadFilenameFormat": "Filename Format",
"downloadFilenameFormat": "Dateinamenformat",
"@downloadFilenameFormat": {
"description": "Setting for output filename pattern"
},
"downloadFolderOrganization": "Folder Organization",
"downloadFolderOrganization": "Ordnerstruktur",
"@downloadFolderOrganization": {
"description": "Setting for folder structure"
},
"downloadSeparateSingles": "Separate Singles",
"downloadSeparateSingles": "Singles trennen",
"@downloadSeparateSingles": {
"description": "Toggle to separate single tracks"
},
"downloadSeparateSinglesSubtitle": "Put single tracks in a separate folder",
"downloadSeparateSinglesSubtitle": "Einzelne Titel in separatem Ordner speichern",
"@downloadSeparateSinglesSubtitle": {
"description": "Subtitle for separate singles toggle"
},
"qualityBest": "Best Available",
"qualityBest": "Beste Qualität",
"@qualityBest": {
"description": "Audio quality option - highest available"
},
@@ -219,11 +219,11 @@
"@quality128": {
"description": "Audio quality option - 128kbps MP3"
},
"appearanceTitle": "Appearance",
"appearanceTitle": "Erscheinungsbild",
"@appearanceTitle": {
"description": "Appearance settings page title"
},
"appearanceTheme": "Theme",
"appearanceTheme": "Design",
"@appearanceTheme": {
"description": "Theme mode setting"
},
@@ -231,55 +231,55 @@
"@appearanceThemeSystem": {
"description": "Follow system theme"
},
"appearanceThemeLight": "Light",
"appearanceThemeLight": "Hell",
"@appearanceThemeLight": {
"description": "Light theme"
},
"appearanceThemeDark": "Dark",
"appearanceThemeDark": "Dunkel",
"@appearanceThemeDark": {
"description": "Dark theme"
},
"appearanceDynamicColor": "Dynamic Color",
"appearanceDynamicColor": "Dynamische Farben",
"@appearanceDynamicColor": {
"description": "Material You dynamic colors"
},
"appearanceDynamicColorSubtitle": "Use colors from your wallpaper",
"appearanceDynamicColorSubtitle": "Farben von Ihrem Hintergrundbild verwenden",
"@appearanceDynamicColorSubtitle": {
"description": "Subtitle for dynamic color"
},
"appearanceAccentColor": "Accent Color",
"appearanceAccentColor": "Akzentfarbe",
"@appearanceAccentColor": {
"description": "Custom accent color picker"
},
"appearanceHistoryView": "History View",
"appearanceHistoryView": "Verlaufsansicht",
"@appearanceHistoryView": {
"description": "Layout style for history"
},
"appearanceHistoryViewList": "List",
"appearanceHistoryViewList": "Liste",
"@appearanceHistoryViewList": {
"description": "List layout option"
},
"appearanceHistoryViewGrid": "Grid",
"appearanceHistoryViewGrid": "Raster",
"@appearanceHistoryViewGrid": {
"description": "Grid layout option"
},
"optionsTitle": "Options",
"optionsTitle": "Optionen",
"@optionsTitle": {
"description": "Options settings page title"
},
"optionsSearchSource": "Search Source",
"optionsSearchSource": "Suchquelle",
"@optionsSearchSource": {
"description": "Section for search provider settings"
},
"optionsPrimaryProvider": "Primary Provider",
"optionsPrimaryProvider": "Primärer Anbieter",
"@optionsPrimaryProvider": {
"description": "Main search provider setting"
},
"optionsPrimaryProviderSubtitle": "Service used when searching by track name.",
"optionsPrimaryProviderSubtitle": "Dienst für die Suche nach Titelnamen.",
"@optionsPrimaryProviderSubtitle": {
"description": "Subtitle for primary provider"
},
"optionsUsingExtension": "Using extension: {extensionName}",
"optionsUsingExtension": "Erweiterung verwenden: {extensionName}",
"@optionsUsingExtension": {
"description": "Shows active extension name",
"placeholders": {
@@ -288,55 +288,55 @@
}
}
},
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
"optionsSwitchBack": "Tippen Sie auf Deezer oder Spotify, um von der Erweiterung zurückzuwechseln",
"@optionsSwitchBack": {
"description": "Hint to switch back to built-in providers"
},
"optionsAutoFallback": "Auto Fallback",
"optionsAutoFallback": "Automatischer Fallback",
"@optionsAutoFallback": {
"description": "Auto-retry with other services"
},
"optionsAutoFallbackSubtitle": "Try other services if download fails",
"optionsAutoFallbackSubtitle": "Andere Dienste versuchen, wenn Download fehlschlägt",
"@optionsAutoFallbackSubtitle": {
"description": "Subtitle for auto fallback"
},
"optionsUseExtensionProviders": "Use Extension Providers",
"optionsUseExtensionProviders": "Erweiterungs-Anbieter verwenden",
"@optionsUseExtensionProviders": {
"description": "Enable extension download providers"
},
"optionsUseExtensionProvidersOn": "Extensions will be tried first",
"optionsUseExtensionProvidersOn": "Erweiterungen werden zuerst versucht",
"@optionsUseExtensionProvidersOn": {
"description": "Status when extension providers enabled"
},
"optionsUseExtensionProvidersOff": "Using built-in providers only",
"optionsUseExtensionProvidersOff": "Nur integrierte Anbieter verwenden",
"@optionsUseExtensionProvidersOff": {
"description": "Status when extension providers disabled"
},
"optionsEmbedLyrics": "Embed Lyrics",
"optionsEmbedLyrics": "Liedtexte einbetten",
"@optionsEmbedLyrics": {
"description": "Embed lyrics in audio files"
},
"optionsEmbedLyricsSubtitle": "Embed synced lyrics into FLAC files",
"optionsEmbedLyricsSubtitle": "Synchronisierte Liedtexte in FLAC-Dateien einbetten",
"@optionsEmbedLyricsSubtitle": {
"description": "Subtitle for embed lyrics"
},
"optionsMaxQualityCover": "Max Quality Cover",
"optionsMaxQualityCover": "Maximale Cover-Qualität",
"@optionsMaxQualityCover": {
"description": "Download highest quality album art"
},
"optionsMaxQualityCoverSubtitle": "Download highest resolution cover art",
"optionsMaxQualityCoverSubtitle": "Cover in höchster Auflösung herunterladen",
"@optionsMaxQualityCoverSubtitle": {
"description": "Subtitle for max quality cover"
},
"optionsConcurrentDownloads": "Concurrent Downloads",
"optionsConcurrentDownloads": "Parallele Downloads",
"@optionsConcurrentDownloads": {
"description": "Number of parallel downloads"
},
"optionsConcurrentSequential": "Sequential (1 at a time)",
"optionsConcurrentSequential": "Sequentiell (1 gleichzeitig)",
"@optionsConcurrentSequential": {
"description": "Download one at a time"
},
"optionsConcurrentParallel": "{count} parallel downloads",
"optionsConcurrentParallel": "{count} parallele Downloads",
"@optionsConcurrentParallel": {
"description": "Multiple parallel downloads",
"placeholders": {
@@ -345,67 +345,67 @@
}
}
},
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting",
"optionsConcurrentWarning": "Parallele Downloads können Ratenlimitierung auslösen",
"@optionsConcurrentWarning": {
"description": "Warning about rate limits"
},
"optionsExtensionStore": "Extension Store",
"optionsExtensionStore": "Erweiterungs-Store",
"@optionsExtensionStore": {
"description": "Show/hide store tab"
},
"optionsExtensionStoreSubtitle": "Show Store tab in navigation",
"optionsExtensionStoreSubtitle": "Store-Tab in Navigation anzeigen",
"@optionsExtensionStoreSubtitle": {
"description": "Subtitle for extension store toggle"
},
"optionsCheckUpdates": "Check for Updates",
"optionsCheckUpdates": "Nach Updates suchen",
"@optionsCheckUpdates": {
"description": "Auto update check toggle"
},
"optionsCheckUpdatesSubtitle": "Notify when new version is available",
"optionsCheckUpdatesSubtitle": "Benachrichtigen, wenn neue Version verfügbar",
"@optionsCheckUpdatesSubtitle": {
"description": "Subtitle for update check"
},
"optionsUpdateChannel": "Update Channel",
"optionsUpdateChannel": "Update-Kanal",
"@optionsUpdateChannel": {
"description": "Stable vs preview releases"
},
"optionsUpdateChannelStable": "Stable releases only",
"optionsUpdateChannelStable": "Nur stabile Versionen",
"@optionsUpdateChannelStable": {
"description": "Only stable updates"
},
"optionsUpdateChannelPreview": "Get preview releases",
"optionsUpdateChannelPreview": "Vorschau-Versionen erhalten",
"@optionsUpdateChannelPreview": {
"description": "Include beta/preview updates"
},
"optionsUpdateChannelWarning": "Preview may contain bugs or incomplete features",
"optionsUpdateChannelWarning": "Vorschau kann Fehler oder unvollständige Funktionen enthalten",
"@optionsUpdateChannelWarning": {
"description": "Warning about preview channel"
},
"optionsClearHistory": "Clear Download History",
"optionsClearHistory": "Download-Verlauf löschen",
"@optionsClearHistory": {
"description": "Delete all download history"
},
"optionsClearHistorySubtitle": "Remove all downloaded tracks from history",
"optionsClearHistorySubtitle": "Alle heruntergeladenen Titel aus dem Verlauf entfernen",
"@optionsClearHistorySubtitle": {
"description": "Subtitle for clear history"
},
"optionsDetailedLogging": "Detailed Logging",
"optionsDetailedLogging": "Detaillierte Protokollierung",
"@optionsDetailedLogging": {
"description": "Enable verbose logs for debugging"
},
"optionsDetailedLoggingOn": "Detailed logs are being recorded",
"optionsDetailedLoggingOn": "Detaillierte Protokolle werden aufgezeichnet",
"@optionsDetailedLoggingOn": {
"description": "Status when logging enabled"
},
"optionsDetailedLoggingOff": "Enable for bug reports",
"optionsDetailedLoggingOff": "Für Fehlerberichte aktivieren",
"@optionsDetailedLoggingOff": {
"description": "Status when logging disabled"
},
"optionsSpotifyCredentials": "Spotify Credentials",
"optionsSpotifyCredentials": "Spotify-Anmeldedaten",
"@optionsSpotifyCredentials": {
"description": "Spotify API credentials setting"
},
"optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...",
"optionsSpotifyCredentialsConfigured": "Client-ID: {clientId}...",
"@optionsSpotifyCredentialsConfigured": {
"description": "Shows configured client ID preview",
"placeholders": {
@@ -414,35 +414,35 @@
}
}
},
"optionsSpotifyCredentialsRequired": "Required - tap to configure",
"optionsSpotifyCredentialsRequired": "Erforderlich - zum Konfigurieren tippen",
"@optionsSpotifyCredentialsRequired": {
"description": "Prompt to set up credentials"
},
"optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com",
"optionsSpotifyWarning": "Spotify erfordert eigene API-Anmeldedaten. Kostenlos erhältlich auf developer.spotify.com",
"@optionsSpotifyWarning": {
"description": "Info about Spotify API requirement"
},
"extensionsTitle": "Extensions",
"extensionsTitle": "Erweiterungen",
"@extensionsTitle": {
"description": "Extensions page title"
},
"extensionsInstalled": "Installed Extensions",
"extensionsInstalled": "Installierte Erweiterungen",
"@extensionsInstalled": {
"description": "Section header for installed extensions"
},
"extensionsNone": "No extensions installed",
"extensionsNone": "Keine Erweiterungen installiert",
"@extensionsNone": {
"description": "Empty state title"
},
"extensionsNoneSubtitle": "Install extensions from the Store tab",
"extensionsNoneSubtitle": "Erweiterungen aus dem Store-Tab installieren",
"@extensionsNoneSubtitle": {
"description": "Empty state subtitle"
},
"extensionsEnabled": "Enabled",
"extensionsEnabled": "Aktiviert",
"@extensionsEnabled": {
"description": "Extension status - active"
},
"extensionsDisabled": "Disabled",
"extensionsDisabled": "Deaktiviert",
"@extensionsDisabled": {
"description": "Extension status - inactive"
},
@@ -455,7 +455,7 @@
}
}
},
"extensionsAuthor": "by {author}",
"extensionsAuthor": "von {author}",
"@extensionsAuthor": {
"description": "Extension author credit",
"placeholders": {
@@ -464,47 +464,47 @@
}
}
},
"extensionsUninstall": "Uninstall",
"extensionsUninstall": "Deinstallieren",
"@extensionsUninstall": {
"description": "Uninstall extension button"
},
"extensionsSetAsSearch": "Set as Search Provider",
"extensionsSetAsSearch": "Als Suchanbieter festlegen",
"@extensionsSetAsSearch": {
"description": "Use extension for search"
},
"storeTitle": "Extension Store",
"storeTitle": "Erweiterungs-Store",
"@storeTitle": {
"description": "Store screen title"
},
"storeSearch": "Search extensions...",
"storeSearch": "Erweiterungen suchen...",
"@storeSearch": {
"description": "Store search placeholder"
},
"storeInstall": "Install",
"storeInstall": "Installieren",
"@storeInstall": {
"description": "Install extension button"
},
"storeInstalled": "Installed",
"storeInstalled": "Installiert",
"@storeInstalled": {
"description": "Already installed badge"
},
"storeUpdate": "Update",
"storeUpdate": "Aktualisieren",
"@storeUpdate": {
"description": "Update available button"
},
"aboutTitle": "About",
"aboutTitle": "Über",
"@aboutTitle": {
"description": "About page title"
},
"aboutContributors": "Contributors",
"aboutContributors": "Mitwirkende",
"@aboutContributors": {
"description": "Section for contributors"
},
"aboutMobileDeveloper": "Mobile version developer",
"aboutMobileDeveloper": "Mobile-Version Entwickler",
"@aboutMobileDeveloper": {
"description": "Role description for mobile dev"
},
"aboutOriginalCreator": "Creator of the original SpotiFLAC",
"aboutOriginalCreator": "Schöpfer des ursprünglichen SpotiFLAC",
"@aboutOriginalCreator": {
"description": "Role description for original creator"
},
+180 -142
View File
@@ -9,23 +9,23 @@
"@appDescription": {
"description": "App description shown in about page"
},
"navHome": "Home",
"navHome": "ホーム",
"@navHome": {
"description": "Bottom navigation - Home tab"
},
"navHistory": "History",
"navHistory": "履歴",
"@navHistory": {
"description": "Bottom navigation - History tab"
},
"navSettings": "Settings",
"navSettings": "設定",
"@navSettings": {
"description": "Bottom navigation - Settings tab"
},
"navStore": "Store",
"navStore": "ストア",
"@navStore": {
"description": "Bottom navigation - Extension store tab"
},
"homeTitle": "Home",
"homeTitle": "ホーム",
"@homeTitle": {
"description": "Home screen title"
},
@@ -59,7 +59,7 @@
"@historyTitle": {
"description": "History screen title"
},
"historyDownloading": "Downloading ({count})",
"historyDownloading": "ダウンロード中 ({count})",
"@historyDownloading": {
"description": "Tab showing active downloads count",
"placeholders": {
@@ -69,19 +69,19 @@
}
}
},
"historyDownloaded": "Downloaded",
"historyDownloaded": "ダウンロード済み",
"@historyDownloaded": {
"description": "Tab showing completed downloads"
},
"historyFilterAll": "All",
"historyFilterAll": "すべて",
"@historyFilterAll": {
"description": "Filter chip - show all items"
},
"historyFilterAlbums": "Albums",
"historyFilterAlbums": "アルバム",
"@historyFilterAlbums": {
"description": "Filter chip - show albums only"
},
"historyFilterSingles": "Singles",
"historyFilterSingles": "シングル",
"@historyFilterSingles": {
"description": "Filter chip - show singles only"
},
@@ -127,31 +127,31 @@
"@historyNoSinglesSubtitle": {
"description": "Empty state subtitle for singles filter"
},
"settingsTitle": "Settings",
"settingsTitle": "設定",
"@settingsTitle": {
"description": "Settings screen title"
},
"settingsDownload": "Download",
"settingsDownload": "ダウンロード",
"@settingsDownload": {
"description": "Settings section - download options"
},
"settingsAppearance": "Appearance",
"settingsAppearance": "外観",
"@settingsAppearance": {
"description": "Settings section - visual customization"
},
"settingsOptions": "Options",
"settingsOptions": "オプション",
"@settingsOptions": {
"description": "Settings section - app options"
},
"settingsExtensions": "Extensions",
"settingsExtensions": "拡張",
"@settingsExtensions": {
"description": "Settings section - extension management"
},
"settingsAbout": "About",
"settingsAbout": "アプリについて",
"@settingsAbout": {
"description": "Settings section - app info"
},
"downloadTitle": "Download",
"downloadTitle": "ダウンロード",
"@downloadTitle": {
"description": "Download settings page title"
},
@@ -163,19 +163,19 @@
"@downloadLocationSubtitle": {
"description": "Subtitle for download location"
},
"downloadLocationDefault": "Default location",
"downloadLocationDefault": "デフォルトの場所",
"@downloadLocationDefault": {
"description": "Shown when using default folder"
},
"downloadDefaultService": "Default Service",
"downloadDefaultService": "デフォルトのサービス",
"@downloadDefaultService": {
"description": "Setting for preferred download service (Tidal/Qobuz/Amazon)"
},
"downloadDefaultServiceSubtitle": "Service used for downloads",
"downloadDefaultServiceSubtitle": "ダウンロードに使用したサービス",
"@downloadDefaultServiceSubtitle": {
"description": "Subtitle for default service"
},
"downloadDefaultQuality": "Default Quality",
"downloadDefaultQuality": "デフォルトの品質",
"@downloadDefaultQuality": {
"description": "Setting for audio quality"
},
@@ -187,7 +187,7 @@
"@downloadAskQualitySubtitle": {
"description": "Subtitle for ask quality toggle"
},
"downloadFilenameFormat": "Filename Format",
"downloadFilenameFormat": "ファイル名の形式",
"@downloadFilenameFormat": {
"description": "Setting for output filename pattern"
},
@@ -219,27 +219,27 @@
"@quality128": {
"description": "Audio quality option - 128kbps MP3"
},
"appearanceTitle": "Appearance",
"appearanceTitle": "外観",
"@appearanceTitle": {
"description": "Appearance settings page title"
},
"appearanceTheme": "Theme",
"appearanceTheme": "テーマ",
"@appearanceTheme": {
"description": "Theme mode setting"
},
"appearanceThemeSystem": "System",
"appearanceThemeSystem": "システム",
"@appearanceThemeSystem": {
"description": "Follow system theme"
},
"appearanceThemeLight": "Light",
"appearanceThemeLight": "ライト",
"@appearanceThemeLight": {
"description": "Light theme"
},
"appearanceThemeDark": "Dark",
"appearanceThemeDark": "ダーク",
"@appearanceThemeDark": {
"description": "Dark theme"
},
"appearanceDynamicColor": "Dynamic Color",
"appearanceDynamicColor": "ダイナミックカラー",
"@appearanceDynamicColor": {
"description": "Material You dynamic colors"
},
@@ -247,31 +247,31 @@
"@appearanceDynamicColorSubtitle": {
"description": "Subtitle for dynamic color"
},
"appearanceAccentColor": "Accent Color",
"appearanceAccentColor": "アクセントカラー",
"@appearanceAccentColor": {
"description": "Custom accent color picker"
},
"appearanceHistoryView": "History View",
"appearanceHistoryView": "履歴の表示",
"@appearanceHistoryView": {
"description": "Layout style for history"
},
"appearanceHistoryViewList": "List",
"appearanceHistoryViewList": "リスト",
"@appearanceHistoryViewList": {
"description": "List layout option"
},
"appearanceHistoryViewGrid": "Grid",
"appearanceHistoryViewGrid": "グリッド",
"@appearanceHistoryViewGrid": {
"description": "Grid layout option"
},
"optionsTitle": "Options",
"optionsTitle": "オプション",
"@optionsTitle": {
"description": "Options settings page title"
},
"optionsSearchSource": "Search Source",
"optionsSearchSource": "検索ソース",
"@optionsSearchSource": {
"description": "Section for search provider settings"
},
"optionsPrimaryProvider": "Primary Provider",
"optionsPrimaryProvider": "プライマリーのプロバイダー",
"@optionsPrimaryProvider": {
"description": "Main search provider setting"
},
@@ -279,7 +279,7 @@
"@optionsPrimaryProviderSubtitle": {
"description": "Subtitle for primary provider"
},
"optionsUsingExtension": "Using extension: {extensionName}",
"optionsUsingExtension": "拡張の使用: {extensionName}",
"@optionsUsingExtension": {
"description": "Shows active extension name",
"placeholders": {
@@ -300,7 +300,7 @@
"@optionsAutoFallbackSubtitle": {
"description": "Subtitle for auto fallback"
},
"optionsUseExtensionProviders": "Use Extension Providers",
"optionsUseExtensionProviders": "拡張のプロバイダーを使用する",
"@optionsUseExtensionProviders": {
"description": "Enable extension download providers"
},
@@ -308,11 +308,11 @@
"@optionsUseExtensionProvidersOn": {
"description": "Status when extension providers enabled"
},
"optionsUseExtensionProvidersOff": "Using built-in providers only",
"optionsUseExtensionProvidersOff": "内蔵のプロバイダーのみを使用する",
"@optionsUseExtensionProvidersOff": {
"description": "Status when extension providers disabled"
},
"optionsEmbedLyrics": "Embed Lyrics",
"optionsEmbedLyrics": "歌詞を埋め込む",
"@optionsEmbedLyrics": {
"description": "Embed lyrics in audio files"
},
@@ -320,7 +320,7 @@
"@optionsEmbedLyricsSubtitle": {
"description": "Subtitle for embed lyrics"
},
"optionsMaxQualityCover": "Max Quality Cover",
"optionsMaxQualityCover": "最大品質のカバー",
"@optionsMaxQualityCover": {
"description": "Download highest quality album art"
},
@@ -349,7 +349,7 @@
"@optionsConcurrentWarning": {
"description": "Warning about rate limits"
},
"optionsExtensionStore": "Extension Store",
"optionsExtensionStore": "拡張ストア",
"@optionsExtensionStore": {
"description": "Show/hide store tab"
},
@@ -357,7 +357,7 @@
"@optionsExtensionStoreSubtitle": {
"description": "Subtitle for extension store toggle"
},
"optionsCheckUpdates": "Check for Updates",
"optionsCheckUpdates": "更新を確認",
"@optionsCheckUpdates": {
"description": "Auto update check toggle"
},
@@ -365,15 +365,15 @@
"@optionsCheckUpdatesSubtitle": {
"description": "Subtitle for update check"
},
"optionsUpdateChannel": "Update Channel",
"optionsUpdateChannel": "更新チャンネル",
"@optionsUpdateChannel": {
"description": "Stable vs preview releases"
},
"optionsUpdateChannelStable": "Stable releases only",
"optionsUpdateChannelStable": "安定版リリースのみ",
"@optionsUpdateChannelStable": {
"description": "Only stable updates"
},
"optionsUpdateChannelPreview": "Get preview releases",
"optionsUpdateChannelPreview": "プレビューリリースを入手",
"@optionsUpdateChannelPreview": {
"description": "Include beta/preview updates"
},
@@ -401,11 +401,11 @@
"@optionsDetailedLoggingOff": {
"description": "Status when logging disabled"
},
"optionsSpotifyCredentials": "Spotify Credentials",
"optionsSpotifyCredentials": "Spotify の認証情報",
"@optionsSpotifyCredentials": {
"description": "Spotify API credentials setting"
},
"optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...",
"optionsSpotifyCredentialsConfigured": "クライアント ID: {clientId}...",
"@optionsSpotifyCredentialsConfigured": {
"description": "Shows configured client ID preview",
"placeholders": {
@@ -422,23 +422,23 @@
"@optionsSpotifyWarning": {
"description": "Info about Spotify API requirement"
},
"extensionsTitle": "Extensions",
"extensionsTitle": "拡張",
"@extensionsTitle": {
"description": "Extensions page title"
},
"extensionsInstalled": "Installed Extensions",
"extensionsInstalled": "インストール済みの拡張",
"@extensionsInstalled": {
"description": "Section header for installed extensions"
},
"extensionsNone": "No extensions installed",
"extensionsNone": "拡張はインストールされていません",
"@extensionsNone": {
"description": "Empty state title"
},
"extensionsNoneSubtitle": "Install extensions from the Store tab",
"extensionsNoneSubtitle": "ストアタブから拡張をインストール",
"@extensionsNoneSubtitle": {
"description": "Empty state subtitle"
},
"extensionsEnabled": "Enabled",
"extensionsEnabled": "有効",
"@extensionsEnabled": {
"description": "Extension status - active"
},
@@ -446,7 +446,7 @@
"@extensionsDisabled": {
"description": "Extension status - inactive"
},
"extensionsVersion": "Version {version}",
"extensionsVersion": "バージョン {version}",
"@extensionsVersion": {
"description": "Extension version display",
"placeholders": {
@@ -455,7 +455,7 @@
}
}
},
"extensionsAuthor": "by {author}",
"extensionsAuthor": "作者 {author}",
"@extensionsAuthor": {
"description": "Extension author credit",
"placeholders": {
@@ -464,43 +464,43 @@
}
}
},
"extensionsUninstall": "Uninstall",
"extensionsUninstall": "アンインストール",
"@extensionsUninstall": {
"description": "Uninstall extension button"
},
"extensionsSetAsSearch": "Set as Search Provider",
"extensionsSetAsSearch": "検索プロバイダーを設定",
"@extensionsSetAsSearch": {
"description": "Use extension for search"
},
"storeTitle": "Extension Store",
"storeTitle": "拡張ストア",
"@storeTitle": {
"description": "Store screen title"
},
"storeSearch": "Search extensions...",
"storeSearch": "拡張を検索...",
"@storeSearch": {
"description": "Store search placeholder"
},
"storeInstall": "Install",
"storeInstall": "インストール",
"@storeInstall": {
"description": "Install extension button"
},
"storeInstalled": "Installed",
"storeInstalled": "インストール済み",
"@storeInstalled": {
"description": "Already installed badge"
},
"storeUpdate": "Update",
"storeUpdate": "更新",
"@storeUpdate": {
"description": "Update available button"
},
"aboutTitle": "About",
"aboutTitle": "アプリについて",
"@aboutTitle": {
"description": "About page title"
},
"aboutContributors": "Contributors",
"aboutContributors": "貢献者",
"@aboutContributors": {
"description": "Section for contributors"
},
"aboutMobileDeveloper": "Mobile version developer",
"aboutMobileDeveloper": "モバイルバージョンの開発者",
"@aboutMobileDeveloper": {
"description": "Role description for mobile dev"
},
@@ -512,23 +512,23 @@
"@aboutLogoArtist": {
"description": "Role description for logo artist"
},
"aboutSpecialThanks": "Special Thanks",
"aboutSpecialThanks": "スペシャルサンクス",
"@aboutSpecialThanks": {
"description": "Section for special thanks"
},
"aboutLinks": "Links",
"aboutLinks": "リンク",
"@aboutLinks": {
"description": "Section for external links"
},
"aboutMobileSource": "Mobile source code",
"aboutMobileSource": "モバイル版のソースコード",
"@aboutMobileSource": {
"description": "Link to mobile GitHub repo"
},
"aboutPCSource": "PC source code",
"aboutPCSource": "PC 版のソースコード",
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutReportIssue": "Report an issue",
"aboutReportIssue": "Issue で報告する",
"@aboutReportIssue": {
"description": "Link to report bugs"
},
@@ -536,7 +536,7 @@
"@aboutReportIssueSubtitle": {
"description": "Subtitle for report issue"
},
"aboutFeatureRequest": "Feature request",
"aboutFeatureRequest": "機能の要望",
"@aboutFeatureRequest": {
"description": "Link to suggest features"
},
@@ -548,19 +548,19 @@
"@aboutSupport": {
"description": "Section for support/donation links"
},
"aboutBuyMeCoffee": "Buy me a coffee",
"aboutBuyMeCoffee": "コーヒーを買ってください",
"@aboutBuyMeCoffee": {
"description": "Donation link"
},
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
"aboutBuyMeCoffeeSubtitle": "Ko-fi で開発をサポートします",
"@aboutBuyMeCoffeeSubtitle": {
"description": "Subtitle for donation"
},
"aboutApp": "App",
"aboutApp": "アプリ",
"@aboutApp": {
"description": "Section for app info"
},
"aboutVersion": "Version",
"aboutVersion": "バージョン",
"@aboutVersion": {
"description": "Version info label"
},
@@ -625,11 +625,11 @@
"@artistAlbums": {
"description": "Section header for artist albums"
},
"artistSingles": "Singles & EPs",
"artistSingles": "シングルと EP",
"@artistSingles": {
"description": "Section header for singles/EPs"
},
"artistCompilations": "Compilations",
"artistCompilations": "コンピレーション",
"@artistCompilations": {
"description": "Section header for compilations"
},
@@ -642,6 +642,20 @@
}
}
},
"artistPopular": "Popular",
"@artistPopular": {
"description": "Section header for popular/top tracks"
},
"artistMonthlyListeners": "{count} monthly listeners",
"@artistMonthlyListeners": {
"description": "Monthly listener count display",
"placeholders": {
"count": {
"type": "String",
"description": "Formatted listener count"
}
}
},
"trackMetadataTitle": "Track Info",
"@trackMetadataTitle": {
"description": "Track metadata screen title"
@@ -730,15 +744,15 @@
"@setupChooseFolder": {
"description": "Button to pick folder"
},
"setupContinue": "Continue",
"setupContinue": "続行",
"@setupContinue": {
"description": "Continue to next step button"
},
"setupSkip": "Skip for now",
"setupSkip": "今はスキップ",
"@setupSkip": {
"description": "Skip current step button"
},
"setupStorageAccessRequired": "Storage Access Required",
"setupStorageAccessRequired": "ストレージアクセスが必要です",
"@setupStorageAccessRequired": {
"description": "Title when storage access needed"
},
@@ -841,7 +855,7 @@
"@setupStepSpotify": {
"description": "Setup step indicator - Spotify API"
},
"setupStepPermission": "Permission",
"setupStepPermission": "権限",
"@setupStepPermission": {
"description": "Setup step indicator - permission"
},
@@ -861,7 +875,7 @@
"@setupNotificationGranted": {
"description": "Success message for notification permission"
},
"setupNotificationEnable": "Enable Notifications",
"setupNotificationEnable": "通知を有効化する",
"@setupNotificationEnable": {
"description": "Button to enable notifications"
},
@@ -869,7 +883,7 @@
"@setupNotificationDescription": {
"description": "Explanation for notifications"
},
"setupFolderSelected": "Download Folder Selected!",
"setupFolderSelected": "ダウンロードフォルダが選択済みです!",
"@setupFolderSelected": {
"description": "Success message for folder selection"
},
@@ -889,7 +903,7 @@
"@setupSelectFolder": {
"description": "Button to select folder"
},
"setupSpotifyApiOptional": "Spotify API (Optional)",
"setupSpotifyApiOptional": "Spotify API (任意)",
"@setupSpotifyApiOptional": {
"description": "Spotify API step title"
},
@@ -897,7 +911,7 @@
"@setupSpotifyApiDescription": {
"description": "Explanation for Spotify API"
},
"setupUseSpotifyApi": "Use Spotify API",
"setupUseSpotifyApi": "Spotify API を使用する",
"@setupUseSpotifyApi": {
"description": "Toggle to enable Spotify API"
},
@@ -905,15 +919,15 @@
"@setupEnterCredentialsBelow": {
"description": "Prompt to enter credentials"
},
"setupUsingDeezer": "Using Deezer (no account needed)",
"setupUsingDeezer": "Deezer を使用中 (アカウントは不要です)",
"@setupUsingDeezer": {
"description": "Status when using Deezer"
},
"setupEnterClientId": "Enter Spotify Client ID",
"setupEnterClientId": "Spotify クライアント ID を入力",
"@setupEnterClientId": {
"description": "Placeholder for client ID field"
},
"setupEnterClientSecret": "Enter Spotify Client Secret",
"setupEnterClientSecret": "Spotify クライアントシークレットを入力",
"@setupEnterClientSecret": {
"description": "Placeholder for client secret field"
},
@@ -937,15 +951,15 @@
"@setupNotificationBackgroundDescription": {
"description": "Detailed notification explanation"
},
"setupSkipForNow": "Skip for now",
"setupSkipForNow": "今はスキップ",
"@setupSkipForNow": {
"description": "Skip button text"
},
"setupBack": "Back",
"setupBack": "戻る",
"@setupBack": {
"description": "Back button text"
},
"setupNext": "Next",
"setupNext": "次へ",
"@setupNext": {
"description": "Next button text"
},
@@ -953,7 +967,7 @@
"@setupGetStarted": {
"description": "Final setup button"
},
"setupSkipAndStart": "Skip & Start",
"setupSkipAndStart": "スキップと開始",
"@setupSkipAndStart": {
"description": "Skip setup and start app"
},
@@ -1069,7 +1083,7 @@
"@dialogRemoveExtensionMessage": {
"description": "Dialog message - uninstall confirmation"
},
"dialogUninstallExtension": "Uninstall Extension?",
"dialogUninstallExtension": "拡張をアンインストールしますか?",
"@dialogUninstallExtension": {
"description": "Dialog title - uninstall extension"
},
@@ -1103,7 +1117,7 @@
}
}
},
"dialogImportPlaylistTitle": "Import Playlist",
"dialogImportPlaylistTitle": "プレイリストをインポート",
"@dialogImportPlaylistTitle": {
"description": "Dialog title - import CSV playlist"
},
@@ -1242,7 +1256,7 @@
"@snackbarFailedToUpdate": {
"description": "Snackbar - extension update error"
},
"errorRateLimited": "Rate Limited",
"errorRateLimited": "レート制限",
"@errorRateLimited": {
"description": "Error title - too many requests"
},
@@ -1509,7 +1523,7 @@
}
}
},
"updateDownload": "Download",
"updateDownload": "ダウンロード",
"@updateDownload": {
"description": "Update button - download update"
},
@@ -1537,7 +1551,7 @@
"@updateNewVersionReady": {
"description": "Update subtitle"
},
"updateCurrent": "Current",
"updateCurrent": "現在",
"@updateCurrent": {
"description": "Label for current version"
},
@@ -1669,15 +1683,15 @@
"@logClearLogsMessage": {
"description": "Clear logs confirmation message"
},
"logIspBlocking": "ISP BLOCKING DETECTED",
"logIspBlocking": "ISP のブロックを検出しました",
"@logIspBlocking": {
"description": "Error category - ISP blocking"
},
"logRateLimited": "RATE LIMITED",
"logRateLimited": "レート制限",
"@logRateLimited": {
"description": "Error category - rate limiting"
},
"logNetworkError": "NETWORK ERROR",
"logNetworkError": "ネットワークエラー",
"@logNetworkError": {
"description": "Error category - network issues"
},
@@ -1851,27 +1865,15 @@
},
"sectionLanguage": "Language",
"@sectionLanguage": {
"description": "Settings section header for language selection"
"description": "Settings section header for language"
},
"appearanceLanguage": "App Language",
"@appearanceLanguage": {
"description": "Setting title for language selection"
"description": "Language setting title"
},
"appearanceLanguageSubtitle": "Choose your preferred language",
"@appearanceLanguageSubtitle": {
"description": "Subtitle for language setting"
},
"languageSystem": "System Default",
"@languageSystem": {
"description": "Use device system language"
},
"languageEnglish": "English",
"@languageEnglish": {
"description": "English language option"
},
"languageIndonesian": "Bahasa Indonesia",
"@languageIndonesian": {
"description": "Indonesian language option"
"description": "Language setting subtitle"
},
"settingsAppearanceSubtitle": "Theme, colors, display",
"@settingsAppearanceSubtitle": {
@@ -1939,27 +1941,27 @@
"@trackMetadata": {
"description": "Tab title - track metadata"
},
"trackFileInfo": "File Info",
"trackFileInfo": "ファイル情報",
"@trackFileInfo": {
"description": "Tab title - file information"
},
"trackLyrics": "Lyrics",
"trackLyrics": "歌詞",
"@trackLyrics": {
"description": "Tab title - lyrics"
},
"trackFileNotFound": "File not found",
"trackFileNotFound": "ファイルがありません",
"@trackFileNotFound": {
"description": "Error - file doesn't exist"
},
"trackOpenInDeezer": "Open in Deezer",
"trackOpenInDeezer": "Deezer で開く",
"@trackOpenInDeezer": {
"description": "Action - open track in Deezer app"
},
"trackOpenInSpotify": "Open in Spotify",
"trackOpenInSpotify": "Spotify で開く",
"@trackOpenInSpotify": {
"description": "Action - open track in Spotify app"
},
"trackTrackName": "Track name",
"trackTrackName": "トラック名",
"@trackTrackName": {
"description": "Metadata label - track title"
},
@@ -2131,11 +2133,11 @@
"@extensionDefaultProvider": {
"description": "Default search provider option"
},
"extensionDefaultProviderSubtitle": "Use built-in search",
"extensionDefaultProviderSubtitle": "内蔵の検索を使用する",
"@extensionDefaultProviderSubtitle": {
"description": "Subtitle for default provider"
},
"extensionAuthor": "Author",
"extensionAuthor": "作者",
"@extensionAuthor": {
"description": "Extension detail - author"
},
@@ -2143,7 +2145,7 @@
"@extensionId": {
"description": "Extension detail - unique ID"
},
"extensionError": "Error",
"extensionError": "エラー",
"@extensionError": {
"description": "Extension detail - error message"
},
@@ -2183,19 +2185,19 @@
"@extensionSettings": {
"description": "Section header - extension settings"
},
"extensionRemoveButton": "Remove Extension",
"extensionRemoveButton": "拡張を削除",
"@extensionRemoveButton": {
"description": "Button to uninstall extension"
},
"extensionUpdated": "Updated",
"extensionUpdated": "更新済み",
"@extensionUpdated": {
"description": "Extension detail - last update"
},
"extensionMinAppVersion": "Min App Version",
"extensionMinAppVersion": "最小のアプリバージョン",
"@extensionMinAppVersion": {
"description": "Extension detail - minimum app version"
},
"extensionCustomTrackMatching": "Custom Track Matching",
"extensionCustomTrackMatching": "カスタムトラックマッチング",
"@extensionCustomTrackMatching": {
"description": "Capability - custom track matching algorithm"
},
@@ -2234,11 +2236,11 @@
"@extensionsProviderPrioritySection": {
"description": "Section header - provider priority"
},
"extensionsInstalledSection": "Installed Extensions",
"extensionsInstalledSection": "インストール済みの拡張",
"@extensionsInstalledSection": {
"description": "Section header - installed extensions"
},
"extensionsNoExtensions": "No extensions installed",
"extensionsNoExtensions": "拡張はインストールされていません",
"@extensionsNoExtensions": {
"description": "Empty state - no extensions"
},
@@ -2246,7 +2248,7 @@
"@extensionsNoExtensionsSubtitle": {
"description": "Empty state subtitle"
},
"extensionsInstallButton": "Install Extension",
"extensionsInstallButton": "拡張をインストール",
"@extensionsInstallButton": {
"description": "Button to install extension from file"
},
@@ -2302,7 +2304,7 @@
"@extensionsErrorLoading": {
"description": "Error message when extension fails to load"
},
"qualityFlacLossless": "FLAC Lossless",
"qualityFlacLossless": "FLAC ロスレス",
"@qualityFlacLossless": {
"description": "Quality option - CD quality FLAC"
},
@@ -2310,19 +2312,19 @@
"@qualityFlacLosslessSubtitle": {
"description": "Technical spec for lossless"
},
"qualityHiResFlac": "Hi-Res FLAC",
"qualityHiResFlac": "ハイレゾ FLAC",
"@qualityHiResFlac": {
"description": "Quality option - high resolution FLAC"
},
"qualityHiResFlacSubtitle": "24-bit / up to 96kHz",
"qualityHiResFlacSubtitle": "24-bit / 最大 96kHz",
"@qualityHiResFlacSubtitle": {
"description": "Technical spec for hi-res"
},
"qualityHiResFlacMax": "Hi-Res FLAC Max",
"qualityHiResFlacMax": "ハイレゾ FLAC 最大",
"@qualityHiResFlacMax": {
"description": "Quality option - maximum resolution FLAC"
},
"qualityHiResFlacMaxSubtitle": "24-bit / up to 192kHz",
"qualityHiResFlacMaxSubtitle": "24-bit / 最大 192kHz",
"@qualityHiResFlacMaxSubtitle": {
"description": "Technical spec for hi-res max"
},
@@ -2334,11 +2336,11 @@
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
},
"downloadDirectory": "Download Directory",
"downloadDirectory": "ダウンロードディレクトリ",
"@downloadDirectory": {
"description": "Setting - download folder"
},
"downloadSeparateSinglesFolder": "Separate Singles Folder",
"downloadSeparateSinglesFolder": "シングルのフォルダを分割",
"@downloadSeparateSinglesFolder": {
"description": "Setting - separate folder for singles"
},
@@ -2422,11 +2424,11 @@
"@serviceSpotify": {
"description": "Service name - DO NOT TRANSLATE"
},
"appearanceAmoledDark": "AMOLED Dark",
"appearanceAmoledDark": "AMOLED ダーク",
"@appearanceAmoledDark": {
"description": "Theme option - pure black"
},
"appearanceAmoledDarkSubtitle": "Pure black background",
"appearanceAmoledDarkSubtitle": "ピュアブラックの背景",
"@appearanceAmoledDarkSubtitle": {
"description": "Subtitle for AMOLED dark"
},
@@ -2434,15 +2436,15 @@
"@appearanceChooseAccentColor": {
"description": "Color picker dialog title"
},
"appearanceChooseTheme": "Theme Mode",
"appearanceChooseTheme": "テーマモード",
"@appearanceChooseTheme": {
"description": "Theme picker dialog title"
},
"queueTitle": "Download Queue",
"queueTitle": "ダウンロードキュー",
"@queueTitle": {
"description": "Queue screen title"
},
"queueClearAll": "Clear All",
"queueClearAll": "すべて消去",
"@queueClearAll": {
"description": "Button - clear all queue items"
},
@@ -2573,5 +2575,41 @@
"utilityFunctions": "Utility Functions",
"@utilityFunctions": {
"description": "Extension capability - utility functions"
},
"recentTypeArtist": "Artist",
"@recentTypeArtist": {
"description": "Recent access item type - artist"
},
"recentTypeAlbum": "Album",
"@recentTypeAlbum": {
"description": "Recent access item type - album"
},
"recentTypeSong": "Song",
"@recentTypeSong": {
"description": "Recent access item type - song/track"
},
"recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {
"description": "Recent access item type - playlist"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access",
"placeholders": {
"name": {
"type": "String",
"description": "Playlist name"
}
}
},
"errorGeneric": "Error: {message}",
"@errorGeneric": {
"description": "Generic error message format",
"placeholders": {
"message": {
"type": "String",
"description": "Error message"
}
}
}
}
+613 -575
View File
File diff suppressed because it is too large Load Diff
+54 -16
View File
@@ -1,5 +1,5 @@
{
"@@locale": "zh_CN",
"@@locale": "zh-CN",
"@@last_modified": "2026-01-16",
"appName": "SpotiFLAC",
"@appName": {
@@ -642,6 +642,20 @@
}
}
},
"artistPopular": "Popular",
"@artistPopular": {
"description": "Section header for popular/top tracks"
},
"artistMonthlyListeners": "{count} monthly listeners",
"@artistMonthlyListeners": {
"description": "Monthly listener count display",
"placeholders": {
"count": {
"type": "String",
"description": "Formatted listener count"
}
}
},
"trackMetadataTitle": "Track Info",
"@trackMetadataTitle": {
"description": "Track metadata screen title"
@@ -1851,27 +1865,15 @@
},
"sectionLanguage": "Language",
"@sectionLanguage": {
"description": "Settings section header for language selection"
"description": "Settings section header for language"
},
"appearanceLanguage": "App Language",
"@appearanceLanguage": {
"description": "Setting title for language selection"
"description": "Language setting title"
},
"appearanceLanguageSubtitle": "Choose your preferred language",
"@appearanceLanguageSubtitle": {
"description": "Subtitle for language setting"
},
"languageSystem": "System Default",
"@languageSystem": {
"description": "Use device system language"
},
"languageEnglish": "English",
"@languageEnglish": {
"description": "English language option"
},
"languageIndonesian": "Bahasa Indonesia",
"@languageIndonesian": {
"description": "Indonesian language option"
"description": "Language setting subtitle"
},
"settingsAppearanceSubtitle": "Theme, colors, display",
"@settingsAppearanceSubtitle": {
@@ -2573,5 +2575,41 @@
"utilityFunctions": "Utility Functions",
"@utilityFunctions": {
"description": "Extension capability - utility functions"
},
"recentTypeArtist": "Artist",
"@recentTypeArtist": {
"description": "Recent access item type - artist"
},
"recentTypeAlbum": "Album",
"@recentTypeAlbum": {
"description": "Recent access item type - album"
},
"recentTypeSong": "Song",
"@recentTypeSong": {
"description": "Recent access item type - song/track"
},
"recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {
"description": "Recent access item type - playlist"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access",
"placeholders": {
"name": {
"type": "String",
"description": "Playlist name"
}
}
},
"errorGeneric": "Error: {message}",
"@errorGeneric": {
"description": "Generic error message format",
"placeholders": {
"message": {
"type": "String",
"description": "Error message"
}
}
}
}
+63 -1
View File
@@ -1,5 +1,5 @@
{
"@@locale": "zh_TW",
"@@locale": "zh-TW",
"@@last_modified": "2026-01-16",
"appName": "SpotiFLAC",
"@appName": {
@@ -642,6 +642,20 @@
}
}
},
"artistPopular": "Popular",
"@artistPopular": {
"description": "Section header for popular/top tracks"
},
"artistMonthlyListeners": "{count} monthly listeners",
"@artistMonthlyListeners": {
"description": "Monthly listener count display",
"placeholders": {
"count": {
"type": "String",
"description": "Formatted listener count"
}
}
},
"trackMetadataTitle": "Track Info",
"@trackMetadataTitle": {
"description": "Track metadata screen title"
@@ -1849,6 +1863,18 @@
"@sectionLayout": {
"description": "Settings section header"
},
"sectionLanguage": "Language",
"@sectionLanguage": {
"description": "Settings section header for language"
},
"appearanceLanguage": "App Language",
"@appearanceLanguage": {
"description": "Language setting title"
},
"appearanceLanguageSubtitle": "Choose your preferred language",
"@appearanceLanguageSubtitle": {
"description": "Language setting subtitle"
},
"settingsAppearanceSubtitle": "Theme, colors, display",
"@settingsAppearanceSubtitle": {
"description": "Appearance settings description"
@@ -2549,5 +2575,41 @@
"utilityFunctions": "Utility Functions",
"@utilityFunctions": {
"description": "Extension capability - utility functions"
},
"recentTypeArtist": "Artist",
"@recentTypeArtist": {
"description": "Recent access item type - artist"
},
"recentTypeAlbum": "Album",
"@recentTypeAlbum": {
"description": "Recent access item type - album"
},
"recentTypeSong": "Song",
"@recentTypeSong": {
"description": "Recent access item type - song/track"
},
"recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {
"description": "Recent access item type - playlist"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access",
"placeholders": {
"name": {
"type": "String",
"description": "Playlist name"
}
}
},
"errorGeneric": "Error: {message}",
"@errorGeneric": {
"description": "Generic error message format",
"placeholders": {
"message": {
"type": "String",
"description": "Error message"
}
}
}
}
+28 -4
View File
@@ -1,24 +1,48 @@
// GENERATED FILE - DO NOT EDIT
// Generated by: dart run tool/check_translations.dart 70
// Only languages with >= 70% translation completion are included.
// Generated by: dart run tool/check_translations.dart 0
// Only languages with >= 0% translation completion are included.
// Translation is measured by comparing VALUES (not just key existence).
//
// To regenerate, run: dart run tool/check_translations.dart 70
// To regenerate, run: dart run tool/check_translations.dart 0
import 'package:flutter/widgets.dart';
/// Minimum translation completion threshold used to filter languages.
const int translationThreshold = 70;
const int translationThreshold = 0;
/// List of locales that meet the translation threshold.
/// Only these languages will be available in the app.
const List<Locale> filteredSupportedLocales = <Locale>[
Locale('en'),
Locale('ru'),
Locale('id'),
Locale('ja'),
Locale('de'),
Locale('es'),
Locale('fr'),
Locale('hi'),
Locale('ko'),
Locale('nl'),
Locale('pt'),
Locale('zh'),
Locale('zh', 'CN'),
Locale('zh', 'TW'),
];
/// Set of locale codes for quick lookup.
const Set<String> filteredLocaleCodes = <String>{
'en',
'ru',
'id',
'ja',
'de',
'es',
'fr',
'hi',
'ko',
'nl',
'pt',
'zh',
'zh_CN',
'zh_TW',
};
@@ -1084,11 +1084,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.d('Metadata map content: $metadata');
try {
// Convert duration from seconds to milliseconds for better lyrics matching
final durationMs = track.duration * 1000;
final lrcContent = await PlatformBridge.getLyricsLRC(
track.id, // spotifyID
track.name,
track.artistName,
filePath: '', // No local file path yet (processed in memory)
durationMs: durationMs,
);
if (lrcContent.isNotEmpty) {
+88 -1
View File
@@ -32,6 +32,21 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
final FocusNode _searchFocusNode = FocusNode();
String? _lastSearchQuery;
/// Debounce timer for live search (extension-only feature)
Timer? _liveSearchDebounce;
/// Flag to prevent concurrent live search calls (prevents race conditions in extensions)
bool _isLiveSearchInProgress = false;
/// Pending query to execute after current search completes
String? _pendingLiveSearchQuery;
/// Minimum characters required to trigger live search
static const int _minLiveSearchChars = 3;
/// Debounce duration for live search
static const Duration _liveSearchDelay = Duration(milliseconds: 800);
@override
bool get wantKeepAlive => true;
@@ -44,6 +59,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
@override
void dispose() {
_liveSearchDebounce?.cancel();
_urlController.removeListener(_onSearchChanged);
_searchFocusNode.removeListener(_onSearchFocusChanged);
_urlController.dispose();
@@ -68,7 +84,22 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
_urlController.clear();
setState(() => _isTyping = false);
}
} void _onSearchChanged() {
}
/// Check if live search is available (extension is set as search provider)
bool _isLiveSearchEnabled() {
final settings = ref.read(settingsProvider);
final extState = ref.read(extensionProvider);
final searchProvider = settings.searchProvider;
if (searchProvider == null || searchProvider.isEmpty) return false;
// Check if the extension is enabled and has search capability
final extension = extState.extensions.where((e) => e.id == searchProvider && e.enabled).firstOrNull;
return extension != null;
}
void _onSearchChanged() {
final text = _urlController.text.trim();
ref.read(trackProvider.notifier).setSearchText(text.isNotEmpty);
@@ -77,10 +108,60 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
setState(() => _isTyping = true);
} else if (text.isEmpty && _isTyping) {
setState(() => _isTyping = false);
_liveSearchDebounce?.cancel();
// Don't clear provider here - it causes focus issues
// Provider will be cleared when user explicitly clears or navigates away
return;
}
// Live search - only for extensions
if (_isLiveSearchEnabled() && text.length >= _minLiveSearchChars) {
// Skip if it's a URL (let user press enter for URLs)
if (text.startsWith('http') || text.startsWith('spotify:')) return;
_liveSearchDebounce?.cancel();
_liveSearchDebounce = Timer(_liveSearchDelay, () {
if (mounted && _urlController.text.trim() == text) {
_executeLiveSearch(text);
}
});
}
}
/// Execute live search with concurrency protection
/// Prevents race conditions in extensions by ensuring only one search runs at a time
Future<void> _executeLiveSearch(String query) async {
// If a search is already in progress, queue this one
if (_isLiveSearchInProgress) {
_pendingLiveSearchQuery = query;
return;
}
_isLiveSearchInProgress = true;
_pendingLiveSearchQuery = null;
try {
await _performSearch(query);
} finally {
_isLiveSearchInProgress = false;
// Check if there's a pending query that was queued while we were searching
final pending = _pendingLiveSearchQuery;
_pendingLiveSearchQuery = null;
// Execute pending query if it's different from what we just searched
// and still matches current text field content
if (pending != null &&
pending != query &&
mounted &&
_urlController.text.trim() == pending) {
// Small delay to let extension's state settle
await Future.delayed(const Duration(milliseconds: 100));
if (mounted && _urlController.text.trim() == pending) {
_executeLiveSearch(pending);
}
}
}
}
Future<void> _performSearch(String query) async {
@@ -119,6 +200,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
}
Future<void> _clearAndRefresh() async {
_liveSearchDebounce?.cancel();
_pendingLiveSearchQuery = null;
_urlController.clear();
_searchFocusNode.unfocus();
_lastSearchQuery = null;
@@ -1260,6 +1343,10 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
/// Handle Enter key press - search or fetch URL
void _onSearchSubmitted() {
// Cancel any pending live search since user explicitly pressed enter
_liveSearchDebounce?.cancel();
_pendingLiveSearchQuery = null;
final text = _urlController.text.trim();
if (text.isEmpty) return;
+9 -2
View File
@@ -452,8 +452,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
},
child: Stack(
children: [
NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) => [
// ScrollConfiguration disables stretch overscroll to fix _StretchController exception
// This is a known Flutter issue with NestedScrollView + Material 3 stretch indicator
ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
overscroll: false,
),
child: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
@@ -696,6 +702,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
),
), // ScrollConfiguration
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
+4
View File
@@ -766,12 +766,16 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
});
try {
// Convert duration from seconds to milliseconds
final durationMs = (item.duration ?? 0) * 1000;
// Add timeout to prevent infinite loading
final result = await PlatformBridge.getLyricsLRC(
item.spotifyId ?? '',
item.trackName,
item.artistName,
filePath: _fileExists ? cleanFilePath : null, // Try embedded lyrics first
durationMs: durationMs,
).timeout(
const Duration(seconds: 20),
onTimeout: () => '', // Return empty string on timeout
+2 -2
View File
@@ -156,10 +156,10 @@ class CsvImportService {
}
String? trackName = getVal(['track name', 'track', 'name', 'title']);
String? artistName = getVal(['artist name', 'artist']);
String? artistName = getVal(['artist name(s)', 'artist name', 'artist', 'artists']);
String? albumName = getVal(['album name', 'album']);
String? isrc = getVal(['isrc']);
String? spotifyId = getVal(['spotify - id', 'spotify id', 'id', 'uri']);
String? spotifyId = getVal(['track uri', 'spotify - id', 'spotify id', 'spotify_id', 'id', 'uri']);
if (spotifyId != null && spotifyId.startsWith('spotify:track:')) {
spotifyId = spotifyId.replaceAll('spotify:track:', '');
+8 -2
View File
@@ -236,32 +236,38 @@ class PlatformBridge {
}
/// Fetch lyrics for a track
/// [durationMs] is the track duration in milliseconds for better matching
static Future<Map<String, dynamic>> fetchLyrics(
String spotifyId,
String trackName,
String artistName,
) async {
String artistName, {
int durationMs = 0,
}) async {
final result = await _channel.invokeMethod('fetchLyrics', {
'spotify_id': spotifyId,
'track_name': trackName,
'artist_name': artistName,
'duration_ms': durationMs,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Get lyrics in LRC format
/// First tries to extract from embedded file, then falls back to internet
/// [durationMs] is the track duration in milliseconds for better matching
static Future<String> getLyricsLRC(
String spotifyId,
String trackName,
String artistName, {
String? filePath,
int durationMs = 0,
}) async {
final result = await _channel.invokeMethod('getLyricsLRC', {
'spotify_id': spotifyId,
'track_name': trackName,
'artist_name': artistName,
'file_path': filePath ?? '',
'duration_ms': durationMs,
});
return result as String;
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none"
version: 3.1.0+59
version: 3.1.1+60
environment:
sdk: ^3.10.0
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none"
version: 3.1.0+59
version: 3.1.1+60
environment:
sdk: ^3.10.0