mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 19:27:57 +02:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 35532b0c73 | |||
| 4c09b988e4 | |||
| c673581c32 | |||
| bcd718b178 | |||
| 2b9357cb6d | |||
| 26d84041c7 | |||
| a6d488696b |
@@ -16,7 +16,7 @@ body:
|
||||
options:
|
||||
- label: I have searched existing issues and this bug hasn't been reported yet
|
||||
required: true
|
||||
- label: I am using the latest version of SpotiFLAC
|
||||
- label: I am using the latest version of SpotiFLAC (Stable Version)
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
|
||||
@@ -3,3 +3,6 @@ contact_links:
|
||||
- name: README
|
||||
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
|
||||
about: Check the README for setup instructions and FAQ
|
||||
- name: Extension Development Guide
|
||||
url: https://zarz.moe/docs
|
||||
about: Documentation for building SpotiFLAC extensions
|
||||
|
||||
@@ -16,7 +16,7 @@ body:
|
||||
options:
|
||||
- label: I have tried downloading with a different service (Tidal/Qobuz/Amazon)
|
||||
required: true
|
||||
- label: I am using the latest version of SpotiFLAC
|
||||
- label: I am using the latest version of SpotiFLAC (Stable Version)
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
|
||||
@@ -1,5 +1,50 @@
|
||||
# Changelog
|
||||
|
||||
## [3.0.0-alpha.2] - 2026-01-12
|
||||
|
||||
### Added
|
||||
|
||||
- **Full HTTP Method Support**: New shortcut methods for all common HTTP verbs
|
||||
- `http.put(url, body, headers)` - PUT requests
|
||||
- `http.delete(url, headers)` - DELETE requests
|
||||
- `http.patch(url, body, headers)` - PATCH requests
|
||||
- `http.clearCookies()` - Clear all cookies for the extension
|
||||
- **Persistent Cookie Jar**: Each extension now has its own cookie jar
|
||||
- Cookies automatically stored from `Set-Cookie` headers
|
||||
- Cookies automatically sent with subsequent requests to same domain
|
||||
- Useful for APIs requiring session cookies (YouTube, etc.)
|
||||
- **Multi-Value Header Support**: Response headers now return arrays for multi-value headers
|
||||
- `Set-Cookie` and other headers with multiple values returned as arrays
|
||||
- Single-value headers still returned as strings for convenience
|
||||
- **Generic HTTP Request Method**: New `http.request()` for full HTTP control
|
||||
- Supports all HTTP methods (GET, POST, PUT, DELETE, PATCH, etc.)
|
||||
- Single options object for cleaner API: `http.request(url, { method, body, headers })`
|
||||
- **Response Helper Properties**: HTTP responses now include convenience properties
|
||||
- `response.ok` - true if status code is 2xx
|
||||
- `response.status` - alias for `statusCode`
|
||||
|
||||
### Fixed
|
||||
|
||||
- **User-Agent Header Respect**: Custom `User-Agent` headers are now respected
|
||||
- Previously, extension-provided User-Agent was overwritten
|
||||
- Now only sets default User-Agent if extension doesn't provide one
|
||||
- **HTTP POST Body Auto-Stringify**: `http.post()` now automatically stringifies objects to JSON
|
||||
- Previously, passing an object as body resulted in `[object Object]`
|
||||
- Now objects and arrays are automatically JSON.stringify'd
|
||||
- String bodies still work as before (no double-encoding)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Updated `docs/EXTENSION_DEVELOPMENT.md`:
|
||||
- Added complete HTTP API documentation with all methods
|
||||
- Added Cookie Jar documentation
|
||||
- Added `http.put()`, `http.delete()`, `http.patch()`, `http.clearCookies()` docs
|
||||
- Added YouTube Music / Innertube API example with custom User-Agent
|
||||
- Added common domain lists for YouTube, SoundCloud, Bandcamp
|
||||
- Improved HTTP API documentation with response properties
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-alpha.1] - 2026-01-11
|
||||
|
||||
#### Extension System
|
||||
@@ -93,6 +138,32 @@
|
||||
|
||||
---
|
||||
|
||||
## [2.2.8] - 2026-01-12
|
||||
|
||||
### Added
|
||||
|
||||
- **Multi-Select Batch Delete**: Long-press tracks in History to enter selection mode
|
||||
- Select multiple tracks at once
|
||||
- "Select All" and "Delete Selected" actions
|
||||
- Modern Material 3 bottom action bar (slides up from bottom)
|
||||
- Works in both grid and list view modes
|
||||
- **History Filter Tabs**: Filter history by All/Albums/Singles
|
||||
- Album = tracks where album has >1 track in history
|
||||
- Single = tracks where album has only 1 track in history
|
||||
- Filter chips show counts for each category
|
||||
- **Album Grouping View**: When "Albums" filter is selected, tracks are grouped by album
|
||||
- Album cards displayed in 2-column grid with cover art and track count badge
|
||||
- Tap album to open dedicated album detail screen
|
||||
- Album detail shows all downloaded tracks from that album
|
||||
- Multi-select delete support within album view
|
||||
- Auto-navigates back when album has <2 tracks remaining
|
||||
|
||||
### Changed
|
||||
|
||||
- **Issue Templates**: Updated version confirmation checkbox to specify "(Stable Version)"
|
||||
|
||||
---
|
||||
|
||||
## [2.2.7] - 2026-01-11
|
||||
|
||||
### Added
|
||||
|
||||
@@ -218,6 +218,12 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"hasSpotifyCredentials" -> {
|
||||
val hasCredentials = withContext(Dispatchers.IO) {
|
||||
Gobackend.checkSpotifyCredentials()
|
||||
}
|
||||
result.success(hasCredentials)
|
||||
}
|
||||
"preWarmTrackCache" -> {
|
||||
val tracksJson = call.argument<String>("tracks") ?: "[]"
|
||||
withContext(Dispatchers.IO) {
|
||||
|
||||
+36
-17
@@ -32,18 +32,26 @@ func ParseSpotifyURL(url string) (string, error) {
|
||||
}
|
||||
|
||||
// SetSpotifyAPICredentials sets custom Spotify API credentials from Flutter
|
||||
// Pass empty strings to use default credentials
|
||||
func SetSpotifyAPICredentials(clientID, clientSecret string) {
|
||||
SetSpotifyCredentials(clientID, clientSecret)
|
||||
}
|
||||
|
||||
// CheckSpotifyCredentials checks if Spotify credentials are configured
|
||||
// Returns true if credentials are available (custom or env vars)
|
||||
func CheckSpotifyCredentials() bool {
|
||||
return HasSpotifyCredentials()
|
||||
}
|
||||
|
||||
// GetSpotifyMetadata fetches metadata from Spotify URL
|
||||
// Returns JSON with track/album/playlist data
|
||||
func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := NewSpotifyMetadataClient()
|
||||
client, err := NewSpotifyMetadataClient()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -63,7 +71,10 @@ func SearchSpotify(query string, limit int) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := NewSpotifyMetadataClient()
|
||||
client, err := NewSpotifyMetadataClient()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
results, err := client.SearchTracks(ctx, query, limit)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -83,7 +94,10 @@ func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := NewSpotifyMetadataClient()
|
||||
client, err := NewSpotifyMetadataClient()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -893,21 +907,26 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
||||
defer cancel()
|
||||
|
||||
// Try Spotify first
|
||||
client := NewSpotifyMetadataClient()
|
||||
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
||||
if err == nil {
|
||||
jsonBytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
client, err := NewSpotifyMetadataClient()
|
||||
if err != nil {
|
||||
// No Spotify credentials - fall through to Deezer fallback
|
||||
LogWarn("Spotify", "Credentials not configured, falling back to Deezer")
|
||||
} else {
|
||||
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
||||
if err == nil {
|
||||
jsonBytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// Check if it's a rate limit error
|
||||
errStr := strings.ToLower(err.Error())
|
||||
if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") {
|
||||
// Not a rate limit error, return original error
|
||||
return "", err
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// Check if it's a rate limit error
|
||||
errStr := strings.ToLower(err.Error())
|
||||
if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") {
|
||||
// Not a rate limit error, return original error
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Rate limited - try Deezer fallback for tracks and albums
|
||||
|
||||
+358
-17
@@ -101,24 +101,57 @@ type ExtensionRuntime struct {
|
||||
manifest *ExtensionManifest
|
||||
settings map[string]interface{}
|
||||
httpClient *http.Client
|
||||
cookieJar http.CookieJar
|
||||
dataDir string
|
||||
vm *goja.Runtime
|
||||
}
|
||||
|
||||
// NewExtensionRuntime creates a new runtime for an extension
|
||||
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
// Create a cookie jar for this extension
|
||||
jar, _ := newSimpleCookieJar()
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Jar: jar,
|
||||
}
|
||||
|
||||
return &ExtensionRuntime{
|
||||
extensionID: ext.ID,
|
||||
manifest: ext.Manifest,
|
||||
settings: make(map[string]interface{}),
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
dataDir: ext.DataDir,
|
||||
vm: ext.VM,
|
||||
httpClient: client,
|
||||
cookieJar: jar,
|
||||
dataDir: ext.DataDir,
|
||||
vm: ext.VM,
|
||||
}
|
||||
}
|
||||
|
||||
// simpleCookieJar is a simple in-memory cookie jar
|
||||
type simpleCookieJar struct {
|
||||
cookies map[string][]*http.Cookie
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func newSimpleCookieJar() (*simpleCookieJar, error) {
|
||||
return &simpleCookieJar{
|
||||
cookies: make(map[string][]*http.Cookie),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (j *simpleCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) {
|
||||
j.mu.Lock()
|
||||
defer j.mu.Unlock()
|
||||
key := u.Host
|
||||
j.cookies[key] = append(j.cookies[key], cookies...)
|
||||
}
|
||||
|
||||
func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
|
||||
j.mu.RLock()
|
||||
defer j.mu.RUnlock()
|
||||
return j.cookies[u.Host]
|
||||
}
|
||||
|
||||
// SetSettings updates the runtime settings
|
||||
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
|
||||
r.settings = settings
|
||||
@@ -132,6 +165,11 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
httpObj := vm.NewObject()
|
||||
httpObj.Set("get", r.httpGet)
|
||||
httpObj.Set("post", r.httpPost)
|
||||
httpObj.Set("put", r.httpPut)
|
||||
httpObj.Set("delete", r.httpDelete)
|
||||
httpObj.Set("patch", r.httpPatch)
|
||||
httpObj.Set("request", r.httpRequest) // Generic HTTP request (GET, POST, PUT, DELETE, etc.)
|
||||
httpObj.Set("clearCookies", r.httpClearCookies)
|
||||
vm.Set("http", httpObj)
|
||||
|
||||
// Storage API
|
||||
@@ -274,11 +312,14 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Set headers
|
||||
// Set headers - user headers first
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
// Only set default User-Agent if not provided by extension
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
}
|
||||
|
||||
// Execute request
|
||||
resp, err := r.httpClient.Do(req)
|
||||
@@ -297,16 +338,20 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Extract response headers
|
||||
respHeaders := make(map[string]string)
|
||||
// Extract response headers - return all values as arrays for multi-value headers (cookies, etc.)
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) > 0 {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v // Return as array if multiple values
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode, // Alias for convenience
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
@@ -330,10 +375,26 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Get body if provided
|
||||
// Get body if provided - support both string and object
|
||||
var bodyStr string
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
bodyStr = call.Arguments[1].String()
|
||||
bodyArg := call.Arguments[1].Export()
|
||||
switch v := bodyArg.(type) {
|
||||
case string:
|
||||
bodyStr = v
|
||||
case map[string]interface{}, []interface{}:
|
||||
// Auto-stringify objects and arrays to JSON
|
||||
jsonBytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": fmt.Sprintf("failed to stringify body: %v", err),
|
||||
})
|
||||
}
|
||||
bodyStr = string(jsonBytes)
|
||||
default:
|
||||
// Fallback to string conversion
|
||||
bodyStr = call.Arguments[1].String()
|
||||
}
|
||||
}
|
||||
|
||||
// Get headers if provided
|
||||
@@ -355,11 +416,14 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Set headers
|
||||
// Set headers - user headers first
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
// Only set defaults if not provided by extension
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
}
|
||||
if req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
@@ -381,21 +445,298 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Extract response headers
|
||||
respHeaders := make(map[string]string)
|
||||
// Extract response headers - return all values as arrays for multi-value headers
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) > 0 {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v // Return as array if multiple values
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode, // Alias for convenience
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
// httpRequest performs a generic HTTP request (GET, POST, PUT, DELETE, etc.)
|
||||
// Usage: http.request(url, options) where options = { method, body, headers }
|
||||
func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
})
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Default options
|
||||
method := "GET"
|
||||
var bodyStr string
|
||||
headers := make(map[string]string)
|
||||
|
||||
// Parse options if provided
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
optionsObj := call.Arguments[1].Export()
|
||||
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||
// Get method
|
||||
if m, ok := opts["method"].(string); ok {
|
||||
method = strings.ToUpper(m)
|
||||
}
|
||||
|
||||
// Get body - support both string and object
|
||||
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
|
||||
switch v := bodyArg.(type) {
|
||||
case string:
|
||||
bodyStr = v
|
||||
case map[string]interface{}, []interface{}:
|
||||
// Auto-stringify objects and arrays to JSON
|
||||
jsonBytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": fmt.Sprintf("failed to stringify body: %v", err),
|
||||
})
|
||||
}
|
||||
bodyStr = string(jsonBytes)
|
||||
default:
|
||||
bodyStr = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// Get headers
|
||||
if h, ok := opts["headers"].(map[string]interface{}); ok {
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create request
|
||||
var reqBody io.Reader
|
||||
if bodyStr != "" {
|
||||
reqBody = strings.NewReader(bodyStr)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, urlStr, reqBody)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Set headers - user headers first
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
// Only set defaults if not provided by extension
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
}
|
||||
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
// Execute request
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Extract response headers - return all values as arrays for multi-value headers
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v // Return as array if multiple values
|
||||
}
|
||||
}
|
||||
|
||||
// Return response with helper properties
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode, // Alias for convenience
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
// httpPut performs a PUT request (shortcut for http.request with method: "PUT")
|
||||
func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("PUT", call)
|
||||
}
|
||||
|
||||
// httpDelete performs a DELETE request (shortcut for http.request with method: "DELETE")
|
||||
func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("DELETE", call)
|
||||
}
|
||||
|
||||
// httpPatch performs a PATCH request (shortcut for http.request with method: "PATCH")
|
||||
func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("PATCH", call)
|
||||
}
|
||||
|
||||
// httpMethodShortcut is a helper for PUT/DELETE/PATCH shortcuts
|
||||
// Signature: http.put(url, body, headers) / http.delete(url, headers) / http.patch(url, body, headers)
|
||||
func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
})
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
var bodyStr string
|
||||
headers := make(map[string]string)
|
||||
|
||||
// For DELETE, second arg is headers; for PUT/PATCH, second arg is body
|
||||
if method == "DELETE" {
|
||||
// http.delete(url, headers)
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
headersObj := call.Arguments[1].Export()
|
||||
if h, ok := headersObj.(map[string]interface{}); ok {
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// http.put(url, body, headers) / http.patch(url, body, headers)
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
bodyArg := call.Arguments[1].Export()
|
||||
switch v := bodyArg.(type) {
|
||||
case string:
|
||||
bodyStr = v
|
||||
case map[string]interface{}, []interface{}:
|
||||
jsonBytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": fmt.Sprintf("failed to stringify body: %v", err),
|
||||
})
|
||||
}
|
||||
bodyStr = string(jsonBytes)
|
||||
default:
|
||||
bodyStr = call.Arguments[1].String()
|
||||
}
|
||||
}
|
||||
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
headersObj := call.Arguments[2].Export()
|
||||
if h, ok := headersObj.(map[string]interface{}); ok {
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create request
|
||||
var reqBody io.Reader
|
||||
if bodyStr != "" {
|
||||
reqBody = strings.NewReader(bodyStr)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, urlStr, reqBody)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Set headers - user headers first
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
}
|
||||
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
// Execute request
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Extract response headers
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
// httpClearCookies clears all cookies for this extension
|
||||
func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
|
||||
if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
|
||||
jar.mu.Lock()
|
||||
jar.cookies = make(map[string][]*http.Cookie)
|
||||
jar.mu.Unlock()
|
||||
GoLog("[Extension:%s] Cookies cleared\n", r.extensionID)
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
// ==================== File API (Sandboxed) ====================
|
||||
|
||||
// validatePath checks if the path is within the extension's data directory
|
||||
|
||||
+87
-69
@@ -2,7 +2,6 @@ package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -17,14 +16,14 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
spotifyTokenURL = "https://accounts.spotify.com/api/token"
|
||||
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
|
||||
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
|
||||
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
|
||||
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
||||
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
|
||||
searchBaseURL = "https://api.spotify.com/v1/search"
|
||||
|
||||
spotifyTokenURL = "https://accounts.spotify.com/api/token"
|
||||
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
|
||||
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
|
||||
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
|
||||
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
||||
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
|
||||
searchBaseURL = "https://api.spotify.com/v1/search"
|
||||
|
||||
// Cache TTL settings
|
||||
artistCacheTTL = 10 * time.Minute
|
||||
searchCacheTTL = 5 * time.Minute
|
||||
@@ -54,7 +53,7 @@ type SpotifyMetadataClient struct {
|
||||
rng *rand.Rand
|
||||
rngMu sync.Mutex
|
||||
userAgent string
|
||||
|
||||
|
||||
// Caches to reduce API calls
|
||||
artistCache map[string]*cacheEntry // key: artistID
|
||||
searchCache map[string]*cacheEntry // key: query+type
|
||||
@@ -69,8 +68,10 @@ var (
|
||||
credentialsMu sync.RWMutex
|
||||
)
|
||||
|
||||
// ErrNoSpotifyCredentials is returned when Spotify credentials are not configured
|
||||
var ErrNoSpotifyCredentials = errors.New("Spotify credentials not configured. Please set your own Client ID and Secret in Settings, or use Deezer as metadata source (free, no credentials required)")
|
||||
|
||||
// SetSpotifyCredentials sets custom Spotify API credentials
|
||||
// Pass empty strings to use default credentials
|
||||
func SetSpotifyCredentials(clientID, clientSecret string) {
|
||||
credentialsMu.Lock()
|
||||
defer credentialsMu.Unlock()
|
||||
@@ -78,39 +79,56 @@ func SetSpotifyCredentials(clientID, clientSecret string) {
|
||||
customClientSecret = clientSecret
|
||||
}
|
||||
|
||||
// getCredentials returns the current credentials (custom or default)
|
||||
func getCredentials() (string, string) {
|
||||
// HasSpotifyCredentials checks if Spotify credentials are configured
|
||||
func HasSpotifyCredentials() bool {
|
||||
credentialsMu.RLock()
|
||||
defer credentialsMu.RUnlock()
|
||||
|
||||
|
||||
// Check custom credentials first
|
||||
if customClientID != "" && customClientSecret != "" {
|
||||
return customClientID, customClientSecret
|
||||
}
|
||||
|
||||
// Fall back to default credentials
|
||||
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
||||
if clientID == "" {
|
||||
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
|
||||
clientID = string(decoded)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
||||
if clientSecret == "" {
|
||||
if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil {
|
||||
clientSecret = string(decoded)
|
||||
}
|
||||
// Check environment variables
|
||||
if os.Getenv("SPOTIFY_CLIENT_ID") != "" && os.Getenv("SPOTIFY_CLIENT_SECRET") != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
return clientID, clientSecret
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// getCredentials returns the current credentials or error if not configured
|
||||
func getCredentials() (string, string, error) {
|
||||
credentialsMu.RLock()
|
||||
defer credentialsMu.RUnlock()
|
||||
|
||||
// Check custom credentials first
|
||||
if customClientID != "" && customClientSecret != "" {
|
||||
return customClientID, customClientSecret, nil
|
||||
}
|
||||
|
||||
// Check environment variables
|
||||
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
||||
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
||||
|
||||
if clientID != "" && clientSecret != "" {
|
||||
return clientID, clientSecret, nil
|
||||
}
|
||||
|
||||
// No credentials available
|
||||
return "", "", ErrNoSpotifyCredentials
|
||||
}
|
||||
|
||||
// NewSpotifyMetadataClient creates a new Spotify client
|
||||
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
||||
src := rand.NewSource(time.Now().UnixNano())
|
||||
// Returns error if credentials are not configured
|
||||
func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
|
||||
// Get credentials - will error if not configured
|
||||
clientID, clientSecret, err := getCredentials()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get credentials (custom or default)
|
||||
clientID, clientSecret := getCredentials()
|
||||
src := rand.NewSource(time.Now().UnixNano())
|
||||
|
||||
c := &SpotifyMetadataClient{
|
||||
httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling
|
||||
@@ -122,7 +140,7 @@ func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
||||
albumCache: make(map[string]*cacheEntry),
|
||||
}
|
||||
c.userAgent = c.randomUserAgent()
|
||||
return c
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// TrackMetadata represents track information
|
||||
@@ -331,14 +349,14 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
||||
}
|
||||
|
||||
searchURL := fmt.Sprintf("%s?q=%s&type=track&limit=%d", searchBaseURL, url.QueryEscape(query), limit)
|
||||
|
||||
|
||||
var response struct {
|
||||
Tracks struct {
|
||||
Items []trackFull `json:"items"`
|
||||
Total int `json:"total"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
|
||||
|
||||
if err := c.getJSON(ctx, searchURL, token, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -373,7 +391,7 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
||||
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
||||
// Create cache key
|
||||
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
|
||||
|
||||
|
||||
// Check cache first
|
||||
c.cacheMu.RLock()
|
||||
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||
@@ -388,24 +406,24 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
|
||||
}
|
||||
|
||||
searchURL := fmt.Sprintf("%s?q=%s&type=track,artist&limit=%d", searchBaseURL, url.QueryEscape(query), trackLimit)
|
||||
|
||||
|
||||
var response struct {
|
||||
Tracks struct {
|
||||
Items []trackFull `json:"items"`
|
||||
} `json:"tracks"`
|
||||
Artists struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Images []image `json:"images"`
|
||||
Followers struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Images []image `json:"images"`
|
||||
Followers struct {
|
||||
Total int `json:"total"`
|
||||
} `json:"followers"`
|
||||
Popularity int `json:"popularity"`
|
||||
} `json:"items"`
|
||||
} `json:"artists"`
|
||||
}
|
||||
|
||||
|
||||
if err := c.getJSON(ctx, searchURL, token, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -438,7 +456,7 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
|
||||
if artistCount > artistLimit {
|
||||
artistCount = artistLimit
|
||||
}
|
||||
|
||||
|
||||
for i := 0; i < artistCount; i++ {
|
||||
artist := response.Artists.Items[i]
|
||||
result.Artists = append(result.Artists, SearchArtistResult{
|
||||
@@ -534,7 +552,7 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
// Collect all tracks (including paginated)
|
||||
allTrackItems := data.Tracks.Items
|
||||
nextURL := data.Tracks.Next
|
||||
|
||||
|
||||
// Fetch remaining tracks using pagination (no limit)
|
||||
for nextURL != "" {
|
||||
var pageData struct {
|
||||
@@ -563,7 +581,7 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(allTrackItems))
|
||||
for _, item := range allTrackItems {
|
||||
isrc := isrcMap[item.ID]
|
||||
|
||||
|
||||
tracks = append(tracks, AlbumTrackMetadata{
|
||||
SpotifyID: item.ID,
|
||||
Artists: joinArtists(item.Artists),
|
||||
@@ -602,23 +620,23 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
// Similar to Deezer implementation for consistency
|
||||
func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs []string, token string) map[string]string {
|
||||
const maxParallelISRC = 10 // Max concurrent ISRC fetches
|
||||
|
||||
|
||||
result := make(map[string]string)
|
||||
var resultMu sync.Mutex
|
||||
|
||||
|
||||
if len(trackIDs) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
// Use semaphore to limit concurrent requests
|
||||
sem := make(chan struct{}, maxParallelISRC)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
|
||||
for _, trackID := range trackIDs {
|
||||
wg.Add(1)
|
||||
go func(id string) {
|
||||
defer wg.Done()
|
||||
|
||||
|
||||
// Acquire semaphore
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
@@ -626,15 +644,15 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
isrc := c.fetchTrackISRC(ctx, id, token)
|
||||
|
||||
|
||||
resultMu.Lock()
|
||||
result[id] = isrc
|
||||
resultMu.Unlock()
|
||||
}(trackID)
|
||||
}
|
||||
|
||||
|
||||
wg.Wait()
|
||||
return result
|
||||
}
|
||||
@@ -668,7 +686,7 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
||||
|
||||
// Pre-allocate with expected capacity
|
||||
tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total)
|
||||
|
||||
|
||||
// Add first batch of tracks
|
||||
for _, item := range data.Tracks.Items {
|
||||
if item.Track == nil {
|
||||
@@ -695,7 +713,7 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
||||
|
||||
// Fetch remaining tracks using pagination (NO LIMIT - fetch all tracks)
|
||||
nextURL := data.Tracks.Next
|
||||
|
||||
|
||||
for nextURL != "" {
|
||||
var pageData struct {
|
||||
Items []struct {
|
||||
@@ -755,10 +773,10 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
|
||||
|
||||
// Fetch artist info
|
||||
var artistData struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Images []image `json:"images"`
|
||||
Followers struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Images []image `json:"images"`
|
||||
Followers struct {
|
||||
Total int `json:"total"`
|
||||
} `json:"followers"`
|
||||
Popularity int `json:"popularity"`
|
||||
@@ -941,15 +959,15 @@ func (c *SpotifyMetadataClient) randomUserAgent() string {
|
||||
defer c.rngMu.Unlock()
|
||||
|
||||
// Use Mac User-Agent format (same as PC version)
|
||||
macMajor := c.rng.Intn(4) + 11 // 11-14
|
||||
macMinor := c.rng.Intn(5) + 4 // 4-8
|
||||
webkitMajor := c.rng.Intn(7) + 530 // 530-536
|
||||
webkitMinor := c.rng.Intn(7) + 30 // 30-36
|
||||
chromeMajor := c.rng.Intn(25) + 80 // 80-104
|
||||
macMajor := c.rng.Intn(4) + 11 // 11-14
|
||||
macMinor := c.rng.Intn(5) + 4 // 4-8
|
||||
webkitMajor := c.rng.Intn(7) + 530 // 530-536
|
||||
webkitMinor := c.rng.Intn(7) + 30 // 30-36
|
||||
chromeMajor := c.rng.Intn(25) + 80 // 80-104
|
||||
chromeBuild := c.rng.Intn(1500) + 3000 // 3000-4499
|
||||
chromePatch := c.rng.Intn(65) + 60 // 60-124
|
||||
safariMajor := c.rng.Intn(7) + 530 // 530-536
|
||||
safariMinor := c.rng.Intn(6) + 30 // 30-35
|
||||
chromePatch := c.rng.Intn(65) + 60 // 60-124
|
||||
safariMajor := c.rng.Intn(7) + 530 // 530-536
|
||||
safariMinor := c.rng.Intn(6) + 30 // 30-35
|
||||
|
||||
return fmt.Sprintf(
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
|
||||
|
||||
@@ -256,6 +256,10 @@ import Gobackend // Import Go framework
|
||||
GobackendSetSpotifyAPICredentials(clientId, clientSecret)
|
||||
return nil
|
||||
|
||||
case "hasSpotifyCredentials":
|
||||
let hasCredentials = GobackendCheckSpotifyCredentials()
|
||||
return hasCredentials
|
||||
|
||||
// Log methods
|
||||
case "getLogs":
|
||||
let response = GobackendGetLogs()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/// App version and info constants
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '3.0.0-alpha.1';
|
||||
static const String buildNumber = '50';
|
||||
static const String version = '3.0.0-alpha.2';
|
||||
static const String buildNumber = '51';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
|
||||
|
||||
+34
-3
@@ -1,7 +1,10 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/app.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/services/notification_service.dart';
|
||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||
|
||||
@@ -24,14 +27,42 @@ void main() async {
|
||||
}
|
||||
|
||||
/// Widget to eagerly initialize providers that need to load data on startup
|
||||
class _EagerInitialization extends ConsumerWidget {
|
||||
class _EagerInitialization extends ConsumerStatefulWidget {
|
||||
const _EagerInitialization({required this.child});
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<_EagerInitialization> createState() => _EagerInitializationState();
|
||||
}
|
||||
|
||||
class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeExtensions();
|
||||
}
|
||||
|
||||
Future<void> _initializeExtensions() async {
|
||||
try {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final extensionsDir = '${appDir.path}/extensions';
|
||||
final dataDir = '${appDir.path}/extension_data';
|
||||
|
||||
// Create directories if needed
|
||||
await Directory(extensionsDir).create(recursive: true);
|
||||
await Directory(dataDir).create(recursive: true);
|
||||
|
||||
// Initialize extension system
|
||||
await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to initialize extensions: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Eagerly initialize download history provider to load from storage
|
||||
ref.watch(downloadHistoryProvider);
|
||||
return child;
|
||||
return widget.child;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ class AppSettings {
|
||||
final bool hasSearchedBefore; // Hide helper text after first search
|
||||
final String folderOrganization; // none, artist, album, artist_album
|
||||
final String historyViewMode; // list, grid
|
||||
final String historyFilterMode; // all, albums, singles
|
||||
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)
|
||||
@@ -42,6 +43,7 @@ class AppSettings {
|
||||
this.hasSearchedBefore = false, // Default: show helper text
|
||||
this.folderOrganization = 'none', // Default: no folder organization
|
||||
this.historyViewMode = 'grid', // Default: grid view
|
||||
this.historyFilterMode = 'all', // Default: show all
|
||||
this.askQualityBeforeDownload = true, // Default: ask quality before download
|
||||
this.spotifyClientId = '', // Default: use built-in credentials
|
||||
this.spotifyClientSecret = '', // Default: use built-in credentials
|
||||
@@ -67,6 +69,7 @@ class AppSettings {
|
||||
bool? hasSearchedBefore,
|
||||
String? folderOrganization,
|
||||
String? historyViewMode,
|
||||
String? historyFilterMode,
|
||||
bool? askQualityBeforeDownload,
|
||||
String? spotifyClientId,
|
||||
String? spotifyClientSecret,
|
||||
@@ -91,6 +94,7 @@ class AppSettings {
|
||||
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
||||
folderOrganization: folderOrganization ?? this.folderOrganization,
|
||||
historyViewMode: historyViewMode ?? this.historyViewMode,
|
||||
historyFilterMode: historyFilterMode ?? this.historyFilterMode,
|
||||
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
|
||||
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
|
||||
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
||||
|
||||
@@ -21,6 +21,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
||||
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
||||
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
||||
historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
|
||||
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
||||
spotifyClientId: json['spotifyClientId'] as String? ?? '',
|
||||
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
|
||||
@@ -48,6 +49,7 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||
'folderOrganization': instance.folderOrganization,
|
||||
'historyViewMode': instance.historyViewMode,
|
||||
'historyFilterMode': instance.historyFilterMode,
|
||||
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
||||
'spotifyClientId': instance.spotifyClientId,
|
||||
'spotifyClientSecret': instance.spotifyClientSecret,
|
||||
|
||||
@@ -520,22 +520,34 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
await PlatformBridge.setExtensionEnabled(extensionId, enabled);
|
||||
_log.d('Set extension $extensionId enabled: $enabled');
|
||||
|
||||
// Get extension info before updating state
|
||||
final ext = state.extensions.where((e) => e.id == extensionId).firstOrNull;
|
||||
|
||||
// Update local state
|
||||
final extensions = state.extensions.map((ext) {
|
||||
if (ext.id == extensionId) {
|
||||
return ext.copyWith(enabled: enabled);
|
||||
final extensions = state.extensions.map((e) {
|
||||
if (e.id == extensionId) {
|
||||
return e.copyWith(enabled: enabled);
|
||||
}
|
||||
return ext;
|
||||
return e;
|
||||
}).toList();
|
||||
|
||||
state = state.copyWith(extensions: extensions);
|
||||
|
||||
// If disabling an extension that is the current search provider, clear it
|
||||
if (!enabled) {
|
||||
// If disabling an extension, reset related settings
|
||||
if (!enabled && ext != null) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
// If this extension was the search provider, clear it and reset to Deezer
|
||||
if (settings.searchProvider == extensionId) {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
_log.d('Cleared search provider because extension $extensionId was disabled');
|
||||
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
|
||||
_log.d('Cleared search provider and reset to Deezer because extension $extensionId was disabled');
|
||||
}
|
||||
|
||||
// If this extension was the default download service, reset to Tidal
|
||||
if (ext.hasDownloadProvider && settings.defaultService == extensionId) {
|
||||
ref.read(settingsProvider.notifier).setDefaultService('tidal');
|
||||
_log.d('Reset default service to Tidal because extension $extensionId was disabled');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -60,18 +60,16 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
|
||||
/// 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 &&
|
||||
// Only apply if both fields are set
|
||||
if (state.spotifyClientId.isNotEmpty &&
|
||||
state.spotifyClientSecret.isNotEmpty) {
|
||||
await PlatformBridge.setSpotifyCredentials(
|
||||
state.spotifyClientId,
|
||||
state.spotifyClientSecret,
|
||||
);
|
||||
} else {
|
||||
// Clear to use default
|
||||
await PlatformBridge.setSpotifyCredentials('', '');
|
||||
}
|
||||
// Note: If credentials are empty, Spotify API will return error
|
||||
// User should use Deezer as metadata source instead
|
||||
}
|
||||
|
||||
void setDefaultService(String service) {
|
||||
@@ -148,6 +146,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setHistoryFilterMode(String mode) {
|
||||
state = state.copyWith(historyFilterMode: mode);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setAskQualityBeforeDownload(bool enabled) {
|
||||
state = state.copyWith(askQualityBeforeDownload: enabled);
|
||||
_saveSettings();
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
|
||||
/// Screen to display downloaded tracks from a specific album
|
||||
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
|
||||
final String albumName;
|
||||
final String artistName;
|
||||
final String? coverUrl;
|
||||
|
||||
const DownloadedAlbumScreen({
|
||||
super.key,
|
||||
required this.albumName,
|
||||
required this.artistName,
|
||||
this.coverUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<DownloadedAlbumScreen> createState() => _DownloadedAlbumScreenState();
|
||||
}
|
||||
|
||||
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
// Multi-select state
|
||||
bool _isSelectionMode = false;
|
||||
final Set<String> _selectedIds = {};
|
||||
|
||||
/// Get tracks for this album from history provider (reactive)
|
||||
List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) {
|
||||
return allItems.where((item) {
|
||||
final itemKey = '${item.albumName}|${item.albumArtist ?? item.artistName}';
|
||||
final albumKey = '${widget.albumName}|${widget.artistName}';
|
||||
return itemKey == albumKey;
|
||||
}).toList()
|
||||
..sort((a, b) {
|
||||
final aNum = a.trackNumber ?? 999;
|
||||
final bNum = b.trackNumber ?? 999;
|
||||
if (aNum != bNum) return aNum.compareTo(bNum);
|
||||
return a.trackName.compareTo(b.trackName);
|
||||
});
|
||||
}
|
||||
|
||||
void _enterSelectionMode(String itemId) {
|
||||
HapticFeedback.mediumImpact();
|
||||
setState(() {
|
||||
_isSelectionMode = true;
|
||||
_selectedIds.add(itemId);
|
||||
});
|
||||
}
|
||||
|
||||
void _exitSelectionMode() {
|
||||
setState(() {
|
||||
_isSelectionMode = false;
|
||||
_selectedIds.clear();
|
||||
});
|
||||
}
|
||||
|
||||
void _toggleSelection(String itemId) {
|
||||
setState(() {
|
||||
if (_selectedIds.contains(itemId)) {
|
||||
_selectedIds.remove(itemId);
|
||||
if (_selectedIds.isEmpty) {
|
||||
_isSelectionMode = false;
|
||||
}
|
||||
} else {
|
||||
_selectedIds.add(itemId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _selectAll(List<DownloadHistoryItem> tracks) {
|
||||
setState(() {
|
||||
_selectedIds.addAll(tracks.map((e) => e.id));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _deleteSelected(List<DownloadHistoryItem> currentTracks) async {
|
||||
final count = _selectedIds.length;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Delete Selected'),
|
||||
content: Text('Delete $count ${count == 1 ? 'track' : 'tracks'} from this album?\n\nThis will also delete the files from storage.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
||||
final idsToDelete = _selectedIds.toList();
|
||||
|
||||
int deletedCount = 0;
|
||||
for (final id in idsToDelete) {
|
||||
final item = currentTracks.where((e) => e.id == id).firstOrNull;
|
||||
if (item != null) {
|
||||
try {
|
||||
final file = File(item.filePath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
historyNotifier.removeFromHistory(id);
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
_exitSelectionMode();
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Deleted $deletedCount ${deletedCount == 1 ? 'track' : 'tracks'}')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openFile(String filePath) async {
|
||||
try {
|
||||
await OpenFilex.open(filePath);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Cannot open file: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateToMetadataScreen(DownloadHistoryItem item) {
|
||||
Navigator.push(context, PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||
pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: item),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child),
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||
|
||||
// Watch history and get tracks for this album (reactive!)
|
||||
final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
||||
final tracks = _getAlbumTracks(allHistoryItems);
|
||||
|
||||
// Auto-pop if album has less than 2 tracks (no longer an "album")
|
||||
if (tracks.length < 2) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) Navigator.pop(context);
|
||||
});
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// Clean up selected IDs that no longer exist
|
||||
final validIds = tracks.map((t) => t.id).toSet();
|
||||
_selectedIds.removeWhere((id) => !validIds.contains(id));
|
||||
if (_selectedIds.isEmpty && _isSelectionMode) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) setState(() => _isSelectionMode = false);
|
||||
});
|
||||
}
|
||||
|
||||
return PopScope(
|
||||
canPop: !_isSelectionMode,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (!didPop && _isSelectionMode) {
|
||||
_exitSelectionMode();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
CustomScrollView(
|
||||
slivers: [
|
||||
_buildAppBar(context, colorScheme),
|
||||
_buildInfoCard(context, colorScheme, tracks),
|
||||
_buildTrackListHeader(context, colorScheme, tracks),
|
||||
_buildTrackList(context, colorScheme, tracks),
|
||||
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)),
|
||||
],
|
||||
),
|
||||
|
||||
// Bottom Selection Action Bar
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
|
||||
child: _buildSelectionBottomBar(context, colorScheme, tracks, bottomPadding),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||
return SliverAppBar(
|
||||
expandedHeight: 280,
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (widget.coverUrl != null)
|
||||
CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
colorBlendMode: BlendMode.darken,
|
||||
memCacheWidth: 600,
|
||||
),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
colorScheme.surface.withValues(alpha: 0.8),
|
||||
colorScheme.surface,
|
||||
],
|
||||
stops: const [0.0, 0.7, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
child: Container(
|
||||
width: 140,
|
||||
height: 140,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: widget.coverUrl != null
|
||||
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
|
||||
: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.album, size: 48, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle),
|
||||
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.albumName,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.artistName,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.download_done, size: 14, color: colorScheme.onPrimaryContainer),
|
||||
const SizedBox(width: 4),
|
||||
Text('${tracks.length} downloaded', style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (_getCommonQuality(tracks) != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: _getCommonQuality(tracks)!.startsWith('24')
|
||||
? colorScheme.tertiaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
_getCommonQuality(tracks)!,
|
||||
style: TextStyle(
|
||||
color: _getCommonQuality(tracks)!.startsWith('24')
|
||||
? colorScheme.onTertiaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String? _getCommonQuality(List<DownloadHistoryItem> tracks) {
|
||||
if (tracks.isEmpty) return null;
|
||||
final firstQuality = tracks.first.quality;
|
||||
if (firstQuality == null) return null;
|
||||
for (final track in tracks) {
|
||||
if (track.quality != firstQuality) return null;
|
||||
}
|
||||
return firstQuality;
|
||||
}
|
||||
|
||||
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
|
||||
const Spacer(),
|
||||
if (!_isSelectionMode)
|
||||
TextButton.icon(
|
||||
onPressed: tracks.isNotEmpty ? () => _enterSelectionMode(tracks.first.id) : null,
|
||||
icon: const Icon(Icons.checklist, size: 18),
|
||||
label: const Text('Select'),
|
||||
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final track = tracks[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(track.id),
|
||||
child: _buildTrackItem(context, colorScheme, track),
|
||||
);
|
||||
},
|
||||
childCount: tracks.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrackItem(BuildContext context, ColorScheme colorScheme, DownloadHistoryItem track) {
|
||||
final isSelected = _selectedIds.contains(track.id);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: isSelected ? colorScheme.primaryContainer.withValues(alpha: 0.3) : Colors.transparent,
|
||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: ListTile(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
onTap: _isSelectionMode
|
||||
? () => _toggleSelection(track.id)
|
||||
: () => _navigateToMetadataScreen(track),
|
||||
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id),
|
||||
leading: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_isSelectionMode) ...[
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? colorScheme.primary : Colors.transparent,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outline, width: 2),
|
||||
),
|
||||
child: isSelected
|
||||
? Icon(Icons.check, color: colorScheme.onPrimary, size: 16)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
SizedBox(
|
||||
width: 24,
|
||||
child: Text(
|
||||
track.trackNumber?.toString() ?? '-',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Text(
|
||||
track.trackName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(
|
||||
track.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
trailing: _isSelectionMode ? null : IconButton(
|
||||
onPressed: () => _openFile(track.filePath),
|
||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSelectionBottomBar(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks, double bottomPadding) {
|
||||
final selectedCount = _selectedIds.length;
|
||||
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.15),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, -4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
onPressed: _exitSelectionMode,
|
||||
icon: const Icon(Icons.close),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'$selectedCount selected',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
allSelected ? 'All tracks selected' : 'Tap tracks to select',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
if (allSelected) {
|
||||
_exitSelectionMode();
|
||||
} else {
|
||||
_selectAll(tracks);
|
||||
}
|
||||
},
|
||||
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20),
|
||||
label: Text(allSelected ? 'Deselect' : 'Select All'),
|
||||
style: TextButton.styleFrom(foregroundColor: colorScheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: Text(
|
||||
selectedCount > 0
|
||||
? 'Delete $selectedCount ${selectedCount == 1 ? 'track' : 'tracks'}'
|
||||
: 'Select tracks to delete',
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest,
|
||||
foregroundColor: selectedCount > 0 ? colorScheme.onError : colorScheme.onSurfaceVariant,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -320,6 +320,10 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
final error = ref.watch(trackProvider.select((s) => s.error));
|
||||
final hasSearchedBefore = ref.watch(settingsProvider.select((s) => s.hasSearchedBefore));
|
||||
|
||||
// Watch extension state to update search hint when extensions load/change
|
||||
ref.watch(extensionProvider.select((s) => s.isInitialized));
|
||||
ref.watch(extensionProvider.select((s) => s.extensions));
|
||||
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final hasResults = _isTyping || tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty) || isLoading;
|
||||
final screenHeight = MediaQuery.of(context).size.height;
|
||||
@@ -775,9 +779,14 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
String _getSearchHint() {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final searchProvider = settings.searchProvider;
|
||||
final extState = ref.read(extensionProvider);
|
||||
|
||||
// If extension system not initialized yet, show default hint
|
||||
if (!extState.isInitialized) {
|
||||
return 'Paste Spotify URL or search...';
|
||||
}
|
||||
|
||||
if (searchProvider != null && searchProvider.isNotEmpty) {
|
||||
final extState = ref.read(extensionProvider);
|
||||
final ext = extState.extensions.where((e) => e.id == searchProvider).firstOrNull;
|
||||
// Only show extension placeholder if extension exists AND is enabled
|
||||
if (ext != null && ext.enabled) {
|
||||
|
||||
+978
-398
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class DownloadSettingsPage extends ConsumerWidget {
|
||||
@@ -546,9 +547,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
example: 'SpotiFLAC/Track.flac',
|
||||
isSelected: current == 'none',
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setFolderOrganization('none');
|
||||
ref.read(settingsProvider.notifier).setFolderOrganization('none');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -558,9 +557,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
example: 'SpotiFLAC/Artist Name/Track.flac',
|
||||
isSelected: current == 'artist',
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setFolderOrganization('artist');
|
||||
ref.read(settingsProvider.notifier).setFolderOrganization('artist');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -570,9 +567,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
example: 'SpotiFLAC/Album Name/Track.flac',
|
||||
isSelected: current == 'album',
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setFolderOrganization('album');
|
||||
ref.read(settingsProvider.notifier).setFolderOrganization('album');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -582,9 +577,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
example: 'SpotiFLAC/Artist/Album/Track.flac',
|
||||
isSelected: current == 'artist_album',
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setFolderOrganization('artist_album');
|
||||
ref.read(settingsProvider.notifier).setFolderOrganization('artist_album');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -596,7 +589,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ServiceSelector extends StatelessWidget {
|
||||
class _ServiceSelector extends ConsumerWidget {
|
||||
final String currentService;
|
||||
final ValueChanged<String> onChanged;
|
||||
const _ServiceSelector({
|
||||
@@ -605,31 +598,75 @@ class _ServiceSelector extends StatelessWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final extState = ref.watch(extensionProvider);
|
||||
|
||||
// Get enabled extension download providers
|
||||
final extensionProviders = extState.extensions
|
||||
.where((e) => e.enabled && e.hasDownloadProvider)
|
||||
.toList();
|
||||
|
||||
// Check if current service is an extension that's now disabled
|
||||
final isExtensionService = !['tidal', 'qobuz', 'amazon'].contains(currentService);
|
||||
final isCurrentExtensionEnabled = isExtensionService
|
||||
? extensionProviders.any((e) => e.id == currentService)
|
||||
: true;
|
||||
|
||||
// If current extension is disabled, show it as not selected
|
||||
final effectiveService = isCurrentExtensionEnabled ? currentService : '';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
child: Column(
|
||||
children: [
|
||||
_ServiceChip(
|
||||
icon: Icons.music_note,
|
||||
label: 'Tidal',
|
||||
isSelected: currentService == 'tidal',
|
||||
onTap: () => onChanged('tidal'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(
|
||||
icon: Icons.album,
|
||||
label: 'Qobuz',
|
||||
isSelected: currentService == 'qobuz',
|
||||
onTap: () => onChanged('qobuz'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(
|
||||
icon: Icons.shopping_bag,
|
||||
label: 'Amazon',
|
||||
isSelected: currentService == 'amazon',
|
||||
onTap: () => onChanged('amazon'),
|
||||
Row(
|
||||
children: [
|
||||
_ServiceChip(
|
||||
icon: Icons.music_note,
|
||||
label: 'Tidal',
|
||||
isSelected: effectiveService == 'tidal',
|
||||
onTap: () => onChanged('tidal'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(
|
||||
icon: Icons.album,
|
||||
label: 'Qobuz',
|
||||
isSelected: effectiveService == 'qobuz',
|
||||
onTap: () => onChanged('qobuz'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(
|
||||
icon: Icons.shopping_bag,
|
||||
label: 'Amazon',
|
||||
isSelected: effectiveService == 'amazon',
|
||||
onTap: () => onChanged('amazon'),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Show extension download providers if any
|
||||
if (extensionProviders.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
for (int i = 0; i < extensionProviders.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _ServiceChip(
|
||||
icon: Icons.extension,
|
||||
label: extensionProviders[i].displayName,
|
||||
isSelected: effectiveService == extensionProviders[i].id,
|
||||
onTap: () => onChanged(extensionProviders[i].id),
|
||||
),
|
||||
),
|
||||
],
|
||||
// Fill remaining space if less than 3 extensions
|
||||
for (int i = extensionProviders.length; i < 3; i++) ...[
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(child: SizedBox()),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -76,38 +76,50 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
.setMetadataSource(v),
|
||||
),
|
||||
if (settings.metadataSource == 'spotify') ...[
|
||||
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);
|
||||
if (v && settings.spotifyClientId.isEmpty) {
|
||||
_showSpotifyCredentialsDialog(context, ref, settings);
|
||||
}
|
||||
},
|
||||
showDivider: true,
|
||||
),
|
||||
// Info card about Spotify credentials requirement
|
||||
if (settings.spotifyClientId.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Card(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Spotify requires your own API credentials. Get them free from developer.spotify.com',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.key,
|
||||
title: 'Set Credentials',
|
||||
title: 'Spotify Credentials',
|
||||
subtitle: settings.spotifyClientId.isNotEmpty
|
||||
? 'Client ID: ${settings.spotifyClientId.length > 8 ? '${settings.spotifyClientId.substring(0, 8)}...' : settings.spotifyClientId}'
|
||||
: 'Not configured',
|
||||
: 'Required - tap to configure',
|
||||
onTap: () =>
|
||||
_showSpotifyCredentialsDialog(context, ref, settings),
|
||||
trailing: Icon(
|
||||
settings.spotifyClientId.isNotEmpty
|
||||
? Icons.edit
|
||||
: Icons.add,
|
||||
? Icons.check_circle
|
||||
: Icons.error_outline,
|
||||
color: settings.spotifyClientId.isNotEmpty
|
||||
? Theme.of(context).colorScheme.onSurfaceVariant
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.error,
|
||||
size: 20,
|
||||
),
|
||||
showDivider: false,
|
||||
@@ -782,14 +794,18 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final extState = ref.watch(extensionProvider);
|
||||
|
||||
// Check if extension search provider is active
|
||||
final hasExtensionSearch = settings.searchProvider != null &&
|
||||
settings.searchProvider!.isNotEmpty;
|
||||
// Check if extension search provider is active AND enabled
|
||||
Extension? activeExtension;
|
||||
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) {
|
||||
activeExtension = extState.extensions
|
||||
.where((e) => e.id == settings.searchProvider && e.enabled)
|
||||
.firstOrNull;
|
||||
}
|
||||
final hasExtensionSearch = activeExtension != null;
|
||||
|
||||
String? extensionName;
|
||||
if (hasExtensionSearch) {
|
||||
final ext = extState.extensions.where((e) => e.id == settings.searchProvider).firstOrNull;
|
||||
extensionName = ext?.displayName ?? settings.searchProvider;
|
||||
extensionName = activeExtension.displayName;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
@@ -820,6 +836,8 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
_SourceChip(
|
||||
icon: Icons.graphic_eq,
|
||||
label: 'Deezer',
|
||||
badge: 'Free',
|
||||
badgeColor: colorScheme.tertiary,
|
||||
// Not selected if extension is active
|
||||
isSelected: currentSource == 'deezer' && !hasExtensionSearch,
|
||||
onTap: () {
|
||||
@@ -834,6 +852,8 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
_SourceChip(
|
||||
icon: Icons.music_note,
|
||||
label: 'Spotify',
|
||||
badge: 'API Key',
|
||||
badgeColor: colorScheme.secondary,
|
||||
// Not selected if extension is active
|
||||
isSelected: currentSource == 'spotify' && !hasExtensionSearch,
|
||||
onTap: () {
|
||||
@@ -878,12 +898,16 @@ class _SourceChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback? onTap;
|
||||
final String? badge;
|
||||
final Color? badgeColor;
|
||||
|
||||
const _SourceChip({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
this.onTap,
|
||||
this.badge,
|
||||
this.badgeColor,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -929,6 +953,24 @@ class _SourceChip extends StatelessWidget {
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (badge != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: (badgeColor ?? colorScheme.tertiary).withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
badge!,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: badgeColor ?? colorScheme.tertiary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -380,11 +380,10 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
_clientIdController.text.trim(),
|
||||
_clientSecretController.text.trim(),
|
||||
);
|
||||
ref.read(settingsProvider.notifier).setUseCustomSpotifyCredentials(true);
|
||||
// Set search source to Spotify when using custom credentials
|
||||
// Set search source to Spotify when credentials are provided
|
||||
ref.read(settingsProvider.notifier).setMetadataSource('spotify');
|
||||
} else {
|
||||
// Use Deezer as default search source
|
||||
// Use Deezer as default search source (free, no credentials required)
|
||||
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
|
||||
}
|
||||
|
||||
|
||||
@@ -331,7 +331,6 @@ class PlatformBridge {
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -339,6 +338,13 @@ class PlatformBridge {
|
||||
});
|
||||
}
|
||||
|
||||
/// Check if Spotify credentials are configured
|
||||
/// Returns true if credentials are available (custom or env vars)
|
||||
static Future<bool> hasSpotifyCredentials() async {
|
||||
final result = await _channel.invokeMethod('hasSpotifyCredentials');
|
||||
return result as bool;
|
||||
}
|
||||
|
||||
/// Pre-warm track ID cache for album/playlist tracks
|
||||
/// This runs in background and returns immediately
|
||||
/// Speeds up subsequent downloads by caching ISRC → Track ID mappings
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||
publish_to: "none"
|
||||
version: 3.0.0-alpha.1+50
|
||||
version: 3.0.0-alpha.2+51
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||
publish_to: "none"
|
||||
version: 3.0.0-alpha.1+50
|
||||
version: 3.0.0-alpha.2+51
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
|
||||
Reference in New Issue
Block a user