refactor: more code cleanup

This commit is contained in:
zarzet
2026-01-17 10:04:21 +07:00
parent b99764b1ad
commit 8d92d22fda
37 changed files with 33 additions and 356 deletions
+1 -5
View File
@@ -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 {
-1
View File
@@ -52,7 +52,6 @@ func cancelDownload(itemID string) {
}
cancelMu.Unlock()
// Hide progress for cancelled items.
RemoveItemProgress(itemID)
}
-9
View File
@@ -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)
+1 -3
View File
@@ -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
+1 -7
View File
@@ -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)
-23
View File
@@ -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 {
+4 -7
View File
@@ -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
-5
View File
@@ -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{
-7
View File
@@ -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)
}
}
+13 -26
View File
@@ -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
+1 -3
View File
@@ -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()
-4
View File
@@ -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)
+3 -11
View File
@@ -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
}
+1 -2
View File
@@ -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,
-8
View File
@@ -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 {
-6
View File
@@ -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())
}
+1 -30
View File
@@ -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]
+1 -8
View File
@@ -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,
-1
View File
@@ -31,7 +31,6 @@ type TidalDownloader struct {
}
var (
// Global Tidal downloader instance for token reuse
globalTidalDownloader *TidalDownloader
tidalDownloaderOnce sync.Once
)
+1 -2
View File
@@ -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,
-4
View File
@@ -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;
}
-2
View File
@@ -280,7 +280,6 @@ class TrackNotifier extends Notifier<TrackState> {
Future<void> 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<TrackState> {
Future<void> customSearch(String extensionId, String query, {Map<String, dynamic>? options}) async {
final requestId = ++_currentRequestId;
// Preserve hasSearchText during search
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try {
-6
View File
@@ -65,7 +65,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
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<AlbumScreen> {
);
});
// Priority: widget.tracks > cache > fetch
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
if (_tracks == null) {
_fetchTracks();
@@ -104,7 +102,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
// Store in cache
_AlbumCache.set(widget.albumId, tracks);
if (mounted) {
@@ -411,7 +408,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
);
}
// 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(
-22
View File
@@ -100,7 +100,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
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<ArtistScreen> {
_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<ArtistScreen> {
_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<ArtistScreen> {
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
// Parse top tracks if available
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
if (topTracksList.isNotEmpty) {
topTracks = topTracksList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
@@ -178,14 +174,12 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
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<dynamic>;
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).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<ArtistScreen> {
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<ArtistScreen> {
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<ArtistScreen> {
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<ArtistScreen> {
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<ArtistScreen> {
),
),
),
// Artist name and listeners at bottom
Positioned(
left: 16,
right: 16,
@@ -428,7 +416,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
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<ArtistScreen> {
/// 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<ArtistScreen> {
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<ArtistScreen> {
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<ArtistScreen> {
),
),
const SizedBox(width: 12),
// Album art
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: track.coverUrl != null
@@ -520,7 +503,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
),
),
const SizedBox(width: 12),
// Track info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -545,7 +527,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
],
),
),
// Download button with status
_buildPopularDownloadButton(
track: track,
colorScheme: colorScheme,
@@ -729,7 +710,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
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<ArtistScreen> {
),
),
const SizedBox(height: 8),
// Album name
Text(
album.name,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
@@ -769,7 +748,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
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)}'
-4
View File
@@ -27,7 +27,6 @@ class DownloadedAlbumScreen extends ConsumerStatefulWidget {
}
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
// Multi-select state
bool _isSelectionMode = false;
final Set<String> _selectedIds = {};
@@ -162,7 +161,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
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<DownloadedAlbumScreen> {
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<DownloadedAlbumScreen> {
],
),
// Bottom Selection Action Bar
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
-10
View File
@@ -75,7 +75,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
setState(() => _currentIndex = index);
switch (index) {
case 0:
// Already on home
break;
case 1:
context.push('/queue');
@@ -112,7 +111,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
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<HomeScreen> {
),
),
// Error message
if (trackState.error != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
@@ -142,15 +139,12 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
),
),
// 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<HomeScreen> {
),
),
// Track list
Expanded(
child: trackState.tracks.isEmpty
? _buildEmptyState(colorScheme)
@@ -252,7 +245,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
],
),
),
// Play all button
FilledButton.tonal(
onPressed: _downloadAll,
style: FilledButton.styleFrom(
@@ -271,7 +263,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
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<HomeScreen> {
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);
+3 -100
View File
@@ -30,7 +30,7 @@ class _HomeTabState extends ConsumerState<HomeTab> 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<HomeTab> 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<HomeTab> with AutomaticKeepAliveClient
/// Called when trackState changes - used to sync search bar with state
void _onTrackStateChanged(TrackState? previous, TrackState next) {
// If state was cleared (no content, no search text, not loading), clear the search bar
// BUT only if search field is not focused (to prevent clearing while user is typing)
if (previous != null &&
!next.hasContent &&
!next.hasSearchText &&
@@ -86,9 +81,6 @@ class _HomeTabState extends ConsumerState<HomeTab> 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<void> _performSearch(String query) async {
@@ -96,7 +88,6 @@ class _HomeTabState extends ConsumerState<HomeTab> 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<HomeTab> 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<HomeTab> with AutomaticKeepAliveClient
Future<void> _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<HomeTab> 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<HomeTab> 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<HomeTab> 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<HomeTab> with AutomaticKeepAliveClient
}
Future<void> _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<HomeTab> with AutomaticKeepAliveClient
},
);
// Close progress dialog
if (dialogShown && mounted) {
Navigator.of(this.context).pop();
}
@@ -287,7 +271,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
// ignore: use_build_context_synchronously
final l10n = context.l10n;
// Optionally show confirmation dialog
final confirmed = await showDialog<bool>(
context: this.context,
builder: (dialogCtx) => AlertDialog(
@@ -321,8 +304,6 @@ class _HomeTabState extends ConsumerState<HomeTab> 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<HomeTab> with AutomaticKeepAliveClient
Widget build(BuildContext context) {
super.build(context);
// Listen for state changes to sync search bar and auto-navigate
ref.listen<TrackState>(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<HomeTab> 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<HomeTab> with AutomaticKeepAliveClient
return GestureDetector(
onTap: () {
// Unfocus search bar when tapping outside
if (_searchFocusNode.hasFocus) {
_searchFocusNode.unfocus();
}
@@ -381,7 +356,6 @@ class _HomeTabState extends ConsumerState<HomeTab> 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<HomeTab> 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<HomeTab> 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<HomeTab> 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<HomeTab> 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<HomeTab> 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<HomeTab> with AutomaticKeepAliveClient
/// Build recent access history section (shown when search focused)
Widget _buildRecentAccess(List<RecentAccessItem> 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<HomeTab> 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 = <String>{};
final uniqueItems = allItems.where((item) {
final key = '${item.type.name}:${item.id}';
@@ -629,7 +593,6 @@ class _HomeTabState extends ConsumerState<HomeTab> 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<HomeTab> with AutomaticKeepAliveClient
],
),
const SizedBox(height: 8),
// List of recent items
...uniqueItems.map((item) => _buildRecentAccessItem(item, colorScheme)),
],
),
@@ -659,7 +621,6 @@ class _HomeTabState extends ConsumerState<HomeTab> 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<HomeTab> 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<HomeTab> with AutomaticKeepAliveClient
),
),
const SizedBox(width: 12),
// Text content
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -792,19 +751,15 @@ class _HomeTabState extends ConsumerState<HomeTab> 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<HomeTab> with AutomaticKeepAliveClient
);
}
// Default error display
return Card(
elevation: 0,
color: colorScheme.errorContainer.withValues(alpha: 0.5),
@@ -883,7 +837,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
);
}
// Search results slivers - only shows search results (track list)
List<Widget> _buildSearchResults({
required List<Track> tracks,
required List<SearchArtist>? searchArtists,
@@ -896,29 +849,24 @@ class _HomeTabState extends ConsumerState<HomeTab> 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<HomeTab> 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<HomeTab> 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<HomeTab> 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<HomeTab> with AutomaticKeepAliveClient
),
),
// Bottom padding
const SliverToBoxAdapter(child: SizedBox(height: 16)),
];
}
@@ -1094,7 +1037,6 @@ class _HomeTabState extends ConsumerState<HomeTab> 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<HomeTab> 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<HomeTab> 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<HomeTab> 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<HomeTab> 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<HomeTab> 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<HomeTab> 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<HomeTab> 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<HomeTab> 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<HomeTab> 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<ExtensionAlbumScreen> {
return;
}
// Parse tracks from result
final trackList = result['tracks'] as List<dynamic>?;
if (trackList == null) {
setState(() {
@@ -1802,7 +1712,6 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
);
}
// Navigate to AlbumScreen with fetched tracks
return AlbumScreen(
albumId: widget.albumId,
albumName: widget.albumName,
@@ -1863,7 +1772,6 @@ class _ExtensionPlaylistScreenState extends ConsumerState<ExtensionPlaylistScree
return;
}
// Parse tracks from result
final trackList = result['tracks'] as List<dynamic>?;
if (trackList == null) {
setState(() {
@@ -1940,7 +1848,6 @@ class _ExtensionPlaylistScreenState extends ConsumerState<ExtensionPlaylistScree
);
}
// Navigate to PlaylistScreen with fetched tracks
return PlaylistScreen(
playlistName: widget.playlistName,
coverUrl: widget.coverUrl,
@@ -2003,18 +1910,15 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
return;
}
// Parse albums from result
final albumList = result['albums'] as List<dynamic>?;
final albums = albumList?.map((a) => _parseAlbum(a as Map<String, dynamic>)).toList() ?? [];
// Parse top tracks from result
final topTracksList = result['top_tracks'] as List<dynamic>?;
List<Track>? topTracks;
if (topTracksList != null && topTracksList.isNotEmpty) {
topTracks = topTracksList.map((t) => _parseTrack(t as Map<String, dynamic>)).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<ExtensionArtistScreen> {
);
}
// Navigate to ArtistScreen with fetched albums and top tracks
return ArtistScreen(
artistId: widget.artistId,
artistName: widget.artistName,
+1 -14
View File
@@ -30,7 +30,7 @@ class _MainShellState extends ConsumerState<MainShell> {
late PageController _pageController;
bool _hasCheckedUpdate = false;
StreamSubscription<String>? _shareSubscription;
DateTime? _lastBackPress; // For double-tap to exit
DateTime? _lastBackPress;
@override
void initState() {
@@ -49,7 +49,6 @@ class _MainShellState extends ConsumerState<MainShell> {
_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<MainShell> {
}
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<MainShell> {
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<MainShell> {
),
];
// 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<MainShell> {
return;
}
// Handle back press manually when canPop is false
_handleBackPress();
},
child: Scaffold(
-2
View File
@@ -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(
-5
View File
@@ -559,7 +559,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}, 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<QueueTab> {
),
),
// 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<QueueTab> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Handle bar
Container(
width: 32,
height: 4,
@@ -1067,7 +1064,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
const SizedBox(width: 12),
// Selection count
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -1088,7 +1084,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
// Select all toggle
TextButton.icon(
onPressed: () {
if (allSelected) {
@@ -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),
@@ -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<void> _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(
@@ -50,7 +50,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
child: Scaffold(
body: CustomScrollView(
slivers: [
// App Bar
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
@@ -120,7 +119,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
),
),
// Provider Priority
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionsProviderPrioritySection),
),
@@ -216,7 +214,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
),
),
// 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,
@@ -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) {
+1 -5
View File
@@ -267,7 +267,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
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<SetupScreen> {
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<SetupScreen> {
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;
-2
View File
@@ -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,
-2
View File
@@ -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');