diff --git a/CHANGELOG.md b/CHANGELOG.md index 523dcc0d..313ce43f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## [2.0.2] - 2026-01-03 + +### Added +- **Actual Quality Display**: Shows real audio quality (bit depth/sample rate) after download + - Quality badge on download history items (e.g., "24-bit", "16-bit") + - Full quality info in Track Metadata screen (e.g., "24-bit/96kHz") + - Tertiary color highlight for Hi-Res (24-bit) downloads +- **Quality Disclaimer**: Added note in quality picker explaining that actual quality depends on track availability +- **Instant Lyrics Loading**: Lyrics now load from embedded file first (instant) before falling back to internet fetch + +### Fixed +- **Fallback Service Display**: Fixed download history showing wrong service when fallback occurs (e.g., showing "TIDAL" when actually downloaded from "QOBUZ") +- **Open in Spotify**: Fixed "Open in Spotify" button not opening Spotify app correctly + +### Removed +- **Romaji Conversion**: Removed Japanese lyrics to romaji conversion feature (Kanji not supported, results were incomplete) + +### Technical +- Go backend now returns `actual_bit_depth` and `actual_sample_rate` in download response +- Go backend now returns `service` field indicating actual service used (important for fallback) +- Tidal API v2 response provides exact quality info +- Qobuz uses track metadata for quality info +- Amazon now reads quality from downloaded FLAC file (previously returned unknown) + ## [2.0.1] - 2026-01-03 ### Added diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index d82c5aeb..6a124c10 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -157,8 +157,9 @@ class MainActivity: FlutterActivity() { val spotifyId = call.argument("spotify_id") ?: "" val trackName = call.argument("track_name") ?: "" val artistName = call.argument("artist_name") ?: "" + val filePath = call.argument("file_path") ?: "" val response = withContext(Dispatchers.IO) { - Gobackend.getLyricsLRC(spotifyId, trackName, artistName) + Gobackend.getLyricsLRC(spotifyId, trackName, artistName, filePath) } result.success(response) } diff --git a/go_backend/amazon.go b/go_backend/amazon.go index 55c8870a..af83f15c 100644 --- a/go_backend/amazon.go +++ b/go_backend/amazon.go @@ -254,38 +254,45 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) return nil } +// AmazonDownloadResult contains download result with quality info +type AmazonDownloadResult struct { + FilePath string + BitDepth int + SampleRate int +} + // downloadFromAmazon downloads a track using the request parameters // Uses DoubleDouble service (same as PC version) -func downloadFromAmazon(req DownloadRequest) (string, error) { +func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { downloader := NewAmazonDownloader() // Check for existing file first if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { - return "EXISTS:" + existingFile, nil + return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil } // Get Amazon URL from SongLink songlink := NewSongLinkClient() availability, err := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC) if err != nil { - return "", fmt.Errorf("failed to check Amazon availability via SongLink: %w", err) + return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err) } if !availability.Amazon || availability.AmazonURL == "" { - return "", fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)") + return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)") } // Create output directory if needed if req.OutputDir != "." { if err := os.MkdirAll(req.OutputDir, 0755); err != nil { - return "", fmt.Errorf("failed to create output directory: %w", err) + return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err) } } // Download using DoubleDouble service (same as PC) downloadURL, trackName, artistName, err := downloader.downloadFromDoubleDoubleService(availability.AmazonURL, req.OutputDir) if err != nil { - return "", fmt.Errorf("failed to get download URL: %w", err) + return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err) } // Build filename using Spotify metadata (more accurate) @@ -302,12 +309,12 @@ func downloadFromAmazon(req DownloadRequest) (string, error) { // Check if file already exists if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { - return "EXISTS:" + outputPath, nil + return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil } // Download file with item ID for progress tracking if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil { - return "", fmt.Errorf("download failed: %w", err) + return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err) } // Set progress to 100% and status to finalizing (before embedding) @@ -363,17 +370,6 @@ func downloadFromAmazon(req DownloadRequest) (string, error) { fmt.Println("[Amazon] No lyrics found for this track") } else { fmt.Printf("[Amazon] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines)) - - // Convert Japanese lyrics to romaji if enabled - if req.ConvertLyricsToRomaji { - for i := range lyrics.Lines { - if ContainsKana(lyrics.Lines[i].Words) { - lyrics.Lines[i].Words = ToRomaji(lyrics.Lines[i].Words) - } - } - fmt.Println("[Amazon] Converted Japanese lyrics to romaji") - } - lrcContent := convertToLRC(lyrics) if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil { fmt.Printf("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr) @@ -384,5 +380,24 @@ func downloadFromAmazon(req DownloadRequest) (string, error) { } fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music") - return outputPath, nil + + // Read actual quality from the downloaded FLAC file + // Amazon API doesn't provide quality info, but we can read it from the file itself + quality, err := GetAudioQuality(outputPath) + if err != nil { + fmt.Printf("[Amazon] Warning: couldn't read quality from file: %v\n", err) + // Return 0 to indicate unknown quality + return AmazonDownloadResult{ + FilePath: outputPath, + BitDepth: 0, + SampleRate: 0, + }, nil + } + + fmt.Printf("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate) + return AmazonDownloadResult{ + FilePath: outputPath, + BitDepth: quality.BitDepth, + SampleRate: quality.SampleRate, + }, nil } diff --git a/go_backend/exports.go b/go_backend/exports.go index a1654f4f..95073a9d 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -122,7 +122,6 @@ type DownloadRequest struct { Quality string `json:"quality"` // LOSSLESS, HI_RES, HI_RES_LOSSLESS EmbedLyrics bool `json:"embed_lyrics"` EmbedMaxQualityCover bool `json:"embed_max_quality_cover"` - ConvertLyricsToRomaji bool `json:"convert_lyrics_to_romaji"` TrackNumber int `json:"track_number"` DiscNumber int `json:"disc_number"` TotalTracks int `json:"total_tracks"` @@ -137,6 +136,17 @@ type DownloadResponse struct { FilePath string `json:"file_path,omitempty"` Error string `json:"error,omitempty"` AlreadyExists bool `json:"already_exists,omitempty"` + // Actual quality info from the source + ActualBitDepth int `json:"actual_bit_depth,omitempty"` + ActualSampleRate int `json:"actual_sample_rate,omitempty"` + Service string `json:"service,omitempty"` // Actual service used (for fallback) +} + +// DownloadResult is a generic result type for all downloaders +type DownloadResult struct { + FilePath string + BitDepth int + SampleRate int } // DownloadTrack downloads a track from the specified service @@ -155,16 +165,40 @@ func DownloadTrack(requestJSON string) (string, error) { req.AlbumArtist = strings.TrimSpace(req.AlbumArtist) req.OutputDir = strings.TrimSpace(req.OutputDir) - var filePath string + var result DownloadResult var err error switch req.Service { case "tidal": - filePath, err = downloadFromTidal(req) + tidalResult, tidalErr := downloadFromTidal(req) + if tidalErr == nil { + result = DownloadResult{ + FilePath: tidalResult.FilePath, + BitDepth: tidalResult.BitDepth, + SampleRate: tidalResult.SampleRate, + } + } + err = tidalErr case "qobuz": - filePath, err = downloadFromQobuz(req) + qobuzResult, qobuzErr := downloadFromQobuz(req) + if qobuzErr == nil { + result = DownloadResult{ + FilePath: qobuzResult.FilePath, + BitDepth: qobuzResult.BitDepth, + SampleRate: qobuzResult.SampleRate, + } + } + err = qobuzErr case "amazon": - filePath, err = downloadFromAmazon(req) + amazonResult, amazonErr := downloadFromAmazon(req) + if amazonErr == nil { + result = DownloadResult{ + FilePath: amazonResult.FilePath, + BitDepth: amazonResult.BitDepth, + SampleRate: amazonResult.SampleRate, + } + } + err = amazonErr default: return errorResponse("Unknown service: " + req.Service) } @@ -174,21 +208,25 @@ func DownloadTrack(requestJSON string) (string, error) { } // Check if file already exists - if len(filePath) > 7 && filePath[:7] == "EXISTS:" { + if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" { resp := DownloadResponse{ Success: true, Message: "File already exists", - FilePath: filePath[7:], + FilePath: result.FilePath[7:], AlreadyExists: true, + Service: req.Service, } jsonBytes, _ := json.Marshal(resp) return string(jsonBytes), nil } resp := DownloadResponse{ - Success: true, - Message: "Download complete", - FilePath: filePath, + Success: true, + Message: "Download complete", + FilePath: result.FilePath, + ActualBitDepth: result.BitDepth, + ActualSampleRate: result.SampleRate, + Service: req.Service, } jsonBytes, _ := json.Marshal(resp) @@ -230,35 +268,63 @@ func DownloadWithFallback(requestJSON string) (string, error) { for _, service := range services { req.Service = service - var filePath string + var result DownloadResult var err error switch service { case "tidal": - filePath, err = downloadFromTidal(req) + tidalResult, tidalErr := downloadFromTidal(req) + if tidalErr == nil { + result = DownloadResult{ + FilePath: tidalResult.FilePath, + BitDepth: tidalResult.BitDepth, + SampleRate: tidalResult.SampleRate, + } + } + err = tidalErr case "qobuz": - filePath, err = downloadFromQobuz(req) + qobuzResult, qobuzErr := downloadFromQobuz(req) + if qobuzErr == nil { + result = DownloadResult{ + FilePath: qobuzResult.FilePath, + BitDepth: qobuzResult.BitDepth, + SampleRate: qobuzResult.SampleRate, + } + } + err = qobuzErr case "amazon": - filePath, err = downloadFromAmazon(req) + amazonResult, amazonErr := downloadFromAmazon(req) + if amazonErr == nil { + result = DownloadResult{ + FilePath: amazonResult.FilePath, + BitDepth: amazonResult.BitDepth, + SampleRate: amazonResult.SampleRate, + } + } + err = amazonErr } if err == nil { // Check if file already exists - if len(filePath) > 7 && filePath[:7] == "EXISTS:" { + if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" { resp := DownloadResponse{ Success: true, Message: "File already exists", - FilePath: filePath[7:], + FilePath: result.FilePath[7:], AlreadyExists: true, + Service: service, } jsonBytes, _ := json.Marshal(resp) return string(jsonBytes), nil } resp := DownloadResponse{ - Success: true, - Message: "Downloaded from " + service, - FilePath: filePath, + Success: true, + Message: "Downloaded from " + service, + FilePath: result.FilePath, + ActualBitDepth: result.BitDepth, + ActualSampleRate: result.SampleRate, + Service: service, } jsonBytes, _ := json.Marshal(resp) return string(jsonBytes), nil @@ -367,14 +433,24 @@ func FetchLyrics(spotifyID, trackName, artistName string) (string, error) { } // GetLyricsLRC fetches lyrics and converts to LRC format string -func GetLyricsLRC(spotifyID, trackName, artistName string) (string, error) { +// First tries to extract from file, then falls back to fetching from internet +func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (string, error) { + // Try to extract from file first (much faster) + if filePath != "" { + lyrics, err := ExtractLyrics(filePath) + if err == nil && lyrics != "" { + return lyrics, nil + } + } + + // Fallback to fetching from internet client := NewLyricsClient() - lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName) + lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName) if err != nil { return "", err } - lrcContent := convertToLRC(lyrics) + lrcContent := convertToLRC(lyricsData) return lrcContent, nil } @@ -394,12 +470,6 @@ func EmbedLyricsToFile(filePath, lyrics string) (string, error) { return string(jsonBytes), nil } -// ConvertToRomaji converts Japanese kana (Hiragana/Katakana) to romaji -// Kanji characters are preserved as-is -func ConvertToRomaji(text string) string { - return ToRomaji(text) -} - func errorResponse(msg string) (string, error) { resp := DownloadResponse{ Success: false, diff --git a/go_backend/metadata.go b/go_backend/metadata.go index 496b1dda..a8537470 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -335,3 +335,92 @@ func EmbedLyrics(filePath string, lyrics string) error { return f.Save(filePath) } + +// ExtractLyrics extracts embedded lyrics from a FLAC file +func ExtractLyrics(filePath string) (string, error) { + f, err := flac.ParseFile(filePath) + if err != nil { + return "", fmt.Errorf("failed to parse FLAC file: %w", err) + } + + for _, meta := range f.Meta { + if meta.Type == flac.VorbisComment { + cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta) + if err != nil { + continue + } + + // Try LYRICS tag first + lyrics, err := cmt.Get("LYRICS") + if err == nil && len(lyrics) > 0 && lyrics[0] != "" { + return lyrics[0], nil + } + + // Fallback to UNSYNCEDLYRICS + lyrics, err = cmt.Get("UNSYNCEDLYRICS") + if err == nil && len(lyrics) > 0 && lyrics[0] != "" { + return lyrics[0], nil + } + } + } + + return "", fmt.Errorf("no lyrics found in file") +} + +// AudioQuality represents audio quality info from a FLAC file +type AudioQuality struct { + BitDepth int `json:"bit_depth"` + SampleRate int `json:"sample_rate"` +} + +// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block +// FLAC StreamInfo is always the first metadata block after the 4-byte "fLaC" marker +func GetAudioQuality(filePath string) (AudioQuality, error) { + file, err := os.Open(filePath) + if err != nil { + return AudioQuality{}, fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + // Read FLAC marker (4 bytes: "fLaC") + marker := make([]byte, 4) + if _, err := file.Read(marker); err != nil { + return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err) + } + if string(marker) != "fLaC" { + return AudioQuality{}, fmt.Errorf("not a FLAC file") + } + + // Read metadata block header (4 bytes) + // Byte 0: bit 7 = last block flag, bits 0-6 = block type (0 = STREAMINFO) + // Bytes 1-3: block length (24-bit big-endian) + header := make([]byte, 4) + if _, err := file.Read(header); err != nil { + return AudioQuality{}, fmt.Errorf("failed to read header: %w", err) + } + + blockType := header[0] & 0x7F + if blockType != 0 { + return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO") + } + + // Read STREAMINFO block (34 bytes minimum) + // Bytes 10-13 contain sample rate (20 bits), channels (3 bits), bits per sample (5 bits) + streamInfo := make([]byte, 34) + if _, err := file.Read(streamInfo); err != nil { + return AudioQuality{}, fmt.Errorf("failed to read STREAMINFO: %w", err) + } + + // Parse sample rate (20 bits starting at byte 10) + // Bytes 10-12: [SSSS SSSS] [SSSS SSSS] [SSSS CCCC] where S=sample rate, C=channels + sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4) + + // Parse bits per sample (5 bits) + // Byte 12 bits 0-3 and byte 13 bit 7: [.... BBBB] [B...] where B=bits per sample - 1 + bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1 + + return AudioQuality{ + BitDepth: bitsPerSample, + SampleRate: sampleRate, + }, nil +} diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 97c15511..db4c303b 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -305,13 +305,20 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e return err } +// QobuzDownloadResult contains download result with quality info +type QobuzDownloadResult struct { + FilePath string + BitDepth int + SampleRate int +} + // downloadFromQobuz downloads a track using the request parameters -func downloadFromQobuz(req DownloadRequest) (string, error) { +func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { downloader := NewQobuzDownloader() // Check for existing file first if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { - return "EXISTS:" + existingFile, nil + return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil } var track *QobuzTrack @@ -332,7 +339,7 @@ func downloadFromQobuz(req DownloadRequest) (string, error) { if err != nil { errMsg = err.Error() } - return "", fmt.Errorf("qobuz search failed: %s", errMsg) + return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg) } // Build filename @@ -349,7 +356,7 @@ func downloadFromQobuz(req DownloadRequest) (string, error) { // Check if file already exists if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { - return "EXISTS:" + outputPath, nil + return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil } // Map quality from Tidal format to Qobuz format @@ -366,15 +373,20 @@ func downloadFromQobuz(req DownloadRequest) (string, error) { } fmt.Printf("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality) + // Get actual quality from track metadata + actualBitDepth := track.MaximumBitDepth + actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz + fmt.Printf("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate) + // Get download URL using parallel API requests downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality) if err != nil { - return "", fmt.Errorf("failed to get download URL: %w", err) + return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err) } // Download file with item ID for progress tracking if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil { - return "", fmt.Errorf("download failed: %w", err) + return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err) } // Set progress to 100% and status to finalizing (before embedding) @@ -425,17 +437,6 @@ func downloadFromQobuz(req DownloadRequest) (string, error) { fmt.Println("[Qobuz] No lyrics found for this track") } else { fmt.Printf("[Qobuz] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines)) - - // Convert Japanese lyrics to romaji if enabled - if req.ConvertLyricsToRomaji { - for i := range lyrics.Lines { - if ContainsKana(lyrics.Lines[i].Words) { - lyrics.Lines[i].Words = ToRomaji(lyrics.Lines[i].Words) - } - } - fmt.Println("[Qobuz] Converted Japanese lyrics to romaji") - } - lrcContent := convertToLRC(lyrics) if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil { fmt.Printf("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr) @@ -445,5 +446,9 @@ func downloadFromQobuz(req DownloadRequest) (string, error) { } } - return outputPath, nil + return QobuzDownloadResult{ + FilePath: outputPath, + BitDepth: actualBitDepth, + SampleRate: actualSampleRate, + }, nil } diff --git a/go_backend/romaji.go b/go_backend/romaji.go deleted file mode 100644 index 6acf10a6..00000000 --- a/go_backend/romaji.go +++ /dev/null @@ -1,276 +0,0 @@ -package gobackend - -import ( - "strings" - "unicode" -) - -// Japanese character ranges -const ( - hiraganaStart = 0x3040 - hiraganaEnd = 0x309F - katakanaStart = 0x30A0 - katakanaEnd = 0x30FF - kanjiStart = 0x4E00 - kanjiEnd = 0x9FFF -) - -// hiraganaToRomaji maps hiragana characters to romaji -var hiraganaToRomaji = map[rune]string{ - // Basic vowels - 'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o", - // K-row - 'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko", - // S-row - 'さ': "sa", 'し': "shi", 'す': "su", 'せ': "se", 'そ': "so", - // T-row - 'た': "ta", 'ち': "chi", 'つ': "tsu", 'て': "te", 'と': "to", - // N-row - 'な': "na", 'に': "ni", 'ぬ': "nu", 'ね': "ne", 'の': "no", - // H-row - 'は': "ha", 'ひ': "hi", 'ふ': "fu", 'へ': "he", 'ほ': "ho", - // M-row - 'ま': "ma", 'み': "mi", 'む': "mu", 'め': "me", 'も': "mo", - // Y-row - 'や': "ya", 'ゆ': "yu", 'よ': "yo", - // R-row - 'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro", - // W-row - 'わ': "wa", 'を': "wo", - // N - 'ん': "n", - // Voiced (dakuten) - G-row - 'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go", - // Z-row - 'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo", - // D-row - 'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do", - // B-row - 'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo", - // P-row (handakuten) - 'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po", - // Small characters - 'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo", - 'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o", - 'っ': "", // Small tsu - handled specially - // Long vowel mark - 'ー': "", -} - -// katakanaToRomaji maps katakana characters to romaji -var katakanaToRomaji = map[rune]string{ - // Basic vowels - 'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o", - // K-row - 'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko", - // S-row - 'サ': "sa", 'シ': "shi", 'ス': "su", 'セ': "se", 'ソ': "so", - // T-row - 'タ': "ta", 'チ': "chi", 'ツ': "tsu", 'テ': "te", 'ト': "to", - // N-row - 'ナ': "na", 'ニ': "ni", 'ヌ': "nu", 'ネ': "ne", 'ノ': "no", - // H-row - 'ハ': "ha", 'ヒ': "hi", 'フ': "fu", 'ヘ': "he", 'ホ': "ho", - // M-row - 'マ': "ma", 'ミ': "mi", 'ム': "mu", 'メ': "me", 'モ': "mo", - // Y-row - 'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo", - // R-row - 'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro", - // W-row - 'ワ': "wa", 'ヲ': "wo", - // N - 'ン': "n", - // Voiced (dakuten) - G-row - 'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go", - // Z-row - 'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo", - // D-row - 'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do", - // B-row - 'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo", - // P-row (handakuten) - 'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po", - // Small characters - 'ャ': "ya", 'ュ': "yu", 'ョ': "yo", - 'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o", - 'ッ': "", // Small tsu - handled specially - // Extended katakana - 'ヴ': "vu", - // Long vowel mark - 'ー': "", -} - -// Extended katakana combinations (multi-character) -var katakanaExtended = map[string]string{ - "ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo", -} - -// Combination mappings for small ya/yu/yo -var hiraganaCombo = map[string]string{ - "きゃ": "kya", "きゅ": "kyu", "きょ": "kyo", - "しゃ": "sha", "しゅ": "shu", "しょ": "sho", - "ちゃ": "cha", "ちゅ": "chu", "ちょ": "cho", - "にゃ": "nya", "にゅ": "nyu", "にょ": "nyo", - "ひゃ": "hya", "ひゅ": "hyu", "ひょ": "hyo", - "みゃ": "mya", "みゅ": "myu", "みょ": "myo", - "りゃ": "rya", "りゅ": "ryu", "りょ": "ryo", - "ぎゃ": "gya", "ぎゅ": "gyu", "ぎょ": "gyo", - "じゃ": "ja", "じゅ": "ju", "じょ": "jo", - "びゃ": "bya", "びゅ": "byu", "びょ": "byo", - "ぴゃ": "pya", "ぴゅ": "pyu", "ぴょ": "pyo", -} - -var katakanaCombo = map[string]string{ - "キャ": "kya", "キュ": "kyu", "キョ": "kyo", - "シャ": "sha", "シュ": "shu", "ショ": "sho", - "チャ": "cha", "チュ": "chu", "チョ": "cho", - "ニャ": "nya", "ニュ": "nyu", "ニョ": "nyo", - "ヒャ": "hya", "ヒュ": "hyu", "ヒョ": "hyo", - "ミャ": "mya", "ミュ": "myu", "ミョ": "myo", - "リャ": "rya", "リュ": "ryu", "リョ": "ryo", - "ギャ": "gya", "ギュ": "gyu", "ギョ": "gyo", - "ジャ": "ja", "ジュ": "ju", "ジョ": "jo", - "ビャ": "bya", "ビュ": "byu", "ビョ": "byo", - "ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo", - // Extended katakana combinations - "ティ": "ti", "ディ": "di", - "トゥ": "tu", "ドゥ": "du", - "ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo", - "ウィ": "wi", "ウェ": "we", "ウォ": "wo", - "ヴァ": "va", "ヴィ": "vi", "ヴェ": "ve", "ヴォ": "vo", -} - -// ContainsJapanese checks if a string contains Japanese characters (Hiragana, Katakana, or Kanji) -func ContainsJapanese(s string) bool { - for _, r := range s { - if isHiragana(r) || isKatakana(r) || isKanji(r) { - return true - } - } - return false -} - -// ContainsKana checks if a string contains Hiragana or Katakana (convertible to romaji) -func ContainsKana(s string) bool { - for _, r := range s { - if isHiragana(r) || isKatakana(r) { - return true - } - } - return false -} - -func isHiragana(r rune) bool { - return r >= hiraganaStart && r <= hiraganaEnd -} - -func isKatakana(r rune) bool { - return r >= katakanaStart && r <= katakanaEnd -} - -func isKanji(r rune) bool { - return r >= kanjiStart && r <= kanjiEnd -} - -// ToRomaji converts Japanese kana (Hiragana/Katakana) to romaji -// Kanji characters are preserved as-is since they require dictionary lookup -func ToRomaji(s string) string { - if !ContainsKana(s) { - return s - } - - runes := []rune(s) - var result strings.Builder - result.Grow(len(s) * 2) // Romaji is typically longer - - i := 0 - for i < len(runes) { - r := runes[i] - - // Check for two-character combinations first - if i+1 < len(runes) { - combo := string(runes[i : i+2]) - if romaji, ok := hiraganaCombo[combo]; ok { - result.WriteString(romaji) - i += 2 - continue - } - if romaji, ok := katakanaCombo[combo]; ok { - result.WriteString(romaji) - i += 2 - continue - } - } - - // Handle small tsu (っ/ッ) - doubles the next consonant - if r == 'っ' || r == 'ッ' { - if i+1 < len(runes) { - nextRune := runes[i+1] - var nextRomaji string - if romaji, ok := hiraganaToRomaji[nextRune]; ok { - nextRomaji = romaji - } else if romaji, ok := katakanaToRomaji[nextRune]; ok { - nextRomaji = romaji - } - if len(nextRomaji) > 0 { - result.WriteByte(nextRomaji[0]) // Double the consonant - } - } - i++ - continue - } - - // Handle long vowel mark (ー) - if r == 'ー' { - // Extend the previous vowel - resultStr := result.String() - if len(resultStr) > 0 { - lastChar := resultStr[len(resultStr)-1] - if lastChar == 'a' || lastChar == 'i' || lastChar == 'u' || lastChar == 'e' || lastChar == 'o' { - result.WriteByte(lastChar) - } - } - i++ - continue - } - - // Single character conversion - if romaji, ok := hiraganaToRomaji[r]; ok { - result.WriteString(romaji) - i++ - continue - } - - if romaji, ok := katakanaToRomaji[r]; ok { - result.WriteString(romaji) - i++ - continue - } - - // Keep non-Japanese characters as-is - if unicode.IsSpace(r) { - result.WriteRune(' ') - } else { - result.WriteRune(r) - } - i++ - } - - return result.String() -} - -// GetRomajiVariants returns search variants for Japanese text -// Returns the original string plus romaji version if applicable -func GetRomajiVariants(s string) []string { - variants := []string{s} - - if ContainsKana(s) { - romaji := ToRomaji(s) - if romaji != s && strings.TrimSpace(romaji) != "" { - variants = append(variants, romaji) - } - } - - return variants -} diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 664c0105..142fc662 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -335,33 +335,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s queries = append(queries, trackName) } - // Strategy 3: Romaji versions if Japanese detected - if ContainsJapanese(trackName) || ContainsJapanese(artistName) { - // Try romaji version of track name - if ContainsKana(trackName) { - romajiTrack := ToRomaji(trackName) - if romajiTrack != trackName { - if artistName != "" { - queries = append(queries, artistName+" "+romajiTrack) - } - queries = append(queries, romajiTrack) - } - } - // Try romaji version of artist name - if ContainsKana(artistName) { - romajiArtist := ToRomaji(artistName) - if romajiArtist != artistName { - queries = append(queries, romajiArtist+" "+trackName) - // Try both romaji - if ContainsKana(trackName) { - romajiTrack := ToRomaji(trackName) - queries = append(queries, romajiArtist+" "+romajiTrack) - } - } - } - } - - // Strategy 4: Artist only as last resort + // Strategy 3: Artist only as last resort if artistName != "" { queries = append(queries, artistName) } @@ -483,11 +457,18 @@ func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (* } +// TidalDownloadInfo contains download URL and quality info +type TidalDownloadInfo struct { + URL string + BitDepth int + SampleRate int +} + // getDownloadURLSequential requests download URL from APIs sequentially // Returns the first successful result (supports both v1 and v2 API formats) -func getDownloadURLSequential(apis []string, trackID int64, quality string) (string, string, error) { +func getDownloadURLSequential(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) { if len(apis) == 0 { - return "", "", fmt.Errorf("no APIs available") + return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available") } client := NewHTTPClientWithTimeout(DefaultTimeout) @@ -519,7 +500,12 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str // Try v2 format first (object with manifest) var v2Response TidalAPIResponseV2 if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" { - return apiURL, "MANIFEST:" + v2Response.Data.Manifest, nil + info := TidalDownloadInfo{ + URL: "MANIFEST:" + v2Response.Data.Manifest, + BitDepth: v2Response.Data.BitDepth, + SampleRate: v2Response.Data.SampleRate, + } + return apiURL, info, nil } // Fallback to v1 format (array with OriginalTrackUrl) @@ -529,7 +515,13 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str if err := json.Unmarshal(body, &v1Responses); err == nil { for _, item := range v1Responses { if item.OriginalTrackURL != "" { - return apiURL, item.OriginalTrackURL, nil + // v1 format doesn't have quality info, assume 16-bit/44.1kHz + info := TidalDownloadInfo{ + URL: item.OriginalTrackURL, + BitDepth: 16, + SampleRate: 44100, + } + return apiURL, info, nil } } } @@ -537,22 +529,22 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "no download URL or manifest in response")) } - return "", "", fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors) + return "", TidalDownloadInfo{}, fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors) } // GetDownloadURL gets download URL for a track - tries APIs sequentially -func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) { +func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDownloadInfo, error) { apis := t.GetAvailableAPIs() if len(apis) == 0 { - return "", fmt.Errorf("no API URL configured") + return TidalDownloadInfo{}, fmt.Errorf("no API URL configured") } - _, downloadURL, err := getDownloadURLSequential(apis, trackID, quality) + _, info, err := getDownloadURLSequential(apis, trackID, quality) if err != nil { - return "", fmt.Errorf("failed to get download URL: %w", err) + return TidalDownloadInfo{}, fmt.Errorf("failed to get download URL: %w", err) } - return downloadURL, nil + return info, nil } // parseManifest parses Tidal manifest (supports both BTS and DASH formats) @@ -812,13 +804,20 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s return nil } +// TidalDownloadResult contains download result with quality info +type TidalDownloadResult struct { + FilePath string + BitDepth int + SampleRate int +} + // downloadFromTidal downloads a track using the request parameters -func downloadFromTidal(req DownloadRequest) (string, error) { +func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { downloader := NewTidalDownloader() // Check for existing file first if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { - return "EXISTS:" + existingFile, nil + return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil } var track *TidalTrack @@ -851,7 +850,7 @@ func downloadFromTidal(req DownloadRequest) (string, error) { if err != nil { errMsg = err.Error() } - return "", fmt.Errorf("tidal search failed: %s", errMsg) + return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg) } // Build filename @@ -868,7 +867,7 @@ func downloadFromTidal(req DownloadRequest) (string, error) { // Check if file already exists if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { - return "EXISTS:" + outputPath, nil + return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil } // Determine quality to use (default to LOSSLESS if not specified) @@ -879,14 +878,17 @@ func downloadFromTidal(req DownloadRequest) (string, error) { fmt.Printf("[Tidal] Using quality: %s\n", quality) // Get download URL using parallel API requests - downloadURL, err := downloader.GetDownloadURL(track.ID, quality) + downloadInfo, err := downloader.GetDownloadURL(track.ID, quality) if err != nil { - return "", fmt.Errorf("failed to get download URL: %w", err) + return TidalDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err) } + // Log actual quality received + fmt.Printf("[Tidal] Actual quality: %d-bit/%dHz\n", downloadInfo.BitDepth, downloadInfo.SampleRate) + // Download file with item ID for progress tracking - if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil { - return "", fmt.Errorf("download failed: %w", err) + if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil { + return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err) } // Set progress to 100% and status to finalizing (before embedding) @@ -906,7 +908,7 @@ func downloadFromTidal(req DownloadRequest) (string, error) { fmt.Printf("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath) } else if _, err := os.Stat(outputPath); err != nil { // Neither FLAC nor M4A exists - return "", fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath) + return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath) } // Embed metadata @@ -952,17 +954,6 @@ func downloadFromTidal(req DownloadRequest) (string, error) { fmt.Println("[Tidal] No lyrics found for this track") } else { fmt.Printf("[Tidal] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines)) - - // Convert Japanese lyrics to romaji if enabled - if req.ConvertLyricsToRomaji { - for i := range lyrics.Lines { - if ContainsKana(lyrics.Lines[i].Words) { - lyrics.Lines[i].Words = ToRomaji(lyrics.Lines[i].Words) - } - } - fmt.Println("[Tidal] Converted Japanese lyrics to romaji") - } - lrcContent := convertToLRC(lyrics) if embedErr := EmbedLyrics(actualOutputPath, lrcContent); embedErr != nil { fmt.Printf("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr) @@ -975,5 +966,9 @@ func downloadFromTidal(req DownloadRequest) (string, error) { fmt.Printf("[Tidal] Skipping metadata embed for M4A file (will be handled after conversion): %s\n", actualOutputPath) } - return actualOutputPath, nil + return TidalDownloadResult{ + FilePath: actualOutputPath, + BitDepth: downloadInfo.BitDepth, + SampleRate: downloadInfo.SampleRate, + }, nil } diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index e637c7ae..88359b76 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -164,7 +164,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 = GobackendGetLyricsLRC(spotifyId, trackName, artistName, &error) + let filePath = args["file_path"] as? String ?? "" + let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, &error) if let error = error { throw error } return response diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 95aabbe6..38f39966 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -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 = '2.0.1'; - static const String buildNumber = '31'; + static const String version = '2.0.2'; + static const String buildNumber = '32'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 410a1c7e..12be2412 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -16,7 +16,6 @@ class AppSettings { final bool checkForUpdates; // Check for updates on app start final bool hasSearchedBefore; // Hide helper text after first search final String folderOrganization; // none, artist, album, artist_album - final bool convertLyricsToRomaji; // Convert Japanese lyrics to romaji final String historyViewMode; // list, grid final bool askQualityBeforeDownload; // Show quality picker before each download @@ -33,7 +32,6 @@ class AppSettings { this.checkForUpdates = true, // Default: enabled this.hasSearchedBefore = false, // Default: show helper text this.folderOrganization = 'none', // Default: no folder organization - this.convertLyricsToRomaji = false, // Default: keep original Japanese this.historyViewMode = 'grid', // Default: grid view this.askQualityBeforeDownload = true, // Default: ask quality before download }); @@ -51,7 +49,6 @@ class AppSettings { bool? checkForUpdates, bool? hasSearchedBefore, String? folderOrganization, - bool? convertLyricsToRomaji, String? historyViewMode, bool? askQualityBeforeDownload, }) { @@ -68,7 +65,6 @@ class AppSettings { checkForUpdates: checkForUpdates ?? this.checkForUpdates, hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore, folderOrganization: folderOrganization ?? this.folderOrganization, - convertLyricsToRomaji: convertLyricsToRomaji ?? this.convertLyricsToRomaji, historyViewMode: historyViewMode ?? this.historyViewMode, askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload, ); diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index bc855a2a..46aefe93 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -19,7 +19,6 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( checkForUpdates: json['checkForUpdates'] as bool? ?? true, hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false, folderOrganization: json['folderOrganization'] as String? ?? 'none', - convertLyricsToRomaji: json['convertLyricsToRomaji'] as bool? ?? false, historyViewMode: json['historyViewMode'] as String? ?? 'grid', askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true, ); @@ -38,7 +37,6 @@ Map _$AppSettingsToJson(AppSettings instance) => 'checkForUpdates': instance.checkForUpdates, 'hasSearchedBefore': instance.hasSearchedBefore, 'folderOrganization': instance.folderOrganization, - 'convertLyricsToRomaji': instance.convertLyricsToRomaji, 'historyViewMode': instance.historyViewMode, 'askQualityBeforeDownload': instance.askQualityBeforeDownload, }; diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index a92d7eb6..daba2218 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1007,7 +1007,6 @@ class DownloadQueueNotifier extends Notifier { releaseDate: item.track.releaseDate, preferredService: item.service, itemId: item.id, // Pass item ID for progress tracking - convertLyricsToRomaji: settings.convertLyricsToRomaji, ); } else { result = await PlatformBridge.downloadTrack( @@ -1026,7 +1025,6 @@ class DownloadQueueNotifier extends Notifier { discNumber: item.track.discNumber ?? 1, releaseDate: item.track.releaseDate, itemId: item.id, // Pass item ID for progress tracking - convertLyricsToRomaji: settings.convertLyricsToRomaji, ); } @@ -1036,6 +1034,20 @@ class DownloadQueueNotifier extends Notifier { var filePath = result['file_path'] as String?; _log.i('Download success, file: $filePath'); + // Get actual quality from response (if available) + final actualBitDepth = result['actual_bit_depth'] as int?; + final actualSampleRate = result['actual_sample_rate'] as int?; + String actualQuality = quality; // Default to requested quality + + if (actualBitDepth != null && actualBitDepth > 0) { + // Format: "24-bit/96kHz" or "16-bit/44.1kHz" + final sampleRateKHz = actualSampleRate != null && actualSampleRate > 0 + ? (actualSampleRate / 1000).toStringAsFixed(actualSampleRate % 1000 == 0 ? 0 : 1) + : '?'; + actualQuality = '$actualBitDepth-bit/${sampleRateKHz}kHz'; + _log.i('Actual quality: $actualQuality'); + } + // Check if file is M4A (DASH stream from Tidal) and needs remuxing to FLAC if (filePath != null && filePath.endsWith('.m4a')) { _log.d('Converting M4A to FLAC...'); @@ -1096,7 +1108,7 @@ class DownloadQueueNotifier extends Notifier { discNumber: item.track.discNumber, duration: item.track.duration, releaseDate: item.track.releaseDate, - quality: quality, + quality: actualQuality, ), ); diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index cb54f9ea..bc58ba77 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -89,11 +89,6 @@ class SettingsNotifier extends Notifier { _saveSettings(); } - void setConvertLyricsToRomaji(bool enabled) { - state = state.copyWith(convertLyricsToRomaji: enabled); - _saveSettings(); - } - void setHistoryViewMode(String mode) { state = state.copyWith(historyViewMode: mode); _saveSettings(); diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 1eb2024a..4ae15a02 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -349,6 +349,17 @@ class _AlbumScreenState extends ConsumerState { padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), ), + // Disclaimer + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), + child: Text( + 'Actual quality depends on track availability. Hi-Res may not be available for all tracks.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ), _QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); }), _QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES'); }), _QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }), diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index ac30d485..18266612 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -222,6 +222,17 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), ), + // Disclaimer + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), + child: Text( + 'Actual quality depends on track availability. Hi-Res may not be available for all tracks.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ), _QualityPickerOption( title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 2d42642d..c9ab581f 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -211,6 +211,17 @@ class PlaylistScreen extends ConsumerWidget { Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))), ], Padding(padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold))), + // Disclaimer + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), + child: Text( + 'Actual quality depends on track availability. Hi-Res may not be available for all tracks.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ), _QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); }), _QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES'); }), _QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }), diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 0b4aadb9..6a616f27 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -558,6 +558,31 @@ class _QueueTabState extends ConsumerState { ), ), ), + // Quality badge (top-left) + if (item.quality != null && item.quality!.contains('bit')) + Positioned( + left: 4, + top: 4, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: item.quality!.startsWith('24') + ? colorScheme.tertiary + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + item.quality!.split('/').first, // Just show "24-bit" or "16-bit" + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: item.quality!.startsWith('24') + ? colorScheme.onTertiary + : colorScheme.onSurfaceVariant, + fontSize: 9, + fontWeight: FontWeight.w600, + ), + ), + ), + ), // Play button overlay if (fileExists) Positioned( @@ -677,11 +702,38 @@ class _QueueTabState extends ConsumerState { ), ), const SizedBox(height: 2), - Text( - dateStr, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), - ), + Row( + children: [ + Text( + dateStr, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), + ), + ), + // Quality badge + if (item.quality != null && item.quality!.contains('bit')) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: item.quality!.startsWith('24') + ? colorScheme.tertiaryContainer + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + item.quality!, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: item.quality!.startsWith('24') + ? colorScheme.onTertiaryContainer + : colorScheme.onSurfaceVariant, + fontSize: 10, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ], ), ], ), diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index 96a7c996..df39925f 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -99,23 +99,6 @@ class OptionsSettingsPage extends ConsumerWidget { ), ), - // Lyrics section - const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Lyrics')), - SliverToBoxAdapter( - child: SettingsGroup( - children: [ - SettingsSwitchItem( - icon: Icons.translate, - title: 'Convert Japanese to Romaji', - subtitle: 'Auto-convert Hiragana/Katakana lyrics', - value: settings.convertLyricsToRomaji, - onChanged: (v) => ref.read(settingsProvider.notifier).setConvertLyricsToRomaji(v), - showDivider: false, - ), - ], - ), - ), - // App section const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'App')), SliverToBoxAdapter( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 6bd8f184..7b0e3fdf 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -392,7 +392,19 @@ class SettingsScreen extends ConsumerWidget { title: const Text('Select Quality'), content: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Disclaimer + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + 'Actual quality depends on track availability. Hi-Res may not be available for all tracks.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ), _buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme), _buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 192kHz', current, colorScheme), ], diff --git a/lib/screens/settings_tab.dart b/lib/screens/settings_tab.dart index 9b9de98c..29c41236 100644 --- a/lib/screens/settings_tab.dart +++ b/lib/screens/settings_tab.dart @@ -389,7 +389,19 @@ class _SettingsTabState extends ConsumerState with AutomaticKeepAli title: const Text('Select Quality'), content: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Disclaimer + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + 'Actual quality depends on track availability. Hi-Res may not be available for all tracks.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ), _buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme), _buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 96kHz', current, colorScheme), _buildQualityOption(context, ref, 'HI_RES_LOSSLESS', 'Hi-Res FLAC Max', '24-bit / up to 192kHz', current, colorScheme), diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 6012c291..bfeb61dd 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -47,6 +47,11 @@ class _TrackMetadataScreenState extends ConsumerState { _fileExists = exists; _fileSize = size; }); + + // Auto-load lyrics if file exists (embedded lyrics are instant) + if (exists) { + _fetchLyrics(); + } } } @@ -359,22 +364,38 @@ class _TrackMetadataScreenState extends ConsumerState { Future _openSpotifyUrl(BuildContext context) async { if (item.spotifyId == null) return; - final url = 'https://open.spotify.com/track/${item.spotifyId}'; + final webUrl = 'https://open.spotify.com/track/${item.spotifyId}'; + final spotifyUri = Uri.parse('spotify:track:${item.spotifyId}'); + try { - // Try to open in Spotify app first, fallback to browser - final uri = Uri.parse('spotify:track:${item.spotifyId}'); - // ignore: deprecated_member_use - if (await canLaunchUrl(uri)) { - await launchUrl(uri); - } else { - await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); + // Try to open in Spotify app first using URI scheme + final launched = await launchUrl( + spotifyUri, + mode: LaunchMode.externalApplication, + ); + + if (!launched) { + // Fallback to web URL which will redirect to app if installed + await launchUrl( + Uri.parse(webUrl), + mode: LaunchMode.externalApplication, + ); } } catch (e) { - if (context.mounted) { - _copyToClipboard(context, url); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Spotify URL copied to clipboard')), + // If URI scheme fails, try web URL + try { + await launchUrl( + Uri.parse(webUrl), + mode: LaunchMode.externalApplication, ); + } catch (_) { + // Last resort: copy to clipboard + if (context.mounted) { + _copyToClipboard(context, webUrl); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Spotify URL copied to clipboard')), + ); + } } } } @@ -392,6 +413,8 @@ class _TrackMetadataScreenState extends ConsumerState { _MetadataItem('Disc number', item.discNumber.toString()), if (item.duration != null) _MetadataItem('Duration', _formatDuration(item.duration!)), + if (item.quality != null && item.quality!.contains('bit')) + _MetadataItem('Audio quality', item.quality!), if (item.releaseDate != null && item.releaseDate!.isNotEmpty) _MetadataItem('Release date', item.releaseDate!), if (item.isrc != null && item.isrc!.isNotEmpty) @@ -740,6 +763,7 @@ class _TrackMetadataScreenState extends ConsumerState { item.spotifyId ?? '', item.trackName, item.artistName, + filePath: _fileExists ? item.filePath : null, // Try embedded lyrics first ); if (mounted) { diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 8597c3e8..f97216f4 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -60,7 +60,6 @@ class PlatformBridge { String quality = 'LOSSLESS', bool embedLyrics = true, bool embedMaxQualityCover = true, - bool convertLyricsToRomaji = false, int trackNumber = 1, int discNumber = 1, int totalTracks = 1, @@ -81,7 +80,6 @@ class PlatformBridge { 'quality': quality, 'embed_lyrics': embedLyrics, 'embed_max_quality_cover': embedMaxQualityCover, - 'convert_lyrics_to_romaji': convertLyricsToRomaji, 'track_number': trackNumber, 'disc_number': discNumber, 'total_tracks': totalTracks, @@ -107,7 +105,6 @@ class PlatformBridge { String quality = 'LOSSLESS', bool embedLyrics = true, bool embedMaxQualityCover = true, - bool convertLyricsToRomaji = false, int trackNumber = 1, int discNumber = 1, int totalTracks = 1, @@ -129,7 +126,6 @@ class PlatformBridge { 'quality': quality, 'embed_lyrics': embedLyrics, 'embed_max_quality_cover': embedMaxQualityCover, - 'convert_lyrics_to_romaji': convertLyricsToRomaji, 'track_number': trackNumber, 'disc_number': discNumber, 'total_tracks': totalTracks, @@ -214,15 +210,18 @@ class PlatformBridge { } /// Get lyrics in LRC format + /// First tries to extract from embedded file, then falls back to internet static Future getLyricsLRC( String spotifyId, String trackName, - String artistName, - ) async { + String artistName, { + String? filePath, + }) async { final result = await _channel.invokeMethod('getLyricsLRC', { 'spotify_id': spotifyId, 'track_name': trackName, 'artist_name': artistName, + 'file_path': filePath ?? '', }); return result as String; } diff --git a/pubspec.yaml b/pubspec.yaml index 182d7fa0..979a973c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: 'none' -version: 2.0.1+31 +version: 2.0.2+32 environment: sdk: ^3.10.0