mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-04 19:57:55 +02:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 85bb67da47 | |||
| 794486a200 | |||
| 8ce5e958ee | |||
| 5c6bf02f1c |
+64
-10
@@ -1,5 +1,69 @@
|
|||||||
# Changelog
|
# 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
|
## [2.0.0] - 2026-01-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -48,16 +112,6 @@
|
|||||||
- Theme/view mode chips have visible borders in light mode
|
- Theme/view mode chips have visible borders in light mode
|
||||||
- **Navigation Bar Styling**: Distinct background color from content area
|
- **Navigation Bar Styling**: Distinct background color from content area
|
||||||
- **Ask Before Download Default**: Now enabled by default for better UX
|
- **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
|
### Fixed
|
||||||
- **Artist Profile Images**: Fixed artist images not showing in search results (field name mismatch)
|
- **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 spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||||
val trackName = call.argument<String>("track_name") ?: ""
|
val trackName = call.argument<String>("track_name") ?: ""
|
||||||
val artistName = call.argument<String>("artist_name") ?: ""
|
val artistName = call.argument<String>("artist_name") ?: ""
|
||||||
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
Gobackend.getLyricsLRC(spotifyId, trackName, artistName)
|
Gobackend.getLyricsLRC(spotifyId, trackName, artistName, filePath)
|
||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
@@ -199,6 +200,14 @@ class MainActivity: FlutterActivity() {
|
|||||||
"isDownloadServiceRunning" -> {
|
"isDownloadServiceRunning" -> {
|
||||||
result.success(DownloadService.isServiceRunning())
|
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()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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
|
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
// Set current file being downloaded (legacy)
|
// Initialize item progress (required for all downloads)
|
||||||
SetCurrentFile(filepath.Base(outputPath))
|
|
||||||
SetDownloading(true)
|
|
||||||
defer SetDownloading(false)
|
|
||||||
|
|
||||||
// Initialize item progress if itemID provided
|
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
defer CompleteItemProgress(itemID)
|
defer CompleteItemProgress(itemID)
|
||||||
@@ -232,11 +227,8 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set total bytes if available
|
// Set total bytes if available
|
||||||
if resp.ContentLength > 0 {
|
if resp.ContentLength > 0 && itemID != "" {
|
||||||
SetBytesTotal(resp.ContentLength)
|
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||||
if itemID != "" {
|
|
||||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := os.Create(outputPath)
|
||||||
@@ -245,14 +237,14 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
// Use appropriate progress writer
|
// Use item progress writer
|
||||||
var bytesWritten int64
|
var bytesWritten int64
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
pw := NewItemProgressWriter(out, itemID)
|
pw := NewItemProgressWriter(out, itemID)
|
||||||
bytesWritten, err = io.Copy(pw, resp.Body)
|
bytesWritten, err = io.Copy(pw, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
pw := NewProgressWriter(out)
|
// Fallback: direct copy without progress tracking
|
||||||
bytesWritten, err = io.Copy(pw, resp.Body)
|
bytesWritten, err = io.Copy(out, resp.Body)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to write file: %w", err)
|
return fmt.Errorf("failed to write file: %w", err)
|
||||||
@@ -262,38 +254,45 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||||||
return nil
|
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
|
// downloadFromAmazon downloads a track using the request parameters
|
||||||
// Uses DoubleDouble service (same as PC version)
|
// Uses DoubleDouble service (same as PC version)
|
||||||
func downloadFromAmazon(req DownloadRequest) (string, error) {
|
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||||
downloader := NewAmazonDownloader()
|
downloader := NewAmazonDownloader()
|
||||||
|
|
||||||
// Check for existing file first
|
// Check for existing file first
|
||||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||||
return "EXISTS:" + existingFile, nil
|
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get Amazon URL from SongLink
|
// Get Amazon URL from SongLink
|
||||||
songlink := NewSongLinkClient()
|
songlink := NewSongLinkClient()
|
||||||
availability, err := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
availability, err := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||||
if err != nil {
|
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 == "" {
|
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
|
// Create output directory if needed
|
||||||
if req.OutputDir != "." {
|
if req.OutputDir != "." {
|
||||||
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
|
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)
|
// Download using DoubleDouble service (same as PC)
|
||||||
downloadURL, trackName, artistName, err := downloader.downloadFromDoubleDoubleService(availability.AmazonURL, req.OutputDir)
|
downloadURL, trackName, artistName, err := downloader.downloadFromDoubleDoubleService(availability.AmazonURL, req.OutputDir)
|
||||||
if err != nil {
|
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)
|
// Build filename using Spotify metadata (more accurate)
|
||||||
@@ -310,12 +309,12 @@ func downloadFromAmazon(req DownloadRequest) (string, error) {
|
|||||||
|
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
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
|
// Download file with item ID for progress tracking
|
||||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
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)
|
// 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")
|
fmt.Println("[Amazon] No lyrics found for this track")
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[Amazon] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
|
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)
|
lrcContent := convertToLRC(lyrics)
|
||||||
if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil {
|
if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil {
|
||||||
fmt.Printf("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
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")
|
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
|
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
|
// GetSpotifyMetadata fetches metadata from Spotify URL
|
||||||
// Returns JSON with track/album/playlist data
|
// Returns JSON with track/album/playlist data
|
||||||
func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
||||||
@@ -122,7 +128,6 @@ type DownloadRequest struct {
|
|||||||
Quality string `json:"quality"` // LOSSLESS, HI_RES, HI_RES_LOSSLESS
|
Quality string `json:"quality"` // LOSSLESS, HI_RES, HI_RES_LOSSLESS
|
||||||
EmbedLyrics bool `json:"embed_lyrics"`
|
EmbedLyrics bool `json:"embed_lyrics"`
|
||||||
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
|
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
|
||||||
ConvertLyricsToRomaji bool `json:"convert_lyrics_to_romaji"`
|
|
||||||
TrackNumber int `json:"track_number"`
|
TrackNumber int `json:"track_number"`
|
||||||
DiscNumber int `json:"disc_number"`
|
DiscNumber int `json:"disc_number"`
|
||||||
TotalTracks int `json:"total_tracks"`
|
TotalTracks int `json:"total_tracks"`
|
||||||
@@ -137,6 +142,17 @@ type DownloadResponse struct {
|
|||||||
FilePath string `json:"file_path,omitempty"`
|
FilePath string `json:"file_path,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
AlreadyExists bool `json:"already_exists,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
|
// 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.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
||||||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||||
|
|
||||||
var filePath string
|
var result DownloadResult
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
switch req.Service {
|
switch req.Service {
|
||||||
case "tidal":
|
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":
|
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":
|
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:
|
default:
|
||||||
return errorResponse("Unknown service: " + req.Service)
|
return errorResponse("Unknown service: " + req.Service)
|
||||||
}
|
}
|
||||||
@@ -174,21 +214,25 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if len(filePath) > 7 && filePath[:7] == "EXISTS:" {
|
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "File already exists",
|
Message: "File already exists",
|
||||||
FilePath: filePath[7:],
|
FilePath: result.FilePath[7:],
|
||||||
AlreadyExists: true,
|
AlreadyExists: true,
|
||||||
|
Service: req.Service,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "Download complete",
|
Message: "Download complete",
|
||||||
FilePath: filePath,
|
FilePath: result.FilePath,
|
||||||
|
ActualBitDepth: result.BitDepth,
|
||||||
|
ActualSampleRate: result.SampleRate,
|
||||||
|
Service: req.Service,
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
@@ -230,35 +274,63 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
for _, service := range services {
|
for _, service := range services {
|
||||||
req.Service = service
|
req.Service = service
|
||||||
|
|
||||||
var filePath string
|
var result DownloadResult
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
switch service {
|
switch service {
|
||||||
case "tidal":
|
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":
|
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":
|
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 {
|
if err == nil {
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if len(filePath) > 7 && filePath[:7] == "EXISTS:" {
|
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "File already exists",
|
Message: "File already exists",
|
||||||
FilePath: filePath[7:],
|
FilePath: result.FilePath[7:],
|
||||||
AlreadyExists: true,
|
AlreadyExists: true,
|
||||||
|
Service: service,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "Downloaded from " + service,
|
Message: "Downloaded from " + service,
|
||||||
FilePath: filePath,
|
FilePath: result.FilePath,
|
||||||
|
ActualBitDepth: result.BitDepth,
|
||||||
|
ActualSampleRate: result.SampleRate,
|
||||||
|
Service: service,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
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
|
// 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()
|
client := NewLyricsClient()
|
||||||
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
|
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
lrcContent := convertToLRC(lyrics)
|
lrcContent := convertToLRC(lyricsData)
|
||||||
return lrcContent, nil
|
return lrcContent, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,12 +476,6 @@ func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
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) {
|
func errorResponse(msg string) (string, error) {
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
|
|||||||
@@ -335,3 +335,92 @@ func EmbedLyrics(filePath string, lyrics string) error {
|
|||||||
|
|
||||||
return f.Save(filePath)
|
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
|
||||||
|
}
|
||||||
|
|||||||
+29
-124
@@ -5,7 +5,8 @@ import (
|
|||||||
"sync"
|
"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 {
|
type DownloadProgress struct {
|
||||||
CurrentFile string `json:"current_file"`
|
CurrentFile string `json:"current_file"`
|
||||||
Progress float64 `json:"progress"`
|
Progress float64 `json:"progress"`
|
||||||
@@ -32,21 +33,33 @@ type MultiProgress struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
currentProgress DownloadProgress
|
downloadDir string
|
||||||
progressMu sync.RWMutex
|
downloadDirMu sync.RWMutex
|
||||||
downloadDir string
|
|
||||||
downloadDirMu sync.RWMutex
|
|
||||||
|
|
||||||
// Multi-download progress tracking
|
// Multi-download progress tracking (unified system)
|
||||||
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
||||||
multiMu sync.RWMutex
|
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 {
|
func getProgress() DownloadProgress {
|
||||||
progressMu.RLock()
|
multiMu.RLock()
|
||||||
defer progressMu.RUnlock()
|
defer multiMu.RUnlock()
|
||||||
return currentProgress
|
|
||||||
|
// 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
|
// GetMultiProgress returns progress for all active downloads as JSON
|
||||||
@@ -119,12 +132,15 @@ func CompleteItemProgress(itemID string) {
|
|||||||
if item, ok := multiProgress.Items[itemID]; ok {
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
item.Progress = 1.0
|
item.Progress = 1.0
|
||||||
item.IsDownloading = false
|
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) {
|
func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal int64) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
if item, ok := multiProgress.Items[itemID]; ok {
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
item.Progress = progress
|
item.Progress = progress
|
||||||
if bytesReceived > 0 {
|
if bytesReceived > 0 {
|
||||||
@@ -134,32 +150,17 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal
|
|||||||
item.BytesTotal = 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)
|
// SetItemFinalizing marks an item as finalizing (embedding metadata)
|
||||||
func SetItemFinalizing(itemID string) {
|
func SetItemFinalizing(itemID string) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
if item, ok := multiProgress.Items[itemID]; ok {
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
item.Progress = 1.0
|
item.Progress = 1.0
|
||||||
item.Status = "finalizing"
|
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
|
// RemoveItemProgress removes progress tracking for an item
|
||||||
@@ -178,42 +179,6 @@ func ClearAllItemProgress() {
|
|||||||
multiProgress.Items = make(map[string]*ItemProgress)
|
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
|
// setDownloadDir sets the default download directory
|
||||||
func setDownloadDir(path string) error {
|
func setDownloadDir(path string) error {
|
||||||
downloadDirMu.Lock()
|
downloadDirMu.Lock()
|
||||||
@@ -229,64 +194,6 @@ func getDownloadDir() string {
|
|||||||
return downloadDir
|
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
|
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
||||||
type ItemProgressWriter struct {
|
type ItemProgressWriter struct {
|
||||||
writer interface{ Write([]byte) (int, error) }
|
writer interface{ Write([]byte) (int, error) }
|
||||||
@@ -311,7 +218,5 @@ func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
|||||||
}
|
}
|
||||||
pw.current += int64(n)
|
pw.current += int64(n)
|
||||||
SetItemBytesReceived(pw.itemID, pw.current)
|
SetItemBytesReceived(pw.itemID, pw.current)
|
||||||
// Also update legacy progress for backward compatibility
|
|
||||||
SetBytesReceived(pw.current)
|
|
||||||
return n, nil
|
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
|
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||||
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
// Set current file being downloaded (legacy)
|
// Initialize item progress (required for all downloads)
|
||||||
SetCurrentFile(filepath.Base(outputPath))
|
|
||||||
SetDownloading(true)
|
|
||||||
defer SetDownloading(false)
|
|
||||||
|
|
||||||
// Initialize item progress if itemID provided
|
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
defer CompleteItemProgress(itemID)
|
defer CompleteItemProgress(itemID)
|
||||||
@@ -289,11 +284,8 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set total bytes if available
|
// Set total bytes if available
|
||||||
if resp.ContentLength > 0 {
|
if resp.ContentLength > 0 && itemID != "" {
|
||||||
SetBytesTotal(resp.ContentLength)
|
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||||
if itemID != "" {
|
|
||||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := os.Create(outputPath)
|
||||||
@@ -302,24 +294,31 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
// Use appropriate progress writer
|
// Use item progress writer
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
progressWriter := NewItemProgressWriter(out, itemID)
|
progressWriter := NewItemProgressWriter(out, itemID)
|
||||||
_, err = io.Copy(progressWriter, resp.Body)
|
_, err = io.Copy(progressWriter, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
progressWriter := NewProgressWriter(out)
|
// Fallback: direct copy without progress tracking
|
||||||
_, err = io.Copy(progressWriter, resp.Body)
|
_, err = io.Copy(out, resp.Body)
|
||||||
}
|
}
|
||||||
return err
|
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
|
// downloadFromQobuz downloads a track using the request parameters
|
||||||
func downloadFromQobuz(req DownloadRequest) (string, error) {
|
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||||
downloader := NewQobuzDownloader()
|
downloader := NewQobuzDownloader()
|
||||||
|
|
||||||
// Check for existing file first
|
// Check for existing file first
|
||||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||||
return "EXISTS:" + existingFile, nil
|
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var track *QobuzTrack
|
var track *QobuzTrack
|
||||||
@@ -340,7 +339,7 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg = err.Error()
|
errMsg = err.Error()
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("qobuz search failed: %s", errMsg)
|
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build filename
|
// Build filename
|
||||||
@@ -357,7 +356,7 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
|
|||||||
|
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
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
|
// 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)
|
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
|
// Get download URL using parallel API requests
|
||||||
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
||||||
if err != nil {
|
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
|
// Download file with item ID for progress tracking
|
||||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
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)
|
// 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")
|
fmt.Println("[Qobuz] No lyrics found for this track")
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[Qobuz] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
|
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)
|
lrcContent := convertToLRC(lyrics)
|
||||||
if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil {
|
if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil {
|
||||||
fmt.Printf("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
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
|
cacheMu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSpotifyMetadataClient creates a new Spotify client
|
// Custom credentials storage (set from Flutter)
|
||||||
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
var (
|
||||||
src := rand.NewSource(time.Now().UnixNano())
|
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")
|
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
|
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
|
||||||
@@ -81,6 +102,16 @@ func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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{
|
c := &SpotifyMetadataClient{
|
||||||
httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling
|
httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling
|
||||||
clientID: clientID,
|
clientID: clientID,
|
||||||
|
|||||||
+65
-86
@@ -335,33 +335,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
queries = append(queries, trackName)
|
queries = append(queries, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 3: Romaji versions if Japanese detected
|
// Strategy 3: Artist only as last resort
|
||||||
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
|
|
||||||
if artistName != "" {
|
if artistName != "" {
|
||||||
queries = append(queries, 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
|
// getDownloadURLSequential requests download URL from APIs sequentially
|
||||||
// Returns the first successful result (supports both v1 and v2 API formats)
|
// 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 {
|
if len(apis) == 0 {
|
||||||
return "", "", fmt.Errorf("no APIs available")
|
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
|
||||||
}
|
}
|
||||||
|
|
||||||
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
||||||
@@ -519,7 +500,12 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
|
|||||||
// Try v2 format first (object with manifest)
|
// Try v2 format first (object with manifest)
|
||||||
var v2Response TidalAPIResponseV2
|
var v2Response TidalAPIResponseV2
|
||||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
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)
|
// 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 {
|
if err := json.Unmarshal(body, &v1Responses); err == nil {
|
||||||
for _, item := range v1Responses {
|
for _, item := range v1Responses {
|
||||||
if item.OriginalTrackURL != "" {
|
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"))
|
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
|
// 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()
|
apis := t.GetAvailableAPIs()
|
||||||
if len(apis) == 0 {
|
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 {
|
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)
|
// 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)
|
return t.downloadFromManifest(strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set current file being downloaded (legacy)
|
// Initialize item progress (required for all downloads)
|
||||||
SetCurrentFile(filepath.Base(outputPath))
|
|
||||||
SetDownloading(true)
|
|
||||||
defer SetDownloading(false)
|
|
||||||
|
|
||||||
// Initialize item progress if itemID provided
|
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
defer CompleteItemProgress(itemID)
|
defer CompleteItemProgress(itemID)
|
||||||
@@ -673,11 +660,8 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set total bytes if available
|
// Set total bytes if available
|
||||||
if resp.ContentLength > 0 {
|
if resp.ContentLength > 0 && itemID != "" {
|
||||||
SetBytesTotal(resp.ContentLength)
|
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||||
if itemID != "" {
|
|
||||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := os.Create(outputPath)
|
||||||
@@ -686,13 +670,13 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
// Use appropriate progress writer
|
// Use item progress writer
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
progressWriter := NewItemProgressWriter(out, itemID)
|
progressWriter := NewItemProgressWriter(out, itemID)
|
||||||
_, err = io.Copy(progressWriter, resp.Body)
|
_, err = io.Copy(progressWriter, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
progressWriter := NewProgressWriter(out)
|
// Fallback: direct copy without progress tracking
|
||||||
_, err = io.Copy(progressWriter, resp.Body)
|
_, err = io.Copy(out, resp.Body)
|
||||||
}
|
}
|
||||||
return err
|
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 we have a direct URL (BTS format), download directly with progress tracking
|
||||||
if directURL != "" {
|
if directURL != "" {
|
||||||
// Set current file being downloaded (legacy)
|
// Initialize item progress (required for all downloads)
|
||||||
SetCurrentFile(filepath.Base(outputPath))
|
|
||||||
SetDownloading(true)
|
|
||||||
defer SetDownloading(false)
|
|
||||||
|
|
||||||
// Initialize item progress if itemID provided
|
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
defer CompleteItemProgress(itemID)
|
defer CompleteItemProgress(itemID)
|
||||||
@@ -736,11 +715,8 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set total bytes for progress tracking
|
// Set total bytes for progress tracking
|
||||||
if resp.ContentLength > 0 {
|
if resp.ContentLength > 0 && itemID != "" {
|
||||||
SetBytesTotal(resp.ContentLength)
|
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||||
if itemID != "" {
|
|
||||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := os.Create(outputPath)
|
||||||
@@ -749,13 +725,13 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
// Use appropriate progress writer
|
// Use item progress writer
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
progressWriter := NewItemProgressWriter(out, itemID)
|
progressWriter := NewItemProgressWriter(out, itemID)
|
||||||
_, err = io.Copy(progressWriter, resp.Body)
|
_, err = io.Copy(progressWriter, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
progressWriter := NewProgressWriter(out)
|
// Fallback: direct copy without progress tracking
|
||||||
_, err = io.Copy(progressWriter, resp.Body)
|
_, err = io.Copy(out, resp.Body)
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -828,13 +804,20 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
return nil
|
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
|
// downloadFromTidal downloads a track using the request parameters
|
||||||
func downloadFromTidal(req DownloadRequest) (string, error) {
|
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||||
downloader := NewTidalDownloader()
|
downloader := NewTidalDownloader()
|
||||||
|
|
||||||
// Check for existing file first
|
// Check for existing file first
|
||||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||||
return "EXISTS:" + existingFile, nil
|
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var track *TidalTrack
|
var track *TidalTrack
|
||||||
@@ -867,7 +850,7 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg = err.Error()
|
errMsg = err.Error()
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("tidal search failed: %s", errMsg)
|
return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build filename
|
// Build filename
|
||||||
@@ -884,7 +867,7 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
|
|||||||
|
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
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)
|
// 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)
|
fmt.Printf("[Tidal] Using quality: %s\n", quality)
|
||||||
|
|
||||||
// Get download URL using parallel API requests
|
// Get download URL using parallel API requests
|
||||||
downloadURL, err := downloader.GetDownloadURL(track.ID, quality)
|
downloadInfo, err := downloader.GetDownloadURL(track.ID, quality)
|
||||||
if err != nil {
|
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
|
// Download file with item ID for progress tracking
|
||||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil {
|
||||||
return "", fmt.Errorf("download failed: %w", err)
|
return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set progress to 100% and status to finalizing (before embedding)
|
// 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)
|
fmt.Printf("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
|
||||||
} else if _, err := os.Stat(outputPath); err != nil {
|
} else if _, err := os.Stat(outputPath); err != nil {
|
||||||
// Neither FLAC nor M4A exists
|
// 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
|
// Embed metadata
|
||||||
@@ -968,17 +954,6 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
|
|||||||
fmt.Println("[Tidal] No lyrics found for this track")
|
fmt.Println("[Tidal] No lyrics found for this track")
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[Tidal] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
|
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)
|
lrcContent := convertToLRC(lyrics)
|
||||||
if embedErr := EmbedLyrics(actualOutputPath, lrcContent); embedErr != nil {
|
if embedErr := EmbedLyrics(actualOutputPath, lrcContent); embedErr != nil {
|
||||||
fmt.Printf("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr)
|
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)
|
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 spotifyId = args["spotify_id"] as! String
|
||||||
let trackName = args["track_name"] as! String
|
let trackName = args["track_name"] as! String
|
||||||
let artistName = args["artist_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 }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/// App version and info constants
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '2.0.0';
|
static const String version = '2.0.3';
|
||||||
static const String buildNumber = '30';
|
static const String buildNumber = '33';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ class AppSettings {
|
|||||||
final bool checkForUpdates; // Check for updates on app start
|
final bool checkForUpdates; // Check for updates on app start
|
||||||
final bool hasSearchedBefore; // Hide helper text after first search
|
final bool hasSearchedBefore; // Hide helper text after first search
|
||||||
final String folderOrganization; // none, artist, album, artist_album
|
final String folderOrganization; // none, artist, album, artist_album
|
||||||
final bool convertLyricsToRomaji; // Convert Japanese lyrics to romaji
|
|
||||||
final String historyViewMode; // list, grid
|
final String historyViewMode; // list, grid
|
||||||
final bool askQualityBeforeDownload; // Show quality picker before each download
|
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({
|
const AppSettings({
|
||||||
this.defaultService = 'tidal',
|
this.defaultService = 'tidal',
|
||||||
@@ -33,9 +35,11 @@ class AppSettings {
|
|||||||
this.checkForUpdates = true, // Default: enabled
|
this.checkForUpdates = true, // Default: enabled
|
||||||
this.hasSearchedBefore = false, // Default: show helper text
|
this.hasSearchedBefore = false, // Default: show helper text
|
||||||
this.folderOrganization = 'none', // Default: no folder organization
|
this.folderOrganization = 'none', // Default: no folder organization
|
||||||
this.convertLyricsToRomaji = false, // Default: keep original Japanese
|
|
||||||
this.historyViewMode = 'grid', // Default: grid view
|
this.historyViewMode = 'grid', // Default: grid view
|
||||||
this.askQualityBeforeDownload = true, // Default: ask quality before download
|
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({
|
AppSettings copyWith({
|
||||||
@@ -51,9 +55,11 @@ class AppSettings {
|
|||||||
bool? checkForUpdates,
|
bool? checkForUpdates,
|
||||||
bool? hasSearchedBefore,
|
bool? hasSearchedBefore,
|
||||||
String? folderOrganization,
|
String? folderOrganization,
|
||||||
bool? convertLyricsToRomaji,
|
|
||||||
String? historyViewMode,
|
String? historyViewMode,
|
||||||
bool? askQualityBeforeDownload,
|
bool? askQualityBeforeDownload,
|
||||||
|
String? spotifyClientId,
|
||||||
|
String? spotifyClientSecret,
|
||||||
|
bool? useCustomSpotifyCredentials,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
defaultService: defaultService ?? this.defaultService,
|
defaultService: defaultService ?? this.defaultService,
|
||||||
@@ -68,9 +74,11 @@ class AppSettings {
|
|||||||
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
|
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
|
||||||
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
||||||
folderOrganization: folderOrganization ?? this.folderOrganization,
|
folderOrganization: folderOrganization ?? this.folderOrganization,
|
||||||
convertLyricsToRomaji: convertLyricsToRomaji ?? this.convertLyricsToRomaji,
|
|
||||||
historyViewMode: historyViewMode ?? this.historyViewMode,
|
historyViewMode: historyViewMode ?? this.historyViewMode,
|
||||||
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
|
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,
|
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
|
||||||
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
||||||
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
||||||
convertLyricsToRomaji: json['convertLyricsToRomaji'] as bool? ?? false,
|
|
||||||
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
||||||
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
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) =>
|
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||||
@@ -38,7 +41,9 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
|||||||
'checkForUpdates': instance.checkForUpdates,
|
'checkForUpdates': instance.checkForUpdates,
|
||||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||||
'folderOrganization': instance.folderOrganization,
|
'folderOrganization': instance.folderOrganization,
|
||||||
'convertLyricsToRomaji': instance.convertLyricsToRomaji,
|
|
||||||
'historyViewMode': instance.historyViewMode,
|
'historyViewMode': instance.historyViewMode,
|
||||||
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
'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
|
static const _queueStorageKey = 'download_queue'; // Storage key for queue persistence
|
||||||
final NotificationService _notificationService = NotificationService();
|
final NotificationService _notificationService = NotificationService();
|
||||||
int _totalQueuedAtStart = 0; // Track total items when queue started
|
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;
|
bool _isLoaded = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -354,69 +356,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _startProgressPolling(String itemId) {
|
/// Start multi-progress polling for all downloads (sequential and parallel)
|
||||||
_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
|
|
||||||
void _startMultiProgressPolling() {
|
void _startMultiProgressPolling() {
|
||||||
_progressTimer?.cancel();
|
_progressTimer?.cancel();
|
||||||
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async {
|
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async {
|
||||||
@@ -424,6 +364,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final allProgress = await PlatformBridge.getAllDownloadProgress();
|
final allProgress = await PlatformBridge.getAllDownloadProgress();
|
||||||
final items = allProgress['items'] as Map<String, dynamic>? ?? {};
|
final items = allProgress['items'] as Map<String, dynamic>? ?? {};
|
||||||
|
|
||||||
|
bool hasFinalizingItem = false;
|
||||||
|
String? finalizingTrackName;
|
||||||
|
String? finalizingArtistName;
|
||||||
|
|
||||||
for (final entry in items.entries) {
|
for (final entry in items.entries) {
|
||||||
final itemId = entry.key;
|
final itemId = entry.key;
|
||||||
final itemProgress = entry.value as Map<String, dynamic>;
|
final itemProgress = entry.value as Map<String, dynamic>;
|
||||||
@@ -433,16 +377,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final status = itemProgress['status'] as String? ?? 'downloading';
|
final status = itemProgress['status'] as String? ?? 'downloading';
|
||||||
|
|
||||||
// Check if status is "finalizing" (embedding metadata)
|
// 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);
|
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;
|
final currentItem = state.items.where((i) => i.id == itemId).firstOrNull;
|
||||||
if (currentItem != null) {
|
if (currentItem != null) {
|
||||||
_notificationService.showDownloadFinalizing(
|
hasFinalizingItem = true;
|
||||||
trackName: currentItem.track.name,
|
finalizingTrackName = currentItem.track.name;
|
||||||
artistName: currentItem.track.artistName,
|
finalizingArtistName = currentItem.track.artistName;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
continue;
|
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) {
|
if (items.isNotEmpty) {
|
||||||
final firstEntry = items.entries.first;
|
final firstEntry = items.entries.first;
|
||||||
final firstProgress = firstEntry.value as Map<String, dynamic>;
|
final firstProgress = firstEntry.value as Map<String, dynamic>;
|
||||||
final bytesReceived = firstProgress['bytes_received'] as int? ?? 0;
|
final bytesReceived = firstProgress['bytes_received'] as int? ?? 0;
|
||||||
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
|
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
|
||||||
|
|
||||||
// Find the item to get track info
|
// Find downloading items (not finalizing)
|
||||||
final downloadingItems = state.items.where((i) => i.status == DownloadStatus.downloading || i.status == DownloadStatus.finalizing).toList();
|
final downloadingItems = state.items.where((i) => i.status == DownloadStatus.downloading).toList();
|
||||||
if (downloadingItems.isNotEmpty) {
|
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(
|
_notificationService.showDownloadProgress(
|
||||||
trackName: '${downloadingItems.length} downloads',
|
trackName: trackName,
|
||||||
artistName: 'Downloading...',
|
artistName: artistName,
|
||||||
progress: bytesReceived,
|
progress: bytesReceived,
|
||||||
total: bytesTotal > 0 ? bytesTotal : 1,
|
total: bytesTotal > 0 ? bytesTotal : 1,
|
||||||
);
|
);
|
||||||
@@ -823,6 +784,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
// Track total items at start for notification
|
// Track total items at start for notification
|
||||||
_totalQueuedAtStart = state.items.where((i) => i.status == DownloadStatus.queued).length;
|
_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)
|
// Start foreground service to keep downloads running in background (Android only)
|
||||||
if (Platform.isAndroid && _totalQueuedAtStart > 0) {
|
if (Platform.isAndroid && _totalQueuedAtStart > 0) {
|
||||||
@@ -893,12 +856,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show queue completion notification
|
// Show queue completion notification
|
||||||
final completedCount = state.completedCount;
|
_log.i('Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart');
|
||||||
final failedCount = state.failedCount;
|
|
||||||
if (_totalQueuedAtStart > 0) {
|
if (_totalQueuedAtStart > 0) {
|
||||||
await _notificationService.showQueueComplete(
|
await _notificationService.showQueueComplete(
|
||||||
completedCount: completedCount,
|
completedCount: _completedInSession,
|
||||||
failedCount: failedCount,
|
failedCount: _failedInSession,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -906,8 +868,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
state = state.copyWith(isProcessing: false, currentDownload: null);
|
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 {
|
Future<void> _processQueueSequential() async {
|
||||||
|
// Start multi-progress polling (works for both sequential and parallel)
|
||||||
|
_startMultiProgressPolling();
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
// Check if paused
|
// Check if paused
|
||||||
if (state.isPaused) {
|
if (state.isPaused) {
|
||||||
@@ -932,7 +897,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await _downloadSingleItem(nextItem);
|
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
|
/// Parallel download processing with worker pool
|
||||||
@@ -940,7 +911,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final maxConcurrent = state.concurrentDownloads;
|
final maxConcurrent = state.concurrentDownloads;
|
||||||
final activeDownloads = <String, Future<void>>{}; // Map item ID to future
|
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();
|
_startMultiProgressPolling();
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -991,6 +962,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
if (activeDownloads.isNotEmpty) {
|
if (activeDownloads.isNotEmpty) {
|
||||||
await Future.wait(activeDownloads.values);
|
await Future.wait(activeDownloads.values);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop polling when queue is done
|
||||||
|
_stopProgressPolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download a single item (used by both sequential and parallel processing)
|
/// 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('Processing: ${item.track.name} by ${item.track.artistName}');
|
||||||
_log.d('Cover URL: ${item.track.coverUrl}');
|
_log.d('Cover URL: ${item.track.coverUrl}');
|
||||||
|
|
||||||
// Only set currentDownload for sequential mode (for progress polling)
|
// Set currentDownload for UI reference
|
||||||
if (state.concurrentDownloads == 1) {
|
state = state.copyWith(currentDownload: item);
|
||||||
state = state.copyWith(currentDownload: item);
|
|
||||||
_startProgressPolling(item.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateItemStatus(item.id, DownloadStatus.downloading);
|
updateItemStatus(item.id, DownloadStatus.downloading);
|
||||||
|
|
||||||
@@ -1036,7 +1007,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
releaseDate: item.track.releaseDate,
|
releaseDate: item.track.releaseDate,
|
||||||
preferredService: item.service,
|
preferredService: item.service,
|
||||||
itemId: item.id, // Pass item ID for progress tracking
|
itemId: item.id, // Pass item ID for progress tracking
|
||||||
convertLyricsToRomaji: settings.convertLyricsToRomaji,
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
result = await PlatformBridge.downloadTrack(
|
result = await PlatformBridge.downloadTrack(
|
||||||
@@ -1055,21 +1025,49 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
discNumber: item.track.discNumber ?? 1,
|
discNumber: item.track.discNumber ?? 1,
|
||||||
releaseDate: item.track.releaseDate,
|
releaseDate: item.track.releaseDate,
|
||||||
itemId: item.id, // Pass item ID for progress tracking
|
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');
|
_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) {
|
if (result['success'] == true) {
|
||||||
var filePath = result['file_path'] as String?;
|
var filePath = result['file_path'] as String?;
|
||||||
_log.i('Download success, file: $filePath');
|
_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
|
// Check if file is M4A (DASH stream from Tidal) and needs remuxing to FLAC
|
||||||
if (filePath != null && filePath.endsWith('.m4a')) {
|
if (filePath != null && filePath.endsWith('.m4a')) {
|
||||||
_log.d('Converting M4A to FLAC...');
|
_log.d('Converting M4A to FLAC...');
|
||||||
@@ -1093,6 +1091,25 @@ 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(
|
updateItemStatus(
|
||||||
item.id,
|
item.id,
|
||||||
DownloadStatus.completed,
|
DownloadStatus.completed,
|
||||||
@@ -1100,11 +1117,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
filePath: filePath,
|
filePath: filePath,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Increment completed counter
|
||||||
|
_completedInSession++;
|
||||||
|
|
||||||
// Show completion notification for this track
|
// Show completion notification for this track
|
||||||
await _notificationService.showDownloadComplete(
|
await _notificationService.showDownloadComplete(
|
||||||
trackName: item.track.name,
|
trackName: item.track.name,
|
||||||
artistName: item.track.artistName,
|
artistName: item.track.artistName,
|
||||||
completedCount: state.completedCount,
|
completedCount: _completedInSession,
|
||||||
totalCount: _totalQueuedAtStart,
|
totalCount: _totalQueuedAtStart,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1127,7 +1147,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
discNumber: item.track.discNumber,
|
discNumber: item.track.discNumber,
|
||||||
duration: item.track.duration,
|
duration: item.track.duration,
|
||||||
releaseDate: item.track.releaseDate,
|
releaseDate: item.track.releaseDate,
|
||||||
quality: quality,
|
quality: actualQuality,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1142,6 +1162,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
DownloadStatus.failed,
|
DownloadStatus.failed,
|
||||||
error: errorMsg,
|
error: errorMsg,
|
||||||
);
|
);
|
||||||
|
_failedInSession++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Increment download counter and cleanup connections periodically
|
// Increment download counter and cleanup connections periodically
|
||||||
@@ -1155,15 +1176,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
if (state.concurrentDownloads == 1) {
|
|
||||||
_stopProgressPolling();
|
|
||||||
}
|
|
||||||
_log.e('Exception: $e', e, stackTrace);
|
_log.e('Exception: $e', e, stackTrace);
|
||||||
updateItemStatus(
|
updateItemStatus(
|
||||||
item.id,
|
item.id,
|
||||||
DownloadStatus.failed,
|
DownloadStatus.failed,
|
||||||
error: e.toString(),
|
error: e.toString(),
|
||||||
);
|
);
|
||||||
|
_failedInSession++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:convert';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:spotiflac_android/models/settings.dart';
|
import 'package:spotiflac_android/models/settings.dart';
|
||||||
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
|
||||||
const _settingsKey = 'app_settings';
|
const _settingsKey = 'app_settings';
|
||||||
|
|
||||||
@@ -17,6 +18,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
final json = prefs.getString(_settingsKey);
|
final json = prefs.getString(_settingsKey);
|
||||||
if (json != null) {
|
if (json != null) {
|
||||||
state = AppSettings.fromJson(jsonDecode(json));
|
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()));
|
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) {
|
void setDefaultService(String service) {
|
||||||
state = state.copyWith(defaultService: service);
|
state = state.copyWith(defaultService: service);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
@@ -89,11 +108,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setConvertLyricsToRomaji(bool enabled) {
|
|
||||||
state = state.copyWith(convertLyricsToRomaji: enabled);
|
|
||||||
_saveSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
void setHistoryViewMode(String mode) {
|
void setHistoryViewMode(String mode) {
|
||||||
state = state.copyWith(historyViewMode: mode);
|
state = state.copyWith(historyViewMode: mode);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
@@ -103,6 +117,40 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
state = state.copyWith(askQualityBeforeDownload: enabled);
|
state = state.copyWith(askQualityBeforeDownload: enabled);
|
||||||
_saveSettings();
|
_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>(
|
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||||
|
|||||||
@@ -118,7 +118,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
// Increment request ID to cancel any pending requests
|
// Increment request ID to cancel any pending requests
|
||||||
final requestId = ++_currentRequestId;
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
state = const TrackState(isLoading: true);
|
// Preserve hasSearchText during fetch
|
||||||
|
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
||||||
@@ -174,7 +175,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
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
|
// Increment request ID to cancel any pending requests
|
||||||
final requestId = ++_currentRequestId;
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
state = const TrackState(isLoading: true);
|
// Preserve hasSearchText during search
|
||||||
|
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
|
final results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
|
||||||
@@ -198,10 +201,12 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
searchArtists: artists,
|
searchArtists: artists,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
hasSearchText: state.hasSearchText,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
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),
|
padding: EdgeInsets.all(32),
|
||||||
child: Center(child: CircularProgressIndicator()),
|
child: Center(child: CircularProgressIndicator()),
|
||||||
)),
|
)),
|
||||||
if (_error != null)
|
if (_error != null)
|
||||||
SliverToBoxAdapter(child: Padding(
|
SliverToBoxAdapter(child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Text(_error!, style: TextStyle(color: colorScheme.error)),
|
child: _buildErrorWidget(_error!, colorScheme),
|
||||||
)),
|
)),
|
||||||
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
|
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
|
||||||
_buildTrackListHeader(context, colorScheme),
|
_buildTrackListHeader(context, colorScheme),
|
||||||
@@ -349,15 +349,89 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||||
child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
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: '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', 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),
|
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 {
|
class _QualityOption extends StatelessWidget {
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
if (_error != null)
|
if (_error != null)
|
||||||
SliverToBoxAdapter(child: Padding(
|
SliverToBoxAdapter(child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Text(_error!, style: TextStyle(color: colorScheme.error)),
|
child: _buildErrorWidget(_error!, colorScheme),
|
||||||
)),
|
)),
|
||||||
if (!_isLoadingDiscography && _error == null) ...[
|
if (!_isLoadingDiscography && _error == null) ...[
|
||||||
if (albumsOnly.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Albums', albumsOnly, colorScheme)),
|
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 {
|
class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
|
||||||
final _urlController = TextEditingController();
|
final _urlController = TextEditingController();
|
||||||
Timer? _debounce;
|
|
||||||
bool _isTyping = false;
|
bool _isTyping = false;
|
||||||
final FocusNode _searchFocusNode = FocusNode();
|
final FocusNode _searchFocusNode = FocusNode();
|
||||||
String? _lastSearchQuery; // Track last searched query to avoid duplicate searches
|
String? _lastSearchQuery; // Track last searched query to avoid duplicate searches
|
||||||
@@ -38,7 +37,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_debounce?.cancel();
|
|
||||||
_urlController.removeListener(_onSearchChanged);
|
_urlController.removeListener(_onSearchChanged);
|
||||||
_urlController.dispose();
|
_urlController.dispose();
|
||||||
_searchFocusNode.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
|
/// Called when trackState changes - used to sync search bar with state
|
||||||
void _onTrackStateChanged(TrackState? previous, TrackState next) {
|
void _onTrackStateChanged(TrackState? previous, TrackState next) {
|
||||||
// If state was cleared (no content, no search text, not loading), clear the search bar
|
// 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 &&
|
if (previous != null &&
|
||||||
!next.hasContent &&
|
!next.hasContent &&
|
||||||
!next.hasSearchText &&
|
!next.hasSearchText &&
|
||||||
!next.isLoading &&
|
!next.isLoading &&
|
||||||
_urlController.text.isNotEmpty) {
|
_urlController.text.isNotEmpty &&
|
||||||
|
!_searchFocusNode.hasFocus) {
|
||||||
_urlController.clear();
|
_urlController.clear();
|
||||||
setState(() => _isTyping = false);
|
setState(() => _isTyping = false);
|
||||||
}
|
}
|
||||||
} void _onSearchChanged() {
|
} void _onSearchChanged() {
|
||||||
final text = _urlController.text.trim();
|
final text = _urlController.text.trim();
|
||||||
final wasFocused = _searchFocusNode.hasFocus;
|
|
||||||
|
|
||||||
// Update search text state for MainShell back button handling
|
// Update search text state for MainShell back button handling
|
||||||
ref.read(trackProvider.notifier).setSearchText(text.isNotEmpty);
|
ref.read(trackProvider.notifier).setSearchText(text.isNotEmpty);
|
||||||
@@ -68,30 +67,13 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
setState(() => _isTyping = true);
|
setState(() => _isTyping = true);
|
||||||
} else if (text.isEmpty && _isTyping) {
|
} else if (text.isEmpty && _isTyping) {
|
||||||
setState(() => _isTyping = false);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-request focus after rebuild if it was focused
|
// No auto-search - user must press Enter to search
|
||||||
if (wasFocused) {
|
// This saves API calls and avoids rate limiting
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _performSearch(String query) async {
|
Future<void> _performSearch(String query) async {
|
||||||
@@ -116,7 +98,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _clearAndRefresh() async {
|
Future<void> _clearAndRefresh() async {
|
||||||
_debounce?.cancel();
|
|
||||||
_urlController.clear();
|
_urlController.clear();
|
||||||
_searchFocusNode.unfocus();
|
_searchFocusNode.unfocus();
|
||||||
_lastSearchQuery = null; // Reset last query
|
_lastSearchQuery = null; // Reset last query
|
||||||
@@ -222,19 +203,33 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||||
child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
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(
|
_QualityPickerOption(
|
||||||
title: 'FLAC Lossless',
|
title: 'FLAC Lossless',
|
||||||
subtitle: '16-bit / 44.1kHz',
|
subtitle: '16-bit / 44.1kHz',
|
||||||
|
icon: Icons.music_note,
|
||||||
onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); },
|
onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); },
|
||||||
),
|
),
|
||||||
_QualityPickerOption(
|
_QualityPickerOption(
|
||||||
title: 'Hi-Res FLAC',
|
title: 'Hi-Res FLAC',
|
||||||
subtitle: '24-bit / up to 96kHz',
|
subtitle: '24-bit / up to 96kHz',
|
||||||
|
icon: Icons.high_quality,
|
||||||
onTap: () { Navigator.pop(context); onSelect('HI_RES'); },
|
onTap: () { Navigator.pop(context); onSelect('HI_RES'); },
|
||||||
),
|
),
|
||||||
_QualityPickerOption(
|
_QualityPickerOption(
|
||||||
title: 'Hi-Res FLAC Max',
|
title: 'Hi-Res FLAC Max',
|
||||||
subtitle: '24-bit / up to 192kHz',
|
subtitle: '24-bit / up to 192kHz',
|
||||||
|
icon: Icons.four_k,
|
||||||
onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); },
|
onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); },
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@@ -271,6 +266,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
|
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||||
slivers: [
|
slivers: [
|
||||||
// App Bar - always present
|
// App Bar - always present
|
||||||
SliverAppBar(
|
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)
|
// Search results slivers - only shows search results (track list)
|
||||||
List<Widget> _buildSearchResults({
|
List<Widget> _buildSearchResults({
|
||||||
required List<Track> tracks,
|
required List<Track> tracks,
|
||||||
@@ -479,11 +538,11 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
// Error message
|
// Error message - with special handling for rate limit (429)
|
||||||
if (error != null)
|
if (error != null)
|
||||||
SliverToBoxAdapter(child: Padding(
|
SliverToBoxAdapter(child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text(error, style: TextStyle(color: colorScheme.error)),
|
child: _buildErrorWidget(error, colorScheme),
|
||||||
)),
|
)),
|
||||||
|
|
||||||
// Loading indicator
|
// Loading indicator
|
||||||
@@ -660,25 +719,45 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
),
|
),
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
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 {
|
class _QualityPickerOption extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final String subtitle;
|
final String subtitle;
|
||||||
|
final IconData icon;
|
||||||
final VoidCallback onTap;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
return ListTile(
|
return ListTile(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||||
leading: Icon(Icons.music_note, color: colorScheme.primary),
|
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),
|
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||||
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -125,6 +125,13 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
void _handleBackPress() {
|
void _handleBackPress() {
|
||||||
final trackState = ref.read(trackProvider);
|
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 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)) {
|
if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) {
|
||||||
ref.read(trackProvider.notifier).clear();
|
ref.read(trackProvider.notifier).clear();
|
||||||
@@ -163,12 +170,17 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
||||||
final trackState = ref.watch(trackProvider);
|
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)
|
// Determine if we can pop (for predictive back animation)
|
||||||
// canPop is true when we're at root with no content - enables predictive back gesture
|
// 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 &&
|
final canPop = _currentIndex == 0 &&
|
||||||
!trackState.hasSearchText &&
|
!trackState.hasSearchText &&
|
||||||
!trackState.hasContent &&
|
!trackState.hasContent &&
|
||||||
!trackState.isLoading;
|
!trackState.isLoading &&
|
||||||
|
!isKeyboardVisible;
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: canPop,
|
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)))),
|
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))),
|
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: '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', 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),
|
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
|
// Play button overlay
|
||||||
if (fileExists)
|
if (fileExists)
|
||||||
Positioned(
|
Positioned(
|
||||||
@@ -677,11 +702,38 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Row(
|
||||||
dateStr,
|
children: [
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
Text(
|
||||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.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
|
// App section
|
||||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'App')),
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'App')),
|
||||||
SliverToBoxAdapter(
|
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
|
// Data section
|
||||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Data')),
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Data')),
|
||||||
SliverToBoxAdapter(
|
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 {
|
class _ConcurrentDownloadsItem extends StatelessWidget {
|
||||||
|
|||||||
@@ -392,7 +392,19 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
title: const Text('Select Quality'),
|
title: const Text('Select Quality'),
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
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, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme),
|
||||||
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 192kHz', 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'),
|
title: const Text('Select Quality'),
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
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, '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', '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),
|
_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;
|
_fileExists = exists;
|
||||||
_fileSize = size;
|
_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 {
|
Future<void> _openSpotifyUrl(BuildContext context) async {
|
||||||
if (item.spotifyId == null) return;
|
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 {
|
||||||
// Try to open in Spotify app first, fallback to browser
|
// Try to open in Spotify app first using URI scheme
|
||||||
final uri = Uri.parse('spotify:track:${item.spotifyId}');
|
final launched = await launchUrl(
|
||||||
// ignore: deprecated_member_use
|
spotifyUri,
|
||||||
if (await canLaunchUrl(uri)) {
|
mode: LaunchMode.externalApplication,
|
||||||
await launchUrl(uri);
|
);
|
||||||
} else {
|
|
||||||
await launchUrl(Uri.parse(url), 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) {
|
} catch (e) {
|
||||||
if (context.mounted) {
|
// If URI scheme fails, try web URL
|
||||||
_copyToClipboard(context, url);
|
try {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
await launchUrl(
|
||||||
const SnackBar(content: Text('Spotify URL copied to clipboard')),
|
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()),
|
_MetadataItem('Disc number', item.discNumber.toString()),
|
||||||
if (item.duration != null)
|
if (item.duration != null)
|
||||||
_MetadataItem('Duration', _formatDuration(item.duration!)),
|
_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)
|
if (item.releaseDate != null && item.releaseDate!.isNotEmpty)
|
||||||
_MetadataItem('Release date', item.releaseDate!),
|
_MetadataItem('Release date', item.releaseDate!),
|
||||||
if (item.isrc != null && item.isrc!.isNotEmpty)
|
if (item.isrc != null && item.isrc!.isNotEmpty)
|
||||||
@@ -740,6 +763,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
item.spotifyId ?? '',
|
item.spotifyId ?? '',
|
||||||
item.trackName,
|
item.trackName,
|
||||||
item.artistName,
|
item.artistName,
|
||||||
|
filePath: _fileExists ? item.filePath : null, // Try embedded lyrics first
|
||||||
);
|
);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ class PlatformBridge {
|
|||||||
String quality = 'LOSSLESS',
|
String quality = 'LOSSLESS',
|
||||||
bool embedLyrics = true,
|
bool embedLyrics = true,
|
||||||
bool embedMaxQualityCover = true,
|
bool embedMaxQualityCover = true,
|
||||||
bool convertLyricsToRomaji = false,
|
|
||||||
int trackNumber = 1,
|
int trackNumber = 1,
|
||||||
int discNumber = 1,
|
int discNumber = 1,
|
||||||
int totalTracks = 1,
|
int totalTracks = 1,
|
||||||
@@ -81,7 +80,6 @@ class PlatformBridge {
|
|||||||
'quality': quality,
|
'quality': quality,
|
||||||
'embed_lyrics': embedLyrics,
|
'embed_lyrics': embedLyrics,
|
||||||
'embed_max_quality_cover': embedMaxQualityCover,
|
'embed_max_quality_cover': embedMaxQualityCover,
|
||||||
'convert_lyrics_to_romaji': convertLyricsToRomaji,
|
|
||||||
'track_number': trackNumber,
|
'track_number': trackNumber,
|
||||||
'disc_number': discNumber,
|
'disc_number': discNumber,
|
||||||
'total_tracks': totalTracks,
|
'total_tracks': totalTracks,
|
||||||
@@ -107,7 +105,6 @@ class PlatformBridge {
|
|||||||
String quality = 'LOSSLESS',
|
String quality = 'LOSSLESS',
|
||||||
bool embedLyrics = true,
|
bool embedLyrics = true,
|
||||||
bool embedMaxQualityCover = true,
|
bool embedMaxQualityCover = true,
|
||||||
bool convertLyricsToRomaji = false,
|
|
||||||
int trackNumber = 1,
|
int trackNumber = 1,
|
||||||
int discNumber = 1,
|
int discNumber = 1,
|
||||||
int totalTracks = 1,
|
int totalTracks = 1,
|
||||||
@@ -129,7 +126,6 @@ class PlatformBridge {
|
|||||||
'quality': quality,
|
'quality': quality,
|
||||||
'embed_lyrics': embedLyrics,
|
'embed_lyrics': embedLyrics,
|
||||||
'embed_max_quality_cover': embedMaxQualityCover,
|
'embed_max_quality_cover': embedMaxQualityCover,
|
||||||
'convert_lyrics_to_romaji': convertLyricsToRomaji,
|
|
||||||
'track_number': trackNumber,
|
'track_number': trackNumber,
|
||||||
'disc_number': discNumber,
|
'disc_number': discNumber,
|
||||||
'total_tracks': totalTracks,
|
'total_tracks': totalTracks,
|
||||||
@@ -214,15 +210,18 @@ class PlatformBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get lyrics in LRC format
|
/// Get lyrics in LRC format
|
||||||
|
/// First tries to extract from embedded file, then falls back to internet
|
||||||
static Future<String> getLyricsLRC(
|
static Future<String> getLyricsLRC(
|
||||||
String spotifyId,
|
String spotifyId,
|
||||||
String trackName,
|
String trackName,
|
||||||
String artistName,
|
String artistName, {
|
||||||
) async {
|
String? filePath,
|
||||||
|
}) async {
|
||||||
final result = await _channel.invokeMethod('getLyricsLRC', {
|
final result = await _channel.invokeMethod('getLyricsLRC', {
|
||||||
'spotify_id': spotifyId,
|
'spotify_id': spotifyId,
|
||||||
'track_name': trackName,
|
'track_name': trackName,
|
||||||
'artist_name': artistName,
|
'artist_name': artistName,
|
||||||
|
'file_path': filePath ?? '',
|
||||||
});
|
});
|
||||||
return result as String;
|
return result as String;
|
||||||
}
|
}
|
||||||
@@ -285,4 +284,13 @@ class PlatformBridge {
|
|||||||
final result = await _channel.invokeMethod('isDownloadServiceRunning');
|
final result = await _channel.invokeMethod('isDownloadServiceRunning');
|
||||||
return result as bool;
|
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
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 2.0.0+30
|
version: 2.0.3+33
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
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