Compare commits

...

8 Commits

Author SHA1 Message Date
zarzet 51b2ad5c77 v2.0.5: Large playlist support + duration verification fix
- Add pagination for playlists (up to 1000 tracks)
- Add duration verification to prevent wrong track downloads
- When Tidal returns wrong version, fallback to Qobuz/Amazon
2026-01-05 03:08:15 +07:00
zarzet d641a517b8 ci: Fix disk cleanup - only remove safe directories 2026-01-04 12:09:13 +07:00
zarzet 608fa2ca74 ci: Fix disk cleanup - don't remove Android SDK path 2026-01-04 12:08:18 +07:00
zarzet 343b309314 ci: Add disk cleanup step to fix runner space issue 2026-01-04 12:01:34 +07:00
zarzet 0787b32dd8 v2.0.4: Fix Android 11 storage permission denied 2026-01-04 11:51:09 +07:00
zarzet 6927fdf7a9 fix: Android 11 storage permission denied issue 2026-01-04 11:48:19 +07:00
zarzet fe6af34478 Update screenshots 2026-01-04 00:14:33 +07:00
zarzet 85bb67da47 v2.0.3: Custom Spotify credentials, rate limit UI, search fixes 2026-01-03 23:56:03 +07:00
27 changed files with 969 additions and 68 deletions
+14
View File
@@ -45,6 +45,20 @@ jobs:
needs: get-version needs: get-version
steps: steps:
- name: Free disk space
run: |
# Remove large unused tools (~15GB total)
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf /opt/hostedtoolcache/CodeQL
sudo rm -rf /usr/local/share/boost
sudo rm -rf /usr/share/swift
sudo rm -rf /usr/local/.ghcup
# Clean docker images
sudo docker image prune --all --force
# Show available space
df -h
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
+34
View File
@@ -1,5 +1,39 @@
# Changelog # Changelog
## [2.0.5] - 2026-01-05
### Added
- **Large Playlist Support**: Playlists with up to 1000 tracks are now fully fetched (was limited to 100)
### Fixed
- **Wrong Track Download**: Fixed issue where tracks with same ISRC but different versions (e.g., short/instrumental vs full version) would download the wrong track. Now verifies duration matches before downloading (30 second tolerance).
## [2.0.4] - 2026-01-04
### Fixed
- **Android 11 Storage Permission**: Fixed "Permission denied" error on Android 11 (API 30) devices
- Added `MANAGE_EXTERNAL_STORAGE` permission for Android 11-12
- Shows explanation dialog before opening system settings
## [2.0.3] - 2026-01-03
### Added
- **Custom Spotify API Credentials**: Set your own Spotify Client ID and Secret in Settings > Options to avoid rate limiting
- Toggle to enable/disable custom credentials without deleting them
- Material Expressive 3 bottom sheet UI for entering credentials
- **Keyboard Dismiss on Scroll**: Keyboard now automatically dismisses when scrolling search results
- **Rate Limit Error UI**: Shows friendly error card when API rate limit (429) is hit on Home, Artist, and Album screens
### Changed
- **Search on Enter Only**: Removed auto-search debounce, now only searches when pressing Enter key (saves API calls)
### Fixed
- **Download Cancel**: Fixed cancelled downloads still completing in background and appearing in history. Cancelled files are now properly deleted.
- **Search Keyboard Dismiss**: Fixed keyboard randomly dismissing and navigating back when starting to search
- **Back Button During Search**: Back button now properly dismisses keyboard first before clearing search
- **Search Error Navigation**: Fixed pressing Enter during search (when loading or error) navigating back to home instead of staying on search screen
- **Duplicate Search on Enter**: Enter key no longer triggers duplicate search if results already loaded
## [2.0.2] - 2026-01-03 ## [2.0.2] - 2026-01-03
### Added ### Added
+4 -4
View File
@@ -18,10 +18,10 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
## Screenshots ## Screenshots
<p align="center"> <p align="center">
<img src="assets/images/1.jpg" width="200" /> <img src="assets/images/1.jpg?v=2" width="200" />
<img src="assets/images/2.jpg" width="200" /> <img src="assets/images/2.jpg?v=2" width="200" />
<img src="assets/images/3.jpg" width="200" /> <img src="assets/images/3.jpg?v=2" width="200" />
<img src="assets/images/4.jpg" width="200" /> <img src="assets/images/4.jpg?v=2" width="200" />
</p> </p>
## Other project ## Other project
+3 -1
View File
@@ -4,9 +4,11 @@
<!-- Permissions --> <!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" /> android:maxSdkVersion="29" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" /> android:maxSdkVersion="32" />
<!-- For Android 11+ (API 30-32) - full storage access -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" /> <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
@@ -200,6 +200,14 @@ class MainActivity: FlutterActivity() {
"isDownloadServiceRunning" -> { "isDownloadServiceRunning" -> {
result.success(DownloadService.isServiceRunning()) result.success(DownloadService.isServiceRunning())
} }
"setSpotifyCredentials" -> {
val clientId = call.argument<String>("client_id") ?: ""
val clientSecret = call.argument<String>("client_secret") ?: ""
withContext(Dispatchers.IO) {
Gobackend.setSpotifyAPICredentials(clientId, clientSecret)
}
result.success(null)
}
else -> result.notImplemented() else -> result.notImplemented()
} }
} catch (e: Exception) { } catch (e: Exception) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 135 KiB

