Compare commits

...

11 Commits

Author SHA1 Message Date
zarzet 525f2fd0cd chore: bump version to 2.0.6+36 2026-01-05 12:21:05 +07:00
zarzet 3e841cef06 fix: duration display, audio quality from file, artist verification, metadata case-sensitivity, settings navigation freeze
- Fix duration showing incorrect values (ms to seconds conversion)
- Read audio quality from FLAC file instead of trusting API
- Add artist verification for Tidal/Qobuz/Amazon downloads
- Fix FLAC metadata case-insensitive replacement
- Fix settings navigation freeze on Android 14+ (PopScope handling)
2026-01-05 10:30:57 +07:00
zarzet a8527df80a docs: application stabilized, remove dev notice 2026-01-05 03:12:30 +07:00
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
32 changed files with 1485 additions and 297 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
+53
View File
@@ -1,5 +1,58 @@
# Changelog # Changelog
## [2.0.6] - 2026-01-05
### Fixed
- **Duration Display Bug**: Fixed duration showing incorrect values like "4135:53" instead of "4:14"
- `duration_ms` (milliseconds) was being stored directly without conversion to seconds
- Now properly converts milliseconds to seconds before display
- **Audio Quality from File**: Quality info (bit depth/sample rate) now read from actual FLAC file instead of trusting API
- More accurate quality display for all services (Tidal, Qobuz, Amazon)
- Also reads quality from existing files when skipping duplicates
- **Artist Verification for Downloads**: Added artist name verification to prevent downloading wrong tracks
- Verifies artist matches between Spotify metadata and streaming service
- Handles different scripts (Japanese/Chinese vs Latin) as same artist with different transliteration
- Applied to Tidal, Qobuz, and Amazon downloads
- **Metadata Case-Sensitivity**: Fixed FLAC metadata not being properly overwritten when downloaded file has lowercase tags
- Now uses case-insensitive comparison when replacing existing Vorbis comments
- Fixes issue where Amazon downloads could have duplicate metadata tags
- **Settings Navigation Freeze**: Fixed app freezing when navigating back from settings sub-menus on some devices
- Added proper PopScope handling for predictive back gesture on Android 14+
## [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 -6
View File
@@ -11,17 +11,15 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
</div> </div>
> **Active Development Notice**: This app is under heavy development. New builds may be pushed multiple times daily. If frequent update notifications are annoying, tap "Don't remind" when the update dialog appears, or disable update checks in Settings.
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases) ### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
## 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

