Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51b2ad5c77 | |||
| d641a517b8 | |||
| 608fa2ca74 | |||
| 343b309314 | |||
| 0787b32dd8 | |||
| 6927fdf7a9 | |||
| fe6af34478 | |||
| 85bb67da47 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,39 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [2.0.5] - 2026-01-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Large Playlist Support**: Playlists with up to 1000 tracks are now fully fetched (was limited to 100)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Wrong Track Download**: Fixed issue where tracks with same ISRC but different versions (e.g., short/instrumental vs full version) would download the wrong track. Now verifies duration matches before downloading (30 second tolerance).
|
||||||
|
|
||||||
|
## [2.0.4] - 2026-01-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Android 11 Storage Permission**: Fixed "Permission denied" error on Android 11 (API 30) devices
|
||||||
|
- Added `MANAGE_EXTERNAL_STORAGE` permission for Android 11-12
|
||||||
|
- Shows explanation dialog before opening system settings
|
||||||
|
|
||||||
|
## [2.0.3] - 2026-01-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Custom Spotify API Credentials**: Set your own Spotify Client ID and Secret in Settings > Options to avoid rate limiting
|
||||||
|
- Toggle to enable/disable custom credentials without deleting them
|
||||||
|
- Material Expressive 3 bottom sheet UI for entering credentials
|
||||||
|
- **Keyboard Dismiss on Scroll**: Keyboard now automatically dismisses when scrolling search results
|
||||||
|
- **Rate Limit Error UI**: Shows friendly error card when API rate limit (429) is hit on Home, Artist, and Album screens
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Search on Enter Only**: Removed auto-search debounce, now only searches when pressing Enter key (saves API calls)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Download Cancel**: Fixed cancelled downloads still completing in background and appearing in history. Cancelled files are now properly deleted.
|
||||||
|
- **Search Keyboard Dismiss**: Fixed keyboard randomly dismissing and navigating back when starting to search
|
||||||
|
- **Back Button During Search**: Back button now properly dismisses keyboard first before clearing search
|
||||||
|
- **Search Error Navigation**: Fixed pressing Enter during search (when loading or error) navigating back to home instead of staying on search screen
|
||||||
|
- **Duplicate Search on Enter**: Enter key no longer triggers duplicate search if results already loaded
|
||||||
|
|
||||||
## [2.0.2] - 2026-01-03
|
## [2.0.2] - 2026-01-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
|||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="assets/images/1.jpg" width="200" />
|
<img src="assets/images/1.jpg?v=2" width="200" />
|
||||||
<img src="assets/images/2.jpg" width="200" />
|
<img src="assets/images/2.jpg?v=2" width="200" />
|
||||||
<img src="assets/images/3.jpg" width="200" />
|
<img src="assets/images/3.jpg?v=2" width="200" />
|
||||||
<img src="assets/images/4.jpg" width="200" />
|
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Other project
|
## Other project
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 300 KiB After Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 135 KiB |
@@ -30,6 +30,12 @@ func ParseSpotifyURL(url string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSpotifyAPICredentials sets custom Spotify API credentials from Flutter
|
||||||
|
// Pass empty strings to use default credentials
|
||||||
|
func SetSpotifyAPICredentials(clientID, clientSecret string) {
|
||||||
|
SetSpotifyCredentials(clientID, clientSecret)
|
||||||
|
}
|
||||||
|
|
||||||
// GetSpotifyMetadata fetches metadata from Spotify URL
|
// GetSpotifyMetadata fetches metadata from Spotify URL
|
||||||
// Returns JSON with track/album/playlist data
|
// Returns JSON with track/album/playlist data
|
||||||
func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
||||||
@@ -126,7 +132,8 @@ type DownloadRequest struct {
|
|||||||
DiscNumber int `json:"disc_number"`
|
DiscNumber int `json:"disc_number"`
|
||||||
TotalTracks int `json:"total_tracks"`
|
TotalTracks int `json:"total_tracks"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
ItemID string `json:"item_id"` // Unique ID for progress tracking
|
ItemID string `json:"item_id"` // Unique ID for progress tracking
|
||||||
|
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadResponse represents the result of a download
|
// DownloadResponse represents the result of a download
|
||||||
|
|||||||
@@ -112,8 +112,96 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
|
|||||||
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchTrackByISRCWithTitle searches for a track by ISRC with duration verification
|
||||||
|
// expectedDurationSec is the expected duration in seconds (0 to skip verification)
|
||||||
|
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||||
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||||
|
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", searchURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := DoRequestWithUserAgent(q.client, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Tracks struct {
|
||||||
|
Items []QobuzTrack `json:"items"`
|
||||||
|
} `json:"tracks"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find ISRC matches
|
||||||
|
var isrcMatches []*QobuzTrack
|
||||||
|
for i := range result.Tracks.Items {
|
||||||
|
if result.Tracks.Items[i].ISRC == isrc {
|
||||||
|
isrcMatches = append(isrcMatches, &result.Tracks.Items[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(isrcMatches) > 0 {
|
||||||
|
// Verify duration if provided
|
||||||
|
if expectedDurationSec > 0 {
|
||||||
|
var durationVerifiedMatches []*QobuzTrack
|
||||||
|
for _, track := range isrcMatches {
|
||||||
|
durationDiff := track.Duration - expectedDurationSec
|
||||||
|
if durationDiff < 0 {
|
||||||
|
durationDiff = -durationDiff
|
||||||
|
}
|
||||||
|
// Allow 30 seconds tolerance
|
||||||
|
if durationDiff <= 30 {
|
||||||
|
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(durationVerifiedMatches) > 0 {
|
||||||
|
fmt.Printf("[Qobuz] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
||||||
|
durationVerifiedMatches[0].Title, expectedDurationSec, durationVerifiedMatches[0].Duration)
|
||||||
|
return durationVerifiedMatches[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISRC matches but duration doesn't
|
||||||
|
fmt.Printf("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
|
||||||
|
isrc, expectedDurationSec, isrcMatches[0].Duration)
|
||||||
|
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)",
|
||||||
|
expectedDurationSec, isrcMatches[0].Duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No duration to verify, return first match
|
||||||
|
fmt.Printf("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
||||||
|
return isrcMatches[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Tracks.Items) == 0 {
|
||||||
|
return nil, fmt.Errorf("no tracks found for ISRC: %s", isrc)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchTrackByISRCWithTitle is deprecated, use SearchTrackByISRCWithDuration instead
|
||||||
|
func (q *QobuzDownloader) SearchTrackByISRCWithTitle(isrc, expectedTitle string) (*QobuzTrack, error) {
|
||||||
|
return q.SearchTrackByISRCWithDuration(isrc, 0)
|
||||||
|
}
|
||||||
|
|
||||||
// SearchTrackByMetadata searches for a track using artist name and track name
|
// SearchTrackByMetadata searches for a track using artist name and track name
|
||||||
func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) {
|
func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) {
|
||||||
|
return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchTrackByMetadataWithDuration searches for a track with duration verification
|
||||||
|
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||||
|
|
||||||
// Try multiple search strategies
|
// Try multiple search strategies
|
||||||
@@ -129,6 +217,8 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
|
|||||||
queries = append(queries, trackName)
|
queries = append(queries, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var allTracks []QobuzTrack
|
||||||
|
|
||||||
for _, query := range queries {
|
for _, query := range queries {
|
||||||
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(query), q.appID)
|
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(query), q.appID)
|
||||||
|
|
||||||
@@ -159,19 +249,50 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
|
|||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|
||||||
if len(result.Tracks.Items) > 0 {
|
if len(result.Tracks.Items) > 0 {
|
||||||
// Return first result with best quality
|
allTracks = append(allTracks, result.Tracks.Items...)
|
||||||
for i := range result.Tracks.Items {
|
}
|
||||||
track := &result.Tracks.Items[i]
|
}
|
||||||
|
|
||||||
|
if len(allTracks) == 0 {
|
||||||
|
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If duration verification is requested
|
||||||
|
if expectedDurationSec > 0 {
|
||||||
|
var durationMatches []*QobuzTrack
|
||||||
|
for i := range allTracks {
|
||||||
|
track := &allTracks[i]
|
||||||
|
durationDiff := track.Duration - expectedDurationSec
|
||||||
|
if durationDiff < 0 {
|
||||||
|
durationDiff = -durationDiff
|
||||||
|
}
|
||||||
|
if durationDiff <= 30 {
|
||||||
|
durationMatches = append(durationMatches, track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(durationMatches) > 0 {
|
||||||
|
// Return best quality among duration matches
|
||||||
|
for _, track := range durationMatches {
|
||||||
if track.MaximumBitDepth >= 24 {
|
if track.MaximumBitDepth >= 24 {
|
||||||
return track, nil
|
return track, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Return first result if no hi-res found
|
return durationMatches[0], nil
|
||||||
return &result.Tracks.Items[0], nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No duration match found
|
||||||
|
return nil, fmt.Errorf("no tracks found with matching duration (expected %ds)", expectedDurationSec)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
|
// No duration verification, return best quality
|
||||||
|
for i := range allTracks {
|
||||||
|
track := &allTracks[i]
|
||||||
|
if track.MaximumBitDepth >= 24 {
|
||||||
|
return track, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &allTracks[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getQobuzDownloadURLSequential requests download URL from APIs sequentially
|
// getQobuzDownloadURLSequential requests download URL from APIs sequentially
|
||||||
@@ -321,17 +442,20 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert expected duration from ms to seconds
|
||||||
|
expectedDurationSec := req.DurationMS / 1000
|
||||||
|
|
||||||
var track *QobuzTrack
|
var track *QobuzTrack
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// Strategy 1: Search by ISRC
|
// Strategy 1: Search by ISRC with duration verification
|
||||||
if req.ISRC != "" {
|
if req.ISRC != "" {
|
||||||
track, err = downloader.SearchTrackByISRC(req.ISRC)
|
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: Search by metadata
|
// Strategy 2: Search by metadata with duration verification
|
||||||
if track == nil {
|
if track == nil {
|
||||||
track, err = downloader.SearchTrackByMetadata(req.TrackName, req.ArtistName)
|
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
|
||||||
}
|
}
|
||||||
|
|
||||||
if track == nil {
|
if track == nil {
|
||||||
@@ -342,6 +466,20 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
|
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Final duration verification
|
||||||
|
if expectedDurationSec > 0 {
|
||||||
|
durationDiff := track.Duration - expectedDurationSec
|
||||||
|
if durationDiff < 0 {
|
||||||
|
durationDiff = -durationDiff
|
||||||
|
}
|
||||||
|
if durationDiff > 30 {
|
||||||
|
return QobuzDownloadResult{}, fmt.Errorf("duration mismatch: expected %ds, found %ds (diff: %ds). Track may be wrong version",
|
||||||
|
expectedDurationSec, track.Duration, durationDiff)
|
||||||
|
}
|
||||||
|
fmt.Printf("[Qobuz] Duration verified: expected %ds, found %ds (diff: %ds)\n",
|
||||||
|
expectedDurationSec, track.Duration, durationDiff)
|
||||||
|
}
|
||||||
|
|
||||||
// Build filename
|
// Build filename
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -315,6 +315,28 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
|
|||||||
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeTitle normalizes a track title for comparison (kept for potential future use)
|
||||||
|
func normalizeTitle(title string) string {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(title))
|
||||||
|
|
||||||
|
// Remove common suffixes in parentheses or brackets
|
||||||
|
suffixPatterns := []string{
|
||||||
|
" (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)",
|
||||||
|
" (bonus track)", " (single)", " (album version)", " (radio edit)",
|
||||||
|
" [remaster]", " [remastered]", " [deluxe]", " [bonus track]",
|
||||||
|
}
|
||||||
|
for _, suffix := range suffixPatterns {
|
||||||
|
normalized = strings.TrimSuffix(normalized, suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove multiple spaces
|
||||||
|
for strings.Contains(normalized, " ") {
|
||||||
|
normalized = strings.ReplaceAll(normalized, " ", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
// SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority
|
// SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority
|
||||||
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
|
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
|
||||||
token, err := t.GetAccessToken()
|
token, err := t.GetAccessToken()
|
||||||
@@ -390,14 +412,50 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
return nil, fmt.Errorf("no tracks found for any search query")
|
return nil, fmt.Errorf("no tracks found for any search query")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 1: Match by ISRC (exact match)
|
// Priority 1: Match by ISRC (exact match) WITH title verification
|
||||||
if spotifyISRC != "" {
|
if spotifyISRC != "" {
|
||||||
|
var isrcMatches []*TidalTrack
|
||||||
for i := range allTracks {
|
for i := range allTracks {
|
||||||
track := &allTracks[i]
|
track := &allTracks[i]
|
||||||
if track.ISRC == spotifyISRC {
|
if track.ISRC == spotifyISRC {
|
||||||
return track, nil
|
isrcMatches = append(isrcMatches, track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(isrcMatches) > 0 {
|
||||||
|
// Verify duration first (most important check)
|
||||||
|
if expectedDuration > 0 {
|
||||||
|
var durationVerifiedMatches []*TidalTrack
|
||||||
|
for _, track := range isrcMatches {
|
||||||
|
durationDiff := track.Duration - expectedDuration
|
||||||
|
if durationDiff < 0 {
|
||||||
|
durationDiff = -durationDiff
|
||||||
|
}
|
||||||
|
// Allow 30 seconds tolerance for duration
|
||||||
|
if durationDiff <= 30 {
|
||||||
|
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(durationVerifiedMatches) > 0 {
|
||||||
|
// Return first duration-verified match
|
||||||
|
fmt.Printf("[Tidal] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
||||||
|
durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration)
|
||||||
|
return durationVerifiedMatches[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISRC matches but duration doesn't - this is likely wrong version
|
||||||
|
fmt.Printf("[Tidal] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
|
||||||
|
spotifyISRC, expectedDuration, isrcMatches[0].Duration)
|
||||||
|
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version/edit)",
|
||||||
|
expectedDuration, isrcMatches[0].Duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No duration to verify, just return first ISRC match
|
||||||
|
fmt.Printf("[Tidal] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
||||||
|
return isrcMatches[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
// If ISRC was provided but no match found, return error
|
// If ISRC was provided but no match found, return error
|
||||||
return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC)
|
return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC)
|
||||||
}
|
}
|
||||||
@@ -820,6 +878,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert expected duration from ms to seconds
|
||||||
|
expectedDurationSec := req.DurationMS / 1000
|
||||||
|
|
||||||
var track *TidalTrack
|
var track *TidalTrack
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
@@ -831,18 +892,31 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
|
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
|
||||||
if idErr == nil {
|
if idErr == nil {
|
||||||
track, err = downloader.GetTrackInfoByID(trackID)
|
track, err = downloader.GetTrackInfoByID(trackID)
|
||||||
|
// Verify duration if we have expected duration
|
||||||
|
if track != nil && expectedDurationSec > 0 {
|
||||||
|
durationDiff := track.Duration - expectedDurationSec
|
||||||
|
if durationDiff < 0 {
|
||||||
|
durationDiff = -durationDiff
|
||||||
|
}
|
||||||
|
// Allow 30 seconds tolerance
|
||||||
|
if durationDiff > 30 {
|
||||||
|
fmt.Printf("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
|
||||||
|
expectedDurationSec, track.Duration)
|
||||||
|
track = nil // Reject this match
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: Search by ISRC with multi-strategy fallback
|
// Strategy 2: Search by ISRC with duration verification
|
||||||
if track == nil && req.ISRC != "" {
|
if track == nil && req.ISRC != "" {
|
||||||
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, 0)
|
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 3: Search by metadata only (no ISRC requirement)
|
// Strategy 3: Search by metadata only (no ISRC requirement)
|
||||||
if track == nil {
|
if track == nil {
|
||||||
track, err = downloader.SearchTrackByMetadata(req.TrackName, req.ArtistName)
|
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec)
|
||||||
}
|
}
|
||||||
|
|
||||||
if track == nil {
|
if track == nil {
|
||||||
@@ -853,6 +927,20 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg)
|
return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Final duration verification
|
||||||
|
if expectedDurationSec > 0 {
|
||||||
|
durationDiff := track.Duration - expectedDurationSec
|
||||||
|
if durationDiff < 0 {
|
||||||
|
durationDiff = -durationDiff
|
||||||
|
}
|
||||||
|
if durationDiff > 30 {
|
||||||
|
return TidalDownloadResult{}, fmt.Errorf("duration mismatch: expected %ds, found %ds (diff: %ds). Track may be wrong version",
|
||||||
|
expectedDurationSec, track.Duration, durationDiff)
|
||||||
|
}
|
||||||
|
fmt.Printf("[Tidal] Duration verified: expected %ds, found %ds (diff: %ds)\n",
|
||||||
|
expectedDurationSec, track.Duration, durationDiff)
|
||||||
|
}
|
||||||
|
|
||||||
// Build filename
|
// Build filename
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/// App version and info constants
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '2.0.2';
|
static const String version = '2.0.5';
|
||||||
static const String buildNumber = '32';
|
static const String buildNumber = '35';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -125,6 +125,13 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
void _handleBackPress() {
|
void _handleBackPress() {
|
||||||
final trackState = ref.read(trackProvider);
|
final trackState = ref.read(trackProvider);
|
||||||
|
|
||||||
|
// Check if keyboard is visible - if so, just dismiss keyboard, don't clear search
|
||||||
|
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||||
|
if (isKeyboardVisible) {
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If on Home tab and has text in search bar or has content (but not loading), clear it
|
// If on Home tab and has text in search bar or has content (but not loading), clear it
|
||||||
if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) {
|
if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) {
|
||||||
ref.read(trackProvider.notifier).clear();
|
ref.read(trackProvider.notifier).clear();
|
||||||
@@ -163,12 +170,17 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
||||||
final trackState = ref.watch(trackProvider);
|
final trackState = ref.watch(trackProvider);
|
||||||
|
|
||||||
|
// Check if keyboard is visible (bottom inset > 0 means keyboard is showing)
|
||||||
|
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||||
|
|
||||||
// Determine if we can pop (for predictive back animation)
|
// Determine if we can pop (for predictive back animation)
|
||||||
// canPop is true when we're at root with no content - enables predictive back gesture
|
// canPop is true when we're at root with no content - enables predictive back gesture
|
||||||
|
// IMPORTANT: Never allow pop when keyboard is visible to prevent accidental navigation
|
||||||
final canPop = _currentIndex == 0 &&
|
final canPop = _currentIndex == 0 &&
|
||||||
!trackState.hasSearchText &&
|
!trackState.hasSearchText &&
|
||||||
!trackState.hasContent &&
|
!trackState.hasContent &&
|
||||||
!trackState.isLoading;
|
!trackState.isLoading &&
|
||||||
|
!isKeyboardVisible;
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: canPop,
|
canPop: canPop,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:spotiflac_android/models/settings.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
@@ -116,6 +117,38 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Spotify API section
|
||||||
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Spotify API')),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.key,
|
||||||
|
title: 'Custom Credentials',
|
||||||
|
subtitle: settings.spotifyClientId.isNotEmpty
|
||||||
|
? 'Client ID: ${settings.spotifyClientId.length > 8 ? '${settings.spotifyClientId.substring(0, 8)}...' : settings.spotifyClientId}'
|
||||||
|
: 'Not configured',
|
||||||
|
onTap: () => _showSpotifyCredentialsDialog(context, ref, settings),
|
||||||
|
trailing: settings.spotifyClientId.isNotEmpty
|
||||||
|
? Icon(Icons.edit, color: Theme.of(context).colorScheme.onSurfaceVariant, size: 20)
|
||||||
|
: Icon(Icons.add, color: Theme.of(context).colorScheme.primary, size: 20),
|
||||||
|
showDivider: settings.spotifyClientId.isNotEmpty,
|
||||||
|
),
|
||||||
|
if (settings.spotifyClientId.isNotEmpty)
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon: Icons.toggle_on,
|
||||||
|
title: 'Use Custom Credentials',
|
||||||
|
subtitle: settings.useCustomSpotifyCredentials
|
||||||
|
? 'Using your credentials'
|
||||||
|
: 'Using default credentials',
|
||||||
|
value: settings.useCustomSpotifyCredentials,
|
||||||
|
onChanged: (v) => ref.read(settingsProvider.notifier).setUseCustomSpotifyCredentials(v),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Data section
|
// Data section
|
||||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Data')),
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Data')),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
@@ -163,6 +196,132 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showSpotifyCredentialsDialog(BuildContext context, WidgetRef ref, AppSettings settings) {
|
||||||
|
final clientIdController = TextEditingController(text: settings.spotifyClientId);
|
||||||
|
final clientSecretController = TextEditingController(text: settings.spotifyClientSecret);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||||
|
builder: (context) => Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 20, 24, 8),
|
||||||
|
child: Text('Spotify API Credentials', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||||
|
child: Text(
|
||||||
|
'Use your own credentials to avoid rate limiting.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
child: TextField(
|
||||||
|
controller: clientIdController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Client ID',
|
||||||
|
hintText: 'Enter Spotify Client ID',
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surfaceContainerLow,
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.3))),
|
||||||
|
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.3))),
|
||||||
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.primary, width: 2)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
child: TextField(
|
||||||
|
controller: clientSecretController,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Client Secret',
|
||||||
|
hintText: 'Enter Spotify Client Secret',
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surfaceContainerLow,
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.3))),
|
||||||
|
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.3))),
|
||||||
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.primary, width: 2)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (settings.spotifyClientId.isNotEmpty)
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: () {
|
||||||
|
ref.read(settingsProvider.notifier).clearSpotifyCredentials();
|
||||||
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Credentials cleared')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: colorScheme.error,
|
||||||
|
side: BorderSide(color: colorScheme.error),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
minimumSize: const Size.fromHeight(52),
|
||||||
|
),
|
||||||
|
child: const Text('Clear'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (settings.spotifyClientId.isNotEmpty) const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
final clientId = clientIdController.text.trim();
|
||||||
|
final clientSecret = clientSecretController.text.trim();
|
||||||
|
|
||||||
|
if (clientId.isNotEmpty && clientSecret.isNotEmpty) {
|
||||||
|
ref.read(settingsProvider.notifier).setSpotifyCredentials(clientId, clientSecret);
|
||||||
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Credentials saved')),
|
||||||
|
);
|
||||||
|
} else if (clientId.isEmpty && clientSecret.isEmpty) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Please fill both Client ID and Secret')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
minimumSize: const Size.fromHeight(52),
|
||||||
|
),
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ConcurrentDownloadsItem extends StatelessWidget {
|
class _ConcurrentDownloadsItem extends StatelessWidget {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 2.0.2+32
|
version: 2.0.5+35
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
|
|||||||