diff --git a/go_backend/amazon.go b/go_backend/amazon.go index d15d293f..dc2a07c0 100644 --- a/go_backend/amazon.go +++ b/go_backend/amazon.go @@ -27,10 +27,9 @@ type AmazonDownloader struct { } var ( - // Global Amazon downloader instance for connection reuse globalAmazonDownloader *AmazonDownloader amazonDownloaderOnce sync.Once - amazonRateLimitMu sync.Mutex // Mutex for rate limiting + amazonRateLimitMu sync.Mutex ) // DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint @@ -55,7 +54,6 @@ func amazonArtistsMatch(expectedArtist, foundArtist string) bool { normExpected := strings.ToLower(strings.TrimSpace(expectedArtist)) normFound := strings.ToLower(strings.TrimSpace(foundArtist)) - // Exact match if normExpected == normFound { return true } @@ -82,8 +80,6 @@ func amazonArtistsMatch(expectedArtist, foundArtist string) bool { return true } - // If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean), - // assume they're the same artist with different transliteration expectedASCII := amazonIsASCIIString(expectedArtist) foundASCII := amazonIsASCIIString(foundArtist) if expectedASCII != foundASCII { diff --git a/go_backend/cancel.go b/go_backend/cancel.go index cc72c05d..9dc3c28e 100644 --- a/go_backend/cancel.go +++ b/go_backend/cancel.go @@ -52,7 +52,6 @@ func cancelDownload(itemID string) { } cancelMu.Unlock() - // Hide progress for cancelled items. RemoveItemProgress(itemID) } diff --git a/go_backend/cover.go b/go_backend/cover.go index af43d754..46ca89fd 100644 --- a/go_backend/cover.go +++ b/go_backend/cover.go @@ -32,13 +32,11 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) { GoLog("[Cover] Original URL: %s", coverURL) - // First upgrade small (300) to medium (640) - always do this downloadURL := convertSmallToMedium(coverURL) if downloadURL != coverURL { GoLog("[Cover] Upgraded 300x300 → 640x640") } - // Then upgrade to max quality if requested if maxQuality { maxURL := upgradeToMaxQuality(downloadURL) if maxURL != downloadURL { @@ -53,7 +51,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) { client := NewHTTPClientWithTimeout(DefaultTimeout) - // Create request with User-Agent (required by Spotify CDN) req, err := http.NewRequest("GET", downloadURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) @@ -74,8 +71,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) { return nil, fmt.Errorf("failed to read cover data: %w", err) } - // Calculate approximate resolution from file size - // JPEG ~2000x2000 is typically 300-600KB, 640x640 is ~50-100KB sizeKB := len(data) / 1024 var resolution string if sizeKB > 200 { @@ -94,10 +89,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) { // Same logic as PC version - directly replaces 640x640 size code with max resolution // No HEAD verification needed - Spotify CDN always serves max resolution if available func upgradeToMaxQuality(coverURL string) string { - // Spotify image URLs can be upgraded by changing the size parameter - // Format: https://i.scdn.co/image/ab67616d0000b273... - // ab67616d0000b273 = 640x640 - // ab67616d000082c1 = Max resolution (~2000x2000) if strings.Contains(coverURL, spotifySize640) { return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1) diff --git a/go_backend/deezer.go b/go_backend/deezer.go index 6c88c529..a3fcedd5 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -22,8 +22,7 @@ const ( deezerCacheTTL = 10 * time.Minute - // Parallel ISRC fetching settings - deezerMaxParallelISRC = 10 // Max concurrent ISRC fetches + deezerMaxParallelISRC = 10 ) // DeezerClient handles Deezer API interactions (no auth required) @@ -36,7 +35,6 @@ type DeezerClient struct { cacheMu sync.RWMutex } -// Singleton instance var ( deezerClient *DeezerClient deezerClientOnce sync.Once diff --git a/go_backend/duplicate.go b/go_backend/duplicate.go index bcfc3fcb..c169a4b6 100644 --- a/go_backend/duplicate.go +++ b/go_backend/duplicate.go @@ -18,11 +18,10 @@ type ISRCIndex struct { mu sync.RWMutex } -// Global ISRC index cache (per output directory) var ( isrcIndexCache = make(map[string]*ISRCIndex) isrcIndexCacheMu sync.RWMutex - isrcIndexTTL = 5 * time.Minute // Cache TTL - rebuild after 5 minutes + isrcIndexTTL = 5 * time.Minute ) // GetISRCIndex returns or builds an ISRC index for the given directory @@ -31,7 +30,6 @@ func GetISRCIndex(outputDir string) *ISRCIndex { idx, exists := isrcIndexCache[outputDir] isrcIndexCacheMu.RUnlock() - // Return cached index if still valid if exists && time.Since(idx.buildTime) < isrcIndexTTL { return idx } @@ -40,7 +38,6 @@ func GetISRCIndex(outputDir string) *ISRCIndex { } // buildISRCIndex scans a directory and builds a map of ISRC -> file path -// Same implementation as PC version for consistency func buildISRCIndex(outputDir string) *ISRCIndex { idx := &ISRCIndex{ index: make(map[string]string), @@ -85,7 +82,6 @@ func buildISRCIndex(outputDir string) *ISRCIndex { return idx } -// lookup checks if an ISRC exists in the index (internal, returns bool) func (idx *ISRCIndex) lookup(isrc string) (string, bool) { if isrc == "" { return "", false @@ -188,7 +184,6 @@ type FileExistenceResult struct { // It builds an ISRC index from the output directory once, then checks all tracks against it // Same implementation as PC version for consistency func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error) { - // Parse input JSON var tracks []struct { ISRC string `json:"isrc"` TrackName string `json:"track_name"` @@ -232,7 +227,6 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error wg.Wait() - // Return results as JSON resultJSON, err := json.Marshal(results) if err != nil { return "", fmt.Errorf("failed to marshal results: %w", err) diff --git a/go_backend/exports.go b/go_backend/exports.go index 21a9ec6f..aced12de 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -184,7 +184,6 @@ type DownloadResponse struct { SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"` } -// DownloadResult is a generic result type for all downloaders // DownloadResult is a generic result type for all downloaders type DownloadResult struct { FilePath string @@ -531,7 +530,6 @@ func InitItemProgress(itemID string) { // FinishItemProgress marks a download item as complete and removes tracking func FinishItemProgress(itemID string) { CompleteItemProgress(itemID) - // Don't remove immediately - let Flutter poll one more time to see 100% } // ClearItemProgress removes progress tracking for a specific item @@ -579,7 +577,6 @@ func ReadFileMetadata(filePath string) (string, error) { "duration": duration, } - // Add quality info if available if qualityErr == nil { result["bit_depth"] = quality.BitDepth result["sample_rate"] = quality.SampleRate @@ -677,7 +674,6 @@ func FetchLyrics(spotifyID, trackName, artistName string) (string, error) { // GetLyricsLRC fetches lyrics and converts to LRC format string with metadata headers // First tries to extract from file, then falls back to fetching from internet func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (string, error) { - // Try to extract from file first (much faster) if filePath != "" { lyrics, err := ExtractLyrics(filePath) if err == nil && lyrics != "" { @@ -685,7 +681,6 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (str } } - // Fallback to fetching from internet client := NewLyricsClient() lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName) if err != nil { @@ -739,7 +734,6 @@ func PreWarmTrackCacheJSON(tracksJSON string) (string, error) { } } - // Run in background go PreWarmTrackCache(requests) resp := map[string]interface{}{ @@ -873,7 +867,6 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) { return "", fmt.Errorf("could not find Deezer equivalent: %w", err) } - // Fetch metadata from Deezer trackResp, err := deezerClient.GetTrack(ctx, deezerID) if err != nil { return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err) @@ -893,7 +886,6 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) { return "", fmt.Errorf("could not find Deezer album: %w", err) } - // Fetch album metadata from Deezer albumResp, err := deezerClient.GetAlbum(ctx, deezerID) if err != nil { return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err) @@ -916,10 +908,8 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - // Try Spotify first client, err := NewSpotifyMetadataClient() if err != nil { - // No Spotify credentials - fall through to Deezer fallback LogWarn("Spotify", "Credentials not configured, falling back to Deezer") } else { data, err := client.GetFilteredData(ctx, spotifyURL, false, 0) @@ -933,12 +923,10 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) { errStr := strings.ToLower(err.Error()) if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") { - // Not a rate limit error, return original error return "", err } } - // Rate limited - try Deezer fallback for tracks and albums parsed, parseErr := parseSpotifyURI(spotifyURL) if parseErr != nil { return "", fmt.Errorf("spotify rate limited and failed to parse URL: %w", parseErr) @@ -950,7 +938,6 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) { return ConvertSpotifyToDeezer(parsed.Type, parsed.ID) } - // Artist and playlist not supported for fallback if parsed.Type == "artist" { return "", fmt.Errorf("spotify rate limited. Artist pages require Spotify API - please try again later") } @@ -1015,7 +1002,6 @@ func GetAmazonURLFromDeezerTrack(deezerTrackID string) (string, error) { } func errorResponse(msg string) (string, error) { - // Determine error type based on message errorType := "unknown" lowerMsg := strings.ToLower(msg) @@ -1104,7 +1090,6 @@ func LoadExtensionFromPath(filePath string) (string, error) { return "", err } - // Initialize with saved settings settingsStore := GetExtensionSettingsStore() settings := settingsStore.GetAll(ext.ID) if len(settings) > 0 { @@ -1255,7 +1240,6 @@ func SetExtensionSettingsJSON(extensionID, settingsJSON string) error { return err } - // Re-initialize extension with new settings manager := GetExtensionManager() return manager.InitializeExtension(extensionID, settings) } @@ -1450,7 +1434,6 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) } if !ext.Manifest.IsMetadataProvider() { - // Not a metadata provider, return original return trackJSON, nil } @@ -1462,7 +1445,6 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) provider := NewExtensionProviderWrapper(ext) enrichedTrack, err := provider.EnrichTrack(&track) if err != nil { - // Error enriching, return original return trackJSON, nil } @@ -1576,7 +1558,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) { "cover_url": result.CoverURL, } - // Add track if single track if result.Track != nil { response["track"] = map[string]interface{}{ "id": result.Track.ID, @@ -1594,7 +1575,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) { } } - // Add tracks if multiple if len(result.Tracks) > 0 { tracks := make([]map[string]interface{}, len(result.Tracks)) for i, track := range result.Tracks { @@ -1632,7 +1612,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) { } } - // Add artist info if present if result.Artist != nil { artistResponse := map[string]interface{}{ "id": result.Artist.ID, @@ -1643,7 +1622,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) { "provider_id": result.Artist.ProviderID, } - // Add albums if present if len(result.Artist.Albums) > 0 { albums := make([]map[string]interface{}, len(result.Artist.Albums)) for i, album := range result.Artist.Albums { @@ -1666,7 +1644,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) { artistResponse["albums"] = albums } - // Add top tracks if present if len(result.Artist.TopTracks) > 0 { topTracks := make([]map[string]interface{}, len(result.Artist.TopTracks)) for i, track := range result.Artist.TopTracks { diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index 52bdc78a..857a8dae 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -18,11 +18,9 @@ import ( // compareVersions compares two semantic version strings // Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2 func compareVersions(v1, v2 string) int { - // Parse version parts parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".") parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".") - // Pad shorter version with zeros maxLen := len(parts1) if len(parts2) > maxLen { maxLen = len(parts2) @@ -52,12 +50,12 @@ func compareVersions(v1, v2 string) int { type LoadedExtension struct { ID string `json:"id"` Manifest *ExtensionManifest `json:"manifest"` - VM *goja.Runtime `json:"-"` // Goja VM instance (not serialized) + VM *goja.Runtime `json:"-"` Enabled bool `json:"enabled"` Error string `json:"error,omitempty"` - DataDir string `json:"data_dir"` // Extension's data directory - SourceDir string `json:"source_dir"` // Where extension files are extracted - IconPath string `json:"icon_path"` // Full path to icon file (if exists) + DataDir string `json:"data_dir"` + SourceDir string `json:"source_dir"` + IconPath string `json:"icon_path"` } // ExtensionManager manages all loaded extensions @@ -68,7 +66,6 @@ type ExtensionManager struct { dataDir string // Base directory for extension data } -// Global extension manager instance var ( globalExtManager *ExtensionManager globalExtManagerOnce sync.Once diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index 81a8db45..a41b4ef6 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -10,10 +10,8 @@ import ( "github.com/dop251/goja" ) -// Default timeout for JS execution (30 seconds) const DefaultJSTimeout = 30 * time.Second -// Global auth state for extensions (stores pending auth codes) var ( extensionAuthState = make(map[string]*ExtensionAuthState) extensionAuthStateMu sync.RWMutex @@ -39,7 +37,6 @@ type PendingAuthRequest struct { CallbackURL string } -// Global pending auth requests (Flutter polls this) var ( pendingAuthRequests = make(map[string]*PendingAuthRequest) pendingAuthRequestsMu sync.RWMutex @@ -52,7 +49,6 @@ func GetPendingAuthRequest(extensionID string) *PendingAuthRequest { return pendingAuthRequests[extensionID] } -// ClearPendingAuthRequest clears pending auth request (called from Flutter after opening URL) func ClearPendingAuthRequest(extensionID string) { pendingAuthRequestsMu.Lock() defer pendingAuthRequestsMu.Unlock() @@ -101,7 +97,6 @@ type ExtensionRuntime struct { // NewExtensionRuntime creates a new runtime for an extension func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { - // Create a cookie jar for this extension jar, _ := newSimpleCookieJar() runtime := &ExtensionRuntime{ diff --git a/go_backend/filename.go b/go_backend/filename.go index bcd8434d..2be92b20 100644 --- a/go_backend/filename.go +++ b/go_backend/filename.go @@ -11,23 +11,18 @@ var invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`) // sanitizeFilename removes invalid characters from filename func sanitizeFilename(filename string) string { - // Replace invalid characters with underscore sanitized := invalidChars.ReplaceAllString(filename, "_") - // Remove leading/trailing spaces and dots sanitized = strings.TrimSpace(sanitized) sanitized = strings.Trim(sanitized, ".") - // Collapse multiple underscores multiUnderscore := regexp.MustCompile(`_+`) sanitized = multiUnderscore.ReplaceAllString(sanitized, "_") - // Limit length (Android has 255 byte limit for filenames) if len(sanitized) > 200 { sanitized = sanitized[:200] } - // Ensure not empty if sanitized == "" { sanitized = "untitled" } @@ -43,7 +38,6 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{}) result := template - // Replace placeholders placeholders := map[string]string{ "{title}": getString(metadata, "title"), "{artist}": getString(metadata, "artist"), @@ -63,7 +57,6 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{}) func getString(m map[string]interface{}, key string) string { if v, ok := m[key]; ok { if s, ok := v.(string); ok { - // Trim leading/trailing whitespace to prevent filename issues return strings.TrimSpace(s) } } diff --git a/go_backend/httputil.go b/go_backend/httputil.go index 8686ad5f..0700cfde 100644 --- a/go_backend/httputil.go +++ b/go_backend/httputil.go @@ -20,13 +20,11 @@ import ( // getRandomUserAgent generates a random Windows Chrome User-Agent string // Uses same format as PC version (referensi/backend/spotify_metadata.go) for better API compatibility func getRandomUserAgent() string { - // Windows 10/11 Chrome format - same as PC version for maximum compatibility - // Some APIs may block mobile User-Agents, so we use desktop format - winMajor := rand.Intn(2) + 10 // Windows 10 or 11 + winMajor := rand.Intn(2) + 10 - chromeVersion := rand.Intn(25) + 100 // Chrome 100-124 - chromeBuild := rand.Intn(1500) + 3000 // Build 3000-4500 - chromePatch := rand.Intn(65) + 60 // Patch 60-125 + chromeVersion := rand.Intn(25) + 100 + chromeBuild := rand.Intn(1500) + 3000 + chromePatch := rand.Intn(65) + 60 return fmt.Sprintf( "Mozilla/5.0 (Windows NT %d.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36", @@ -39,7 +37,6 @@ func getRandomUserAgent() string { // getRandomMacUserAgent generates a random Mac Chrome User-Agent string // Alternative format matching referensi/backend/spotify_metadata.go exactly -// Kept for potential future use // func getRandomMacUserAgent() string { // macMajor := rand.Intn(4) + 11 // macOS 11-14 // macMinor := rand.Intn(5) + 4 // Minor 4-8 @@ -66,7 +63,6 @@ func getRandomUserAgent() string { // } // getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent -// Kept for potential future use // func getRandomDesktopUserAgent() string { // if rand.Intn(2) == 0 { // return getRandomUserAgent() // Windows @@ -74,17 +70,15 @@ func getRandomUserAgent() string { // return getRandomMacUserAgent() // Mac // } -// Default timeout values const ( - DefaultTimeout = 60 * time.Second // Default HTTP timeout - DownloadTimeout = 120 * time.Second // Timeout for file downloads - SongLinkTimeout = 30 * time.Second // Timeout for SongLink API - DefaultMaxRetries = 3 // Default retry count - DefaultRetryDelay = 1 * time.Second // Initial retry delay + DefaultTimeout = 60 * time.Second + DownloadTimeout = 120 * time.Second + SongLinkTimeout = 30 * time.Second + DefaultMaxRetries = 3 + DefaultRetryDelay = 1 * time.Second ) // Shared transport with connection pooling to prevent TCP exhaustion -// Optimized for large file downloads (FLAC ~30-50MB) var sharedTransport = &http.Transport{ DialContext: (&net.Dialer{ Timeout: 30 * time.Second, @@ -96,27 +90,24 @@ var sharedTransport = &http.Transport{ IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, - DisableKeepAlives: false, // Enable keep-alives for connection reuse + DisableKeepAlives: false, ForceAttemptHTTP2: true, - WriteBufferSize: 64 * 1024, // 64KB write buffer - ReadBufferSize: 64 * 1024, // 64KB read buffer - DisableCompression: true, // FLAC is already compressed + WriteBufferSize: 64 * 1024, + ReadBufferSize: 64 * 1024, + DisableCompression: true, } -// Shared HTTP client for general requests (reuses connections) var sharedClient = &http.Client{ Transport: sharedTransport, Timeout: DefaultTimeout, } -// Shared HTTP client for downloads (longer timeout, reuses connections) var downloadClient = &http.Client{ Transport: sharedTransport, Timeout: DownloadTimeout, } // NewHTTPClientWithTimeout creates an HTTP client with specified timeout -// Uses shared transport for connection reuse func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client { return &http.Client{ Transport: sharedTransport, @@ -124,18 +115,15 @@ func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client { } } -// GetSharedClient returns the shared HTTP client for general requests func GetSharedClient() *http.Client { return sharedClient } -// GetDownloadClient returns the shared HTTP client for downloads func GetDownloadClient() *http.Client { return downloadClient } // CloseIdleConnections closes idle connections in the shared transport -// Call this periodically during large batch downloads to prevent connection buildup func CloseIdleConnections() { sharedTransport.CloseIdleConnections() } @@ -146,7 +134,6 @@ func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Respo req.Header.Set("User-Agent", getRandomUserAgent()) resp, err := client.Do(req) if err != nil { - // Check for ISP blocking CheckAndLogISPBlocking(err, req.URL.String(), "HTTP") } return resp, err diff --git a/go_backend/logbuffer.go b/go_backend/logbuffer.go index 87820614..5c08b03c 100644 --- a/go_backend/logbuffer.go +++ b/go_backend/logbuffer.go @@ -21,7 +21,7 @@ type LogBuffer struct { entries []LogEntry maxSize int mu sync.RWMutex - loggingEnabled bool // Whether logging is enabled (controlled by Flutter) + loggingEnabled bool } var ( @@ -60,7 +60,6 @@ func (lb *LogBuffer) Add(level, tag, message string) { lb.mu.Lock() defer lb.mu.Unlock() - // Skip if logging is disabled (except for errors which are always logged) if !lb.loggingEnabled && level != "ERROR" && level != "FATAL" { return } @@ -89,7 +88,6 @@ func (lb *LogBuffer) GetAll() string { return string(jsonBytes) } -// getSince returns log entries since the given index (internal use) func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) { lb.mu.RLock() defer lb.mu.RUnlock() diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index b1aa66cc..feef2c23 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -128,14 +128,12 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsRespons } func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string) (*LyricsResponse, error) { - // Strategy 1: Direct match with artist and track name lyrics, err := c.FetchLyricsWithMetadata(artistName, trackName) if err == nil && lyrics != nil && len(lyrics.Lines) > 0 { lyrics.Source = "LRCLIB" return lyrics, nil } - // Strategy 2: Try with simplified track name simplifiedTrack := simplifyTrackName(trackName) if simplifiedTrack != trackName { lyrics, err = c.FetchLyricsWithMetadata(artistName, simplifiedTrack) @@ -145,7 +143,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st } } - // Strategy 3: Search with full query query := artistName + " " + trackName lyrics, err = c.FetchLyricsFromLRCLibSearch(query) if err == nil && lyrics != nil && len(lyrics.Lines) > 0 { @@ -153,7 +150,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st return lyrics, nil } - // Strategy 4: Search with simplified query if simplifiedTrack != trackName { query = artistName + " " + simplifiedTrack lyrics, err = c.FetchLyricsFromLRCLibSearch(query) diff --git a/go_backend/parallel.go b/go_backend/parallel.go index 3eb7c8e2..37484714 100644 --- a/go_backend/parallel.go +++ b/go_backend/parallel.go @@ -35,7 +35,7 @@ func GetTrackIDCache() *TrackIDCache { trackIDCacheOnce.Do(func() { globalTrackIDCache = &TrackIDCache{ cache: make(map[string]*TrackIDCacheEntry), - ttl: 30 * time.Minute, // Cache for 30 minutes + ttl: 30 * time.Minute, } }) return globalTrackIDCache @@ -135,7 +135,6 @@ func FetchCoverAndLyricsParallel( result := &ParallelDownloadResult{} var wg sync.WaitGroup - // Download cover in parallel if coverURL != "" { wg.Add(1) go func() { @@ -165,7 +164,6 @@ func FetchCoverAndLyricsParallel( fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err) } else if lyrics != nil && len(lyrics.Lines) > 0 { result.LyricsData = lyrics - // Use LRC with metadata headers (like PC version) result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName) fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines)) } else { @@ -202,12 +200,10 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) { fmt.Printf("[Cache] Pre-warming cache for %d tracks...\n", len(requests)) cache := GetTrackIDCache() - // Limit concurrent pre-warm requests - semaphore := make(chan struct{}, 3) // Max 3 concurrent + semaphore := make(chan struct{}, 3) var wg sync.WaitGroup for _, req := range requests { - // Skip if already cached if cached := cache.Get(req.ISRC); cached != nil { continue } @@ -252,11 +248,9 @@ func preWarmQobuzCache(isrc string) { } func preWarmAmazonCache(isrc, spotifyID string) { - // Amazon uses SongLink to get URL, so we pre-warm by checking availability client := NewSongLinkClient() availability, err := client.CheckTrackAvailability(spotifyID, isrc) if err == nil && availability != nil && availability.Amazon { - // Store Amazon URL in cache (using ISRC as key) GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL) fmt.Printf("[Cache] Cached Amazon URL for ISRC %s\n", isrc) } @@ -270,10 +264,8 @@ func preWarmAmazonCache(isrc, spotifyID string) { // tracksJSON is a JSON array of {isrc, track_name, artist_name, service} func PreWarmCache(tracksJSON string) error { var requests []PreWarmCacheRequest - // Parse JSON (simplified - in production use proper JSON parsing) - // For now, this is called from exports.go with proper parsing - go PreWarmTrackCache(requests) // Run in background + go PreWarmTrackCache(requests) return nil } diff --git a/go_backend/progress.go b/go_backend/progress.go index 722b620d..cf18b5ee 100644 --- a/go_backend/progress.go +++ b/go_backend/progress.go @@ -44,7 +44,6 @@ var ( ) // getProgress returns current download progress from multi-progress system -// Returns first active item's progress for backward compatibility func getProgress() DownloadProgress { multiMu.RLock() defer multiMu.RUnlock() @@ -52,7 +51,7 @@ func getProgress() DownloadProgress { for _, item := range multiProgress.Items { return DownloadProgress{ CurrentFile: item.ItemID, - Progress: item.Progress * 100, // Convert to percentage + Progress: item.Progress * 100, BytesTotal: item.BytesTotal, BytesReceived: item.BytesReceived, IsDownloading: item.IsDownloading, diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 6e94ed4c..5e3311f8 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -25,7 +25,6 @@ type QobuzDownloader struct { } var ( - // Global Qobuz downloader instance for connection reuse globalQobuzDownloader *QobuzDownloader qobuzDownloaderOnce sync.Once ) @@ -66,22 +65,17 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool { return true } - // Split expected artists by common separators (comma, feat, ft., &, and) - // e.g., "RADWIMPS, Toko Miura" or "RADWIMPS feat. Toko Miura" expectedArtists := qobuzSplitArtists(normExpected) foundArtists := qobuzSplitArtists(normFound) - // Check if ANY expected artist matches ANY found artist for _, exp := range expectedArtists { for _, fnd := range foundArtists { if exp == fnd { return true } - // Also check contains for partial matches if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) { return true } - // Check same words different order if qobuzSameWordsUnordered(exp, fnd) { GoLog("[Qobuz] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd) return true @@ -89,8 +83,6 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool { } } - // If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration) - // Don't treat Latin Extended (Polish, French, etc.) as different script expectedLatin := qobuzIsLatinScript(expectedArtist) foundLatin := qobuzIsLatinScript(foundArtist) if expectedLatin != foundLatin { diff --git a/go_backend/ratelimit.go b/go_backend/ratelimit.go index eefc0272..1caa54d2 100644 --- a/go_backend/ratelimit.go +++ b/go_backend/ratelimit.go @@ -30,31 +30,25 @@ func (r *RateLimiter) WaitForSlot() { now := time.Now() - // Remove timestamps outside the window r.cleanOldTimestamps(now) - // If under limit, record and return immediately if len(r.timestamps) < r.maxRequests { r.timestamps = append(r.timestamps, now) return } - // Calculate wait time until oldest timestamp expires oldestTimestamp := r.timestamps[0] waitUntil := oldestTimestamp.Add(r.window) waitDuration := waitUntil.Sub(now) if waitDuration > 0 { - // Release lock while waiting r.mu.Unlock() time.Sleep(waitDuration) r.mu.Lock() - // Clean again after waiting r.cleanOldTimestamps(time.Now()) } - // Record this request r.timestamps = append(r.timestamps, time.Now()) } diff --git a/go_backend/songlink.go b/go_backend/songlink.go index 02f9c1f8..63e1bbab 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -31,7 +31,6 @@ type TrackAvailability struct { } var ( - // Global SongLink client instance for connection reuse globalSongLinkClient *SongLinkClient songLinkClientOnce sync.Once ) @@ -40,7 +39,7 @@ var ( func NewSongLinkClient() *SongLinkClient { songLinkClientOnce.Do(func() { globalSongLinkClient = &SongLinkClient{ - client: NewHTTPClientWithTimeout(SongLinkTimeout), // 30s timeout + client: NewHTTPClientWithTimeout(SongLinkTimeout), } }) return globalSongLinkClient @@ -48,15 +47,12 @@ func NewSongLinkClient() *SongLinkClient { // CheckTrackAvailability checks track availability on streaming platforms func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) { - // Validate Spotify ID format (should be 22 characters alphanumeric) if spotifyTrackID == "" { return nil, fmt.Errorf("spotify track ID is empty") } - // Use global rate limiter - blocks until request is allowed songLinkRateLimiter.WaitForSlot() - // Build API URL spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID) @@ -68,7 +64,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri return nil, fmt.Errorf("failed to create request: %w", err) } - // Use retry logic with User-Agent retryConfig := DefaultRetryConfig() resp, err := DoRequestWithRetry(s.client, req, retryConfig) if err != nil { @@ -76,7 +71,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri } defer resp.Body.Close() - // Handle specific error codes if resp.StatusCode == 400 { return nil, fmt.Errorf("track not found on SongLink (invalid Spotify ID or track unavailable)") } @@ -109,27 +103,22 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri SpotifyID: spotifyTrackID, } - // Check Tidal if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" { availability.Tidal = true availability.TidalURL = tidalLink.URL } - // Check Amazon if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" { availability.Amazon = true availability.AmazonURL = amazonLink.URL } - // Check Deezer if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" { availability.Deezer = true availability.DeezerURL = deezerLink.URL - // Extract Deezer ID from URL (e.g., https://www.deezer.com/track/123456) availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL) } - // Check Qobuz using ISRC (SongLink doesn't support Qobuz directly) if isrc != "" { availability.Qobuz = checkQobuzAvailability(isrc) } @@ -191,12 +180,9 @@ func checkQobuzAvailability(isrc string) bool { // extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL func extractDeezerIDFromURL(deezerURL string) string { - // URL format: https://www.deezer.com/track/123456 or https://www.deezer.com/en/track/123456 parts := strings.Split(deezerURL, "/") if len(parts) > 0 { - // Get the last part which should be the ID lastPart := parts[len(parts)-1] - // Remove any query parameters if idx := strings.Index(lastPart, "?"); idx > 0 { lastPart = lastPart[:idx] } @@ -274,7 +260,6 @@ func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAv SpotifyID: spotifyAlbumID, } - // Check Deezer if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" { availability.Deezer = true availability.DeezerURL = deezerLink.URL @@ -309,13 +294,10 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra return nil, fmt.Errorf("deezer track ID is empty") } - // Use global rate limiter songLinkRateLimiter.WaitForSlot() - // Build Deezer URL deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID) - // Build API URL using Deezer URL as source apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") apiURL := fmt.Sprintf("%s%s&userCountry=US", string(apiBase), url.QueryEscape(deezerURL)) @@ -371,25 +353,20 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra DeezerID: deezerTrackID, } - // Check Spotify if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" { - // Extract Spotify ID from URL availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL) } - // Check Tidal if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" { availability.Tidal = true availability.TidalURL = tidalLink.URL } - // Check Amazon if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" { availability.Amazon = true availability.AmazonURL = amazonLink.URL } - // Check Deezer URL if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" { availability.DeezerURL = deezerLink.URL } @@ -459,24 +436,20 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit availability := &TrackAvailability{} - // Check Spotify if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" { availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL) } - // Check Tidal if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" { availability.Tidal = true availability.TidalURL = tidalLink.URL } - // Check Amazon if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" { availability.Amazon = true availability.AmazonURL = amazonLink.URL } - // Check Deezer if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" { availability.Deezer = true availability.DeezerURL = deezerLink.URL @@ -488,10 +461,8 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit // extractSpotifyIDFromURL extracts Spotify track ID from URL func extractSpotifyIDFromURL(spotifyURL string) string { - // URL format: https://open.spotify.com/track/0Jcij1eWd5bDMU5iPbxe2i parts := strings.Split(spotifyURL, "/track/") if len(parts) > 1 { - // Get the ID part and remove any query parameters idPart := parts[1] if idx := strings.Index(idPart, "?"); idx > 0 { idPart = idPart[:idx] diff --git a/go_backend/spotify.go b/go_backend/spotify.go index cbb1657e..b88d275f 100644 --- a/go_backend/spotify.go +++ b/go_backend/spotify.go @@ -84,7 +84,6 @@ func HasSpotifyCredentials() bool { credentialsMu.RLock() defer credentialsMu.RUnlock() - // Check custom credentials first if customClientID != "" && customClientSecret != "" { return true } @@ -112,14 +111,12 @@ func getCredentials() (string, string, error) { return clientID, clientSecret, nil } - // No credentials available return "", "", ErrNoSpotifyCredentials } // NewSpotifyMetadataClient creates a new Spotify client // Returns error if credentials are not configured func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) { - // Get credentials - will error if not configured clientID, clientSecret, err := getCredentials() if err != nil { return nil, err @@ -128,7 +125,7 @@ func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) { src := rand.NewSource(time.Now().UnixNano()) c := &SpotifyMetadataClient{ - httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling + httpClient: NewHTTPClientWithTimeout(15 * time.Second), clientID: clientID, clientSecret: clientSecret, rng: rand.New(src), @@ -451,7 +448,6 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra }) } - // Limit artists to artistLimit artistCount := len(response.Artists.Items) if artistCount > artistLimit { artistCount = artistLimit @@ -468,7 +464,6 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra }) } - // Store in cache c.cacheMu.Lock() c.searchCache[cacheKey] = &cacheEntry{ data: result, @@ -604,7 +599,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s TrackList: tracks, } - // Store in cache c.cacheMu.Lock() c.albumCache[albumID] = &cacheEntry{ data: result, @@ -849,7 +843,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token Albums: albums, } - // Store in cache c.cacheMu.Lock() c.artistCache[artistID] = &cacheEntry{ data: result, diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 91ad16b9..29898552 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -31,7 +31,6 @@ type TidalDownloader struct { } var ( - // Global Tidal downloader instance for token reuse globalTidalDownloader *TidalDownloader tidalDownloaderOnce sync.Once ) diff --git a/lib/app.dart b/lib/app.dart index 224072e9..df0f2158 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -50,8 +50,7 @@ class SpotiFLACApp extends ConsumerWidget { themeAnimationDuration: const Duration(milliseconds: 300), themeAnimationCurve: Curves.easeInOut, routerConfig: router, - // Localization - locale: locale, // null = follow system + locale: locale, localizationsDelegates: const [ AppLocalizations.delegate, GlobalMaterialLocalizations.delegate, diff --git a/lib/main.dart b/lib/main.dart index 615c2750..1a5439fd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,10 +11,8 @@ import 'package:spotiflac_android/services/share_intent_service.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - // Initialize notification service await NotificationService().initialize(); - // Initialize share intent service await ShareIntentService().initialize(); runApp( @@ -51,7 +49,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> { await Directory(extensionsDir).create(recursive: true); await Directory(dataDir).create(recursive: true); - // Initialize extension system await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir); } catch (e) { debugPrint('Failed to initialize extensions: $e'); @@ -60,7 +57,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> { @override Widget build(BuildContext context) { - // Eagerly initialize download history provider to load from storage ref.watch(downloadHistoryProvider); return widget.child; } diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 1f03f7a4..a37bd60c 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -280,7 +280,6 @@ class TrackNotifier extends Notifier { Future search(String query, {String? metadataSource}) async { final requestId = ++_currentRequestId; - // Preserve hasSearchText during search state = TrackState(isLoading: true, hasSearchText: state.hasSearchText); try { @@ -402,7 +401,6 @@ class TrackNotifier extends Notifier { Future customSearch(String extensionId, String query, {Map? options}) async { final requestId = ++_currentRequestId; - // Preserve hasSearchText during search state = TrackState(isLoading: true, hasSearchText: state.hasSearchText); try { diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index ad678432..0b087645 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -65,7 +65,6 @@ class _AlbumScreenState extends ConsumerState { void initState() { super.initState(); - // Record access for recent history WidgetsBinding.instance.addPostFrameCallback((_) { final providerId = widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify'; ref.read(recentAccessProvider.notifier).recordAlbumAccess( @@ -77,7 +76,6 @@ class _AlbumScreenState extends ConsumerState { ); }); - // Priority: widget.tracks > cache > fetch _tracks = widget.tracks ?? _AlbumCache.get(widget.albumId); if (_tracks == null) { _fetchTracks(); @@ -104,7 +102,6 @@ class _AlbumScreenState extends ConsumerState { final trackList = metadata['track_list'] as List; final tracks = trackList.map((t) => _parseTrack(t as Map)).toList(); - // Store in cache _AlbumCache.set(widget.albumId, tracks); if (mounted) { @@ -411,7 +408,6 @@ class _AlbumScreenState extends ConsumerState { ); } - // Default error display return Card( elevation: 0, color: colorScheme.errorContainer.withValues(alpha: 0.5), @@ -441,7 +437,6 @@ class _AlbumTrackItem extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; - // Only watch the specific item for this track final queueItem = ref.watch(downloadQueueProvider.select((state) { return state.items.where((item) => item.track.id == track.id).firstOrNull; })); @@ -456,7 +451,6 @@ class _AlbumTrackItem extends ConsumerWidget { final isCompleted = queueItem?.status == DownloadStatus.completed; final progress = queueItem?.progress ?? 0.0; - // Show as downloaded if in queue completed OR in history final showAsDownloaded = isCompleted || (!isQueued && isInHistory); return Padding( diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 3d224a8e..b2427ce9 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -100,7 +100,6 @@ class _ArtistScreenState extends ConsumerState { void initState() { super.initState(); - // Record access for recent history WidgetsBinding.instance.addPostFrameCallback((_) { final providerId = widget.extensionId ?? (widget.artistId.startsWith('deezer:') ? 'deezer' : 'spotify'); @@ -117,7 +116,6 @@ class _ArtistScreenState extends ConsumerState { _topTracks = widget.topTracks; _headerImageUrl = widget.headerImageUrl; _monthlyListeners = widget.monthlyListeners; - // Extension artists don't need additional fetching return; } @@ -138,7 +136,6 @@ class _ArtistScreenState extends ConsumerState { _headerImageUrl = cached.headerImageUrl; _monthlyListeners = cached.monthlyListeners; - // If cache has no top tracks, fetch if (_topTracks == null || _topTracks!.isEmpty) { _fetchDiscography(); } @@ -169,7 +166,6 @@ class _ArtistScreenState extends ConsumerState { final albumsList = artistData['albums'] as List? ?? []; albums = albumsList.map((a) => _parseArtistAlbum(a as Map)).toList(); - // Parse top tracks if available final topTracksList = artistData['top_tracks'] as List? ?? []; if (topTracksList.isNotEmpty) { topTracks = topTracksList.map((t) => _parseTrack(t as Map)).toList(); @@ -178,14 +174,12 @@ class _ArtistScreenState extends ConsumerState { headerImage = artistData['header_image'] as String?; listeners = artistData['listeners'] as int?; } else { - // Fallback to Spotify API metadata final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url); final albumsList = metadata['albums'] as List; albums = albumsList.map((a) => _parseArtistAlbum(a as Map)).toList(); } } - // Store in cache (preserve existing values if new ones are null) final finalHeaderImage = headerImage ?? _headerImageUrl ?? widget.headerImageUrl; final finalListeners = listeners ?? _monthlyListeners ?? widget.monthlyListeners; @@ -277,10 +271,8 @@ class _ArtistScreenState extends ConsumerState { child: _buildErrorWidget(_error!, colorScheme), )), if (!_isLoadingDiscography && _error == null) ...[ - // Popular tracks section if (_topTracks != null && _topTracks!.isNotEmpty) SliverToBoxAdapter(child: _buildPopularSection(colorScheme)), - // Discography sections if (albumsOnly.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistAlbums, albumsOnly, colorScheme)), if (singles.isNotEmpty) @@ -308,7 +300,6 @@ class _ArtistScreenState extends ConsumerState { imageUrl.isNotEmpty && Uri.tryParse(imageUrl)?.hasAuthority == true; - // Format monthly listeners String? listenersText; final listeners = _monthlyListeners ?? widget.monthlyListeners; if (listeners != null && listeners > 0) { @@ -326,7 +317,6 @@ class _ArtistScreenState extends ConsumerState { background: Stack( fit: StackFit.expand, children: [ - // Background image - full width, no circular crop if (hasValidImage) CachedNetworkImage( imageUrl: imageUrl, @@ -346,7 +336,6 @@ class _ArtistScreenState extends ConsumerState { color: colorScheme.surfaceContainerHighest, child: Icon(Icons.person, size: 80, color: colorScheme.onSurfaceVariant), ), - // Gradient overlay for text readability Container( decoration: BoxDecoration( gradient: LinearGradient( @@ -362,7 +351,6 @@ class _ArtistScreenState extends ConsumerState { ), ), ), - // Artist name and listeners at bottom Positioned( left: 16, right: 16, @@ -428,7 +416,6 @@ class _ArtistScreenState extends ConsumerState { Widget _buildPopularSection(ColorScheme colorScheme) { if (_topTracks == null || _topTracks!.isEmpty) return const SizedBox.shrink(); - // Show max 5 tracks final tracks = _topTracks!.take(5).toList(); return Column( @@ -454,7 +441,6 @@ class _ArtistScreenState extends ConsumerState { /// Build a single popular track item with dynamic download status Widget _buildPopularTrackItem(int rank, Track track, ColorScheme colorScheme) { - // Watch download queue for this track's status final queueItem = ref.watch(downloadQueueProvider.select((state) { return state.items.where((item) => item.track.id == track.id).firstOrNull; })); @@ -469,7 +455,6 @@ class _ArtistScreenState extends ConsumerState { final isCompleted = queueItem?.status == DownloadStatus.completed; final progress = queueItem?.progress ?? 0.0; - // Show as downloaded if in queue completed OR in history final showAsDownloaded = isCompleted || (!isQueued && isInHistory); return InkWell( @@ -478,7 +463,6 @@ class _ArtistScreenState extends ConsumerState { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( children: [ - // Rank number SizedBox( width: 24, child: Text( @@ -490,7 +474,6 @@ class _ArtistScreenState extends ConsumerState { ), ), const SizedBox(width: 12), - // Album art ClipRRect( borderRadius: BorderRadius.circular(4), child: track.coverUrl != null @@ -520,7 +503,6 @@ class _ArtistScreenState extends ConsumerState { ), ), const SizedBox(width: 12), - // Track info Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -545,7 +527,6 @@ class _ArtistScreenState extends ConsumerState { ], ), ), - // Download button with status _buildPopularDownloadButton( track: track, colorScheme: colorScheme, @@ -729,7 +710,6 @@ class _ArtistScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Album cover ClipRRect( borderRadius: BorderRadius.circular(8), child: album.coverUrl != null @@ -759,7 +739,6 @@ class _ArtistScreenState extends ConsumerState { ), ), const SizedBox(height: 8), - // Album name Text( album.name, style: Theme.of(context).textTheme.bodyMedium?.copyWith( @@ -769,7 +748,6 @@ class _ArtistScreenState extends ConsumerState { overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), - // Year and track count Text( album.totalTracks > 0 ? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} ${context.l10n.tracksCount(album.totalTracks)}' diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 2f51391a..c10bb466 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -27,7 +27,6 @@ class DownloadedAlbumScreen extends ConsumerStatefulWidget { } class _DownloadedAlbumScreenState extends ConsumerState { - // Multi-select state bool _isSelectionMode = false; final Set _selectedIds = {}; @@ -162,7 +161,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items)); final tracks = _getAlbumTracks(allHistoryItems); - // Auto-pop if album has less than 2 tracks (no longer an "album") if (tracks.length < 2) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) Navigator.pop(context); @@ -170,7 +168,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { return const SizedBox.shrink(); } - // Clean up selected IDs that no longer exist final validIds = tracks.map((t) => t.id).toSet(); _selectedIds.removeWhere((id) => !validIds.contains(id)); if (_selectedIds.isEmpty && _isSelectionMode) { @@ -199,7 +196,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { ], ), - // Bottom Selection Action Bar AnimatedPositioned( duration: const Duration(milliseconds: 250), curve: Curves.easeOutCubic, diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 3e37bde9..65c61116 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -75,7 +75,6 @@ class _HomeScreenState extends ConsumerState { setState(() => _currentIndex = index); switch (index) { case 0: - // Already on home break; case 1: context.push('/queue'); @@ -112,7 +111,6 @@ class _HomeScreenState extends ConsumerState { body: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // URL Input Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: TextField( @@ -132,7 +130,6 @@ class _HomeScreenState extends ConsumerState { ), ), - // Error message if (trackState.error != null) Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), @@ -142,15 +139,12 @@ class _HomeScreenState extends ConsumerState { ), ), - // Loading indicator if (trackState.isLoading) LinearProgressIndicator(color: colorScheme.primary), - // Album/Playlist header if (trackState.albumName != null || trackState.playlistName != null) _buildHeader(trackState, colorScheme), - // Download All button if (trackState.tracks.length > 1) Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), @@ -164,7 +158,6 @@ class _HomeScreenState extends ConsumerState { ), ), - // Track list Expanded( child: trackState.tracks.isEmpty ? _buildEmptyState(colorScheme) @@ -252,7 +245,6 @@ class _HomeScreenState extends ConsumerState { ], ), ), - // Play all button FilledButton.tonal( onPressed: _downloadAll, style: FilledButton.styleFrom( @@ -271,7 +263,6 @@ class _HomeScreenState extends ConsumerState { final track = ref.watch(trackProvider).tracks[index]; final isCollection = track.isCollection; - // Determine subtitle text based on item type String subtitleText; if (isCollection) { final typeLabel = track.albumType ?? (track.isPlaylistItem ? 'Playlist' : 'Album'); @@ -332,7 +323,6 @@ class _HomeScreenState extends ConsumerState { final extensionId = track.source; if (extensionId == null) return; - // Fetch album/playlist tracks using the extension try { if (track.isAlbumItem) { final albumData = await PlatformBridge.getAlbumWithExtension(extensionId, track.id); diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index baa6be79..d79c5c47 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -30,7 +30,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient final _urlController = TextEditingController(); bool _isTyping = false; final FocusNode _searchFocusNode = FocusNode(); - String? _lastSearchQuery; // Track last searched query to avoid duplicate searches + String? _lastSearchQuery; @override bool get wantKeepAlive => true; @@ -52,9 +52,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } void _onSearchFocusChanged() { - // When focused, enter recent access mode - // When unfocused (keyboard dismissed), keep recent access mode visible - // User must press back button to exit recent access mode if (_searchFocusNode.hasFocus) { ref.read(trackProvider.notifier).setShowingRecentAccess(true); } @@ -62,8 +59,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient /// Called when trackState changes - used to sync search bar with state void _onTrackStateChanged(TrackState? previous, TrackState next) { - // If state was cleared (no content, no search text, not loading), clear the search bar - // BUT only if search field is not focused (to prevent clearing while user is typing) if (previous != null && !next.hasContent && !next.hasSearchText && @@ -86,9 +81,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient // Provider will be cleared when user explicitly clears or navigates away return; } - - // No auto-search - user must press Enter to search - // This saves API calls and avoids rate limiting } Future _performSearch(String query) async { @@ -96,7 +88,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient final extState = ref.read(extensionProvider); final searchProvider = settings.searchProvider; - // Skip if same query already searched with same provider final searchKey = '${searchProvider ?? 'default'}:$query'; if (_lastSearchQuery == searchKey) return; _lastSearchQuery = searchKey; @@ -120,7 +111,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient final data = await Clipboard.getData(Clipboard.kTextPlain); if (data?.text != null) { _urlController.text = data!.text!; - // For URLs, trigger fetch immediately after paste final text = data.text!.trim(); if (text.startsWith('http') || text.startsWith('spotify:')) { _fetchMetadata(); @@ -131,7 +121,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient Future _clearAndRefresh() async { _urlController.clear(); _searchFocusNode.unfocus(); - _lastSearchQuery = null; // Reset last query + _lastSearchQuery = null; setState(() => _isTyping = false); ref.read(trackProvider.notifier).clear(); } @@ -153,7 +143,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient void _navigateToDetailIfNeeded() { final trackState = ref.read(trackProvider); - // Navigate to Album screen (recording is done in AlbumScreen.initState) if (trackState.albumId != null && trackState.albumName != null && trackState.tracks.isNotEmpty) { Navigator.push(context, MaterialPageRoute(builder: (context) => AlbumScreen( albumId: trackState.albumId!, @@ -167,9 +156,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient return; } - // Navigate to Playlist screen if (trackState.playlistName != null && trackState.tracks.isNotEmpty) { - // Record access for playlist (no separate screen to record in) ref.read(recentAccessProvider.notifier).recordPlaylistAccess( id: trackState.playlistName!, name: trackState.playlistName!, @@ -188,7 +175,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient return; } - // Navigate to Artist screen (recording is done in ArtistScreen.initState) if (trackState.artistId != null && trackState.artistName != null && trackState.artistAlbums != null) { Navigator.push(context, MaterialPageRoute(builder: (context) => ArtistScreen( artistId: trackState.artistId!, @@ -228,7 +214,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } Future _importCsv(BuildContext context, WidgetRef ref) async { - // Show loading dialog with progress int currentProgress = 0; int totalTracks = 0; @@ -274,7 +259,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient }, ); - // Close progress dialog if (dialogShown && mounted) { Navigator.of(this.context).pop(); } @@ -287,7 +271,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient // ignore: use_build_context_synchronously final l10n = context.l10n; - // Optionally show confirmation dialog final confirmed = await showDialog( context: this.context, builder: (dialogCtx) => AlertDialog( @@ -321,8 +304,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ); } } - } else { - // Only show error if pick was not cancelled (handled inside service logging usually, but maybe show snackbar if file empty) } } @@ -330,10 +311,8 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient Widget build(BuildContext context) { super.build(context); - // Listen for state changes to sync search bar and auto-navigate ref.listen(trackProvider, (previous, next) { _onTrackStateChanged(previous, next); - // Auto-navigate when URL fetch completes if (previous != null && previous.isLoading && !next.isLoading && next.error == null) { _navigateToDetailIfNeeded(); } @@ -351,18 +330,15 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient final colorScheme = Theme.of(context).colorScheme; final hasActualResults = tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty); final isShowingRecentAccess = ref.watch(trackProvider.select((s) => s.isShowingRecentAccess)); - // Move search bar up when in recent access mode or has results final hasResults = isShowingRecentAccess || hasActualResults || isLoading; final screenHeight = MediaQuery.of(context).size.height; final topPadding = MediaQuery.of(context).padding.top; final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items)); final recentAccessItems = ref.watch(recentAccessProvider.select((s) => s.items)); - // Show recent access when in mode but no actual results yet (includes download history) final hasRecentItems = recentAccessItems.isNotEmpty || historyItems.isNotEmpty; final showRecentAccess = isShowingRecentAccess && hasRecentItems && !hasActualResults && !isLoading; - // Exit recent access mode when results appear if (hasActualResults && isShowingRecentAccess) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) ref.read(trackProvider.notifier).setShowingRecentAccess(false); @@ -371,7 +347,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient return GestureDetector( onTap: () { - // Unfocus search bar when tapping outside if (_searchFocusNode.hasFocus) { _searchFocusNode.unfocus(); } @@ -381,7 +356,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient body: CustomScrollView( keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, slivers: [ - // App Bar - always present SliverAppBar( expandedHeight: 120 + topPadding, collapsedHeight: kToolbarHeight, @@ -412,7 +386,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), ), - // Idle content (logo, title) - always in tree, animated size SliverToBoxAdapter( child: AnimatedSize( duration: const Duration(milliseconds: 250), @@ -431,10 +404,9 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), child: Image.asset( 'assets/images/logo-transparant.png', - color: colorScheme.onPrimary, // Tint with onPrimary color + color: colorScheme.onPrimary, fit: BoxFit.contain, errorBuilder: (_, _, _) => ClipRRect( - // Fallback to original logo if transparent one is missing borderRadius: BorderRadius.circular(24), child: Image.asset( 'assets/images/logo.png', @@ -465,7 +437,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), ), - // Search bar - always present at same position in tree SliverToBoxAdapter( child: Padding( padding: EdgeInsets.fromLTRB(16, hasResults ? 8 : 32, 16, hasResults ? 8 : 16), @@ -473,14 +444,11 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), ), - // Recent access history - shown when in recent access mode (persists after keyboard dismissed) - // User can exit by pressing back button if (showRecentAccess) SliverToBoxAdapter( child: _buildRecentAccess(recentAccessItems, colorScheme), ), - // Idle content below search bar - always in tree SliverToBoxAdapter( child: AnimatedSize( duration: const Duration(milliseconds: 250), @@ -510,7 +478,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), ), - // Results content - search results only (albums/artists/playlists navigate to separate screens) ..._buildSearchResults( tracks: tracks, searchArtists: searchArtists, @@ -598,7 +565,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient /// Build recent access history section (shown when search focused) Widget _buildRecentAccess(List items, ColorScheme colorScheme) { - // Merge with recent downloads to make the list more populated final historyItems = ref.read(downloadHistoryProvider).items; final downloadItems = historyItems.take(10).where((h) => h.spotifyId != null && h.spotifyId!.isNotEmpty).map((h) => RecentAccessItem( @@ -611,11 +577,9 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient providerId: 'download', )).toList(); - // Merge and sort by accessedAt (most recent first) final allItems = [...items, ...downloadItems]; allItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); - // Remove duplicates (keep the most recent one) final seen = {}; final uniqueItems = allItems.where((item) { final key = '${item.type.name}:${item.id}'; @@ -629,7 +593,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header with clear button Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -651,7 +614,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ], ), const SizedBox(height: 8), - // List of recent items ...uniqueItems.map((item) => _buildRecentAccessItem(item, colorScheme)), ], ), @@ -659,7 +621,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } Widget _buildRecentAccessItem(RecentAccessItem item, ColorScheme colorScheme) { - // Icon and label based on type IconData typeIcon; String typeLabel; switch (item.type) { @@ -686,7 +647,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), child: Row( children: [ - // Image ClipRRect( borderRadius: BorderRadius.circular(item.type == RecentAccessType.artist ? 28 : 4), child: item.imageUrl != null && item.imageUrl!.isNotEmpty @@ -711,7 +671,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), ), const SizedBox(width: 12), - // Text content Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -792,19 +751,15 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient )); } case RecentAccessType.track: - // For tracks from download history, navigate to metadata screen final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(item.id); if (historyItem != null) { _navigateToMetadataScreen(historyItem); } else { - // Track not in history anymore ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(item.name)), ); } case RecentAccessType.playlist: - // Playlist needs tracks, so we just show info - // Could potentially re-fetch using URL handler if we stored URL ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.recentPlaylistInfo(item.name))), ); @@ -865,7 +820,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ); } - // Default error display return Card( elevation: 0, color: colorScheme.errorContainer.withValues(alpha: 0.5), @@ -883,7 +837,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ); } - // Search results slivers - only shows search results (track list) List _buildSearchResults({ required List tracks, required List? searchArtists, @@ -896,29 +849,24 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient return [const SliverToBoxAdapter(child: SizedBox.shrink())]; } - // Separate tracks from albums/playlists/artists final realTracks = tracks.where((t) => !t.isCollection).toList(); final albumItems = tracks.where((t) => t.isAlbumItem).toList(); final playlistItems = tracks.where((t) => t.isPlaylistItem).toList(); final artistItems = tracks.where((t) => t.isArtistItem).toList(); return [ - // Error message - with special handling for rate limit (429) if (error != null) SliverToBoxAdapter(child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: _buildErrorWidget(error, colorScheme), )), - // Loading indicator if (isLoading) const SliverToBoxAdapter(child: Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: LinearProgressIndicator())), - // Artist search results (horizontal scroll) - from built-in providers if (searchArtists != null && searchArtists.isNotEmpty) SliverToBoxAdapter(child: _buildArtistSearchResults(searchArtists, colorScheme)), - // Artists section - from extension search if (artistItems.isNotEmpty) SliverToBoxAdapter(child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), @@ -953,7 +901,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), ), - // Albums section if (albumItems.isNotEmpty) SliverToBoxAdapter(child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), @@ -988,7 +935,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), ), - // Playlists section if (playlistItems.isNotEmpty) SliverToBoxAdapter(child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), @@ -1023,14 +969,12 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), ), - // Songs section header if (realTracks.isNotEmpty) SliverToBoxAdapter(child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: Text(context.l10n.searchSongs, style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)), )), - // Track list in grouped card if (realTracks.isNotEmpty) SliverToBoxAdapter( child: Container( @@ -1061,7 +1005,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), ), - // Bottom padding const SliverToBoxAdapter(child: SizedBox(height: 16)), ]; } @@ -1094,7 +1037,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } Widget _buildArtistCard(SearchArtist artist, ColorScheme colorScheme) { - // Validate image URL - must be non-null, non-empty, and have a valid host final hasValidImage = artist.imageUrl != null && artist.imageUrl!.isNotEmpty && Uri.tryParse(artist.imageUrl!)?.hasAuthority == true; @@ -1144,17 +1086,13 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } void _navigateToArtist(String artistId, String artistName, String? imageUrl) { - // Navigate immediately with data from search, fetch albums in ArtistScreen ref.read(settingsProvider.notifier).setHasSearchedBefore(); - // Recording is done in ArtistScreen.initState to avoid duplicates - Navigator.push(context, MaterialPageRoute( builder: (context) => ArtistScreen( artistId: artistId, artistName: artistName, coverUrl: imageUrl, - // albums: null - will be fetched in ArtistScreen ), )); } @@ -1170,7 +1108,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ref.read(settingsProvider.notifier).setHasSearchedBefore(); - // Record access for recent history ref.read(recentAccessProvider.notifier).recordAlbumAccess( id: albumItem.id, name: albumItem.name, @@ -1179,7 +1116,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient providerId: extensionId, ); - // Navigate to AlbumScreen - it will fetch tracks via extension Navigator.push(context, MaterialPageRoute( builder: (context) => ExtensionAlbumScreen( extensionId: extensionId, @@ -1201,7 +1137,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ref.read(settingsProvider.notifier).setHasSearchedBefore(); - // Record access for recent history ref.read(recentAccessProvider.notifier).recordPlaylistAccess( id: playlistItem.id, name: playlistItem.name, @@ -1210,7 +1145,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient providerId: extensionId, ); - // Navigate to ExtensionPlaylistScreen - it will fetch tracks via extension Navigator.push(context, MaterialPageRoute( builder: (context) => ExtensionPlaylistScreen( extensionId: extensionId, @@ -1232,7 +1166,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ref.read(settingsProvider.notifier).setHasSearchedBefore(); - // Record access for recent history ref.read(recentAccessProvider.notifier).recordArtistAccess( id: artistItem.id, name: artistItem.name, @@ -1240,7 +1173,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient providerId: extensionId, ); - // Navigate to ExtensionArtistScreen - it will fetch albums via extension Navigator.push(context, MaterialPageRoute( builder: (context) => ExtensionArtistScreen( extensionId: extensionId, @@ -1257,22 +1189,18 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient final searchProvider = settings.searchProvider; final extState = ref.read(extensionProvider); - // If extension system not initialized yet, show default hint if (!extState.isInitialized) { return 'Paste Spotify URL or search...'; } if (searchProvider != null && searchProvider.isNotEmpty) { final ext = extState.extensions.where((e) => e.id == searchProvider).firstOrNull; - // Only show extension placeholder if extension exists AND is enabled if (ext != null && ext.enabled) { if (ext.searchBehavior?.placeholder != null) { return ext.searchBehavior!.placeholder!; } return 'Search with ${ext.displayName}...'; } - // Extension not found or disabled - clear the search provider setting - // and return default hint } return 'Paste Spotify URL or search...'; } @@ -1335,14 +1263,12 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient 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); } @@ -1370,7 +1296,6 @@ class _TrackItemWithStatus extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; - // Only watch the specific item for this track using select() final queueItem = ref.watch(downloadQueueProvider.select((state) { return state.items.where((item) => item.track.id == track.id).firstOrNull; })); @@ -1392,7 +1317,6 @@ class _TrackItemWithStatus extends ConsumerWidget { final size = extension!.searchBehavior!.getThumbnailSize(defaultSize: 56); thumbWidth = size.$1; thumbHeight = size.$2; - // Debug: log only when using custom size if (thumbWidth != 56 || thumbHeight != 56) { debugPrint('[Thumbnail] ${track.name}: using ${thumbWidth.toInt()}x${thumbHeight.toInt()} from ${extension.id}'); } @@ -1405,7 +1329,6 @@ class _TrackItemWithStatus extends ConsumerWidget { final isCompleted = queueItem?.status == DownloadStatus.completed; final progress = queueItem?.progress ?? 0.0; - // Show as downloaded if in queue completed OR in history final showAsDownloaded = isCompleted || (!isQueued && isInHistory); return Column( @@ -1419,7 +1342,6 @@ class _TrackItemWithStatus extends ConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( children: [ - // Album art with dynamic size based on extension config ClipRRect( borderRadius: BorderRadius.circular(10), child: track.coverUrl != null @@ -1439,7 +1361,6 @@ class _TrackItemWithStatus extends ConsumerWidget { ), ), const SizedBox(width: 12), - // Track info Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -1460,7 +1381,6 @@ class _TrackItemWithStatus extends ConsumerWidget { ], ), ), - // Download button / status indicator _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress), ], ), @@ -1479,7 +1399,6 @@ class _TrackItemWithStatus extends ConsumerWidget { } void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory}) async { - // If already in queue, do nothing if (isQueued) return; if (isInHistory) { @@ -1487,7 +1406,6 @@ class _TrackItemWithStatus extends ConsumerWidget { if (historyItem != null) { final fileExists = await File(historyItem.filePath).exists(); if (fileExists) { - // File exists, show snackbar if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))), @@ -1495,13 +1413,11 @@ class _TrackItemWithStatus extends ConsumerWidget { } return; } else { - // File doesn't exist, remove from history and allow download ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id); } } } - // Proceed with download onDownload(); } @@ -1527,7 +1443,6 @@ class _TrackItemWithStatus extends ConsumerWidget { ), ); } else if (isFinalizing) { - // Show finalizing status (embedding metadata) return SizedBox( width: size, height: size, @@ -1591,7 +1506,6 @@ class _CollectionItemWidget extends StatelessWidget { final isPlaylist = item.isPlaylistItem; final isArtist = item.isArtistItem; - // Determine icon for placeholder IconData placeholderIcon = Icons.album; if (isPlaylist) placeholderIcon = Icons.playlist_play; if (isArtist) placeholderIcon = Icons.person; @@ -1607,7 +1521,6 @@ class _CollectionItemWidget extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( children: [ - // Cover art (circular for artists) ClipRRect( borderRadius: BorderRadius.circular(isArtist ? 28 : 10), child: item.coverUrl != null && item.coverUrl!.isNotEmpty @@ -1630,7 +1543,6 @@ class _CollectionItemWidget extends StatelessWidget { ), ), const SizedBox(width: 12), - // Info Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -1651,7 +1563,6 @@ class _CollectionItemWidget extends StatelessWidget { ], ), ), - // Arrow indicator Icon( Icons.chevron_right, color: colorScheme.onSurfaceVariant, @@ -1725,7 +1636,6 @@ class _ExtensionAlbumScreenState extends ConsumerState { return; } - // Parse tracks from result final trackList = result['tracks'] as List?; if (trackList == null) { setState(() { @@ -1802,7 +1712,6 @@ class _ExtensionAlbumScreenState extends ConsumerState { ); } - // Navigate to AlbumScreen with fetched tracks return AlbumScreen( albumId: widget.albumId, albumName: widget.albumName, @@ -1863,7 +1772,6 @@ class _ExtensionPlaylistScreenState extends ConsumerState?; if (trackList == null) { setState(() { @@ -1940,7 +1848,6 @@ class _ExtensionPlaylistScreenState extends ConsumerState { return; } - // Parse albums from result final albumList = result['albums'] as List?; final albums = albumList?.map((a) => _parseAlbum(a as Map)).toList() ?? []; - // Parse top tracks from result final topTracksList = result['top_tracks'] as List?; List? topTracks; if (topTracksList != null && topTracksList.isNotEmpty) { topTracks = topTracksList.map((t) => _parseTrack(t as Map)).toList(); } - // Parse additional artist info final headerImage = result['header_image'] as String?; final listeners = result['listeners'] as int?; @@ -2097,7 +2001,6 @@ class _ExtensionArtistScreenState extends ConsumerState { ); } - // Navigate to ArtistScreen with fetched albums and top tracks return ArtistScreen( artistId: widget.artistId, artistName: widget.artistName, diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 189e350b..2708e3f6 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -30,7 +30,7 @@ class _MainShellState extends ConsumerState { late PageController _pageController; bool _hasCheckedUpdate = false; StreamSubscription? _shareSubscription; - DateTime? _lastBackPress; // For double-tap to exit + DateTime? _lastBackPress; @override void initState() { @@ -49,7 +49,6 @@ class _MainShellState extends ConsumerState { _handleSharedUrl(pendingUrl); } - // Listen for future shared URLs with error handling _shareSubscription = ShareIntentService().sharedUrlStream.listen( (url) { _log.d('Received shared URL from stream: $url'); @@ -63,18 +62,13 @@ class _MainShellState extends ConsumerState { } void _handleSharedUrl(String url) { - // Pop any existing screens (Album, Artist, Settings sub-pages) to return to root Navigator.of(context).popUntil((route) => route.isFirst); - // Navigate to Home tab if (_currentIndex != 0) { _onNavTap(0); } - // Fetch metadata for shared URL ref.read(trackProvider.notifier).fetchFromUrl(url); - // Mark that user has searched (hide helper text) ref.read(settingsProvider.notifier).setHasSearchedBefore(); - // Show snackbar if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.loadingSharedLink)), @@ -136,31 +130,26 @@ class _MainShellState extends ConsumerState { return; } - // If on Home tab and showing recent access mode, exit it if (_currentIndex == 0 && trackState.isShowingRecentAccess) { ref.read(trackProvider.notifier).setShowingRecentAccess(false); FocusManager.instance.primaryFocus?.unfocus(); return; } - // If on Home tab and has text in search bar or has content (but not loading), clear it if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) { ref.read(trackProvider.notifier).clear(); return; } - // If not on Home tab, go to Home tab first if (_currentIndex != 0) { _onNavTap(0); return; } - // If loading, ignore back press if (trackState.isLoading) { return; } - // Double-tap to exit final now = DateTime.now(); if (_lastBackPress != null && now.difference(_lastBackPress!) < const Duration(seconds: 2)) { SystemNavigator.pop(); @@ -247,7 +236,6 @@ class _MainShellState extends ConsumerState { ), ]; - // Clamp current index if tabs changed final maxIndex = tabs.length - 1; if (_currentIndex > maxIndex) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -267,7 +255,6 @@ class _MainShellState extends ConsumerState { return; } - // Handle back press manually when canPop is false _handleBackPress(); }, child: Scaffold( diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 448ef642..56162437 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -217,7 +217,6 @@ class _PlaylistTrackItem extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; - // Only watch the specific item for this track final queueItem = ref.watch(downloadQueueProvider.select((state) { return state.items.where((item) => item.track.id == track.id).firstOrNull; })); @@ -232,7 +231,6 @@ class _PlaylistTrackItem extends ConsumerWidget { final isCompleted = queueItem?.status == DownloadStatus.completed; final progress = queueItem?.progress ?? 0.0; - // Show as downloaded if in queue completed OR in history final showAsDownloaded = isCompleted || (!isQueued && isInHistory); return Padding( diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 34560419..8c345d6d 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -559,7 +559,6 @@ class _QueueTabState extends ConsumerState { }, childCount: queueItems.length), ), - // Filter chips (only show when history has items) if (allHistoryItems.isNotEmpty) SliverToBoxAdapter( child: Padding( @@ -788,7 +787,6 @@ class _QueueTabState extends ConsumerState { ), ), - // Albums Grid (when Albums filter is selected) if (filterMode == 'albums' && groupedAlbums.isNotEmpty) SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 16), @@ -1045,7 +1043,6 @@ class _QueueTabState extends ConsumerState { child: Column( mainAxisSize: MainAxisSize.min, children: [ - // Handle bar Container( width: 32, height: 4, @@ -1067,7 +1064,6 @@ class _QueueTabState extends ConsumerState { ), const SizedBox(width: 12), - // Selection count Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -1088,7 +1084,6 @@ class _QueueTabState extends ConsumerState { ), ), - // Select all toggle TextButton.icon( onPressed: () { if (allSelected) { diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index 08476175..1f467c8d 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -694,7 +694,6 @@ class _LanguageSelector extends StatelessWidget { required this.onChanged, }); - // All available languages (code, displayName, icon) static const _allLanguages = [ ('system', 'System Default', Icons.phone_android), ('en', 'English', Icons.language), diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index ec41382f..042db0af 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -65,7 +65,6 @@ class DownloadSettingsPage extends ConsumerWidget { ), ), - // Service section SliverToBoxAdapter( child: SettingsSectionHeader(title: context.l10n.sectionService), ), @@ -470,7 +469,6 @@ class DownloadSettingsPage extends ConsumerWidget { Future _pickDirectory(BuildContext context, WidgetRef ref) async { if (Platform.isIOS) { - // iOS: Show options dialog _showIOSDirectoryOptions(context, ref); } else { final result = await FilePicker.platform.getDirectoryPath(); @@ -697,7 +695,6 @@ class _ServiceSelector extends ConsumerWidget { ? extensionProviders.any((e) => e.id == currentService) : true; - // If current extension is disabled, show it as not selected final effectiveService = isCurrentExtensionEnabled ? currentService : ''; return Padding( diff --git a/lib/screens/settings/extensions_page.dart b/lib/screens/settings/extensions_page.dart index 5fd8bc7d..d6308aa3 100644 --- a/lib/screens/settings/extensions_page.dart +++ b/lib/screens/settings/extensions_page.dart @@ -50,7 +50,6 @@ class _ExtensionsPageState extends ConsumerState { child: Scaffold( body: CustomScrollView( slivers: [ - // App Bar SliverAppBar( expandedHeight: 120 + topPadding, collapsedHeight: kToolbarHeight, @@ -120,7 +119,6 @@ class _ExtensionsPageState extends ConsumerState { ), ), - // Provider Priority SliverToBoxAdapter( child: SettingsSectionHeader(title: context.l10n.extensionsProviderPrioritySection), ), @@ -216,7 +214,6 @@ class _ExtensionsPageState extends ConsumerState { ), ), - // Info section SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), @@ -344,7 +341,6 @@ class _ExtensionItem extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ - // Extension icon Container( width: 44, height: 44, @@ -402,7 +398,6 @@ class _ExtensionItem extends StatelessWidget { ], ), ), - // Toggle switch Switch( value: extension.enabled, onChanged: hasError ? null : onToggle, diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index acc4ed3a..2ec153d6 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -835,7 +835,6 @@ class _MetadataSourceSelector extends ConsumerWidget { _SourceChip( icon: Icons.graphic_eq, label: 'Deezer', - // Not selected if extension is active isSelected: currentSource == 'deezer' && !hasExtensionSearch, onTap: () { if (hasExtensionSearch) { diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index 30c89e2c..3c603de6 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -267,7 +267,6 @@ class _SetupScreenState extends ConsumerState { try { if (Platform.isIOS) { - // iOS: Show options dialog await _showIOSDirectoryOptions(); } else { String? selectedDirectory = await FilePicker.platform.getDirectoryPath( @@ -418,7 +417,6 @@ class _SetupScreenState extends ConsumerState { ref.read(settingsProvider.notifier).setDownloadDirectory(_selectedDirectory!); - // Save Spotify credentials if provided if (_useSpotifyApi && _clientIdController.text.trim().isNotEmpty && _clientSecretController.text.trim().isNotEmpty) { @@ -573,15 +571,13 @@ class _SetupScreenState extends ConsumerState { bool _isStepCompleted(int step) { if (_androidSdkVersion >= 33) { - // 4 steps: Storage, Notification, Folder, Spotify switch (step) { case 0: return _storagePermissionGranted; case 1: return _notificationPermissionGranted; case 2: return _selectedDirectory != null; - case 3: return false; // Spotify step never shows checkmark (optional) + case 3: return false; } } else { - // 3 steps: Permission, Folder, Spotify switch (step) { case 0: return _storagePermissionGranted; case 1: return _selectedDirectory != null; diff --git a/lib/theme/dynamic_color_wrapper.dart b/lib/theme/dynamic_color_wrapper.dart index 88b84983..6ee84b86 100644 --- a/lib/theme/dynamic_color_wrapper.dart +++ b/lib/theme/dynamic_color_wrapper.dart @@ -19,7 +19,6 @@ class DynamicColorWrapper extends ConsumerWidget { return DynamicColorBuilder( builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { - // Determine which color scheme to use ColorScheme lightScheme; ColorScheme darkScheme; @@ -28,7 +27,6 @@ class DynamicColorWrapper extends ConsumerWidget { lightScheme = lightDynamic; darkScheme = darkDynamic; } else { - // Fallback to seed color final seedColor = themeSettings.seedColor; lightScheme = ColorScheme.fromSeed( seedColor: seedColor, diff --git a/lib/utils/logger.dart b/lib/utils/logger.dart index c2627d1a..bf6f0bec 100644 --- a/lib/utils/logger.dart +++ b/lib/utils/logger.dart @@ -188,14 +188,12 @@ class BufferedOutput extends LogOutput { @override void output(OutputEvent event) { - // Print to console in debug mode if (kDebugMode) { for (final line in event.lines) { debugPrint(line); } } - // Add to buffer final level = _levelToString(event.level); final message = event.lines.join('\n');