mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 03:15:51 +02:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 85bb67da47 | |||
| 794486a200 | |||
| 8ce5e958ee | |||
| 5c6bf02f1c |
+64
-10
@@ -1,5 +1,69 @@
|
||||
# Changelog
|
||||
|
||||
## [2.0.3] - 2026-01-03
|
||||
|
||||
### Added
|
||||
- **Custom Spotify API Credentials**: Set your own Spotify Client ID and Secret in Settings > Options to avoid rate limiting
|
||||
- Toggle to enable/disable custom credentials without deleting them
|
||||
- Material Expressive 3 bottom sheet UI for entering credentials
|
||||
- **Keyboard Dismiss on Scroll**: Keyboard now automatically dismisses when scrolling search results
|
||||
- **Rate Limit Error UI**: Shows friendly error card when API rate limit (429) is hit on Home, Artist, and Album screens
|
||||
|
||||
### Changed
|
||||
- **Search on Enter Only**: Removed auto-search debounce, now only searches when pressing Enter key (saves API calls)
|
||||
|
||||
### Fixed
|
||||
- **Download Cancel**: Fixed cancelled downloads still completing in background and appearing in history. Cancelled files are now properly deleted.
|
||||
- **Search Keyboard Dismiss**: Fixed keyboard randomly dismissing and navigating back when starting to search
|
||||
- **Back Button During Search**: Back button now properly dismisses keyboard first before clearing search
|
||||
- **Search Error Navigation**: Fixed pressing Enter during search (when loading or error) navigating back to home instead of staying on search screen
|
||||
- **Duplicate Search on Enter**: Enter key no longer triggers duplicate search if results already loaded
|
||||
|
||||
## [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
|
||||
- **Quality Picker Track Info**: Shows track name, artist, and cover in quality picker
|
||||
- Tap to expand long track titles
|
||||
- Expand icon only shows when title is truncated
|
||||
- Ripple effect follows rounded corners including drag handle
|
||||
|
||||
### Changed
|
||||
- **Unified Progress Tracking System**: Deprecated legacy single-download progress
|
||||
- All downloads now use item-based progress tracking
|
||||
- Fixes duplicate notification bug when finalizing
|
||||
- Cleaner codebase with single progress system
|
||||
|
||||
### Fixed
|
||||
- **Duplicate Notification Bug**: Fixed issue where "Finalizing" and "Downloading" notifications appeared simultaneously
|
||||
- **Update Notification Stuck**: Fixed notification staying at 100% after download completes
|
||||
- **Quality Picker Consistency**: Unified quality picker UI across all screens (Home, Album, Playlist)
|
||||
- Container with `primaryContainer` background for each option
|
||||
- Distinct icons: music_note (Lossless), high_quality (Hi-Res), four_k (Max)
|
||||
|
||||
## [2.0.0] - 2026-01-03
|
||||
|
||||
### Added
|
||||
@@ -48,16 +112,6 @@
|
||||
- Theme/view mode chips have visible borders in light mode
|
||||
- **Navigation Bar Styling**: Distinct background color from content area
|
||||
- **Ask Before Download Default**: Now enabled by default for better UX
|
||||
- **Quality Picker Track Info**: Shows track name, artist, and cover in quality picker
|
||||
- Tap to expand long track titles
|
||||
- Expand icon only shows when title is truncated
|
||||
- Ripple effect follows rounded corners including drag handle
|
||||
- **Update Dialog Redesign**: Material Expressive 3 style
|
||||
- Icon header with container
|
||||
- Version chips with "Current" and "New" labels
|
||||
- Changelog in rounded card
|
||||
- Download progress with percentage indicator
|
||||
- Cleaner button layout
|
||||
|
||||
### Fixed
|
||||
- **Artist Profile Images**: Fixed artist images not showing in search results (field name mismatch)
|
||||
|
||||
@@ -157,8 +157,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 filePath = call.argument<String>("file_path") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getLyricsLRC(spotifyId, trackName, artistName)
|
||||
Gobackend.getLyricsLRC(spotifyId, trackName, artistName, filePath)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
@@ -199,6 +200,14 @@ class MainActivity: FlutterActivity() {
|
||||
"isDownloadServiceRunning" -> {
|
||||
result.success(DownloadService.isServiceRunning())
|
||||
}
|
||||
"setSpotifyCredentials" -> {
|
||||
val clientId = call.argument<String>("client_id") ?: ""
|
||||
val clientSecret = call.argument<String>("client_secret") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.setSpotifyAPICredentials(clientId, clientSecret)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 130 KiB |
+41
-34
@@ -203,12 +203,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
||||
|
||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||
// Set current file being downloaded (legacy)
|
||||
SetCurrentFile(filepath.Base(outputPath))
|
||||
SetDownloading(true)
|
||||
defer SetDownloading(false)
|
||||
|
||||
// Initialize item progress if itemID provided
|
||||
// Initialize item progress (required for all downloads)
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
@@ -232,11 +227,8 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
||||
}
|
||||
|
||||
// Set total bytes if available
|
||||
if resp.ContentLength > 0 {
|
||||
SetBytesTotal(resp.ContentLength)
|
||||
if itemID != "" {
|
||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||
}
|
||||
if resp.ContentLength > 0 && itemID != "" {
|
||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
@@ -245,14 +237,14 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Use appropriate progress writer
|
||||
// Use item progress writer
|
||||
var bytesWritten int64
|
||||
if itemID != "" {
|
||||
pw := NewItemProgressWriter(out, itemID)
|
||||
bytesWritten, err = io.Copy(pw, resp.Body)
|
||||
} else {
|
||||
pw := NewProgressWriter(out)
|
||||
bytesWritten, err = io.Copy(pw, resp.Body)
|
||||
// Fallback: direct copy without progress tracking
|
||||
bytesWritten, err = io.Copy(out, resp.Body)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
@@ -262,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)
|
||||
@@ -310,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)
|
||||
@@ -371,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)
|
||||
@@ -392,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
|
||||
}
|
||||
|
||||
+104
-28
@@ -30,6 +30,12 @@ func ParseSpotifyURL(url string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// SetSpotifyAPICredentials sets custom Spotify API credentials from Flutter
|
||||
// Pass empty strings to use default credentials
|
||||
func SetSpotifyAPICredentials(clientID, clientSecret string) {
|
||||
SetSpotifyCredentials(clientID, clientSecret)
|
||||
}
|
||||
|
||||
// GetSpotifyMetadata fetches metadata from Spotify URL
|
||||
// Returns JSON with track/album/playlist data
|
||||
func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
||||
@@ -122,7 +128,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 +142,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 +171,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 +214,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 +274,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 +439,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 +476,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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+38
-133
@@ -5,7 +5,8 @@ import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// DownloadProgress represents current download progress (legacy single download)
|
||||
// DownloadProgress represents current download progress
|
||||
// Now unified - returns data from multi-progress system
|
||||
type DownloadProgress struct {
|
||||
CurrentFile string `json:"current_file"`
|
||||
Progress float64 `json:"progress"`
|
||||
@@ -32,28 +33,40 @@ type MultiProgress struct {
|
||||
}
|
||||
|
||||
var (
|
||||
currentProgress DownloadProgress
|
||||
progressMu sync.RWMutex
|
||||
downloadDir string
|
||||
downloadDirMu sync.RWMutex
|
||||
|
||||
// Multi-download progress tracking
|
||||
downloadDir string
|
||||
downloadDirMu sync.RWMutex
|
||||
|
||||
// Multi-download progress tracking (unified system)
|
||||
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
||||
multiMu sync.RWMutex
|
||||
)
|
||||
|
||||
// getProgress returns current download progress (legacy)
|
||||
// getProgress returns current download progress from multi-progress system
|
||||
// Returns first active item's progress for backward compatibility
|
||||
func getProgress() DownloadProgress {
|
||||
progressMu.RLock()
|
||||
defer progressMu.RUnlock()
|
||||
return currentProgress
|
||||
multiMu.RLock()
|
||||
defer multiMu.RUnlock()
|
||||
|
||||
// Find first active item
|
||||
for _, item := range multiProgress.Items {
|
||||
return DownloadProgress{
|
||||
CurrentFile: item.ItemID,
|
||||
Progress: item.Progress * 100, // Convert to percentage
|
||||
BytesTotal: item.BytesTotal,
|
||||
BytesReceived: item.BytesReceived,
|
||||
IsDownloading: item.IsDownloading,
|
||||
Status: item.Status,
|
||||
}
|
||||
}
|
||||
|
||||
return DownloadProgress{}
|
||||
}
|
||||
|
||||
// GetMultiProgress returns progress for all active downloads as JSON
|
||||
func GetMultiProgress() string {
|
||||
multiMu.RLock()
|
||||
defer multiMu.RUnlock()
|
||||
|
||||
|
||||
jsonBytes, err := json.Marshal(multiProgress)
|
||||
if err != nil {
|
||||
return "{\"items\":{}}"
|
||||
@@ -65,7 +78,7 @@ func GetMultiProgress() string {
|
||||
func GetItemProgress(itemID string) string {
|
||||
multiMu.RLock()
|
||||
defer multiMu.RUnlock()
|
||||
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
jsonBytes, _ := json.Marshal(item)
|
||||
return string(jsonBytes)
|
||||
@@ -77,7 +90,7 @@ func GetItemProgress(itemID string) string {
|
||||
func StartItemProgress(itemID string) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
|
||||
multiProgress.Items[itemID] = &ItemProgress{
|
||||
ItemID: itemID,
|
||||
BytesTotal: 0,
|
||||
@@ -92,7 +105,7 @@ func StartItemProgress(itemID string) {
|
||||
func SetItemBytesTotal(itemID string, total int64) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
item.BytesTotal = total
|
||||
}
|
||||
@@ -102,7 +115,7 @@ func SetItemBytesTotal(itemID string, total int64) {
|
||||
func SetItemBytesReceived(itemID string, received int64) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
item.BytesReceived = received
|
||||
if item.BytesTotal > 0 {
|
||||
@@ -115,16 +128,19 @@ func SetItemBytesReceived(itemID string, received int64) {
|
||||
func CompleteItemProgress(itemID string) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
item.Progress = 1.0
|
||||
item.IsDownloading = false
|
||||
item.Status = "completed"
|
||||
}
|
||||
}
|
||||
|
||||
// SetItemProgress sets progress for an item directly (used to force 100% before embedding)
|
||||
// SetItemProgress sets progress for an item directly
|
||||
func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal int64) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
item.Progress = progress
|
||||
if bytesReceived > 0 {
|
||||
@@ -134,39 +150,24 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal
|
||||
item.BytesTotal = bytesTotal
|
||||
}
|
||||
}
|
||||
multiMu.Unlock()
|
||||
|
||||
// Also update legacy progress for backward compatibility
|
||||
progressMu.Lock()
|
||||
if progress >= 1.0 {
|
||||
currentProgress.Progress = 100.0
|
||||
} else {
|
||||
currentProgress.Progress = progress * 100.0
|
||||
}
|
||||
progressMu.Unlock()
|
||||
}
|
||||
|
||||
// SetItemFinalizing marks an item as finalizing (embedding metadata)
|
||||
func SetItemFinalizing(itemID string) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
item.Progress = 1.0
|
||||
item.Status = "finalizing"
|
||||
}
|
||||
multiMu.Unlock()
|
||||
|
||||
// Also update legacy progress
|
||||
progressMu.Lock()
|
||||
currentProgress.Progress = 100.0
|
||||
currentProgress.Status = "finalizing"
|
||||
progressMu.Unlock()
|
||||
}
|
||||
|
||||
// RemoveItemProgress removes progress tracking for an item
|
||||
func RemoveItemProgress(itemID string) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
|
||||
delete(multiProgress.Items, itemID)
|
||||
}
|
||||
|
||||
@@ -174,46 +175,10 @@ func RemoveItemProgress(itemID string) {
|
||||
func ClearAllItemProgress() {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
|
||||
multiProgress.Items = make(map[string]*ItemProgress)
|
||||
}
|
||||
|
||||
// Legacy functions for backward compatibility
|
||||
|
||||
// SetDownloadProgress sets the current download progress (MB downloaded)
|
||||
func SetDownloadProgress(mbDownloaded float64) {
|
||||
progressMu.Lock()
|
||||
defer progressMu.Unlock()
|
||||
currentProgress.Progress = mbDownloaded
|
||||
currentProgress.IsDownloading = true
|
||||
}
|
||||
|
||||
// SetDownloadSpeed sets the current download speed
|
||||
func SetDownloadSpeed(speedMBps float64) {
|
||||
progressMu.Lock()
|
||||
defer progressMu.Unlock()
|
||||
currentProgress.Speed = speedMBps
|
||||
}
|
||||
|
||||
// SetCurrentFile sets the current file being downloaded and resets progress
|
||||
func SetCurrentFile(filename string) {
|
||||
progressMu.Lock()
|
||||
defer progressMu.Unlock()
|
||||
currentProgress.BytesReceived = 0
|
||||
currentProgress.BytesTotal = 0
|
||||
currentProgress.Progress = 0
|
||||
currentProgress.CurrentFile = filename
|
||||
currentProgress.IsDownloading = true
|
||||
currentProgress.Status = "downloading"
|
||||
}
|
||||
|
||||
// ResetProgress resets the download progress
|
||||
func ResetProgress() {
|
||||
progressMu.Lock()
|
||||
defer progressMu.Unlock()
|
||||
currentProgress = DownloadProgress{}
|
||||
}
|
||||
|
||||
// setDownloadDir sets the default download directory
|
||||
func setDownloadDir(path string) error {
|
||||
downloadDirMu.Lock()
|
||||
@@ -229,64 +194,6 @@ func getDownloadDir() string {
|
||||
return downloadDir
|
||||
}
|
||||
|
||||
// SetDownloading sets the download status
|
||||
func SetDownloading(status bool) {
|
||||
progressMu.Lock()
|
||||
defer progressMu.Unlock()
|
||||
currentProgress.IsDownloading = status
|
||||
}
|
||||
|
||||
// SetBytesTotal sets total bytes to download
|
||||
func SetBytesTotal(total int64) {
|
||||
progressMu.Lock()
|
||||
defer progressMu.Unlock()
|
||||
currentProgress.BytesTotal = total
|
||||
}
|
||||
|
||||
// SetBytesReceived sets bytes received so far
|
||||
func SetBytesReceived(received int64) {
|
||||
progressMu.Lock()
|
||||
defer progressMu.Unlock()
|
||||
currentProgress.BytesReceived = received
|
||||
if currentProgress.BytesTotal > 0 {
|
||||
currentProgress.Progress = float64(received) / float64(currentProgress.BytesTotal) * 100
|
||||
}
|
||||
}
|
||||
|
||||
// ProgressWriter wraps io.Writer to track download progress (legacy single)
|
||||
type ProgressWriter struct {
|
||||
writer interface{ Write([]byte) (int, error) }
|
||||
total int64
|
||||
current int64
|
||||
}
|
||||
|
||||
// NewProgressWriter creates a new progress writer wrapping an io.Writer
|
||||
func NewProgressWriter(w interface{ Write([]byte) (int, error) }) *ProgressWriter {
|
||||
SetBytesReceived(0)
|
||||
return &ProgressWriter{
|
||||
writer: w,
|
||||
current: 0,
|
||||
total: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Write implements io.Writer
|
||||
func (pw *ProgressWriter) Write(p []byte) (int, error) {
|
||||
n, err := pw.writer.Write(p)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
pw.current += int64(n)
|
||||
pw.total += int64(n)
|
||||
SetBytesReceived(pw.current)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// GetTotal returns total bytes written
|
||||
func (pw *ProgressWriter) GetTotal() int64 {
|
||||
return pw.total
|
||||
}
|
||||
|
||||
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
||||
type ItemProgressWriter struct {
|
||||
writer interface{ Write([]byte) (int, error) }
|
||||
@@ -311,7 +218,5 @@ func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
||||
}
|
||||
pw.current += int64(n)
|
||||
SetItemBytesReceived(pw.itemID, pw.current)
|
||||
// Also update legacy progress for backward compatibility
|
||||
SetBytesReceived(pw.current)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
+29
-32
@@ -262,12 +262,7 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
||||
|
||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||
// Set current file being downloaded (legacy)
|
||||
SetCurrentFile(filepath.Base(outputPath))
|
||||
SetDownloading(true)
|
||||
defer SetDownloading(false)
|
||||
|
||||
// Initialize item progress if itemID provided
|
||||
// Initialize item progress (required for all downloads)
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
@@ -289,11 +284,8 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
}
|
||||
|
||||
// Set total bytes if available
|
||||
if resp.ContentLength > 0 {
|
||||
SetBytesTotal(resp.ContentLength)
|
||||
if itemID != "" {
|
||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||
}
|
||||
if resp.ContentLength > 0 && itemID != "" {
|
||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
@@ -302,24 +294,31 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Use appropriate progress writer
|
||||
// Use item progress writer
|
||||
if itemID != "" {
|
||||
progressWriter := NewItemProgressWriter(out, itemID)
|
||||
_, err = io.Copy(progressWriter, resp.Body)
|
||||
} else {
|
||||
progressWriter := NewProgressWriter(out)
|
||||
_, err = io.Copy(progressWriter, resp.Body)
|
||||
// Fallback: direct copy without progress tracking
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
}
|
||||
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
|
||||
@@ -340,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
|
||||
@@ -357,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
|
||||
@@ -374,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)
|
||||
@@ -433,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)
|
||||
@@ -453,5 +446,9 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
return outputPath, nil
|
||||
return QobuzDownloadResult{
|
||||
FilePath: outputPath,
|
||||
BitDepth: actualBitDepth,
|
||||
SampleRate: actualSampleRate,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+35
-4
@@ -62,11 +62,32 @@ type SpotifyMetadataClient struct {
|
||||
cacheMu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewSpotifyMetadataClient creates a new Spotify client
|
||||
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
||||
src := rand.NewSource(time.Now().UnixNano())
|
||||
// Custom credentials storage (set from Flutter)
|
||||
var (
|
||||
customClientID string
|
||||
customClientSecret string
|
||||
credentialsMu sync.RWMutex
|
||||
)
|
||||
|
||||
// Prefer environment variables for credentials (more secure), fall back to built-in
|
||||
// SetSpotifyCredentials sets custom Spotify API credentials
|
||||
// Pass empty strings to use default credentials
|
||||
func SetSpotifyCredentials(clientID, clientSecret string) {
|
||||
credentialsMu.Lock()
|
||||
defer credentialsMu.Unlock()
|
||||
customClientID = clientID
|
||||
customClientSecret = clientSecret
|
||||
}
|
||||
|
||||
// getCredentials returns the current credentials (custom or default)
|
||||
func getCredentials() (string, string) {
|
||||
credentialsMu.RLock()
|
||||
defer credentialsMu.RUnlock()
|
||||
|
||||
if customClientID != "" && customClientSecret != "" {
|
||||
return customClientID, customClientSecret
|
||||
}
|
||||
|
||||
// Fall back to default credentials
|
||||
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
||||
if clientID == "" {
|
||||
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
|
||||
@@ -80,6 +101,16 @@ func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
||||
clientSecret = string(decoded)
|
||||
}
|
||||
}
|
||||
|
||||
return clientID, clientSecret
|
||||
}
|
||||
|
||||
// NewSpotifyMetadataClient creates a new Spotify client
|
||||
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
||||
src := rand.NewSource(time.Now().UnixNano())
|
||||
|
||||
// Get credentials (custom or default)
|
||||
clientID, clientSecret := getCredentials()
|
||||
|
||||
c := &SpotifyMetadataClient{
|
||||
httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling
|
||||
|
||||
+65
-86
@@ -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)
|
||||
@@ -646,12 +638,7 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
return t.downloadFromManifest(strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
|
||||
}
|
||||
|
||||
// Set current file being downloaded (legacy)
|
||||
SetCurrentFile(filepath.Base(outputPath))
|
||||
SetDownloading(true)
|
||||
defer SetDownloading(false)
|
||||
|
||||
// Initialize item progress if itemID provided
|
||||
// Initialize item progress (required for all downloads)
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
@@ -673,11 +660,8 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
}
|
||||
|
||||
// Set total bytes if available
|
||||
if resp.ContentLength > 0 {
|
||||
SetBytesTotal(resp.ContentLength)
|
||||
if itemID != "" {
|
||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||
}
|
||||
if resp.ContentLength > 0 && itemID != "" {
|
||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
@@ -686,13 +670,13 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Use appropriate progress writer
|
||||
// Use item progress writer
|
||||
if itemID != "" {
|
||||
progressWriter := NewItemProgressWriter(out, itemID)
|
||||
_, err = io.Copy(progressWriter, resp.Body)
|
||||
} else {
|
||||
progressWriter := NewProgressWriter(out)
|
||||
_, err = io.Copy(progressWriter, resp.Body)
|
||||
// Fallback: direct copy without progress tracking
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -709,12 +693,7 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
||||
|
||||
// If we have a direct URL (BTS format), download directly with progress tracking
|
||||
if directURL != "" {
|
||||
// Set current file being downloaded (legacy)
|
||||
SetCurrentFile(filepath.Base(outputPath))
|
||||
SetDownloading(true)
|
||||
defer SetDownloading(false)
|
||||
|
||||
// Initialize item progress if itemID provided
|
||||
// Initialize item progress (required for all downloads)
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
@@ -736,11 +715,8 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
||||
}
|
||||
|
||||
// Set total bytes for progress tracking
|
||||
if resp.ContentLength > 0 {
|
||||
SetBytesTotal(resp.ContentLength)
|
||||
if itemID != "" {
|
||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||
}
|
||||
if resp.ContentLength > 0 && itemID != "" {
|
||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
@@ -749,13 +725,13 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Use appropriate progress writer
|
||||
// Use item progress writer
|
||||
if itemID != "" {
|
||||
progressWriter := NewItemProgressWriter(out, itemID)
|
||||
_, err = io.Copy(progressWriter, resp.Body)
|
||||
} else {
|
||||
progressWriter := NewProgressWriter(out)
|
||||
_, err = io.Copy(progressWriter, resp.Body)
|
||||
// Fallback: direct copy without progress tracking
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -828,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
|
||||
@@ -867,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
|
||||
@@ -884,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)
|
||||
@@ -895,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)
|
||||
@@ -922,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
|
||||
@@ -968,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)
|
||||
@@ -991,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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.0';
|
||||
static const String buildNumber = '30';
|
||||
static const String version = '2.0.3';
|
||||
static const String buildNumber = '33';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
|
||||
|
||||
@@ -16,9 +16,11 @@ 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
|
||||
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
|
||||
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
|
||||
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
|
||||
|
||||
const AppSettings({
|
||||
this.defaultService = 'tidal',
|
||||
@@ -33,9 +35,11 @@ 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
|
||||
this.spotifyClientId = '', // Default: use built-in credentials
|
||||
this.spotifyClientSecret = '', // Default: use built-in credentials
|
||||
this.useCustomSpotifyCredentials = true, // Default: use custom if set
|
||||
});
|
||||
|
||||
AppSettings copyWith({
|
||||
@@ -51,9 +55,11 @@ class AppSettings {
|
||||
bool? checkForUpdates,
|
||||
bool? hasSearchedBefore,
|
||||
String? folderOrganization,
|
||||
bool? convertLyricsToRomaji,
|
||||
String? historyViewMode,
|
||||
bool? askQualityBeforeDownload,
|
||||
String? spotifyClientId,
|
||||
String? spotifyClientSecret,
|
||||
bool? useCustomSpotifyCredentials,
|
||||
}) {
|
||||
return AppSettings(
|
||||
defaultService: defaultService ?? this.defaultService,
|
||||
@@ -68,9 +74,11 @@ 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,
|
||||
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
|
||||
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
||||
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,9 +19,12 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> 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,
|
||||
spotifyClientId: json['spotifyClientId'] as String? ?? '',
|
||||
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
|
||||
useCustomSpotifyCredentials:
|
||||
json['useCustomSpotifyCredentials'] as bool? ?? true,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
@@ -38,7 +41,9 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'checkForUpdates': instance.checkForUpdates,
|
||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||
'folderOrganization': instance.folderOrganization,
|
||||
'convertLyricsToRomaji': instance.convertLyricsToRomaji,
|
||||
'historyViewMode': instance.historyViewMode,
|
||||
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
||||
'spotifyClientId': instance.spotifyClientId,
|
||||
'spotifyClientSecret': instance.spotifyClientSecret,
|
||||
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
||||
};
|
||||
|
||||
@@ -267,6 +267,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
static const _queueStorageKey = 'download_queue'; // Storage key for queue persistence
|
||||
final NotificationService _notificationService = NotificationService();
|
||||
int _totalQueuedAtStart = 0; // Track total items when queue started
|
||||
int _completedInSession = 0; // Track completed downloads in current session
|
||||
int _failedInSession = 0; // Track failed downloads in current session
|
||||
bool _isLoaded = false;
|
||||
|
||||
@override
|
||||
@@ -354,69 +356,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
void _startProgressPolling(String itemId) {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async {
|
||||
try {
|
||||
final progress = await PlatformBridge.getDownloadProgress();
|
||||
final bytesReceived = progress['bytes_received'] as int? ?? 0;
|
||||
final bytesTotal = progress['bytes_total'] as int? ?? 0;
|
||||
final isDownloading = progress['is_downloading'] as bool? ?? false;
|
||||
final status = progress['status'] as String? ?? 'downloading';
|
||||
|
||||
// Check if status is "finalizing" (embedding metadata)
|
||||
if (status == 'finalizing') {
|
||||
updateItemStatus(itemId, DownloadStatus.finalizing, progress: 1.0);
|
||||
|
||||
// Update notification to show finalizing
|
||||
final currentItem = state.items.where((i) => i.id == itemId).firstOrNull;
|
||||
if (currentItem != null) {
|
||||
_notificationService.showDownloadFinalizing(
|
||||
trackName: currentItem.track.name,
|
||||
artistName: currentItem.track.artistName,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDownloading && bytesTotal > 0) {
|
||||
final percentage = bytesReceived / bytesTotal;
|
||||
updateProgress(itemId, percentage);
|
||||
|
||||
// Update notification with progress
|
||||
final currentItem = state.currentDownload;
|
||||
if (currentItem != null) {
|
||||
_notificationService.showDownloadProgress(
|
||||
trackName: currentItem.track.name,
|
||||
artistName: currentItem.track.artistName,
|
||||
progress: bytesReceived,
|
||||
total: bytesTotal,
|
||||
);
|
||||
|
||||
// Update foreground service notification (Android)
|
||||
if (Platform.isAndroid) {
|
||||
PlatformBridge.updateDownloadServiceProgress(
|
||||
trackName: currentItem.track.name,
|
||||
artistName: currentItem.track.artistName,
|
||||
progress: bytesReceived,
|
||||
total: bytesTotal,
|
||||
queueCount: state.queuedCount,
|
||||
).catchError((_) {}); // Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
// Log progress
|
||||
final mbReceived = bytesReceived / (1024 * 1024);
|
||||
final mbTotal = bytesTotal / (1024 * 1024);
|
||||
_log.d('Progress: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB)');
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore polling errors
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Start multi-progress polling for concurrent downloads
|
||||
/// Start multi-progress polling for all downloads (sequential and parallel)
|
||||
void _startMultiProgressPolling() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async {
|
||||
@@ -424,6 +364,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final allProgress = await PlatformBridge.getAllDownloadProgress();
|
||||
final items = allProgress['items'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
bool hasFinalizingItem = false;
|
||||
String? finalizingTrackName;
|
||||
String? finalizingArtistName;
|
||||
|
||||
for (final entry in items.entries) {
|
||||
final itemId = entry.key;
|
||||
final itemProgress = entry.value as Map<String, dynamic>;
|
||||
@@ -433,16 +377,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final status = itemProgress['status'] as String? ?? 'downloading';
|
||||
|
||||
// Check if status is "finalizing" (embedding metadata)
|
||||
if (status == 'finalizing') {
|
||||
// Only trust finalizing status if bytesTotal > 0 (download actually happened)
|
||||
if (status == 'finalizing' && bytesTotal > 0) {
|
||||
updateItemStatus(itemId, DownloadStatus.finalizing, progress: 1.0);
|
||||
|
||||
// Update notification to show finalizing
|
||||
// Track finalizing item for notification
|
||||
final currentItem = state.items.where((i) => i.id == itemId).firstOrNull;
|
||||
if (currentItem != null) {
|
||||
_notificationService.showDownloadFinalizing(
|
||||
trackName: currentItem.track.name,
|
||||
artistName: currentItem.track.artistName,
|
||||
);
|
||||
hasFinalizingItem = true;
|
||||
finalizingTrackName = currentItem.track.name;
|
||||
finalizingArtistName = currentItem.track.artistName;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -458,19 +402,36 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
// Update notification with first active download
|
||||
// Show finalizing notification if any item is finalizing (takes priority)
|
||||
if (hasFinalizingItem && finalizingTrackName != null) {
|
||||
_notificationService.showDownloadFinalizing(
|
||||
trackName: finalizingTrackName,
|
||||
artistName: finalizingArtistName ?? '',
|
||||
);
|
||||
return; // Don't show download progress notification
|
||||
}
|
||||
|
||||
// Update notification with active downloads
|
||||
if (items.isNotEmpty) {
|
||||
final firstEntry = items.entries.first;
|
||||
final firstProgress = firstEntry.value as Map<String, dynamic>;
|
||||
final bytesReceived = firstProgress['bytes_received'] as int? ?? 0;
|
||||
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
|
||||
|
||||
// Find the item to get track info
|
||||
final downloadingItems = state.items.where((i) => i.status == DownloadStatus.downloading || i.status == DownloadStatus.finalizing).toList();
|
||||
// Find downloading items (not finalizing)
|
||||
final downloadingItems = state.items.where((i) => i.status == DownloadStatus.downloading).toList();
|
||||
if (downloadingItems.isNotEmpty) {
|
||||
// Show single track name if only 1 download, otherwise show count
|
||||
final trackName = downloadingItems.length == 1
|
||||
? downloadingItems.first.track.name
|
||||
: '${downloadingItems.length} downloads';
|
||||
final artistName = downloadingItems.length == 1
|
||||
? downloadingItems.first.track.artistName
|
||||
: 'Downloading...';
|
||||
|
||||
_notificationService.showDownloadProgress(
|
||||
trackName: '${downloadingItems.length} downloads',
|
||||
artistName: 'Downloading...',
|
||||
trackName: trackName,
|
||||
artistName: artistName,
|
||||
progress: bytesReceived,
|
||||
total: bytesTotal > 0 ? bytesTotal : 1,
|
||||
);
|
||||
@@ -823,6 +784,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
// Track total items at start for notification
|
||||
_totalQueuedAtStart = state.items.where((i) => i.status == DownloadStatus.queued).length;
|
||||
_completedInSession = 0;
|
||||
_failedInSession = 0;
|
||||
|
||||
// Start foreground service to keep downloads running in background (Android only)
|
||||
if (Platform.isAndroid && _totalQueuedAtStart > 0) {
|
||||
@@ -893,12 +856,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
// Show queue completion notification
|
||||
final completedCount = state.completedCount;
|
||||
final failedCount = state.failedCount;
|
||||
_log.i('Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart');
|
||||
if (_totalQueuedAtStart > 0) {
|
||||
await _notificationService.showQueueComplete(
|
||||
completedCount: completedCount,
|
||||
failedCount: failedCount,
|
||||
completedCount: _completedInSession,
|
||||
failedCount: _failedInSession,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -906,8 +868,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
state = state.copyWith(isProcessing: false, currentDownload: null);
|
||||
}
|
||||
|
||||
/// Sequential download processing (original behavior)
|
||||
/// Sequential download processing (uses multi-progress system with single item)
|
||||
Future<void> _processQueueSequential() async {
|
||||
// Start multi-progress polling (works for both sequential and parallel)
|
||||
_startMultiProgressPolling();
|
||||
|
||||
while (true) {
|
||||
// Check if paused
|
||||
if (state.isPaused) {
|
||||
@@ -932,7 +897,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
await _downloadSingleItem(nextItem);
|
||||
|
||||
// Clear item progress after download completes
|
||||
PlatformBridge.clearItemProgress(nextItem.id).catchError((_) {});
|
||||
}
|
||||
|
||||
// Stop polling when queue is done
|
||||
_stopProgressPolling();
|
||||
}
|
||||
|
||||
/// Parallel download processing with worker pool
|
||||
@@ -940,7 +911,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final maxConcurrent = state.concurrentDownloads;
|
||||
final activeDownloads = <String, Future<void>>{}; // Map item ID to future
|
||||
|
||||
// Start multi-progress polling for concurrent downloads
|
||||
// Start multi-progress polling (shared with sequential mode)
|
||||
_startMultiProgressPolling();
|
||||
|
||||
while (true) {
|
||||
@@ -991,6 +962,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (activeDownloads.isNotEmpty) {
|
||||
await Future.wait(activeDownloads.values);
|
||||
}
|
||||
|
||||
// Stop polling when queue is done
|
||||
_stopProgressPolling();
|
||||
}
|
||||
|
||||
/// Download a single item (used by both sequential and parallel processing)
|
||||
@@ -998,11 +972,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_log.d('Processing: ${item.track.name} by ${item.track.artistName}');
|
||||
_log.d('Cover URL: ${item.track.coverUrl}');
|
||||
|
||||
// Only set currentDownload for sequential mode (for progress polling)
|
||||
if (state.concurrentDownloads == 1) {
|
||||
state = state.copyWith(currentDownload: item);
|
||||
_startProgressPolling(item.id);
|
||||
}
|
||||
// Set currentDownload for UI reference
|
||||
state = state.copyWith(currentDownload: item);
|
||||
|
||||
updateItemStatus(item.id, DownloadStatus.downloading);
|
||||
|
||||
@@ -1036,7 +1007,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
releaseDate: item.track.releaseDate,
|
||||
preferredService: item.service,
|
||||
itemId: item.id, // Pass item ID for progress tracking
|
||||
convertLyricsToRomaji: settings.convertLyricsToRomaji,
|
||||
);
|
||||
} else {
|
||||
result = await PlatformBridge.downloadTrack(
|
||||
@@ -1055,21 +1025,49 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
discNumber: item.track.discNumber ?? 1,
|
||||
releaseDate: item.track.releaseDate,
|
||||
itemId: item.id, // Pass item ID for progress tracking
|
||||
convertLyricsToRomaji: settings.convertLyricsToRomaji,
|
||||
);
|
||||
}
|
||||
|
||||
// Stop progress polling for this item (sequential mode only)
|
||||
if (state.concurrentDownloads == 1) {
|
||||
_stopProgressPolling();
|
||||
}
|
||||
|
||||
_log.d('Result: $result');
|
||||
|
||||
// Check if item was cancelled while downloading
|
||||
final currentItem = state.items.firstWhere((i) => i.id == item.id, orElse: () => item);
|
||||
if (currentItem.status == DownloadStatus.skipped) {
|
||||
_log.i('Download was cancelled, skipping result processing');
|
||||
// Delete the downloaded file if it exists
|
||||
final filePath = result['file_path'] as String?;
|
||||
if (filePath != null && result['success'] == true) {
|
||||
try {
|
||||
final file = File(filePath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
_log.d('Deleted cancelled download file: $filePath');
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed to delete cancelled file: $e');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (result['success'] == true) {
|
||||
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...');
|
||||
@@ -1093,18 +1091,40 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
// Check again if cancelled before updating status and adding to history
|
||||
final itemAfterDownload = state.items.firstWhere((i) => i.id == item.id, orElse: () => item);
|
||||
if (itemAfterDownload.status == DownloadStatus.skipped) {
|
||||
_log.i('Download was cancelled during finalization, cleaning up');
|
||||
// Delete the downloaded file
|
||||
if (filePath != null) {
|
||||
try {
|
||||
final file = File(filePath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
_log.d('Deleted cancelled download file: $filePath');
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed to delete cancelled file: $e');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.completed,
|
||||
progress: 1.0,
|
||||
filePath: filePath,
|
||||
);
|
||||
|
||||
// Increment completed counter
|
||||
_completedInSession++;
|
||||
|
||||
// Show completion notification for this track
|
||||
await _notificationService.showDownloadComplete(
|
||||
trackName: item.track.name,
|
||||
artistName: item.track.artistName,
|
||||
completedCount: state.completedCount,
|
||||
completedCount: _completedInSession,
|
||||
totalCount: _totalQueuedAtStart,
|
||||
);
|
||||
|
||||
@@ -1127,7 +1147,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
discNumber: item.track.discNumber,
|
||||
duration: item.track.duration,
|
||||
releaseDate: item.track.releaseDate,
|
||||
quality: quality,
|
||||
quality: actualQuality,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1142,6 +1162,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
DownloadStatus.failed,
|
||||
error: errorMsg,
|
||||
);
|
||||
_failedInSession++;
|
||||
}
|
||||
|
||||
// Increment download counter and cleanup connections periodically
|
||||
@@ -1155,15 +1176,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
if (state.concurrentDownloads == 1) {
|
||||
_stopProgressPolling();
|
||||
}
|
||||
_log.e('Exception: $e', e, stackTrace);
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.failed,
|
||||
error: e.toString(),
|
||||
);
|
||||
_failedInSession++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:convert';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
|
||||
const _settingsKey = 'app_settings';
|
||||
|
||||
@@ -17,6 +18,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
final json = prefs.getString(_settingsKey);
|
||||
if (json != null) {
|
||||
state = AppSettings.fromJson(jsonDecode(json));
|
||||
// Apply Spotify credentials to Go backend on load
|
||||
_applySpotifyCredentials();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +28,22 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
|
||||
}
|
||||
|
||||
/// Apply current Spotify credentials to Go backend
|
||||
Future<void> _applySpotifyCredentials() async {
|
||||
// Only apply custom credentials if enabled and both fields are set
|
||||
if (state.useCustomSpotifyCredentials &&
|
||||
state.spotifyClientId.isNotEmpty &&
|
||||
state.spotifyClientSecret.isNotEmpty) {
|
||||
await PlatformBridge.setSpotifyCredentials(
|
||||
state.spotifyClientId,
|
||||
state.spotifyClientSecret,
|
||||
);
|
||||
} else {
|
||||
// Clear to use default
|
||||
await PlatformBridge.setSpotifyCredentials('', '');
|
||||
}
|
||||
}
|
||||
|
||||
void setDefaultService(String service) {
|
||||
state = state.copyWith(defaultService: service);
|
||||
_saveSettings();
|
||||
@@ -89,11 +108,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setConvertLyricsToRomaji(bool enabled) {
|
||||
state = state.copyWith(convertLyricsToRomaji: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setHistoryViewMode(String mode) {
|
||||
state = state.copyWith(historyViewMode: mode);
|
||||
_saveSettings();
|
||||
@@ -103,6 +117,40 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
state = state.copyWith(askQualityBeforeDownload: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setSpotifyClientId(String clientId) {
|
||||
state = state.copyWith(spotifyClientId: clientId);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setSpotifyClientSecret(String clientSecret) {
|
||||
state = state.copyWith(spotifyClientSecret: clientSecret);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setSpotifyCredentials(String clientId, String clientSecret) {
|
||||
state = state.copyWith(
|
||||
spotifyClientId: clientId,
|
||||
spotifyClientSecret: clientSecret,
|
||||
);
|
||||
_saveSettings();
|
||||
_applySpotifyCredentials();
|
||||
}
|
||||
|
||||
void clearSpotifyCredentials() {
|
||||
state = state.copyWith(
|
||||
spotifyClientId: '',
|
||||
spotifyClientSecret: '',
|
||||
);
|
||||
_saveSettings();
|
||||
_applySpotifyCredentials();
|
||||
}
|
||||
|
||||
void setUseCustomSpotifyCredentials(bool enabled) {
|
||||
state = state.copyWith(useCustomSpotifyCredentials: enabled);
|
||||
_saveSettings();
|
||||
_applySpotifyCredentials();
|
||||
}
|
||||
}
|
||||
|
||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||
|
||||
@@ -118,7 +118,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
// Increment request ID to cancel any pending requests
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
state = const TrackState(isLoading: true);
|
||||
// Preserve hasSearchText during fetch
|
||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||
|
||||
try {
|
||||
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
||||
@@ -174,7 +175,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||
state = TrackState(isLoading: false, error: e.toString());
|
||||
// Preserve hasSearchText on error so user stays on search screen
|
||||
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +184,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
// Increment request ID to cancel any pending requests
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
state = const TrackState(isLoading: true);
|
||||
// Preserve hasSearchText during search
|
||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||
|
||||
try {
|
||||
final results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
|
||||
@@ -198,10 +201,12 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
tracks: tracks,
|
||||
searchArtists: artists,
|
||||
isLoading: false,
|
||||
hasSearchText: state.hasSearchText,
|
||||
);
|
||||
} catch (e) {
|
||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||
state = TrackState(isLoading: false, error: e.toString());
|
||||
// Preserve hasSearchText on error so user stays on search screen
|
||||
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -126,10 +126,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)),
|
||||
if (_error != null)
|
||||
if (_error != null)
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(_error!, style: TextStyle(color: colorScheme.error)),
|
||||
child: _buildErrorWidget(_error!, colorScheme),
|
||||
)),
|
||||
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
|
||||
_buildTrackListHeader(context, colorScheme),
|
||||
@@ -349,15 +349,89 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
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.hd, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }),
|
||||
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build error widget with special handling for rate limit (429)
|
||||
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
||||
final isRateLimit = error.contains('429') ||
|
||||
error.toLowerCase().contains('rate limit') ||
|
||||
error.toLowerCase().contains('too many requests');
|
||||
|
||||
if (isRateLimit) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.errorContainer,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.timer_off, color: colorScheme.onErrorContainer),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Rate Limited',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onErrorContainer,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Too many requests. Please wait a moment and try again.',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onErrorContainer,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Default error display
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.errorContainer.withValues(alpha: 0.5),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: colorScheme.error),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(error, style: TextStyle(color: colorScheme.error))),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QualityOption extends StatelessWidget {
|
||||
|
||||
@@ -128,7 +128,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
if (_error != null)
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(_error!, style: TextStyle(color: colorScheme.error)),
|
||||
child: _buildErrorWidget(_error!, colorScheme),
|
||||
)),
|
||||
if (!_isLoadingDiscography && _error == null) ...[
|
||||
if (albumsOnly.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Albums', albumsOnly, colorScheme)),
|
||||
@@ -318,4 +318,67 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
/// Build error widget with special handling for rate limit (429)
|
||||
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
||||
final isRateLimit = error.contains('429') ||
|
||||
error.toLowerCase().contains('rate limit') ||
|
||||
error.toLowerCase().contains('too many requests');
|
||||
|
||||
if (isRateLimit) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.errorContainer,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.timer_off, color: colorScheme.onErrorContainer),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Rate Limited',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onErrorContainer,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Too many requests. Please wait a moment and try again.',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onErrorContainer,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Default error display
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.errorContainer.withValues(alpha: 0.5),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: colorScheme.error),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(error, style: TextStyle(color: colorScheme.error))),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+111
-32
@@ -22,7 +22,6 @@ class HomeTab extends ConsumerStatefulWidget {
|
||||
|
||||
class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
|
||||
final _urlController = TextEditingController();
|
||||
Timer? _debounce;
|
||||
bool _isTyping = false;
|
||||
final FocusNode _searchFocusNode = FocusNode();
|
||||
String? _lastSearchQuery; // Track last searched query to avoid duplicate searches
|
||||
@@ -38,7 +37,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
_urlController.removeListener(_onSearchChanged);
|
||||
_urlController.dispose();
|
||||
_searchFocusNode.dispose();
|
||||
@@ -48,17 +46,18 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
/// Called when trackState changes - used to sync search bar with state
|
||||
void _onTrackStateChanged(TrackState? previous, TrackState next) {
|
||||
// If state was cleared (no content, no search text, not loading), clear the search bar
|
||||
// BUT only if search field is not focused (to prevent clearing while user is typing)
|
||||
if (previous != null &&
|
||||
!next.hasContent &&
|
||||
!next.hasSearchText &&
|
||||
!next.isLoading &&
|
||||
_urlController.text.isNotEmpty) {
|
||||
_urlController.text.isNotEmpty &&
|
||||
!_searchFocusNode.hasFocus) {
|
||||
_urlController.clear();
|
||||
setState(() => _isTyping = false);
|
||||
}
|
||||
} void _onSearchChanged() {
|
||||
final text = _urlController.text.trim();
|
||||
final wasFocused = _searchFocusNode.hasFocus;
|
||||
|
||||
// Update search text state for MainShell back button handling
|
||||
ref.read(trackProvider.notifier).setSearchText(text.isNotEmpty);
|
||||
@@ -68,30 +67,13 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
setState(() => _isTyping = true);
|
||||
} else if (text.isEmpty && _isTyping) {
|
||||
setState(() => _isTyping = false);
|
||||
ref.read(trackProvider.notifier).clear();
|
||||
// Don't clear provider here - it causes focus issues
|
||||
// Provider will be cleared when user explicitly clears or navigates away
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-request focus after rebuild if it was focused
|
||||
if (wasFocused) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_searchFocusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Debounce all requests (URLs and searches)
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 400), () {
|
||||
if (text.isEmpty) return;
|
||||
|
||||
if (text.startsWith('http') || text.startsWith('spotify:')) {
|
||||
_fetchMetadata();
|
||||
} else if (text.length >= 2) {
|
||||
_performSearch(text);
|
||||
}
|
||||
});
|
||||
// No auto-search - user must press Enter to search
|
||||
// This saves API calls and avoids rate limiting
|
||||
}
|
||||
|
||||
Future<void> _performSearch(String query) async {
|
||||
@@ -116,7 +98,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
}
|
||||
|
||||
Future<void> _clearAndRefresh() async {
|
||||
_debounce?.cancel();
|
||||
_urlController.clear();
|
||||
_searchFocusNode.unfocus();
|
||||
_lastSearchQuery = null; // Reset last query
|
||||
@@ -222,19 +203,33 @@ class _HomeTabState extends ConsumerState<HomeTab> 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',
|
||||
icon: Icons.music_note,
|
||||
onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); },
|
||||
),
|
||||
_QualityPickerOption(
|
||||
title: 'Hi-Res FLAC',
|
||||
subtitle: '24-bit / up to 96kHz',
|
||||
icon: Icons.high_quality,
|
||||
onTap: () { Navigator.pop(context); onSelect('HI_RES'); },
|
||||
),
|
||||
_QualityPickerOption(
|
||||
title: 'Hi-Res FLAC Max',
|
||||
subtitle: '24-bit / up to 192kHz',
|
||||
icon: Icons.four_k,
|
||||
onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); },
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -271,6 +266,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
slivers: [
|
||||
// App Bar - always present
|
||||
SliverAppBar(
|
||||
@@ -465,6 +461,69 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
));
|
||||
}
|
||||
|
||||
/// Build error widget with special handling for rate limit (429)
|
||||
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
||||
final isRateLimit = error.contains('429') ||
|
||||
error.toLowerCase().contains('rate limit') ||
|
||||
error.toLowerCase().contains('too many requests');
|
||||
|
||||
if (isRateLimit) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.errorContainer,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.timer_off, color: colorScheme.onErrorContainer),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Rate Limited',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onErrorContainer,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Too many requests. Please wait a moment before searching again.',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onErrorContainer,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Default error display
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.errorContainer.withValues(alpha: 0.5),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: colorScheme.error),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(error, style: TextStyle(color: colorScheme.error))),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Search results slivers - only shows search results (track list)
|
||||
List<Widget> _buildSearchResults({
|
||||
required List<Track> tracks,
|
||||
@@ -479,11 +538,11 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
}
|
||||
|
||||
return [
|
||||
// Error message
|
||||
// Error message - with special handling for rate limit (429)
|
||||
if (error != null)
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(error, style: TextStyle(color: colorScheme.error)),
|
||||
child: _buildErrorWidget(error, colorScheme),
|
||||
)),
|
||||
|
||||
// Loading indicator
|
||||
@@ -660,25 +719,45 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
),
|
||||
onSubmitted: (_) => _fetchMetadata(),
|
||||
onSubmitted: (_) => _onSearchSubmitted(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle Enter key press - search or fetch URL
|
||||
void _onSearchSubmitted() {
|
||||
final text = _urlController.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
|
||||
// If it's a URL, fetch metadata
|
||||
if (text.startsWith('http') || text.startsWith('spotify:')) {
|
||||
_fetchMetadata();
|
||||
_searchFocusNode.unfocus();
|
||||
return;
|
||||
}
|
||||
|
||||
// For search queries, always search (minimum 2 chars)
|
||||
if (text.length >= 2) {
|
||||
_performSearch(text);
|
||||
}
|
||||
_searchFocusNode.unfocus();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class _QualityPickerOption extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
const _QualityPickerOption({required this.title, required this.subtitle, required this.onTap});
|
||||
const _QualityPickerOption({required this.title, required this.subtitle, required this.icon, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||
leading: Icon(Icons.music_note, color: colorScheme.primary),
|
||||
title: Text(title),
|
||||
leading: Container(padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20)),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
onTap: onTap,
|
||||
);
|
||||
|
||||
@@ -125,6 +125,13 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
void _handleBackPress() {
|
||||
final trackState = ref.read(trackProvider);
|
||||
|
||||
// Check if keyboard is visible - if so, just dismiss keyboard, don't clear search
|
||||
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||
if (isKeyboardVisible) {
|
||||
FocusScope.of(context).unfocus();
|
||||
return;
|
||||
}
|
||||
|
||||
// If on Home tab and has text in search bar or has content (but not loading), clear it
|
||||
if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) {
|
||||
ref.read(trackProvider.notifier).clear();
|
||||
@@ -163,12 +170,17 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
||||
final trackState = ref.watch(trackProvider);
|
||||
|
||||
// Check if keyboard is visible (bottom inset > 0 means keyboard is showing)
|
||||
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||
|
||||
// Determine if we can pop (for predictive back animation)
|
||||
// canPop is true when we're at root with no content - enables predictive back gesture
|
||||
// IMPORTANT: Never allow pop when keyboard is visible to prevent accidental navigation
|
||||
final canPop = _currentIndex == 0 &&
|
||||
!trackState.hasSearchText &&
|
||||
!trackState.hasContent &&
|
||||
!trackState.isLoading;
|
||||
!trackState.isLoading &&
|
||||
!isKeyboardVisible;
|
||||
|
||||
return PopScope(
|
||||
canPop: canPop,
|
||||
|
||||
@@ -211,9 +211,20 @@ 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.hd, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }),
|
||||
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -558,6 +558,31 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// 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<QueueTab> {
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
@@ -99,23 +100,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(
|
||||
@@ -133,6 +117,38 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// Spotify API section
|
||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Spotify API')),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.key,
|
||||
title: 'Custom Credentials',
|
||||
subtitle: settings.spotifyClientId.isNotEmpty
|
||||
? 'Client ID: ${settings.spotifyClientId.length > 8 ? '${settings.spotifyClientId.substring(0, 8)}...' : settings.spotifyClientId}'
|
||||
: 'Not configured',
|
||||
onTap: () => _showSpotifyCredentialsDialog(context, ref, settings),
|
||||
trailing: settings.spotifyClientId.isNotEmpty
|
||||
? Icon(Icons.edit, color: Theme.of(context).colorScheme.onSurfaceVariant, size: 20)
|
||||
: Icon(Icons.add, color: Theme.of(context).colorScheme.primary, size: 20),
|
||||
showDivider: settings.spotifyClientId.isNotEmpty,
|
||||
),
|
||||
if (settings.spotifyClientId.isNotEmpty)
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.toggle_on,
|
||||
title: 'Use Custom Credentials',
|
||||
subtitle: settings.useCustomSpotifyCredentials
|
||||
? 'Using your credentials'
|
||||
: 'Using default credentials',
|
||||
value: settings.useCustomSpotifyCredentials,
|
||||
onChanged: (v) => ref.read(settingsProvider.notifier).setUseCustomSpotifyCredentials(v),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Data section
|
||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Data')),
|
||||
SliverToBoxAdapter(
|
||||
@@ -180,6 +196,132 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showSpotifyCredentialsDialog(BuildContext context, WidgetRef ref, AppSettings settings) {
|
||||
final clientIdController = TextEditingController(text: settings.spotifyClientId);
|
||||
final clientSecretController = TextEditingController(text: settings.spotifyClientSecret);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||
builder: (context) => Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: SingleChildScrollView(
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 20, 24, 8),
|
||||
child: Text('Spotify API Credentials', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
'Use your own credentials to avoid rate limiting.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: TextField(
|
||||
controller: clientIdController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Client ID',
|
||||
hintText: 'Enter Spotify Client ID',
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerLow,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.3))),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.3))),
|
||||
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.primary, width: 2)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: TextField(
|
||||
controller: clientSecretController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Client Secret',
|
||||
hintText: 'Enter Spotify Client Secret',
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerLow,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.3))),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.3))),
|
||||
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.primary, width: 2)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
if (settings.spotifyClientId.isNotEmpty)
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
ref.read(settingsProvider.notifier).clearSpotifyCredentials();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Credentials cleared')),
|
||||
);
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: colorScheme.error,
|
||||
side: BorderSide(color: colorScheme.error),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
),
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
),
|
||||
if (settings.spotifyClientId.isNotEmpty) const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
final clientId = clientIdController.text.trim();
|
||||
final clientSecret = clientSecretController.text.trim();
|
||||
|
||||
if (clientId.isNotEmpty && clientSecret.isNotEmpty) {
|
||||
ref.read(settingsProvider.notifier).setSpotifyCredentials(clientId, clientSecret);
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Credentials saved')),
|
||||
);
|
||||
} else if (clientId.isEmpty && clientSecret.isEmpty) {
|
||||
Navigator.pop(context);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Please fill both Client ID and Secret')),
|
||||
);
|
||||
}
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
),
|
||||
child: const Text('Save'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConcurrentDownloadsItem extends StatelessWidget {
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
|
||||
@@ -389,7 +389,19 @@ class _SettingsTabState extends ConsumerState<SettingsTab> 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),
|
||||
|
||||
@@ -47,6 +47,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
_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<TrackMetadataScreen> {
|
||||
Future<void> _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<TrackMetadataScreen> {
|
||||
_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<TrackMetadataScreen> {
|
||||
item.spotifyId ?? '',
|
||||
item.trackName,
|
||||
item.artistName,
|
||||
filePath: _fileExists ? item.filePath : null, // Try embedded lyrics first
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
|
||||
@@ -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<String> 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;
|
||||
}
|
||||
@@ -285,4 +284,13 @@ class PlatformBridge {
|
||||
final result = await _channel.invokeMethod('isDownloadServiceRunning');
|
||||
return result as bool;
|
||||
}
|
||||
|
||||
/// Set custom Spotify API credentials
|
||||
/// Pass empty strings to use default credentials
|
||||
static Future<void> setSpotifyCredentials(String clientId, String clientSecret) async {
|
||||
await _channel.invokeMethod('setSpotifyCredentials', {
|
||||
'client_id': clientId,
|
||||
'client_secret': clientSecret,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||
publish_to: 'none'
|
||||
version: 2.0.0+30
|
||||
version: 2.0.3+33
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
## [1.1.0] - 2026-01-01
|
||||
|
||||
### Added
|
||||
- **Parallel Downloads**: Download up to 3 tracks simultaneously (configurable in Settings)
|
||||
- Default: Sequential (1 at a time) for stability
|
||||
- Options: 1, 2, or 3 concurrent downloads
|
||||
- Warning about potential rate limiting from streaming services
|
||||
- **Download Progress Tracking**: Real-time progress for BTS manifest downloads from Tidal
|
||||
- **History Persistence**: Download history now persists across app restarts using SharedPreferences
|
||||
- **Connection Pooling**: Shared HTTP transport to prevent TCP connection exhaustion during large batch downloads
|
||||
- **Connection Cleanup**: Automatic cleanup of idle connections every 50 downloads and at queue end
|
||||
- **GitHub & Credits Section**: Added links to SpotiFLAC Mobile and original SpotiFLAC desktop in Settings
|
||||
|
||||
### Fixed
|
||||
- **Download Progress Bug**: Fixed 0% → 100% jump by adding proper progress tracking for BTS format downloads
|
||||
- **TCP Connection Exhaustion**: Fixed slow downloads after ~300 tracks by implementing connection pooling and periodic cleanup
|
||||
- **Trailing Space in Names**: Fixed download failures when playlist/album/track names have trailing spaces
|
||||
- **History Loss on Debug**: History no longer disappears when sideloading via `flutter run --debug`
|
||||
|
||||
### Changed
|
||||
- Updated version to 1.1.0
|
||||
|
||||
### Technical Details
|
||||
- Added `concurrentDownloads` field to `AppSettings` model (default: 1, max: 3)
|
||||
- Implemented worker pool pattern in `DownloadQueueNotifier` for parallel processing
|
||||
- Added `SetCurrentFile()`, `SetBytesTotal()`, and `ProgressWriter` for BTS downloads in Go backend
|
||||
- Added `strings.TrimSpace()` to all string fields in `DownloadTrack()` and `DownloadWithFallback()`
|
||||
- Added shared `http.Transport` with connection pooling in `httputil.go`
|
||||
- Added `CleanupConnections()` export for Flutter to call via method channel
|
||||
|
||||
## [1.0.5] - Previous Release
|
||||
- Material Expressive 3 UI
|
||||
- Dynamic color support
|
||||
- Swipe navigation with PageView
|
||||
- Settings as bottom navigation tab
|
||||
- APK size optimization
|
||||
Reference in New Issue
Block a user