+8 -1
View File
@@ -30,6 +30,12 @@ func ParseSpotifyURL(url string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// SetSpotifyAPICredentials sets custom Spotify API credentials from Flutter
// Pass empty strings to use default credentials
func SetSpotifyAPICredentials(clientID, clientSecret string) {
SetSpotifyCredentials(clientID, clientSecret)
}
// GetSpotifyMetadata fetches metadata from Spotify URL // GetSpotifyMetadata fetches metadata from Spotify URL
// Returns JSON with track/album/playlist data // Returns JSON with track/album/playlist data
func GetSpotifyMetadata(spotifyURL string) (string, error) { func GetSpotifyMetadata(spotifyURL string) (string, error) {
@@ -126,7 +132,8 @@ type DownloadRequest struct {
DiscNumber int `json:"disc_number"` DiscNumber int `json:"disc_number"`
TotalTracks int `json:"total_tracks"` TotalTracks int `json:"total_tracks"`
ReleaseDate string `json:"release_date"` ReleaseDate string `json:"release_date"`
ItemID string `json:"item_id"` // Unique ID for progress tracking ItemID string `json:"item_id"` // Unique ID for progress tracking
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
} }
// DownloadResponse represents the result of a download // DownloadResponse represents the result of a download
+148 -10
View File
@@ -112,8 +112,96 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc) return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
} }
// SearchTrackByISRCWithTitle searches for a track by ISRC with duration verification
// expectedDurationSec is the expected duration in seconds (0 to skip verification)
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil {
return nil, err
}
resp, err := DoRequestWithUserAgent(q.client, req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode)
}
var result struct {
Tracks struct {
Items []QobuzTrack `json:"items"`
} `json:"tracks"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
// Find ISRC matches
var isrcMatches []*QobuzTrack
for i := range result.Tracks.Items {
if result.Tracks.Items[i].ISRC == isrc {
isrcMatches = append(isrcMatches, &result.Tracks.Items[i])
}
}
if len(isrcMatches) > 0 {
// Verify duration if provided
if expectedDurationSec > 0 {
var durationVerifiedMatches []*QobuzTrack
for _, track := range isrcMatches {
durationDiff := track.Duration - expectedDurationSec
if durationDiff < 0 {
durationDiff = -durationDiff
}
// Allow 30 seconds tolerance
if durationDiff <= 30 {
durationVerifiedMatches = append(durationVerifiedMatches, track)
}
}
if len(durationVerifiedMatches) > 0 {
fmt.Printf("[Qobuz] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
durationVerifiedMatches[0].Title, expectedDurationSec, durationVerifiedMatches[0].Duration)
return durationVerifiedMatches[0], nil
}
// ISRC matches but duration doesn't
fmt.Printf("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
isrc, expectedDurationSec, isrcMatches[0].Duration)
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)",
expectedDurationSec, isrcMatches[0].Duration)
}
// No duration to verify, return first match
fmt.Printf("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
return isrcMatches[0], nil
}
if len(result.Tracks.Items) == 0 {
return nil, fmt.Errorf("no tracks found for ISRC: %s", isrc)
}
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
}
// SearchTrackByISRCWithTitle is deprecated, use SearchTrackByISRCWithDuration instead
func (q *QobuzDownloader) SearchTrackByISRCWithTitle(isrc, expectedTitle string) (*QobuzTrack, error) {
return q.SearchTrackByISRCWithDuration(isrc, 0)
}
// SearchTrackByMetadata searches for a track using artist name and track name // SearchTrackByMetadata searches for a track using artist name and track name
func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) { func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) {
return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0)
}
// SearchTrackByMetadataWithDuration searches for a track with duration verification
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
// Try multiple search strategies // Try multiple search strategies
@@ -129,6 +217,8 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
queries = append(queries, trackName) queries = append(queries, trackName)
} }
var allTracks []QobuzTrack
for _, query := range queries { for _, query := range queries {
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(query), q.appID) searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(query), q.appID)
@@ -159,19 +249,50 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
resp.Body.Close() resp.Body.Close()
if len(result.Tracks.Items) > 0 { if len(result.Tracks.Items) > 0 {
// Return first result with best quality allTracks = append(allTracks, result.Tracks.Items...)
for i := range result.Tracks.Items { }
track := &result.Tracks.Items[i] }
if len(allTracks) == 0 {
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
}
// If duration verification is requested
if expectedDurationSec > 0 {
var durationMatches []*QobuzTrack
for i := range allTracks {
track := &allTracks[i]
durationDiff := track.Duration - expectedDurationSec
if durationDiff < 0 {
durationDiff = -durationDiff
}
if durationDiff <= 30 {
durationMatches = append(durationMatches, track)
}
}
if len(durationMatches) > 0 {
// Return best quality among duration matches
for _, track := range durationMatches {
if track.MaximumBitDepth >= 24 { if track.MaximumBitDepth >= 24 {
return track, nil return track, nil
} }
} }
// Return first result if no hi-res found return durationMatches[0], nil
return &result.Tracks.Items[0], nil
} }
// No duration match found
return nil, fmt.Errorf("no tracks found with matching duration (expected %ds)", expectedDurationSec)
} }
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName) // No duration verification, return best quality
for i := range allTracks {
track := &allTracks[i]
if track.MaximumBitDepth >= 24 {
return track, nil
}
}
return &allTracks[0], nil
} }
// getQobuzDownloadURLSequential requests download URL from APIs sequentially // getQobuzDownloadURLSequential requests download URL from APIs sequentially
@@ -321,17 +442,20 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
} }
// Convert expected duration from ms to seconds
expectedDurationSec := req.DurationMS / 1000
var track *QobuzTrack var track *QobuzTrack
var err error var err error
// Strategy 1: Search by ISRC // Strategy 1: Search by ISRC with duration verification
if req.ISRC != "" { if req.ISRC != "" {
track, err = downloader.SearchTrackByISRC(req.ISRC) track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
} }
// Strategy 2: Search by metadata // Strategy 2: Search by metadata with duration verification
if track == nil { if track == nil {
track, err = downloader.SearchTrackByMetadata(req.TrackName, req.ArtistName) track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
} }
if track == nil { if track == nil {
@@ -342,6 +466,20 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg) return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
} }
// Final duration verification
if expectedDurationSec > 0 {
durationDiff := track.Duration - expectedDurationSec
if durationDiff < 0 {
durationDiff = -durationDiff
}
if durationDiff > 30 {
return QobuzDownloadResult{}, fmt.Errorf("duration mismatch: expected %ds, found %ds (diff: %ds). Track may be wrong version",
expectedDurationSec, track.Duration, durationDiff)
}
fmt.Printf("[Qobuz] Duration verified: expected %ds, found %ds (diff: %ds)\n",
expectedDurationSec, track.Duration, durationDiff)
}
// Build filename // Build filename
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName, "title": req.TrackName,
+91 -6
View File
@@ -62,11 +62,32 @@ type SpotifyMetadataClient struct {
cacheMu sync.RWMutex cacheMu sync.RWMutex
} }
// NewSpotifyMetadataClient creates a new Spotify client // Custom credentials storage (set from Flutter)
func NewSpotifyMetadataClient() *SpotifyMetadataClient { var (
src := rand.NewSource(time.Now().UnixNano()) customClientID string
customClientSecret string
credentialsMu sync.RWMutex
)
// Prefer environment variables for credentials (more secure), fall back to built-in // SetSpotifyCredentials sets custom Spotify API credentials
// Pass empty strings to use default credentials
func SetSpotifyCredentials(clientID, clientSecret string) {
credentialsMu.Lock()
defer credentialsMu.Unlock()
customClientID = clientID
customClientSecret = clientSecret
}
// getCredentials returns the current credentials (custom or default)
func getCredentials() (string, string) {
credentialsMu.RLock()
defer credentialsMu.RUnlock()
if customClientID != "" && customClientSecret != "" {
return customClientID, customClientSecret
}
// Fall back to default credentials
clientID := os.Getenv("SPOTIFY_CLIENT_ID") clientID := os.Getenv("SPOTIFY_CLIENT_ID")
if clientID == "" { if clientID == "" {
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil { if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
@@ -81,6 +102,16 @@ func NewSpotifyMetadataClient() *SpotifyMetadataClient {
} }
} }
return clientID, clientSecret
}
// NewSpotifyMetadataClient creates a new Spotify client
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
src := rand.NewSource(time.Now().UnixNano())
// Get credentials (custom or default)
clientID, clientSecret := getCredentials()
c := &SpotifyMetadataClient{ c := &SpotifyMetadataClient{
httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling
clientID: clientID, clientID: clientID,
@@ -536,6 +567,7 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
} }
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) { func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
// First request to get playlist info and first batch of tracks
var data struct { var data struct {
Name string `json:"name"` Name string `json:"name"`
Images []image `json:"images"` Images []image `json:"images"`
@@ -546,7 +578,8 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
Items []struct { Items []struct {
Track *trackFull `json:"track"` Track *trackFull `json:"track"`
} `json:"items"` } `json:"items"`
Total int `json:"total"` Total int `json:"total"`
Next string `json:"next"`
} `json:"tracks"` } `json:"tracks"`
} }
@@ -560,7 +593,10 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
info.Owner.Name = data.Name info.Owner.Name = data.Name
info.Owner.Images = firstImageURL(data.Images) info.Owner.Images = firstImageURL(data.Images)
tracks := make([]AlbumTrackMetadata, 0, len(data.Tracks.Items)) // Pre-allocate with expected capacity
tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total)
// Add first batch of tracks
for _, item := range data.Tracks.Items { for _, item := range data.Tracks.Items {
if item.Track == nil { if item.Track == nil {
continue continue
@@ -584,6 +620,55 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
}) })
} }
// Fetch remaining tracks using pagination (up to 1000 tracks max)
nextURL := data.Tracks.Next
maxTracks := 1000
for nextURL != "" && len(tracks) < maxTracks {
var pageData struct {
Items []struct {
Track *trackFull `json:"track"`
} `json:"items"`
Next string `json:"next"`
}
if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil {
// Log error but return what we have so far
fmt.Printf("[Spotify] Warning: failed to fetch page, returning %d tracks: %v\n", len(tracks), err)
break
}
for _, item := range pageData.Items {
if item.Track == nil {
continue
}
if len(tracks) >= maxTracks {
break
}
tracks = append(tracks, AlbumTrackMetadata{
SpotifyID: item.Track.ID,
Artists: joinArtists(item.Track.Artists),
Name: item.Track.Name,
AlbumName: item.Track.Album.Name,
AlbumArtist: joinArtists(item.Track.Album.Artists),
DurationMS: item.Track.DurationMS,
Images: firstImageURL(item.Track.Album.Images),
ReleaseDate: item.Track.Album.ReleaseDate,
TrackNumber: item.Track.TrackNumber,
TotalTracks: item.Track.Album.TotalTracks,
DiscNumber: item.Track.DiscNumber,
ExternalURL: item.Track.ExternalURL.Spotify,
ISRC: item.Track.ExternalID.ISRC,
AlbumID: item.Track.Album.ID,
AlbumURL: item.Track.Album.ExternalURL.Spotify,
})
}
nextURL = pageData.Next
}
fmt.Printf("[Spotify] Fetched %d tracks from playlist (total: %d)\n", len(tracks), data.Tracks.Total)
return &PlaylistResponsePayload{ return &PlaylistResponsePayload{
PlaylistInfo: info, PlaylistInfo: info,
TrackList: tracks, TrackList: tracks,
+93 -5
View File
@@ -315,6 +315,28 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc) return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
} }
// normalizeTitle normalizes a track title for comparison (kept for potential future use)
func normalizeTitle(title string) string {
normalized := strings.ToLower(strings.TrimSpace(title))
// Remove common suffixes in parentheses or brackets
suffixPatterns := []string{
" (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)",
" (bonus track)", " (single)", " (album version)", " (radio edit)",
" [remaster]", " [remastered]", " [deluxe]", " [bonus track]",
}
for _, suffix := range suffixPatterns {
normalized = strings.TrimSuffix(normalized, suffix)
}
// Remove multiple spaces
for strings.Contains(normalized, " ") {
normalized = strings.ReplaceAll(normalized, " ", " ")
}
return normalized
}
// SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority // SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) { func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
token, err := t.GetAccessToken() token, err := t.GetAccessToken()
@@ -390,14 +412,50 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
return nil, fmt.Errorf("no tracks found for any search query") return nil, fmt.Errorf("no tracks found for any search query")
} }
// Priority 1: Match by ISRC (exact match) // Priority 1: Match by ISRC (exact match) WITH title verification
if spotifyISRC != "" { if spotifyISRC != "" {
var isrcMatches []*TidalTrack
for i := range allTracks { for i := range allTracks {
track := &allTracks[i] track := &allTracks[i]
if track.ISRC == spotifyISRC { if track.ISRC == spotifyISRC {
return track, nil isrcMatches = append(isrcMatches, track)
} }
} }
if len(isrcMatches) > 0 {
// Verify duration first (most important check)
if expectedDuration > 0 {
var durationVerifiedMatches []*TidalTrack
for _, track := range isrcMatches {
durationDiff := track.Duration - expectedDuration
if durationDiff < 0 {
durationDiff = -durationDiff
}
// Allow 30 seconds tolerance for duration
if durationDiff <= 30 {
durationVerifiedMatches = append(durationVerifiedMatches, track)
}
}
if len(durationVerifiedMatches) > 0 {
// Return first duration-verified match
fmt.Printf("[Tidal] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration)
return durationVerifiedMatches[0], nil
}
// ISRC matches but duration doesn't - this is likely wrong version
fmt.Printf("[Tidal] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
spotifyISRC, expectedDuration, isrcMatches[0].Duration)
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version/edit)",
expectedDuration, isrcMatches[0].Duration)
}
// No duration to verify, just return first ISRC match
fmt.Printf("[Tidal] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
return isrcMatches[0], nil
}
// If ISRC was provided but no match found, return error // If ISRC was provided but no match found, return error
return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC) return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC)
} }
@@ -820,6 +878,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
} }
// Convert expected duration from ms to seconds
expectedDurationSec := req.DurationMS / 1000
var track *TidalTrack var track *TidalTrack
var err error var err error
@@ -831,18 +892,31 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL) trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
if idErr == nil { if idErr == nil {
track, err = downloader.GetTrackInfoByID(trackID) track, err = downloader.GetTrackInfoByID(trackID)
// Verify duration if we have expected duration
if track != nil && expectedDurationSec > 0 {
durationDiff := track.Duration - expectedDurationSec
if durationDiff < 0 {
durationDiff = -durationDiff
}
// Allow 30 seconds tolerance
if durationDiff > 30 {
fmt.Printf("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
expectedDurationSec, track.Duration)
track = nil // Reject this match
}
}
} }
} }
} }
// Strategy 2: Search by ISRC with multi-strategy fallback // Strategy 2: Search by ISRC with duration verification
if track == nil && req.ISRC != "" { if track == nil && req.ISRC != "" {
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, 0) track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec)
} }
// Strategy 3: Search by metadata only (no ISRC requirement) // Strategy 3: Search by metadata only (no ISRC requirement)
if track == nil { if track == nil {
track, err = downloader.SearchTrackByMetadata(req.TrackName, req.ArtistName) track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec)
} }
if track == nil { if track == nil {
@@ -853,6 +927,20 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg) return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg)
} }
// Final duration verification
if expectedDurationSec > 0 {
durationDiff := track.Duration - expectedDurationSec
if durationDiff < 0 {
durationDiff = -durationDiff
}
if durationDiff > 30 {
return TidalDownloadResult{}, fmt.Errorf("duration mismatch: expected %ds, found %ds (diff: %ds). Track may be wrong version",
expectedDurationSec, track.Duration, durationDiff)
}
fmt.Printf("[Tidal] Duration verified: expected %ds, found %ds (diff: %ds)\n",
expectedDurationSec, track.Duration, durationDiff)
}
// Build filename // Build filename
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName, "title": req.TrackName,
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants /// App version and info constants
/// Update version here only - all other files will reference this /// Update version here only - all other files will reference this
class AppInfo { class AppInfo {
static const String version = '2.0.2'; static const String version = '2.0.5';
static const String buildNumber = '32'; static const String buildNumber = '35';
static const String fullVersion = '$version+$buildNumber'; static const String fullVersion = '$version+$buildNumber';
+12
View File
@@ -18,6 +18,9 @@ class AppSettings {
final String folderOrganization; // none, artist, album, artist_album final String folderOrganization; // none, artist, album, artist_album
final String historyViewMode; // list, grid final String historyViewMode; // list, grid
final bool askQualityBeforeDownload; // Show quality picker before each download final bool askQualityBeforeDownload; // Show quality picker before each download
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
const AppSettings({ const AppSettings({
this.defaultService = 'tidal', this.defaultService = 'tidal',
@@ -34,6 +37,9 @@ class AppSettings {
this.folderOrganization = 'none', // Default: no folder organization this.folderOrganization = 'none', // Default: no folder organization
this.historyViewMode = 'grid', // Default: grid view this.historyViewMode = 'grid', // Default: grid view
this.askQualityBeforeDownload = true, // Default: ask quality before download this.askQualityBeforeDownload = true, // Default: ask quality before download
this.spotifyClientId = '', // Default: use built-in credentials
this.spotifyClientSecret = '', // Default: use built-in credentials
this.useCustomSpotifyCredentials = true, // Default: use custom if set
}); });
AppSettings copyWith({ AppSettings copyWith({
@@ -51,6 +57,9 @@ class AppSettings {
String? folderOrganization, String? folderOrganization,
String? historyViewMode, String? historyViewMode,
bool? askQualityBeforeDownload, bool? askQualityBeforeDownload,
String? spotifyClientId,
String? spotifyClientSecret,
bool? useCustomSpotifyCredentials,
}) { }) {
return AppSettings( return AppSettings(
defaultService: defaultService ?? this.defaultService, defaultService: defaultService ?? this.defaultService,
@@ -67,6 +76,9 @@ class AppSettings {
folderOrganization: folderOrganization ?? this.folderOrganization, folderOrganization: folderOrganization ?? this.folderOrganization,
historyViewMode: historyViewMode ?? this.historyViewMode, historyViewMode: historyViewMode ?? this.historyViewMode,
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload, askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
); );
} }
+7
View File
@@ -21,6 +21,10 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
folderOrganization: json['folderOrganization'] as String? ?? 'none', folderOrganization: json['folderOrganization'] as String? ?? 'none',
historyViewMode: json['historyViewMode'] as String? ?? 'grid', historyViewMode: json['historyViewMode'] as String? ?? 'grid',
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true, askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
spotifyClientId: json['spotifyClientId'] as String? ?? '',
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
useCustomSpotifyCredentials:
json['useCustomSpotifyCredentials'] as bool? ?? true,
); );
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) => Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
@@ -39,4 +43,7 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'folderOrganization': instance.folderOrganization, 'folderOrganization': instance.folderOrganization,
'historyViewMode': instance.historyViewMode, 'historyViewMode': instance.historyViewMode,
'askQualityBeforeDownload': instance.askQualityBeforeDownload, 'askQualityBeforeDownload': instance.askQualityBeforeDownload,
'spotifyClientId': instance.spotifyClientId,
'spotifyClientSecret': instance.spotifyClientSecret,
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
}; };
@@ -1007,6 +1007,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
releaseDate: item.track.releaseDate, releaseDate: item.track.releaseDate,
preferredService: item.service, preferredService: item.service,
itemId: item.id, // Pass item ID for progress tracking itemId: item.id, // Pass item ID for progress tracking
durationMs: item.track.duration, // Duration in ms for verification
); );
} else { } else {
result = await PlatformBridge.downloadTrack( result = await PlatformBridge.downloadTrack(
@@ -1025,11 +1026,32 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
discNumber: item.track.discNumber ?? 1, discNumber: item.track.discNumber ?? 1,
releaseDate: item.track.releaseDate, releaseDate: item.track.releaseDate,
itemId: item.id, // Pass item ID for progress tracking itemId: item.id, // Pass item ID for progress tracking
durationMs: item.track.duration, // Duration in ms for verification
); );
} }
_log.d('Result: $result'); _log.d('Result: $result');
// Check if item was cancelled while downloading
final currentItem = state.items.firstWhere((i) => i.id == item.id, orElse: () => item);
if (currentItem.status == DownloadStatus.skipped) {
_log.i('Download was cancelled, skipping result processing');
// Delete the downloaded file if it exists
final filePath = result['file_path'] as String?;
if (filePath != null && result['success'] == true) {
try {
final file = File(filePath);
if (await file.exists()) {
await file.delete();
_log.d('Deleted cancelled download file: $filePath');
}
} catch (e) {
_log.w('Failed to delete cancelled file: $e');
}
}
return;
}
if (result['success'] == true) { if (result['success'] == true) {
var filePath = result['file_path'] as String?; var filePath = result['file_path'] as String?;
_log.i('Download success, file: $filePath'); _log.i('Download success, file: $filePath');
@@ -1071,6 +1093,25 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} }
// Check again if cancelled before updating status and adding to history
final itemAfterDownload = state.items.firstWhere((i) => i.id == item.id, orElse: () => item);
if (itemAfterDownload.status == DownloadStatus.skipped) {
_log.i('Download was cancelled during finalization, cleaning up');
// Delete the downloaded file
if (filePath != null) {
try {
final file = File(filePath);
if (await file.exists()) {
await file.delete();
_log.d('Deleted cancelled download file: $filePath');
}
} catch (e) {
_log.w('Failed to delete cancelled file: $e');
}
}
return;
}
updateItemStatus( updateItemStatus(
item.id, item.id,
DownloadStatus.completed, DownloadStatus.completed,
+53
View File
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
const _settingsKey = 'app_settings'; const _settingsKey = 'app_settings';
@@ -17,6 +18,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
final json = prefs.getString(_settingsKey); final json = prefs.getString(_settingsKey);
if (json != null) { if (json != null) {
state = AppSettings.fromJson(jsonDecode(json)); state = AppSettings.fromJson(jsonDecode(json));
// Apply Spotify credentials to Go backend on load
_applySpotifyCredentials();
} }
} }
@@ -25,6 +28,22 @@ class SettingsNotifier extends Notifier<AppSettings> {
await prefs.setString(_settingsKey, jsonEncode(state.toJson())); await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
} }
/// Apply current Spotify credentials to Go backend
Future<void> _applySpotifyCredentials() async {
// Only apply custom credentials if enabled and both fields are set
if (state.useCustomSpotifyCredentials &&
state.spotifyClientId.isNotEmpty &&
state.spotifyClientSecret.isNotEmpty) {
await PlatformBridge.setSpotifyCredentials(
state.spotifyClientId,
state.spotifyClientSecret,
);
} else {
// Clear to use default
await PlatformBridge.setSpotifyCredentials('', '');
}
}
void setDefaultService(String service) { void setDefaultService(String service) {
state = state.copyWith(defaultService: service); state = state.copyWith(defaultService: service);
_saveSettings(); _saveSettings();
@@ -98,6 +117,40 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = state.copyWith(askQualityBeforeDownload: enabled); state = state.copyWith(askQualityBeforeDownload: enabled);
_saveSettings(); _saveSettings();
} }
void setSpotifyClientId(String clientId) {
state = state.copyWith(spotifyClientId: clientId);
_saveSettings();
}
void setSpotifyClientSecret(String clientSecret) {
state = state.copyWith(spotifyClientSecret: clientSecret);
_saveSettings();
}
void setSpotifyCredentials(String clientId, String clientSecret) {
state = state.copyWith(
spotifyClientId: clientId,
spotifyClientSecret: clientSecret,
);
_saveSettings();
_applySpotifyCredentials();
}
void clearSpotifyCredentials() {
state = state.copyWith(
spotifyClientId: '',
spotifyClientSecret: '',
);
_saveSettings();
_applySpotifyCredentials();
}
void setUseCustomSpotifyCredentials(bool enabled) {
state = state.copyWith(useCustomSpotifyCredentials: enabled);
_saveSettings();
_applySpotifyCredentials();
}
} }
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>( final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
+9 -4
View File
@@ -118,7 +118,8 @@ class TrackNotifier extends Notifier<TrackState> {
// Increment request ID to cancel any pending requests // Increment request ID to cancel any pending requests
final requestId = ++_currentRequestId; final requestId = ++_currentRequestId;
state = const TrackState(isLoading: true); // Preserve hasSearchText during fetch
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try { try {
final parsed = await PlatformBridge.parseSpotifyUrl(url); final parsed = await PlatformBridge.parseSpotifyUrl(url);
@@ -174,7 +175,8 @@ class TrackNotifier extends Notifier<TrackState> {
} }
} catch (e) { } catch (e) {
if (!_isRequestValid(requestId)) return; // Request cancelled if (!_isRequestValid(requestId)) return; // Request cancelled
state = TrackState(isLoading: false, error: e.toString()); // Preserve hasSearchText on error so user stays on search screen
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
} }
} }
@@ -182,7 +184,8 @@ class TrackNotifier extends Notifier<TrackState> {
// Increment request ID to cancel any pending requests // Increment request ID to cancel any pending requests
final requestId = ++_currentRequestId; final requestId = ++_currentRequestId;
state = const TrackState(isLoading: true); // Preserve hasSearchText during search
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try { try {
final results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5); final results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
@@ -198,10 +201,12 @@ class TrackNotifier extends Notifier<TrackState> {
tracks: tracks, tracks: tracks,
searchArtists: artists, searchArtists: artists,
isLoading: false, isLoading: false,
hasSearchText: state.hasSearchText,
); );
} catch (e) { } catch (e) {
if (!_isRequestValid(requestId)) return; // Request cancelled if (!_isRequestValid(requestId)) return; // Request cancelled
state = TrackState(isLoading: false, error: e.toString()); // Preserve hasSearchText on error so user stays on search screen
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
} }
} }
+65 -2
View File
@@ -126,10 +126,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
padding: EdgeInsets.all(32), padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()), child: Center(child: CircularProgressIndicator()),
)), )),
if (_error != null) if (_error != null)
SliverToBoxAdapter(child: Padding( SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Text(_error!, style: TextStyle(color: colorScheme.error)), child: _buildErrorWidget(_error!, colorScheme),
)), )),
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[ if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
_buildTrackListHeader(context, colorScheme), _buildTrackListHeader(context, colorScheme),
@@ -369,6 +369,69 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
), ),
); );
} }
/// Build error widget with special handling for rate limit (429)
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
final isRateLimit = error.contains('429') ||
error.toLowerCase().contains('rate limit') ||
error.toLowerCase().contains('too many requests');
if (isRateLimit) {
return Card(
elevation: 0,
color: colorScheme.errorContainer,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.timer_off, color: colorScheme.onErrorContainer),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Rate Limited',
style: TextStyle(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'Too many requests. Please wait a moment and try again.',
style: TextStyle(
color: colorScheme.onErrorContainer,
fontSize: 12,
),
),
],
),
),
],
),
),
);
}
// Default error display
return Card(
elevation: 0,
color: colorScheme.errorContainer.withValues(alpha: 0.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 12),
Expanded(child: Text(error, style: TextStyle(color: colorScheme.error))),
],
),
),
);
}
} }
class _QualityOption extends StatelessWidget { class _QualityOption extends StatelessWidget {
+64 -1
View File
@@ -128,7 +128,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
if (_error != null) if (_error != null)
SliverToBoxAdapter(child: Padding( SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Text(_error!, style: TextStyle(color: colorScheme.error)), child: _buildErrorWidget(_error!, colorScheme),
)), )),
if (!_isLoadingDiscography && _error == null) ...[ if (!_isLoadingDiscography && _error == null) ...[
if (albumsOnly.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Albums', albumsOnly, colorScheme)), if (albumsOnly.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Albums', albumsOnly, colorScheme)),
@@ -318,4 +318,67 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
), ),
)); ));
} }
/// Build error widget with special handling for rate limit (429)
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
final isRateLimit = error.contains('429') ||
error.toLowerCase().contains('rate limit') ||
error.toLowerCase().contains('too many requests');
if (isRateLimit) {
return Card(
elevation: 0,
color: colorScheme.errorContainer,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.timer_off, color: colorScheme.onErrorContainer),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Rate Limited',
style: TextStyle(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'Too many requests. Please wait a moment and try again.',
style: TextStyle(
color: colorScheme.onErrorContainer,
fontSize: 12,
),
),
],
),
),
],
),
),
);
}
// Default error display
return Card(
elevation: 0,
color: colorScheme.errorContainer.withValues(alpha: 0.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 12),
Expanded(child: Text(error, style: TextStyle(color: colorScheme.error))),
],
),
),
);
}
} }
+93 -29
View File
@@ -22,7 +22,6 @@ class HomeTab extends ConsumerStatefulWidget {
class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin { class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
final _urlController = TextEditingController(); final _urlController = TextEditingController();
Timer? _debounce;
bool _isTyping = false; bool _isTyping = false;
final FocusNode _searchFocusNode = FocusNode(); final FocusNode _searchFocusNode = FocusNode();
String? _lastSearchQuery; // Track last searched query to avoid duplicate searches String? _lastSearchQuery; // Track last searched query to avoid duplicate searches
@@ -38,7 +37,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
@override @override
void dispose() { void dispose() {
_debounce?.cancel();
_urlController.removeListener(_onSearchChanged); _urlController.removeListener(_onSearchChanged);
_urlController.dispose(); _urlController.dispose();
_searchFocusNode.dispose(); _searchFocusNode.dispose();
@@ -48,17 +46,18 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
/// Called when trackState changes - used to sync search bar with state /// Called when trackState changes - used to sync search bar with state
void _onTrackStateChanged(TrackState? previous, TrackState next) { void _onTrackStateChanged(TrackState? previous, TrackState next) {
// If state was cleared (no content, no search text, not loading), clear the search bar // If state was cleared (no content, no search text, not loading), clear the search bar
// BUT only if search field is not focused (to prevent clearing while user is typing)
if (previous != null && if (previous != null &&
!next.hasContent && !next.hasContent &&
!next.hasSearchText && !next.hasSearchText &&
!next.isLoading && !next.isLoading &&
_urlController.text.isNotEmpty) { _urlController.text.isNotEmpty &&
!_searchFocusNode.hasFocus) {
_urlController.clear(); _urlController.clear();
setState(() => _isTyping = false); setState(() => _isTyping = false);
} }
} void _onSearchChanged() { } void _onSearchChanged() {
final text = _urlController.text.trim(); final text = _urlController.text.trim();
final wasFocused = _searchFocusNode.hasFocus;
// Update search text state for MainShell back button handling // Update search text state for MainShell back button handling
ref.read(trackProvider.notifier).setSearchText(text.isNotEmpty); ref.read(trackProvider.notifier).setSearchText(text.isNotEmpty);
@@ -68,30 +67,13 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
setState(() => _isTyping = true); setState(() => _isTyping = true);
} else if (text.isEmpty && _isTyping) { } else if (text.isEmpty && _isTyping) {
setState(() => _isTyping = false); setState(() => _isTyping = false);
ref.read(trackProvider.notifier).clear(); // Don't clear provider here - it causes focus issues
// Provider will be cleared when user explicitly clears or navigates away
return; return;
} }
// Re-request focus after rebuild if it was focused // No auto-search - user must press Enter to search
if (wasFocused) { // This saves API calls and avoids rate limiting
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_searchFocusNode.requestFocus();
}
});
}
// Debounce all requests (URLs and searches)
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 400), () {
if (text.isEmpty) return;
if (text.startsWith('http') || text.startsWith('spotify:')) {
_fetchMetadata();
} else if (text.length >= 2) {
_performSearch(text);
}
});
} }
Future<void> _performSearch(String query) async { Future<void> _performSearch(String query) async {
@@ -116,7 +98,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
} }
Future<void> _clearAndRefresh() async { Future<void> _clearAndRefresh() async {
_debounce?.cancel();
_urlController.clear(); _urlController.clear();
_searchFocusNode.unfocus(); _searchFocusNode.unfocus();
_lastSearchQuery = null; // Reset last query _lastSearchQuery = null; // Reset last query
@@ -285,6 +266,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
return Scaffold( return Scaffold(
body: CustomScrollView( body: CustomScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
slivers: [ slivers: [
// App Bar - always present // App Bar - always present
SliverAppBar( SliverAppBar(
@@ -479,6 +461,69 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
)); ));
} }
/// Build error widget with special handling for rate limit (429)
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
final isRateLimit = error.contains('429') ||
error.toLowerCase().contains('rate limit') ||
error.toLowerCase().contains('too many requests');
if (isRateLimit) {
return Card(
elevation: 0,
color: colorScheme.errorContainer,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.timer_off, color: colorScheme.onErrorContainer),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Rate Limited',
style: TextStyle(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'Too many requests. Please wait a moment before searching again.',
style: TextStyle(
color: colorScheme.onErrorContainer,
fontSize: 12,
),
),
],
),
),
],
),
),
);
}
// Default error display
return Card(
elevation: 0,
color: colorScheme.errorContainer.withValues(alpha: 0.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 12),
Expanded(child: Text(error, style: TextStyle(color: colorScheme.error))),
],
),
),
);
}
// Search results slivers - only shows search results (track list) // Search results slivers - only shows search results (track list)
List<Widget> _buildSearchResults({ List<Widget> _buildSearchResults({
required List<Track> tracks, required List<Track> tracks,
@@ -493,11 +538,11 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
} }
return [ return [
// Error message // Error message - with special handling for rate limit (429)
if (error != null) if (error != null)
SliverToBoxAdapter(child: Padding( SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(error, style: TextStyle(color: colorScheme.error)), child: _buildErrorWidget(error, colorScheme),
)), )),
// Loading indicator // Loading indicator
@@ -674,10 +719,29 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
), ),
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
), ),
onSubmitted: (_) => _fetchMetadata(), onSubmitted: (_) => _onSearchSubmitted(),
); );
} }
/// Handle Enter key press - search or fetch URL
void _onSearchSubmitted() {
final text = _urlController.text.trim();
if (text.isEmpty) return;
// If it's a URL, fetch metadata
if (text.startsWith('http') || text.startsWith('spotify:')) {
_fetchMetadata();
_searchFocusNode.unfocus();
return;
}
// For search queries, always search (minimum 2 chars)
if (text.length >= 2) {
_performSearch(text);
}
_searchFocusNode.unfocus();
}
} }
class _QualityPickerOption extends StatelessWidget { class _QualityPickerOption extends StatelessWidget {
+13 -1
View File
@@ -125,6 +125,13 @@ class _MainShellState extends ConsumerState<MainShell> {
void _handleBackPress() { void _handleBackPress() {
final trackState = ref.read(trackProvider); final trackState = ref.read(trackProvider);
// Check if keyboard is visible - if so, just dismiss keyboard, don't clear search
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
if (isKeyboardVisible) {
FocusScope.of(context).unfocus();
return;
}
// If on Home tab and has text in search bar or has content (but not loading), clear it // If on Home tab and has text in search bar or has content (but not loading), clear it
if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) { if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) {
ref.read(trackProvider.notifier).clear(); ref.read(trackProvider.notifier).clear();
@@ -163,12 +170,17 @@ class _MainShellState extends ConsumerState<MainShell> {
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount)); final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
final trackState = ref.watch(trackProvider); final trackState = ref.watch(trackProvider);
// Check if keyboard is visible (bottom inset > 0 means keyboard is showing)
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
// Determine if we can pop (for predictive back animation) // Determine if we can pop (for predictive back animation)
// canPop is true when we're at root with no content - enables predictive back gesture // canPop is true when we're at root with no content - enables predictive back gesture
// IMPORTANT: Never allow pop when keyboard is visible to prevent accidental navigation
final canPop = _currentIndex == 0 && final canPop = _currentIndex == 0 &&
!trackState.hasSearchText && !trackState.hasSearchText &&
!trackState.hasContent && !trackState.hasContent &&
!trackState.isLoading; !trackState.isLoading &&
!isKeyboardVisible;
return PopScope( return PopScope(
canPop: canPop, canPop: canPop,
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
@@ -116,6 +117,38 @@ class OptionsSettingsPage extends ConsumerWidget {
), ),
), ),
// Spotify API section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Spotify API')),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.key,
title: 'Custom Credentials',
subtitle: settings.spotifyClientId.isNotEmpty
? 'Client ID: ${settings.spotifyClientId.length > 8 ? '${settings.spotifyClientId.substring(0, 8)}...' : settings.spotifyClientId}'
: 'Not configured',
onTap: () => _showSpotifyCredentialsDialog(context, ref, settings),
trailing: settings.spotifyClientId.isNotEmpty
? Icon(Icons.edit, color: Theme.of(context).colorScheme.onSurfaceVariant, size: 20)
: Icon(Icons.add, color: Theme.of(context).colorScheme.primary, size: 20),
showDivider: settings.spotifyClientId.isNotEmpty,
),
if (settings.spotifyClientId.isNotEmpty)
SettingsSwitchItem(
icon: Icons.toggle_on,
title: 'Use Custom Credentials',
subtitle: settings.useCustomSpotifyCredentials
? 'Using your credentials'
: 'Using default credentials',
value: settings.useCustomSpotifyCredentials,
onChanged: (v) => ref.read(settingsProvider.notifier).setUseCustomSpotifyCredentials(v),
showDivider: false,
),
],
),
),
// Data section // Data section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Data')), const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Data')),
SliverToBoxAdapter( SliverToBoxAdapter(
@@ -163,6 +196,132 @@ class OptionsSettingsPage extends ConsumerWidget {
), ),
); );
} }
void _showSpotifyCredentialsDialog(BuildContext context, WidgetRef ref, AppSettings settings) {
final clientIdController = TextEditingController(text: settings.spotifyClientId);
final clientSecretController = TextEditingController(text: settings.spotifyClientSecret);
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
builder: (context) => Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: SingleChildScrollView(
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 8),
child: Text('Spotify API Credentials', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'Use your own credentials to avoid rate limiting.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: TextField(
controller: clientIdController,
decoration: InputDecoration(
labelText: 'Client ID',
hintText: 'Enter Spotify Client ID',
filled: true,
fillColor: colorScheme.surfaceContainerLow,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.3))),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.3))),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.primary, width: 2)),
),
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: TextField(
controller: clientSecretController,
obscureText: true,
decoration: InputDecoration(
labelText: 'Client Secret',
hintText: 'Enter Spotify Client Secret',
filled: true,
fillColor: colorScheme.surfaceContainerLow,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.3))),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.3))),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.primary, width: 2)),
),
),
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Row(
children: [
if (settings.spotifyClientId.isNotEmpty)
Expanded(
child: OutlinedButton(
onPressed: () {
ref.read(settingsProvider.notifier).clearSpotifyCredentials();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Credentials cleared')),
);
},
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.error,
side: BorderSide(color: colorScheme.error),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
minimumSize: const Size.fromHeight(52),
),
child: const Text('Clear'),
),
),
if (settings.spotifyClientId.isNotEmpty) const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: () {
final clientId = clientIdController.text.trim();
final clientSecret = clientSecretController.text.trim();
if (clientId.isNotEmpty && clientSecret.isNotEmpty) {
ref.read(settingsProvider.notifier).setSpotifyCredentials(clientId, clientSecret);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Credentials saved')),
);
} else if (clientId.isEmpty && clientSecret.isEmpty) {
Navigator.pop(context);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please fill both Client ID and Secret')),
);
}
},
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
minimumSize: const Size.fromHeight(52),
),
child: const Text('Save'),
),
),
],
),
),
],
),
),
),
),
);
}
} }
class _ConcurrentDownloadsItem extends StatelessWidget { class _ConcurrentDownloadsItem extends StatelessWidget {
+34 -1
View File
@@ -87,10 +87,43 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
PermissionStatus status; PermissionStatus status;
if (_androidSdkVersion >= 33) { if (_androidSdkVersion >= 33) {
// Android 13+: Use audio permission
status = await Permission.audio.request(); status = await Permission.audio.request();
} else if (_androidSdkVersion >= 30) { } else if (_androidSdkVersion >= 30) {
status = await Permission.manageExternalStorage.request(); // Android 11-12: Need MANAGE_EXTERNAL_STORAGE
// This opens system settings, not a dialog
status = await Permission.manageExternalStorage.status;
if (!status.isGranted) {
// Show explanation dialog first
if (mounted) {
final shouldOpen = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Storage Access Required'),
content: const Text(
'Android 11+ requires "All files access" permission to save music files.\n\n'
'Please enable "Allow access to manage all files" in the next screen.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Open Settings'),
),
],
),
);
if (shouldOpen == true) {
status = await Permission.manageExternalStorage.request();
}
}
}
} else { } else {
// Android 10 and below: Use legacy storage permission
status = await Permission.storage.request(); status = await Permission.storage.request();
} }
+13
View File
@@ -65,6 +65,7 @@ class PlatformBridge {
int totalTracks = 1, int totalTracks = 1,
String? releaseDate, String? releaseDate,
String? itemId, String? itemId,
int durationMs = 0,
}) async { }) async {
final request = jsonEncode({ final request = jsonEncode({
'isrc': isrc, 'isrc': isrc,
@@ -85,6 +86,7 @@ class PlatformBridge {
'total_tracks': totalTracks, 'total_tracks': totalTracks,
'release_date': releaseDate ?? '', 'release_date': releaseDate ?? '',
'item_id': itemId ?? '', 'item_id': itemId ?? '',
'duration_ms': durationMs,
}); });
final result = await _channel.invokeMethod('downloadTrack', request); final result = await _channel.invokeMethod('downloadTrack', request);
@@ -111,6 +113,7 @@ class PlatformBridge {
String? releaseDate, String? releaseDate,
String preferredService = 'tidal', String preferredService = 'tidal',
String? itemId, String? itemId,
int durationMs = 0,
}) async { }) async {
final request = jsonEncode({ final request = jsonEncode({
'isrc': isrc, 'isrc': isrc,
@@ -131,6 +134,7 @@ class PlatformBridge {
'total_tracks': totalTracks, 'total_tracks': totalTracks,
'release_date': releaseDate ?? '', 'release_date': releaseDate ?? '',
'item_id': itemId ?? '', 'item_id': itemId ?? '',
'duration_ms': durationMs,
}); });
final result = await _channel.invokeMethod('downloadWithFallback', request); final result = await _channel.invokeMethod('downloadWithFallback', request);
@@ -284,4 +288,13 @@ class PlatformBridge {
final result = await _channel.invokeMethod('isDownloadServiceRunning'); final result = await _channel.invokeMethod('isDownloadServiceRunning');
return result as bool; return result as bool;
} }
/// Set custom Spotify API credentials
/// Pass empty strings to use default credentials
static Future<void> setSpotifyCredentials(String clientId, String clientSecret) async {
await _channel.invokeMethod('setSpotifyCredentials', {
'client_id': clientId,
'client_secret': clientSecret,
});
}
} }
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: 'none' publish_to: 'none'
version: 2.0.2+32 version: 2.0.5+35
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0