+66
View File
@@ -36,6 +36,63 @@ type DoubleDoubleStatusResponse struct {
} `json:"current"` } `json:"current"`
} }
// amazonArtistsMatch checks if the artist names are similar enough
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
}
// Check if one contains the other
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
return true
}
// Check first artist (before comma or feat)
expectedFirst := strings.Split(normExpected, ",")[0]
expectedFirst = strings.Split(expectedFirst, " feat")[0]
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
expectedFirst = strings.TrimSpace(expectedFirst)
foundFirst := strings.Split(normFound, ",")[0]
foundFirst = strings.Split(foundFirst, " feat")[0]
foundFirst = strings.Split(foundFirst, " ft.")[0]
foundFirst = strings.TrimSpace(foundFirst)
if expectedFirst == foundFirst {
return true
}
// Check if first artist is contained in the other
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
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 {
fmt.Printf("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
return true
}
return false
}
// amazonIsASCIIString checks if a string contains only ASCII characters
func amazonIsASCIIString(s string) bool {
for _, r := range s {
if r > 127 {
return false
}
}
return true
}
// NewAmazonDownloader creates a new Amazon downloader using DoubleDouble service // NewAmazonDownloader creates a new Amazon downloader using DoubleDouble service
func NewAmazonDownloader() *AmazonDownloader { func NewAmazonDownloader() *AmazonDownloader {
return &AmazonDownloader{ return &AmazonDownloader{
@@ -295,6 +352,15 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err) return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
} }
// Verify artist matches
if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) {
fmt.Printf("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, artistName)
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
}
// Log match found
fmt.Printf("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
// Build filename using Spotify metadata (more accurate) // Build filename using Spotify metadata (more accurate)
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName, "title": req.TrackName,
+57 -11
View File
@@ -5,6 +5,7 @@ package gobackend
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"strings" "strings"
"time" "time"
) )
@@ -30,6 +31,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 +133,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
@@ -209,17 +217,36 @@ func DownloadTrack(requestJSON string) (string, error) {
// Check if file already exists // Check if file already exists
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" { if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
actualPath := result.FilePath[7:]
// Read actual quality from existing file
quality, qErr := GetAudioQuality(actualPath)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
}
resp := DownloadResponse{ resp := DownloadResponse{
Success: true, Success: true,
Message: "File already exists", Message: "File already exists",
FilePath: result.FilePath[7:], FilePath: actualPath,
AlreadyExists: true, AlreadyExists: true,
Service: req.Service, ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate,
Service: req.Service,
} }
jsonBytes, _ := json.Marshal(resp) jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// Read actual quality from downloaded file (more accurate than API)
quality, qErr := GetAudioQuality(result.FilePath)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
} else {
fmt.Printf("[Download] Could not read quality from file: %v\n", qErr)
}
resp := DownloadResponse{ resp := DownloadResponse{
Success: true, Success: true,
Message: "Download complete", Message: "Download complete",
@@ -307,17 +334,36 @@ func DownloadWithFallback(requestJSON string) (string, error) {
if err == nil { if err == nil {
// Check if file already exists // Check if file already exists
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" { if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
actualPath := result.FilePath[7:]
// Read actual quality from existing file
quality, qErr := GetAudioQuality(actualPath)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
}
resp := DownloadResponse{ resp := DownloadResponse{
Success: true, Success: true,
Message: "File already exists", Message: "File already exists",
FilePath: result.FilePath[7:], FilePath: actualPath,
AlreadyExists: true, AlreadyExists: true,
Service: service, ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate,
Service: service,
} }
jsonBytes, _ := json.Marshal(resp) jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// Read actual quality from downloaded file (more accurate than API)
quality, qErr := GetAudioQuality(result.FilePath)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
} else {
fmt.Printf("[Download] Could not read quality from file: %v\n", qErr)
}
resp := DownloadResponse{ resp := DownloadResponse{
Success: true, Success: true,
Message: "Downloaded from " + service, Message: "Downloaded from " + service,
+10 -3
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"strconv" "strconv"
"strings"
"github.com/go-flac/flacpicture" "github.com/go-flac/flacpicture"
"github.com/go-flac/flacvorbis" "github.com/go-flac/flacvorbis"
@@ -273,10 +274,16 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
if value == "" { if value == "" {
return return
} }
// Remove existing // Remove existing (case-insensitive comparison for Vorbis comments)
keyUpper := strings.ToUpper(key)
for i := len(cmt.Comments) - 1; i >= 0; i-- { for i := len(cmt.Comments) - 1; i >= 0; i-- {
if len(cmt.Comments[i]) > len(key)+1 && cmt.Comments[i][:len(key)+1] == key+"=" { comment := cmt.Comments[i]
cmt.Comments = append(cmt.Comments[:i], cmt.Comments[i+1:]...) eqIdx := strings.Index(comment, "=")
if eqIdx > 0 {
existingKey := strings.ToUpper(comment[:eqIdx])
if existingKey == keyUpper {
cmt.Comments = append(cmt.Comments[:i], cmt.Comments[i+1:]...)
}
} }
} }
// Add new // Add new
+208 -11
View File
@@ -9,6 +9,7 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings"
) )
// QobuzDownloader handles Qobuz downloads // QobuzDownloader handles Qobuz downloads
@@ -39,6 +40,63 @@ type QobuzTrack struct {
} `json:"performer"` } `json:"performer"`
} }
// qobuzArtistsMatch checks if the artist names are similar enough
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
// Exact match
if normExpected == normFound {
return true
}
// Check if one contains the other
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
return true
}
// Check first artist (before comma or feat)
expectedFirst := strings.Split(normExpected, ",")[0]
expectedFirst = strings.Split(expectedFirst, " feat")[0]
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
expectedFirst = strings.TrimSpace(expectedFirst)
foundFirst := strings.Split(normFound, ",")[0]
foundFirst = strings.Split(foundFirst, " feat")[0]
foundFirst = strings.Split(foundFirst, " ft.")[0]
foundFirst = strings.TrimSpace(foundFirst)
if expectedFirst == foundFirst {
return true
}
// Check if first artist is contained in the other
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
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 := qobuzIsASCIIString(expectedArtist)
foundASCII := qobuzIsASCIIString(foundArtist)
if expectedASCII != foundASCII {
fmt.Printf("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
return true
}
return false
}
// qobuzIsASCIIString checks if a string contains only ASCII characters
func qobuzIsASCIIString(s string) bool {
for _, r := range s {
if r > 127 {
return false
}
}
return true
}
// NewQobuzDownloader creates a new Qobuz downloader // NewQobuzDownloader creates a new Qobuz downloader
func NewQobuzDownloader() *QobuzDownloader { func NewQobuzDownloader() *QobuzDownloader {
return &QobuzDownloader{ return &QobuzDownloader{
@@ -112,8 +170,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 +275,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 +307,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,27 +500,45 @@ 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)
// Verify artist
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
fmt.Printf("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, track.Performer.Name)
track = nil
}
} }
// 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)
// Verify artist
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
fmt.Printf("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, track.Performer.Name)
track = nil
}
} }
if track == nil { if track == nil {
errMsg := "could not find track on Qobuz" errMsg := "could not find matching track on Qobuz (artist/duration mismatch)"
if err != nil { if err != nil {
errMsg = err.Error() errMsg = err.Error()
} }
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg) return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
} }
// Log match found
fmt.Printf("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
// 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 {
@@ -80,6 +101,16 @@ func NewSpotifyMetadataClient() *SpotifyMetadataClient {
clientSecret = string(decoded) clientSecret = string(decoded)
} }
} }
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
@@ -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,
+200 -6
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)
} }
@@ -811,6 +869,64 @@ type TidalDownloadResult struct {
SampleRate int SampleRate int
} }
// artistsMatch checks if the artist names are similar enough
func artistsMatch(spotifyArtist, tidalArtist string) bool {
normSpotify := strings.ToLower(strings.TrimSpace(spotifyArtist))
normTidal := strings.ToLower(strings.TrimSpace(tidalArtist))
// Exact match
if normSpotify == normTidal {
return true
}
// Check if one contains the other (for cases like "Artist" vs "Artist feat. Someone")
if strings.Contains(normSpotify, normTidal) || strings.Contains(normTidal, normSpotify) {
return true
}
// Check first artist (before comma or feat)
spotifyFirst := strings.Split(normSpotify, ",")[0]
spotifyFirst = strings.Split(spotifyFirst, " feat")[0]
spotifyFirst = strings.Split(spotifyFirst, " ft.")[0]
spotifyFirst = strings.TrimSpace(spotifyFirst)
tidalFirst := strings.Split(normTidal, ",")[0]
tidalFirst = strings.Split(tidalFirst, " feat")[0]
tidalFirst = strings.Split(tidalFirst, " ft.")[0]
tidalFirst = strings.TrimSpace(tidalFirst)
if spotifyFirst == tidalFirst {
return true
}
// Check if first artist is contained in the other
if strings.Contains(spotifyFirst, tidalFirst) || strings.Contains(tidalFirst, spotifyFirst) {
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
// This handles cases like "鈴木雅之" vs "Masayuki Suzuki"
spotifyASCII := isASCIIString(spotifyArtist)
tidalASCII := isASCIIString(tidalArtist)
if spotifyASCII != tidalASCII {
fmt.Printf("[Tidal] Artist names in different scripts, assuming match: '%s' vs '%s'\n", spotifyArtist, tidalArtist)
return true
}
return false
}
// isASCIIString checks if a string contains only ASCII characters
func isASCIIString(s string) bool {
for _, r := range s {
if r > 127 {
return false
}
}
return true
}
// downloadFromTidal downloads a track using the request parameters // downloadFromTidal downloads a track using the request parameters
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
downloader := NewTidalDownloader() downloader := NewTidalDownloader()
@@ -820,6 +936,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,28 +950,103 @@ 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)
if track != nil {
// Get artist name from track
tidalArtist := track.Artist.Name
if len(track.Artists) > 0 {
var artistNames []string
for _, a := range track.Artists {
artistNames = append(artistNames, a.Name)
}
tidalArtist = strings.Join(artistNames, ", ")
}
// Verify artist matches
if !artistsMatch(req.ArtistName, tidalArtist) {
fmt.Printf("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, tidalArtist)
track = nil
}
// 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)
// Verify artist for ISRC match too
if track != nil {
tidalArtist := track.Artist.Name
if len(track.Artists) > 0 {
var artistNames []string
for _, a := range track.Artists {
artistNames = append(artistNames, a.Name)
}
tidalArtist = strings.Join(artistNames, ", ")
}
if !artistsMatch(req.ArtistName, tidalArtist) {
fmt.Printf("[Tidal] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, tidalArtist)
track = nil
}
}
} }
// 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)
// Verify artist for metadata search too
if track != nil {
tidalArtist := track.Artist.Name
if len(track.Artists) > 0 {
var artistNames []string
for _, a := range track.Artists {
artistNames = append(artistNames, a.Name)
}
tidalArtist = strings.Join(artistNames, ", ")
}
if !artistsMatch(req.ArtistName, tidalArtist) {
fmt.Printf("[Tidal] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, tidalArtist)
track = nil
}
}
} }
if track == nil { if track == nil {
errMsg := "could not find track on Tidal" errMsg := "could not find matching track on Tidal (artist/duration mismatch)"
if err != nil { if err != nil {
errMsg = err.Error() errMsg = err.Error()
} }
return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg) return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg)
} }
// Final verification logging
tidalArtist := track.Artist.Name
if len(track.Artists) > 0 {
var artistNames []string
for _, a := range track.Artists {
artistNames = append(artistNames, a.Name)
}
tidalArtist = strings.Join(artistNames, ", ")
}
fmt.Printf("[Tidal] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, tidalArtist, track.Duration)
// 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.6';
static const String buildNumber = '32'; static const String buildNumber = '36';
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>(
+11 -6
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);
} }
} }
@@ -261,7 +266,7 @@ class TrackNotifier extends Notifier<TrackState> {
albumArtist: data['album_artist'] as String?, albumArtist: data['album_artist'] as String?,
coverUrl: data['images'] as String?, coverUrl: data['images'] as String?,
isrc: data['isrc'] as String?, isrc: data['isrc'] as String?,
duration: data['duration_ms'] as int? ?? 0, duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?, discNumber: data['disc_number'] as int?,
releaseDate: data['release_date'] as String?, releaseDate: data['release_date'] as String?,
@@ -277,7 +282,7 @@ class TrackNotifier extends Notifier<TrackState> {
albumArtist: data['album_artist'] as String?, albumArtist: data['album_artist'] as String?,
coverUrl: data['images'] as String?, coverUrl: data['images'] as String?,
isrc: data['isrc'] as String?, isrc: data['isrc'] as String?,
duration: data['duration_ms'] as int? ?? 0, duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?, discNumber: data['disc_number'] as int?,
releaseDate: data['release_date'] as String?, releaseDate: data['release_date'] as String?,
+66 -3
View File
@@ -104,7 +104,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
albumArtist: data['album_artist'] as String?, albumArtist: data['album_artist'] as String?,
coverUrl: data['images'] as String?, coverUrl: data['images'] as String?,
isrc: data['isrc'] as String?, isrc: data['isrc'] as String?,
duration: data['duration_ms'] as int? ?? 0, duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?, discNumber: data['disc_number'] as int?,
releaseDate: data['release_date'] as String?, releaseDate: data['release_date'] as String?,
@@ -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,
+38 -44
View File
@@ -12,53 +12,46 @@ class AboutPage extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = MediaQuery.of(context).padding.top;
return Scaffold( return PopScope(
body: CustomScrollView( canPop: true,
slivers: [ child: Scaffold(
// Collapsing App Bar with back button body: CustomScrollView(
SliverAppBar( slivers: [
expandedHeight: 120 + topPadding, // Collapsing App Bar with back button
collapsedHeight: kToolbarHeight, SliverAppBar(
floating: false, expandedHeight: 120 + topPadding,
pinned: true, collapsedHeight: kToolbarHeight,
backgroundColor: colorScheme.surface, floating: false,
surfaceTintColor: Colors.transparent, pinned: true,
leading: IconButton( backgroundColor: colorScheme.surface,
icon: const Icon(Icons.arrow_back), surfaceTintColor: Colors.transparent,
onPressed: () => Navigator.pop(context), leading: IconButton(
), icon: const Icon(Icons.arrow_back),
flexibleSpace: LayoutBuilder( onPressed: () => Navigator.pop(context),
builder: (context, constraints) { ),
final maxHeight = 120 + topPadding; flexibleSpace: LayoutBuilder(
final minHeight = kToolbarHeight + topPadding; builder: (context, constraints) {
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0); final maxHeight = 120 + topPadding;
final animation = AlwaysStoppedAnimation(expandRatio); final minHeight = kToolbarHeight + topPadding;
return FlexibleSpaceBar( final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
expandedTitleScale: 1.0, // When collapsed (expandRatio=0): left=56 to avoid back button
titlePadding: EdgeInsets.zero, // When expanded (expandRatio=1): left=24 for normal padding
title: SafeArea( final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
child: Container( return FlexibleSpaceBar(
alignment: Alignment.bottomLeft, expandedTitleScale: 1.0,
padding: EdgeInsets.only( titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
// When collapsed (expandRatio=0): left=56 to align with back button title: Text(
// When expanded (expandRatio=1): left=24 for normal padding 'About',
left: Tween<double>(begin: 56, end: 24).evaluate(animation), style: TextStyle(
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation), fontSize: 20 + (8 * expandRatio), // 20 -> 28
), fontWeight: FontWeight.bold,
child: Text( color: colorScheme.onSurface,
'About',
style: TextStyle(
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
), ),
), ),
), );
); },
}, ),
), ),
),
// App header card with logo and description // App header card with logo and description
SliverToBoxAdapter( SliverToBoxAdapter(
@@ -166,6 +159,7 @@ class AboutPage extends StatelessWidget {
const SliverToBoxAdapter(child: SizedBox(height: 16)), const SliverToBoxAdapter(child: SizedBox(height: 16)),
], ],
), ),
),
); );
} }
@@ -14,104 +14,113 @@ class AppearanceSettingsPage extends ConsumerWidget {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = MediaQuery.of(context).padding.top;
return Scaffold( return PopScope(
body: CustomScrollView( canPop: true,
slivers: [ child: Scaffold(
// Collapsing App Bar with back button body: CustomScrollView(
SliverAppBar( slivers: [
expandedHeight: 120 + topPadding, // Collapsing App Bar with back button
collapsedHeight: kToolbarHeight, SliverAppBar(
floating: false, expandedHeight: 120 + topPadding,
pinned: true, collapsedHeight: kToolbarHeight,
backgroundColor: colorScheme.surface, floating: false,
surfaceTintColor: Colors.transparent, pinned: true,
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)), backgroundColor: colorScheme.surface,
flexibleSpace: LayoutBuilder( surfaceTintColor: Colors.transparent,
builder: (context, constraints) { leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
final maxHeight = 120 + topPadding; flexibleSpace: _AppBarTitle(title: 'Appearance', topPadding: topPadding),
final minHeight = kToolbarHeight + topPadding; ),
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
final animation = AlwaysStoppedAnimation(expandRatio); // Theme section
return FlexibleSpaceBar( const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Theme')),
expandedTitleScale: 1.0, SliverToBoxAdapter(
titlePadding: EdgeInsets.zero, child: SettingsGroup(
title: SafeArea( children: [
child: Container( _ThemeModeSelector(
alignment: Alignment.bottomLeft, currentMode: themeSettings.themeMode,
padding: EdgeInsets.only( onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
left: Tween<double>(begin: 56, end: 24).evaluate(animation), ),
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation), ],
), ),
child: Text('Appearance', ),
style: TextStyle(
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation), // Color section
fontWeight: FontWeight.bold, const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Color')),
color: colorScheme.onSurface, SliverToBoxAdapter(
), child: SettingsGroup(
), children: [
SettingsSwitchItem(
icon: Icons.auto_awesome,
title: 'Dynamic Color',
subtitle: 'Use colors from your wallpaper',
value: themeSettings.useDynamicColor,
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
showDivider: !themeSettings.useDynamicColor,
),
if (!themeSettings.useDynamicColor)
_ColorPicker(
currentColor: themeSettings.seedColorValue,
onColorSelected: (color) => ref.read(themeProvider.notifier).setSeedColor(color),
), ),
],
),
),
// Layout section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Layout')),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_HistoryViewSelector(
currentMode: settings.historyViewMode,
onChanged: (mode) => ref.read(settingsProvider.notifier).setHistoryViewMode(mode),
), ),
); ],
}, ),
), ),
),
// Theme section // Fill remaining for scroll
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Theme')), const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
SliverToBoxAdapter( ],
child: SettingsGroup( ),
children: [
_ThemeModeSelector(
currentMode: themeSettings.themeMode,
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
),
],
),
),
// Color section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Color')),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.auto_awesome,
title: 'Dynamic Color',
subtitle: 'Use colors from your wallpaper',
value: themeSettings.useDynamicColor,
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
showDivider: !themeSettings.useDynamicColor,
),
if (!themeSettings.useDynamicColor)
_ColorPicker(
currentColor: themeSettings.seedColorValue,
onColorSelected: (color) => ref.read(themeProvider.notifier).setSeedColor(color),
),
],
),
),
// Layout section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Layout')),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_HistoryViewSelector(
currentMode: settings.historyViewMode,
onChanged: (mode) => ref.read(settingsProvider.notifier).setHistoryViewMode(mode),
),
],
),
),
// Fill remaining for scroll
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
],
), ),
); );
} }
} }
/// Optimized app bar title with animation
class _AppBarTitle extends StatelessWidget {
final String title;
final double topPadding;
const _AppBarTitle({required this.title, required this.topPadding});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
title,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
);
}
}
class _ThemeModeSelector extends StatelessWidget { class _ThemeModeSelector extends StatelessWidget {
final ThemeMode currentMode; final ThemeMode currentMode;
final ValueChanged<ThemeMode> onChanged; final ValueChanged<ThemeMode> onChanged;
@@ -13,47 +13,41 @@ class DownloadSettingsPage extends ConsumerWidget {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = MediaQuery.of(context).padding.top;
return Scaffold( return PopScope(
body: CustomScrollView( canPop: true,
slivers: [ child: Scaffold(
// Collapsing App Bar with back button body: CustomScrollView(
SliverAppBar( slivers: [
expandedHeight: 120 + topPadding, // Collapsing App Bar with back button
collapsedHeight: kToolbarHeight, SliverAppBar(
floating: false, expandedHeight: 120 + topPadding,
pinned: true, collapsedHeight: kToolbarHeight,
backgroundColor: colorScheme.surface, floating: false,
surfaceTintColor: Colors.transparent, pinned: true,
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)), backgroundColor: colorScheme.surface,
flexibleSpace: LayoutBuilder( surfaceTintColor: Colors.transparent,
builder: (context, constraints) { leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
final maxHeight = 120 + topPadding; flexibleSpace: LayoutBuilder(
final minHeight = kToolbarHeight + topPadding; builder: (context, constraints) {
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0); final maxHeight = 120 + topPadding;
final animation = AlwaysStoppedAnimation(expandRatio); final minHeight = kToolbarHeight + topPadding;
return FlexibleSpaceBar( final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
expandedTitleScale: 1.0, final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
titlePadding: EdgeInsets.zero, return FlexibleSpaceBar(
title: SafeArea( expandedTitleScale: 1.0,
child: Container( titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
alignment: Alignment.bottomLeft, title: Text(
padding: EdgeInsets.only( 'Download',
left: Tween<double>(begin: 56, end: 24).evaluate(animation), style: TextStyle(
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation), fontSize: 20 + (8 * expandRatio), // 20 -> 28
), fontWeight: FontWeight.bold,
child: Text('Download', color: colorScheme.onSurface,
style: TextStyle(
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
), ),
), ),
), );
); },
}, ),
), ),
),
// Service section // Service section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Service')), const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Service')),
@@ -136,6 +130,7 @@ class DownloadSettingsPage extends ConsumerWidget {
const SliverToBoxAdapter(child: SizedBox(height: 32)), const SliverToBoxAdapter(child: SizedBox(height: 32)),
], ],
), ),
),
); );
} }
+192 -38
View File
@@ -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';
@@ -13,47 +14,41 @@ class OptionsSettingsPage extends ConsumerWidget {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = MediaQuery.of(context).padding.top;
return Scaffold( return PopScope(
body: CustomScrollView( canPop: true,
slivers: [ child: Scaffold(
// Collapsing App Bar with back button body: CustomScrollView(
SliverAppBar( slivers: [
expandedHeight: 120 + topPadding, // Collapsing App Bar with back button
collapsedHeight: kToolbarHeight, SliverAppBar(
floating: false, expandedHeight: 120 + topPadding,
pinned: true, collapsedHeight: kToolbarHeight,
backgroundColor: colorScheme.surface, floating: false,
surfaceTintColor: Colors.transparent, pinned: true,
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)), backgroundColor: colorScheme.surface,
flexibleSpace: LayoutBuilder( surfaceTintColor: Colors.transparent,
builder: (context, constraints) { leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
final maxHeight = 120 + topPadding; flexibleSpace: LayoutBuilder(
final minHeight = kToolbarHeight + topPadding; builder: (context, constraints) {
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0); final maxHeight = 120 + topPadding;
final animation = AlwaysStoppedAnimation(expandRatio); final minHeight = kToolbarHeight + topPadding;
return FlexibleSpaceBar( final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
expandedTitleScale: 1.0, final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
titlePadding: EdgeInsets.zero, return FlexibleSpaceBar(
title: SafeArea( expandedTitleScale: 1.0,
child: Container( titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
alignment: Alignment.bottomLeft, title: Text(
padding: EdgeInsets.only( 'Options',
left: Tween<double>(begin: 56, end: 24).evaluate(animation), style: TextStyle(
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation), fontSize: 20 + (8 * expandRatio), // 20 -> 28
), fontWeight: FontWeight.bold,
child: Text('Options', color: colorScheme.onSurface,
style: TextStyle(
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
), ),
), ),
), );
); },
}, ),
), ),
),
// Download options section // Download options section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Download')), const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Download')),
@@ -116,6 +111,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(
@@ -135,6 +162,7 @@ class OptionsSettingsPage extends ConsumerWidget {
const SliverToBoxAdapter(child: SizedBox(height: 32)), const SliverToBoxAdapter(child: SizedBox(height: 32)),
], ],
), ),
),
); );
} }
@@ -163,6 +191,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.6+36
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0