v3.1.1: Lyrics caching, duration matching, Deezer cover upgrade, live extension search, Russian language, fix race conditions and scroll exceptions

This commit is contained in:
zarzet
2026-01-18 03:15:20 +07:00
parent 47777b4343
commit 6d7b89b881
36 changed files with 1368 additions and 27037 deletions
+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: Is this app safe?**
A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README).
@@ -78,7 +75,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"
},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+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"
}
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+613 -575
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+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