mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-15 13:18:02 +02:00
refactor: more code cleanup
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -52,7 +52,6 @@ func cancelDownload(itemID string) {
|
||||
}
|
||||
cancelMu.Unlock()
|
||||
|
||||
// Hide progress for cancelled items.
|
||||
RemoveItemProgress(itemID)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -31,7 +31,6 @@ type TidalDownloader struct {
|
||||
}
|
||||
|
||||
var (
|
||||
// Global Tidal downloader instance for token reuse
|
||||
globalTidalDownloader *TidalDownloader
|
||||
tidalDownloaderOnce sync.Once
|
||||
)
|
||||
|
||||
+1
-2
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)}'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user