mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-14 12:58:03 +02:00
refactor: remove built-in provider registry in favor of extensions
All search, metadata, and download providers are now exclusively supplied by extensions. The built-in provider registry that previously exposed Deezer/Tidal/Qobuz as hardcoded providers is fully removed. Removed across Go, Dart, Kotlin, and Swift: - BuiltInProviderSpec class, registry, and all accessor helpers - SearchProviderAllJSON, GetBuiltInProvidersJSON, ParseProviderURLJSON, ParseDeezerURLExport Go exports and their platform channel bindings - Built-in provider items in search dropdown, service picker, and provider priority UI lists - provider_ui_utils.dart helper file Deezer metadata enrichment (ISRC lookup, extended metadata, cover upgrade) remains fully functional through direct DeezerClient calls in the download pipeline — these are not part of the provider registry and are unaffected. Mark deezer as a retired built-in metadata provider so stale user priority lists are cleaned up on next launch.
This commit is contained in:
@@ -2795,23 +2795,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"searchProviderAll" -> {
|
||||
val providerId = call.argument<String>("provider_id") ?: ""
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||
val artistLimit = call.argument<Int>("artist_limit") ?: 2
|
||||
val filter = call.argument<String>("filter") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.searchProviderAllJSON(providerId, query, trackLimit.toLong(), artistLimit.toLong(), filter)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getBuiltInProviders" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getBuiltInProvidersJSON()
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getDeezerRelatedArtists" -> {
|
||||
val artistId = call.argument<String>("artist_id") ?: ""
|
||||
val limit = call.argument<Int>("limit") ?: 12
|
||||
@@ -2829,13 +2812,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"parseProviderUrl" -> {
|
||||
val url = call.argument<String>("url") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.parseProviderURLJSON(url)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"searchDeezerByISRC" -> {
|
||||
val isrc = call.argument<String>("isrc") ?: ""
|
||||
val itemId = call.argument<String>("item_id") ?: ""
|
||||
|
||||
@@ -205,6 +205,7 @@ object SafDownloadHandler {
|
||||
mimeType: String,
|
||||
srcPath: String
|
||||
): String? {
|
||||
var stagedDocument: DocumentFile? = null
|
||||
return try {
|
||||
val treeUri = Uri.parse(treeUriStr)
|
||||
val targetDir = ensureDocumentDir(context, treeUri, relativeDir) ?: return null
|
||||
@@ -213,11 +214,18 @@ object SafDownloadHandler {
|
||||
val stagedName = buildStagedSafFileName(finalName, ext)
|
||||
val document = createOrReuseDocumentFile(targetDir, mimeType, stagedName)
|
||||
?: return null
|
||||
context.contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
|
||||
stagedDocument = document
|
||||
val outputStream = context.contentResolver.openOutputStream(document.uri, "wt")
|
||||
if (outputStream == null) {
|
||||
document.delete()
|
||||
stagedDocument = null
|
||||
return null
|
||||
}
|
||||
outputStream.use { output ->
|
||||
File(srcPath).inputStream().use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
} ?: return null
|
||||
}
|
||||
|
||||
val existingFinal = targetDir.findFile(finalName)
|
||||
if (existingFinal != null && existingFinal.uri != document.uri) {
|
||||
@@ -227,8 +235,10 @@ object SafDownloadHandler {
|
||||
document.delete()
|
||||
return null
|
||||
}
|
||||
stagedDocument = null
|
||||
targetDir.findFile(finalName)?.uri?.toString() ?: document.uri.toString()
|
||||
} catch (e: Exception) {
|
||||
stagedDocument?.delete()
|
||||
android.util.Log.w("SpotiFLAC", "Failed to write file to SAF: ${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
+1
-77
@@ -1782,28 +1782,6 @@ func ClearTrackIDCache() {
|
||||
ClearTrackCache()
|
||||
}
|
||||
|
||||
func SearchProviderAllJSON(
|
||||
providerID,
|
||||
query string,
|
||||
trackLimit,
|
||||
artistLimit int,
|
||||
filter string,
|
||||
) (string, error) {
|
||||
normalizedProviderID := strings.ToLower(strings.TrimSpace(providerID))
|
||||
if !isBuiltInSearchProvider(normalizedProviderID) {
|
||||
return "", fmt.Errorf("unsupported search provider: %s", providerID)
|
||||
}
|
||||
return searchBuiltInProviderAll(normalizedProviderID, query, trackLimit, artistLimit, filter)
|
||||
}
|
||||
|
||||
func GetBuiltInProvidersJSON() (string, error) {
|
||||
jsonBytes, err := json.Marshal(getBuiltInProviderSpecs())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func GetDeezerRelatedArtists(artistID string, limit int) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
@@ -2076,12 +2054,7 @@ func GetProviderMetadataJSON(providerID, resourceType, resourceID string) (strin
|
||||
return "", fmt.Errorf("empty provider ID")
|
||||
}
|
||||
|
||||
normalizedProviderID := strings.ToLower(trimmedProviderID)
|
||||
if isBuiltInMetadataProvider(normalizedProviderID) {
|
||||
return getBuiltInProviderMetadata(normalizedProviderID, resourceType, resourceID)
|
||||
}
|
||||
|
||||
switch normalizedProviderID {
|
||||
switch strings.ToLower(trimmedProviderID) {
|
||||
case "deezer":
|
||||
return GetDeezerMetadata(resourceType, resourceID)
|
||||
default:
|
||||
@@ -2098,55 +2071,6 @@ func GetProviderMetadataJSON(providerID, resourceType, resourceID string) (strin
|
||||
}
|
||||
}
|
||||
|
||||
func ParseDeezerURLExport(url string) (string, error) {
|
||||
resourceType, resourceID, err := parseDeezerURL(url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
result := map[string]string{
|
||||
"type": resourceType,
|
||||
"id": resourceID,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func ParseProviderURLJSON(url string) (string, error) {
|
||||
parsers := []struct {
|
||||
providerID string
|
||||
parse func(string) (string, string, error)
|
||||
}{
|
||||
{providerID: "deezer", parse: parseDeezerURL},
|
||||
}
|
||||
|
||||
for _, parser := range parsers {
|
||||
resourceType, resourceID, err := parser.parse(url)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
result := map[string]string{
|
||||
"provider_id": parser.providerID,
|
||||
"type": resourceType,
|
||||
"id": resourceID,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("unsupported provider URL")
|
||||
}
|
||||
|
||||
func GetDeezerExtendedMetadata(trackID string) (string, error) {
|
||||
if trackID == "" {
|
||||
return "", fmt.Errorf("empty track ID")
|
||||
|
||||
@@ -199,13 +199,6 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||
if firstNonEmptyTrimmed(" ", " value ") != "value" {
|
||||
t.Fatal("expected first trimmed value")
|
||||
}
|
||||
if jsonText, err := GetBuiltInProvidersJSON(); err != nil || jsonText == "" {
|
||||
t.Fatalf("GetBuiltInProvidersJSON = %q/%v", jsonText, err)
|
||||
}
|
||||
if _, err := SearchProviderAllJSON("missing", "q", 1, 1, ""); err == nil {
|
||||
t.Fatal("expected unsupported search provider")
|
||||
}
|
||||
|
||||
requestJSON := `{"use_extensions":true,"use_fallback":false,"service":"coverage-ext","source":"coverage-ext","track_name":"Song","artist_name":"Artist","album_name":"Album","output_dir":"` + escapeJSONPath(dir) + `","output_ext":".flac","quality":"LOSSLESS"}`
|
||||
if jsonText, err := DownloadWithExtensionsJSON(requestJSON); err != nil || !strings.Contains(jsonText, "coverage-ext") {
|
||||
t.Fatalf("DownloadWithExtensionsJSON = %q/%v", jsonText, err)
|
||||
@@ -286,15 +279,6 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||
if jsonText, err := GetDeezerRelatedArtists("301", 2); err != nil || !strings.Contains(jsonText, "Related") {
|
||||
t.Fatalf("GetDeezerRelatedArtists = %q/%v", jsonText, err)
|
||||
}
|
||||
if jsonText, err := ParseDeezerURLExport("https://www.deezer.com/track/101"); err != nil || !strings.Contains(jsonText, "101") {
|
||||
t.Fatalf("ParseDeezerURLExport = %q/%v", jsonText, err)
|
||||
}
|
||||
if jsonText, err := ParseProviderURLJSON("https://www.deezer.com/album/201"); err != nil || !strings.Contains(jsonText, "deezer") {
|
||||
t.Fatalf("ParseProviderURLJSON = %q/%v", jsonText, err)
|
||||
}
|
||||
if _, err := ParseProviderURLJSON("https://example.com/1"); err == nil {
|
||||
t.Fatal("expected unsupported provider URL")
|
||||
}
|
||||
if jsonText, err := GetDeezerExtendedMetadata("101"); err != nil || !strings.Contains(jsonText, "Label") {
|
||||
t.Fatalf("GetDeezerExtendedMetadata = %q/%v", jsonText, err)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestExtensionProviderWrapperFullSurface(t *testing.T) {
|
||||
@@ -126,79 +120,7 @@ func TestExtensionProviderWrapperFullSurface(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltInProviderAndManagerSelectionHelpers(t *testing.T) {
|
||||
previousRegistry := builtInProviderRegistry
|
||||
builtInProviderRegistry = []builtInProviderSpec{{
|
||||
ID: "deezer",
|
||||
DisplayName: "Deezer",
|
||||
SupportsMetadata: true,
|
||||
SupportsSearch: true,
|
||||
GetMetadata: GetDeezerMetadata,
|
||||
SearchAll: func(query string, trackLimit, artistLimit int, filter string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
result, err := GetDeezerClient().SearchAll(ctx, query, trackLimit, artistLimit, filter)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := json.Marshal(result)
|
||||
return string(data), err
|
||||
},
|
||||
SearchTracks: func(query string, limit int) ([]ExtTrackMetadata, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
result, err := GetDeezerClient().SearchAll(ctx, query, limit, limit, "track")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tracks := make([]ExtTrackMetadata, len(result.Tracks))
|
||||
for i, track := range result.Tracks {
|
||||
tracks[i] = normalizeBuiltInMetadataTrack(track, "deezer")
|
||||
}
|
||||
return tracks, nil
|
||||
},
|
||||
}}
|
||||
defer func() { builtInProviderRegistry = previousRegistry }()
|
||||
|
||||
deezerClient = &DeezerClient{
|
||||
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
body := fakeDeezerResponse(req.URL.Path, req.URL.RawQuery)
|
||||
if body == "" {
|
||||
body = `{"data":[]}`
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
|
||||
})},
|
||||
searchCache: map[string]*cacheEntry{},
|
||||
albumCache: map[string]*cacheEntry{},
|
||||
artistCache: map[string]*cacheEntry{},
|
||||
isrcCache: map[string]string{},
|
||||
cacheCleanupInterval: time.Hour,
|
||||
}
|
||||
deezerClientOnce.Do(func() {})
|
||||
|
||||
if !isBuiltInProvider("deezer") || !isBuiltInMetadataProvider("deezer") || !isBuiltInSearchProvider("deezer") {
|
||||
t.Fatal("expected Deezer built-in provider")
|
||||
}
|
||||
if _, ok := getBuiltInProviderSpec(" missing "); ok {
|
||||
t.Fatal("unexpected missing provider spec")
|
||||
}
|
||||
if _, err := getBuiltInProviderMetadata("missing", "track", "1"); err == nil {
|
||||
t.Fatal("expected unsupported metadata provider")
|
||||
}
|
||||
if jsonText, err := getBuiltInProviderMetadata("deezer", "track", "101"); err != nil || !strings.Contains(jsonText, "Track 101") {
|
||||
t.Fatalf("built-in metadata = %q/%v", jsonText, err)
|
||||
}
|
||||
if jsonText, err := searchBuiltInProviderAll("deezer", "artist song", 2, 2, "track"); err != nil || !strings.Contains(jsonText, "Track 101") {
|
||||
t.Fatalf("built-in search all = %q/%v", jsonText, err)
|
||||
}
|
||||
tracks, err := searchBuiltInProviderTracks("deezer", "artist song", 2)
|
||||
if err != nil || len(tracks) != 1 || tracks[0].ProviderID != "deezer" {
|
||||
t.Fatalf("built-in tracks = %#v/%v", tracks, err)
|
||||
}
|
||||
if _, err := searchBuiltInProviderTracks("missing", "q", 1); err == nil {
|
||||
t.Fatal("expected unsupported built-in tracks")
|
||||
}
|
||||
|
||||
func TestExtensionProviderAndManagerSelectionHelpers(t *testing.T) {
|
||||
manifest := &ExtensionManifest{Capabilities: map[string]interface{}{
|
||||
"replacesBuiltInProviders": []interface{}{" Deezer ", 7, ""},
|
||||
}}
|
||||
@@ -211,22 +133,11 @@ func TestBuiltInProviderAndManagerSelectionHelpers(t *testing.T) {
|
||||
if trimKnownProviderPrefix("Deezer:101", "deezer") != "101" || trimKnownProviderPrefix("101", "deezer") != "101" {
|
||||
t.Fatal("trimKnownProviderPrefix mismatch")
|
||||
}
|
||||
normalized := normalizeBuiltInMetadataTrack(TrackMetadata{SpotifyID: "deezer:101", Name: "Song", Artists: "Artist", ISRC: "ISRC"}, "deezer")
|
||||
if normalized.DeezerID != "101" || normalized.ProviderID != "deezer" {
|
||||
t.Fatalf("normalized built-in track = %#v", normalized)
|
||||
}
|
||||
if metadataTrackDedupKey(ExtTrackMetadata{ISRC: "usrc"}) != "isrc:USRC" ||
|
||||
metadataTrackDedupKey(ExtTrackMetadata{SpotifyID: "sp"}) != "spotify:sp" ||
|
||||
metadataTrackDedupKey(ExtTrackMetadata{ProviderID: "p", ID: "1"}) != "p:1" {
|
||||
t.Fatal("metadata dedup key mismatch")
|
||||
}
|
||||
searchBuiltInMetadataTracksFunc = func(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
|
||||
return []ExtTrackMetadata{{ID: "built-in", ProviderID: providerID}}, nil
|
||||
}
|
||||
defer func() { searchBuiltInMetadataTracksFunc = searchBuiltInMetadataTracks }()
|
||||
if tracks, err := searchBuiltInMetadataTracksForItemID("deezer", "q", 1, "item"); err != nil || len(tracks) != 1 {
|
||||
t.Fatalf("searchBuiltInMetadataTracksForItemID = %#v/%v", tracks, err)
|
||||
}
|
||||
|
||||
manager := &extensionManager{extensions: map[string]*loadedExtension{}}
|
||||
downloadExt := newTestLoadedExtension(t, ExtensionTypeDownloadProvider, ExtensionTypeMetadataProvider)
|
||||
@@ -247,7 +158,7 @@ func TestBuiltInProviderAndManagerSelectionHelpers(t *testing.T) {
|
||||
t.Fatal("nil fallback list should allow all")
|
||||
}
|
||||
SetMetadataProviderPriority([]string{"spotify", "deezer", "coverage-ext", "coverage-ext"})
|
||||
if priority := GetMetadataProviderPriority(); len(priority) != 2 || priority[0] != "deezer" || priority[1] != "coverage-ext" {
|
||||
if priority := GetMetadataProviderPriority(); len(priority) != 1 || priority[0] != "coverage-ext" {
|
||||
t.Fatalf("metadata priority = %#v", priority)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,58 +101,6 @@ type ExtDownloadURLResult struct {
|
||||
SampleRate int `json:"sample_rate,omitempty"`
|
||||
}
|
||||
|
||||
type builtInProviderSpec struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
SupportsMetadata bool `json:"supports_metadata"`
|
||||
SupportsSearch bool `json:"supports_search"`
|
||||
GetMetadata func(resourceType, resourceID string) (string, error) `json:"-"`
|
||||
SearchAll func(query string, trackLimit, artistLimit int, filter string) (string, error) `json:"-"`
|
||||
SearchTracks func(query string, limit int) ([]ExtTrackMetadata, error) `json:"-"`
|
||||
}
|
||||
|
||||
var builtInProviderRegistry = []builtInProviderSpec{}
|
||||
|
||||
func getBuiltInProviderSpecs() []builtInProviderSpec {
|
||||
specs := make([]builtInProviderSpec, len(builtInProviderRegistry))
|
||||
copy(specs, builtInProviderRegistry)
|
||||
return specs
|
||||
}
|
||||
|
||||
func getBuiltInProviderSpec(providerID string) (builtInProviderSpec, bool) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(providerID))
|
||||
for _, spec := range builtInProviderRegistry {
|
||||
if spec.ID == normalized {
|
||||
return spec, true
|
||||
}
|
||||
}
|
||||
return builtInProviderSpec{}, false
|
||||
}
|
||||
|
||||
func getBuiltInProviderMetadata(providerID, resourceType, resourceID string) (string, error) {
|
||||
spec, ok := getBuiltInProviderSpec(providerID)
|
||||
if !ok || !spec.SupportsMetadata || spec.GetMetadata == nil {
|
||||
return "", fmt.Errorf("unsupported built-in metadata provider: %s", providerID)
|
||||
}
|
||||
return spec.GetMetadata(resourceType, resourceID)
|
||||
}
|
||||
|
||||
func searchBuiltInProviderAll(providerID, query string, trackLimit, artistLimit int, filter string) (string, error) {
|
||||
spec, ok := getBuiltInProviderSpec(providerID)
|
||||
if !ok || !spec.SupportsSearch || spec.SearchAll == nil {
|
||||
return "", fmt.Errorf("unsupported search provider: %s", providerID)
|
||||
}
|
||||
return spec.SearchAll(query, trackLimit, artistLimit, filter)
|
||||
}
|
||||
|
||||
func searchBuiltInProviderTracks(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
|
||||
spec, ok := getBuiltInProviderSpec(providerID)
|
||||
if !ok || !spec.SupportsMetadata || spec.SearchTracks == nil {
|
||||
return nil, fmt.Errorf("unsupported built-in metadata provider: %s", providerID)
|
||||
}
|
||||
return spec.SearchTracks(query, limit)
|
||||
}
|
||||
|
||||
func manifestCapabilityStringList(manifest *ExtensionManifest, key string) []string {
|
||||
if manifest == nil || manifest.Capabilities == nil {
|
||||
return nil
|
||||
@@ -1783,40 +1731,6 @@ var extensionFallbackProviderIDsMu sync.RWMutex
|
||||
var metadataProviderPriority []string
|
||||
var metadataProviderPriorityMu sync.RWMutex
|
||||
|
||||
var searchBuiltInMetadataTracksFunc = searchBuiltInMetadataTracks
|
||||
|
||||
func searchBuiltInMetadataTracksForItemID(providerID, query string, limit int, itemID string) ([]ExtTrackMetadata, error) {
|
||||
if itemID == "" {
|
||||
return searchBuiltInMetadataTracksFunc(providerID, query, limit)
|
||||
}
|
||||
|
||||
ctx := initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return nil, ErrDownloadCancelled
|
||||
}
|
||||
|
||||
type searchResult struct {
|
||||
tracks []ExtTrackMetadata
|
||||
err error
|
||||
}
|
||||
done := make(chan searchResult, 1)
|
||||
go func() {
|
||||
tracks, err := searchBuiltInMetadataTracksFunc(providerID, query, limit)
|
||||
done <- searchResult{tracks: tracks, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ErrDownloadCancelled
|
||||
case result := <-done:
|
||||
if isDownloadCancelled(itemID) {
|
||||
return nil, ErrDownloadCancelled
|
||||
}
|
||||
return result.tracks, result.err
|
||||
}
|
||||
}
|
||||
|
||||
func SetProviderPriority(providerIDs []string) {
|
||||
providerPriorityMu.Lock()
|
||||
defer providerPriorityMu.Unlock()
|
||||
@@ -1880,11 +1794,8 @@ func isRetiredBuiltInMetadataProvider(providerID string) bool {
|
||||
if normalized == "" {
|
||||
return false
|
||||
}
|
||||
if isBuiltInMetadataProvider(normalized) {
|
||||
return false
|
||||
}
|
||||
switch normalized {
|
||||
case "spotify", "qobuz", "tidal":
|
||||
case "deezer", "spotify", "qobuz", "tidal":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -1980,61 +1891,6 @@ func GetMetadataProviderPriority() []string {
|
||||
return result
|
||||
}
|
||||
|
||||
func isBuiltInProvider(providerID string) bool {
|
||||
_, ok := getBuiltInProviderSpec(providerID)
|
||||
return ok
|
||||
}
|
||||
|
||||
func isBuiltInMetadataProvider(providerID string) bool {
|
||||
spec, ok := getBuiltInProviderSpec(providerID)
|
||||
return ok && spec.SupportsMetadata
|
||||
}
|
||||
|
||||
func isBuiltInSearchProvider(providerID string) bool {
|
||||
spec, ok := getBuiltInProviderSpec(providerID)
|
||||
return ok && spec.SupportsSearch
|
||||
}
|
||||
|
||||
func normalizeBuiltInMetadataTrack(track TrackMetadata, providerID string) ExtTrackMetadata {
|
||||
deezerID := ""
|
||||
tidalID := ""
|
||||
qobuzID := ""
|
||||
prefixedID := strings.TrimSpace(track.SpotifyID)
|
||||
|
||||
switch providerID {
|
||||
case "deezer":
|
||||
deezerID = strings.TrimPrefix(prefixedID, "deezer:")
|
||||
case "tidal":
|
||||
tidalID = strings.TrimPrefix(prefixedID, "tidal:")
|
||||
case "qobuz":
|
||||
qobuzID = strings.TrimPrefix(prefixedID, "qobuz:")
|
||||
}
|
||||
|
||||
return ExtTrackMetadata{
|
||||
ID: prefixedID,
|
||||
Name: track.Name,
|
||||
Artists: track.Artists,
|
||||
AlbumName: track.AlbumName,
|
||||
AlbumArtist: track.AlbumArtist,
|
||||
DurationMS: track.DurationMS,
|
||||
CoverURL: track.Images,
|
||||
Images: track.Images,
|
||||
ReleaseDate: track.ReleaseDate,
|
||||
TrackNumber: track.TrackNumber,
|
||||
TotalTracks: track.TotalTracks,
|
||||
DiscNumber: track.DiscNumber,
|
||||
TotalDiscs: track.TotalDiscs,
|
||||
ISRC: track.ISRC,
|
||||
ProviderID: providerID,
|
||||
SpotifyID: prefixedID,
|
||||
DeezerID: deezerID,
|
||||
TidalID: tidalID,
|
||||
QobuzID: qobuzID,
|
||||
AlbumType: track.AlbumType,
|
||||
Composer: track.Composer,
|
||||
}
|
||||
}
|
||||
|
||||
func metadataTrackDedupKey(track ExtTrackMetadata) string {
|
||||
if isrc := strings.TrimSpace(track.ISRC); isrc != "" {
|
||||
return "isrc:" + strings.ToUpper(isrc)
|
||||
@@ -2048,10 +1904,6 @@ func metadataTrackDedupKey(track ExtTrackMetadata) string {
|
||||
return strings.TrimSpace(track.Name) + "|" + strings.TrimSpace(track.Artists)
|
||||
}
|
||||
|
||||
func searchBuiltInMetadataTracks(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
|
||||
return searchBuiltInProviderTracks(providerID, query, limit)
|
||||
}
|
||||
|
||||
func (m *extensionManager) SearchTracksWithMetadataProviders(query string, limit int, includeExtensions bool) ([]ExtTrackMetadata, error) {
|
||||
return m.SearchTracksWithMetadataProvidersForItemID(query, limit, includeExtensions, "")
|
||||
}
|
||||
@@ -2098,29 +1950,17 @@ func (m *extensionManager) SearchTracksWithMetadataProvidersForItemID(query stri
|
||||
return nil, ErrDownloadCancelled
|
||||
}
|
||||
|
||||
var (
|
||||
providerTracks []ExtTrackMetadata
|
||||
err error
|
||||
)
|
||||
|
||||
if isBuiltInProvider(providerID) {
|
||||
providerTracks, err = searchBuiltInMetadataTracksForItemID(providerID, query, limit, itemID)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return nil, ErrDownloadCancelled
|
||||
}
|
||||
} else {
|
||||
if !includeExtensions {
|
||||
continue
|
||||
}
|
||||
provider := extensionProviders[providerID]
|
||||
if provider == nil {
|
||||
continue
|
||||
}
|
||||
var result *ExtSearchResult
|
||||
result, err = provider.SearchTracksForItemID(query, limit, itemID)
|
||||
if result != nil {
|
||||
providerTracks = result.Tracks
|
||||
}
|
||||
if !includeExtensions {
|
||||
continue
|
||||
}
|
||||
provider := extensionProviders[providerID]
|
||||
if provider == nil {
|
||||
continue
|
||||
}
|
||||
result, err := provider.SearchTracksForItemID(query, limit, itemID)
|
||||
providerTracks := []ExtTrackMetadata(nil)
|
||||
if result != nil {
|
||||
providerTracks = result.Tracks
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@@ -2199,9 +2039,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
var sourceExtensionAvailability *ExtAvailabilityResult
|
||||
var sourceExtensionTrackID string
|
||||
|
||||
if req.Source != "" &&
|
||||
!isBuiltInProvider(strings.ToLower(req.Source)) &&
|
||||
selectedProvider != req.Source {
|
||||
if req.Source != "" && selectedProvider != req.Source {
|
||||
ext, err := extManager.GetExtension(req.Source)
|
||||
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() {
|
||||
provider := newExtensionProviderWrapper(ext)
|
||||
@@ -2221,7 +2059,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
}
|
||||
|
||||
if req.Source != "" && !isBuiltInProvider(strings.ToLower(req.Source)) {
|
||||
if req.Source != "" {
|
||||
ext, err := extManager.GetExtension(req.Source)
|
||||
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsMetadataProvider() {
|
||||
GoLog("[DownloadWithExtensionFallback] Enriching track from extension '%s'...\n", req.Source)
|
||||
@@ -2328,7 +2166,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
}
|
||||
|
||||
if req.Source != "" && !isBuiltInProvider(strings.ToLower(req.Source)) &&
|
||||
if req.Source != "" &&
|
||||
req.TrackName != "" && req.ArtistName != "" &&
|
||||
(req.AlbumName == "" || req.ReleaseDate == "" || req.ISRC == "") {
|
||||
|
||||
@@ -2396,9 +2234,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
}
|
||||
|
||||
if req.Source != "" &&
|
||||
!isBuiltInProvider(strings.ToLower(req.Source)) &&
|
||||
selectedProvider == req.Source {
|
||||
if req.Source != "" && selectedProvider == req.Source {
|
||||
if isDownloadCancelled(req.ItemID) {
|
||||
return nil, ErrDownloadCancelled
|
||||
}
|
||||
|
||||
@@ -372,20 +372,12 @@ func TestCanEmbedGenreLabelRequiresExistingAbsoluteLocalFile(t *testing.T) {
|
||||
|
||||
func TestSearchTracksWithMetadataProvidersIgnoresRetiredBuiltIns(t *testing.T) {
|
||||
originalPriority := GetMetadataProviderPriority()
|
||||
originalSearch := searchBuiltInMetadataTracksFunc
|
||||
defer func() {
|
||||
SetMetadataProviderPriority(originalPriority)
|
||||
searchBuiltInMetadataTracksFunc = originalSearch
|
||||
}()
|
||||
|
||||
SetMetadataProviderPriority([]string{"qobuz"})
|
||||
|
||||
var calls []string
|
||||
searchBuiltInMetadataTracksFunc = func(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
|
||||
calls = append(calls, providerID)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
manager := getExtensionManager()
|
||||
tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false)
|
||||
if err != nil {
|
||||
@@ -394,9 +386,6 @@ func TestSearchTracksWithMetadataProvidersIgnoresRetiredBuiltIns(t *testing.T) {
|
||||
if len(tracks) != 0 {
|
||||
t.Fatalf("expected no tracks from retired built-in provider, got %+v", tracks)
|
||||
}
|
||||
if len(calls) != 0 {
|
||||
t.Fatalf("expected retired built-in provider not to be queried, got %v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExtensionSearchResultAcceptsObjectAndArrayShapes(t *testing.T) {
|
||||
|
||||
@@ -458,22 +458,6 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "searchProviderAll":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let providerId = args["provider_id"] as! String
|
||||
let query = args["query"] as! String
|
||||
let trackLimit = args["track_limit"] as? Int ?? 15
|
||||
let artistLimit = args["artist_limit"] as? Int ?? 3
|
||||
let filter = args["filter"] as? String ?? ""
|
||||
let response = GobackendSearchProviderAllJSON(providerId, query, Int(trackLimit), Int(artistLimit), filter, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getBuiltInProviders":
|
||||
let response = GobackendGetBuiltInProvidersJSON(&error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getDeezerRelatedArtists":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let artistId = args["artist_id"] as! String
|
||||
@@ -491,13 +475,6 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "parseProviderUrl":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let url = args["url"] as! String
|
||||
let response = GobackendParseProviderURLJSON(url, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "searchDeezerByISRC":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let isrc = args["isrc"] as! String
|
||||
|
||||
@@ -1773,7 +1773,7 @@ abstract class AppLocalizations {
|
||||
/// Section description for extension fallback selection
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.'**
|
||||
/// **'Choose which installed download extensions can be used during automatic fallback.'**
|
||||
String get providerPriorityFallbackExtensionsDescription;
|
||||
|
||||
/// Hint below the extension fallback selection list
|
||||
|
||||
@@ -956,7 +956,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
|
||||
@@ -943,7 +943,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
|
||||
@@ -943,7 +943,7 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
|
||||
@@ -946,7 +946,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
|
||||
@@ -943,7 +943,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
|
||||
@@ -946,7 +946,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
|
||||
@@ -937,7 +937,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
|
||||
@@ -925,7 +925,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
|
||||
@@ -943,7 +943,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
|
||||
@@ -943,7 +943,7 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
@@ -4734,7 +4734,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
|
||||
@@ -956,7 +956,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
|
||||
@@ -943,7 +943,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
@@ -4710,7 +4710,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
@@ -8187,7 +8187,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
|
||||
@@ -1211,7 +1211,7 @@
|
||||
"@providerPriorityFallbackExtensionsTitle": {
|
||||
"description": "Section title for choosing which download extensions can be used as fallback providers"
|
||||
},
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.",
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
|
||||
@@ -1223,7 +1223,7 @@
|
||||
"@providerPriorityFallbackExtensionsTitle": {
|
||||
"description": "Section title for choosing which download extensions can be used as fallback providers"
|
||||
},
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.",
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
|
||||
@@ -1933,7 +1933,7 @@
|
||||
"@providerPriorityFallbackExtensionsTitle": {
|
||||
"description": "Section title for choosing which download extensions can be used as fallback providers"
|
||||
},
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.",
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
|
||||
@@ -1211,7 +1211,7 @@
|
||||
"@providerPriorityFallbackExtensionsTitle": {
|
||||
"description": "Section title for choosing which download extensions can be used as fallback providers"
|
||||
},
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.",
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
|
||||
@@ -1211,7 +1211,7 @@
|
||||
"@providerPriorityFallbackExtensionsTitle": {
|
||||
"description": "Section title for choosing which download extensions can be used as fallback providers"
|
||||
},
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.",
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
|
||||
@@ -1127,7 +1127,7 @@
|
||||
"@providerPriorityFallbackExtensionsTitle": {
|
||||
"description": "Section title for choosing which download extensions can be used as fallback providers"
|
||||
},
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.",
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
|
||||
@@ -3290,7 +3290,7 @@
|
||||
"@providerPriorityFallbackExtensionsTitle": {
|
||||
"description": "Section title for choosing which download extensions can be used as fallback providers"
|
||||
},
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.",
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
|
||||
@@ -1211,7 +1211,7 @@
|
||||
"@providerPriorityFallbackExtensionsTitle": {
|
||||
"description": "Section title for choosing which download extensions can be used as fallback providers"
|
||||
},
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.",
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
|
||||
@@ -1211,7 +1211,7 @@
|
||||
"@providerPriorityFallbackExtensionsTitle": {
|
||||
"description": "Section title for choosing which download extensions can be used as fallback providers"
|
||||
},
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.",
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
|
||||
@@ -1933,7 +1933,7 @@
|
||||
"@providerPriorityFallbackExtensionsTitle": {
|
||||
"description": "Section title for choosing which download extensions can be used as fallback providers"
|
||||
},
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.",
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
|
||||
@@ -1211,7 +1211,7 @@
|
||||
"@providerPriorityFallbackExtensionsTitle": {
|
||||
"description": "Section title for choosing which download extensions can be used as fallback providers"
|
||||
},
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.",
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
|
||||
@@ -1211,7 +1211,7 @@
|
||||
"@providerPriorityFallbackExtensionsTitle": {
|
||||
"description": "Section title for choosing which download extensions can be used as fallback providers"
|
||||
},
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.",
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
|
||||
@@ -1933,7 +1933,7 @@
|
||||
"@providerPriorityFallbackExtensionsTitle": {
|
||||
"description": "Section title for choosing which download extensions can be used as fallback providers"
|
||||
},
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.",
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
|
||||
@@ -1211,7 +1211,7 @@
|
||||
"@providerPriorityFallbackExtensionsTitle": {
|
||||
"description": "Section title for choosing which download extensions can be used as fallback providers"
|
||||
},
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.",
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
|
||||
@@ -1211,7 +1211,7 @@
|
||||
"@providerPriorityFallbackExtensionsTitle": {
|
||||
"description": "Section title for choosing which download extensions can be used as fallback providers"
|
||||
},
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.",
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
|
||||
@@ -2567,9 +2567,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (normalized.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
if (isBuiltInDownloadProvider(normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
return extensionState.extensions.any(
|
||||
|
||||
@@ -48,44 +48,6 @@ List<String>? _tryDecodeStringListPreference(String rawJson, String key) {
|
||||
}
|
||||
}
|
||||
|
||||
class BuiltInProviderSpec {
|
||||
final String id;
|
||||
final String displayName;
|
||||
final bool supportsMetadata;
|
||||
final bool supportsDownload;
|
||||
final bool supportsSearch;
|
||||
|
||||
const BuiltInProviderSpec({
|
||||
required this.id,
|
||||
required this.displayName,
|
||||
this.supportsMetadata = false,
|
||||
this.supportsDownload = false,
|
||||
this.supportsSearch = false,
|
||||
});
|
||||
|
||||
factory BuiltInProviderSpec.fromJson(Map<String, dynamic> json) {
|
||||
return BuiltInProviderSpec(
|
||||
id: json['id'] as String? ?? '',
|
||||
displayName:
|
||||
json['display_name'] as String? ??
|
||||
json['displayName'] as String? ??
|
||||
'',
|
||||
supportsMetadata: json['supports_metadata'] as bool? ?? false,
|
||||
supportsDownload: json['supports_download'] as bool? ?? false,
|
||||
supportsSearch: json['supports_search'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<BuiltInProviderSpec> _builtInProviderRegistry = const [];
|
||||
|
||||
List<BuiltInProviderSpec> get builtInProviderSpecs =>
|
||||
List<BuiltInProviderSpec>.unmodifiable(_builtInProviderRegistry);
|
||||
|
||||
void _replaceBuiltInProviderRegistry(List<BuiltInProviderSpec> providers) {
|
||||
_builtInProviderRegistry = List<BuiltInProviderSpec>.unmodifiable(providers);
|
||||
}
|
||||
|
||||
class Extension {
|
||||
final String id;
|
||||
final String name;
|
||||
@@ -302,66 +264,16 @@ class Extension {
|
||||
}
|
||||
}
|
||||
|
||||
BuiltInProviderSpec? builtInProviderSpecForId(String? providerId) {
|
||||
if (providerId == null) return null;
|
||||
|
||||
for (final provider in builtInProviderSpecs) {
|
||||
if (provider.id == providerId) {
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
List<BuiltInProviderSpec> _builtInProvidersWhere(
|
||||
bool Function(BuiltInProviderSpec provider) predicate,
|
||||
) {
|
||||
return List<BuiltInProviderSpec>.unmodifiable(
|
||||
builtInProviderSpecs.where(predicate),
|
||||
);
|
||||
}
|
||||
|
||||
List<BuiltInProviderSpec> get builtInSearchProviderSpecs =>
|
||||
_builtInProvidersWhere((provider) => provider.supportsSearch);
|
||||
|
||||
List<BuiltInProviderSpec> get builtInMetadataProviderSpecs =>
|
||||
_builtInProvidersWhere((provider) => provider.supportsMetadata);
|
||||
|
||||
List<BuiltInProviderSpec> get builtInDownloadProviderSpecs =>
|
||||
_builtInProvidersWhere((provider) => provider.supportsDownload);
|
||||
|
||||
List<String> get builtInSearchProviderIds => List<String>.unmodifiable(
|
||||
builtInSearchProviderSpecs.map((provider) => provider.id),
|
||||
);
|
||||
|
||||
List<String> get builtInMetadataProviderIds => List<String>.unmodifiable(
|
||||
builtInMetadataProviderSpecs.map((provider) => provider.id),
|
||||
);
|
||||
|
||||
List<String> get builtInDownloadProviderIds => List<String>.unmodifiable(
|
||||
builtInDownloadProviderSpecs.map((provider) => provider.id),
|
||||
);
|
||||
|
||||
String resolveEffectiveDownloadService(
|
||||
String requestedService,
|
||||
ExtensionState extensionState,
|
||||
) {
|
||||
final normalizedRequested = requestedService.trim().toLowerCase();
|
||||
final builtInDownloadIds = extensionState.builtInProviders
|
||||
.where((provider) => provider.supportsDownload)
|
||||
.map((provider) => provider.id.trim().toLowerCase())
|
||||
.where((providerId) => providerId.isNotEmpty)
|
||||
.toSet();
|
||||
final enabledDownloadExtensions = extensionState.extensions
|
||||
.where((ext) => ext.enabled && ext.hasDownloadProvider)
|
||||
.toList(growable: false);
|
||||
|
||||
if (normalizedRequested.isNotEmpty) {
|
||||
if (builtInDownloadIds.contains(normalizedRequested)) {
|
||||
return normalizedRequested;
|
||||
}
|
||||
|
||||
final matchingExtension = enabledDownloadExtensions
|
||||
.where((ext) => ext.id.trim().toLowerCase() == normalizedRequested)
|
||||
.firstOrNull;
|
||||
@@ -379,25 +291,7 @@ String resolveEffectiveDownloadService(
|
||||
}
|
||||
}
|
||||
|
||||
const preferredBuiltInOrder = ['tidal', 'qobuz', 'deezer'];
|
||||
for (final builtInId in preferredBuiltInOrder) {
|
||||
final replacement = enabledDownloadExtensions
|
||||
.where((ext) => ext.replacesBuiltInProviders.contains(builtInId))
|
||||
.firstOrNull;
|
||||
if (replacement != null) {
|
||||
return replacement.id;
|
||||
}
|
||||
if (builtInDownloadIds.contains(builtInId)) {
|
||||
return builtInId;
|
||||
}
|
||||
}
|
||||
|
||||
return enabledDownloadExtensions.firstOrNull?.id ??
|
||||
extensionState.builtInProviders
|
||||
.where((provider) => provider.supportsDownload)
|
||||
.map((provider) => provider.id)
|
||||
.firstOrNull ??
|
||||
'';
|
||||
return enabledDownloadExtensions.firstOrNull?.id ?? '';
|
||||
}
|
||||
|
||||
String resolveEffectiveMetadataProvider(
|
||||
@@ -405,20 +299,11 @@ String resolveEffectiveMetadataProvider(
|
||||
ExtensionState extensionState,
|
||||
) {
|
||||
final normalizedRequested = requestedProvider.trim().toLowerCase();
|
||||
final builtInMetadataIds = extensionState.builtInProviders
|
||||
.where((provider) => provider.supportsMetadata)
|
||||
.map((provider) => provider.id.trim().toLowerCase())
|
||||
.where((providerId) => providerId.isNotEmpty)
|
||||
.toSet();
|
||||
final enabledMetadataExtensions = extensionState.extensions
|
||||
.where((ext) => ext.enabled && ext.hasMetadataProvider)
|
||||
.toList(growable: false);
|
||||
|
||||
if (normalizedRequested.isNotEmpty) {
|
||||
if (builtInMetadataIds.contains(normalizedRequested)) {
|
||||
return normalizedRequested;
|
||||
}
|
||||
|
||||
final matchingExtension = enabledMetadataExtensions
|
||||
.where((ext) => ext.id.trim().toLowerCase() == normalizedRequested)
|
||||
.firstOrNull;
|
||||
@@ -436,12 +321,7 @@ String resolveEffectiveMetadataProvider(
|
||||
}
|
||||
}
|
||||
|
||||
return enabledMetadataExtensions.firstOrNull?.id ??
|
||||
extensionState.builtInProviders
|
||||
.where((provider) => provider.supportsMetadata)
|
||||
.map((provider) => provider.id)
|
||||
.firstOrNull ??
|
||||
'';
|
||||
return enabledMetadataExtensions.firstOrNull?.id ?? '';
|
||||
}
|
||||
|
||||
bool isDeezerCompatibleDownloadService(
|
||||
@@ -453,10 +333,6 @@ bool isDeezerCompatibleDownloadService(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalizedService == 'deezer') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return extensionState.extensions.any(
|
||||
(ext) =>
|
||||
ext.enabled &&
|
||||
@@ -466,33 +342,10 @@ bool isDeezerCompatibleDownloadService(
|
||||
);
|
||||
}
|
||||
|
||||
bool isBuiltInSearchProvider(String? providerId) =>
|
||||
builtInProviderSpecForId(providerId)?.supportsSearch ?? false;
|
||||
|
||||
bool isBuiltInMetadataProvider(String? providerId) =>
|
||||
builtInProviderSpecForId(providerId)?.supportsMetadata ?? false;
|
||||
|
||||
bool isBuiltInDownloadProvider(String? providerId) =>
|
||||
builtInProviderSpecForId(providerId)?.supportsDownload ?? false;
|
||||
|
||||
String? get defaultBuiltInSearchProviderId => builtInSearchProviderSpecs.isEmpty
|
||||
? null
|
||||
: builtInSearchProviderSpecs.first.id;
|
||||
|
||||
String? get defaultBuiltInSearchProviderDisplayName =>
|
||||
builtInSearchProviderSpecs.isEmpty
|
||||
? null
|
||||
: builtInSearchProviderSpecs.first.displayName;
|
||||
|
||||
String resolveProviderDisplayName(
|
||||
String providerId, {
|
||||
Iterable<Extension> extensions = const [],
|
||||
}) {
|
||||
final builtIn = builtInProviderSpecForId(providerId);
|
||||
if (builtIn != null) {
|
||||
return builtIn.displayName;
|
||||
}
|
||||
|
||||
for (final extension in extensions) {
|
||||
if (extension.id == providerId) {
|
||||
return extension.displayName;
|
||||
@@ -884,7 +737,6 @@ class ExtensionSetting {
|
||||
|
||||
class ExtensionState {
|
||||
final List<Extension> extensions;
|
||||
final List<BuiltInProviderSpec> builtInProviders;
|
||||
final List<String> providerPriority;
|
||||
final List<String> metadataProviderPriority;
|
||||
final Map<String, ExtensionHealthStatus> healthStatuses;
|
||||
@@ -894,7 +746,6 @@ class ExtensionState {
|
||||
|
||||
const ExtensionState({
|
||||
this.extensions = const [],
|
||||
this.builtInProviders = const [],
|
||||
this.providerPriority = const [],
|
||||
this.metadataProviderPriority = const [],
|
||||
this.healthStatuses = const {},
|
||||
@@ -905,7 +756,6 @@ class ExtensionState {
|
||||
|
||||
ExtensionState copyWith({
|
||||
List<Extension>? extensions,
|
||||
List<BuiltInProviderSpec>? builtInProviders,
|
||||
List<String>? providerPriority,
|
||||
List<String>? metadataProviderPriority,
|
||||
Map<String, ExtensionHealthStatus>? healthStatuses,
|
||||
@@ -915,7 +765,6 @@ class ExtensionState {
|
||||
}) {
|
||||
return ExtensionState(
|
||||
extensions: extensions ?? this.extensions,
|
||||
builtInProviders: builtInProviders ?? this.builtInProviders,
|
||||
providerPriority: providerPriority ?? this.providerPriority,
|
||||
metadataProviderPriority:
|
||||
metadataProviderPriority ?? this.metadataProviderPriority,
|
||||
@@ -998,12 +847,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
await refreshBuiltInProviders();
|
||||
} catch (e) {
|
||||
_log.w('Failed to refresh built-in providers before init: $e');
|
||||
}
|
||||
|
||||
if (!PlatformBridge.supportsExtensionSystem) {
|
||||
state = state.copyWith(
|
||||
isInitialized: true,
|
||||
@@ -1168,16 +1011,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
return future;
|
||||
}
|
||||
|
||||
Future<void> refreshBuiltInProviders() async {
|
||||
final list = await PlatformBridge.getBuiltInProviders();
|
||||
final providers = list
|
||||
.map((e) => BuiltInProviderSpec.fromJson(e))
|
||||
.where((provider) => provider.id.isNotEmpty)
|
||||
.toList();
|
||||
_replaceBuiltInProviderRegistry(providers);
|
||||
state = state.copyWith(builtInProviders: providers);
|
||||
}
|
||||
|
||||
void clearError() {
|
||||
state = state.copyWith(error: null);
|
||||
}
|
||||
@@ -1402,8 +1235,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
state.extensions
|
||||
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
||||
.map((ext) => ext.id)
|
||||
.firstOrNull ??
|
||||
defaultBuiltInSearchProviderId;
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
String? replacedBuiltInDownloadProviderFor(String providerId) {
|
||||
@@ -1505,8 +1337,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
currentExtension == null ||
|
||||
!currentExtension.enabled ||
|
||||
!currentExtension.hasDownloadProvider;
|
||||
if (!isBuiltInDownloadProvider(currentService) &&
|
||||
isMissingOrInvalidExtension) {
|
||||
if (isMissingOrInvalidExtension) {
|
||||
final fallbackService = preferredExtensionId ?? '';
|
||||
ref.read(settingsProvider.notifier).setDefaultService(fallbackService);
|
||||
_log.d(
|
||||
@@ -1551,8 +1382,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
(ext) =>
|
||||
ext.enabled && ext.hasCustomSearch && ext.id == currentSearchProvider,
|
||||
);
|
||||
if (!isBuiltInSearchProvider(currentSearchProvider) &&
|
||||
!hasMatchingExtension) {
|
||||
if (!hasMatchingExtension) {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setSearchProvider(
|
||||
@@ -1815,13 +1645,10 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
|
||||
List<String> getAllDownloadProviders() {
|
||||
final providers = List<String>.from(builtInDownloadProviderIds);
|
||||
for (final ext in state.extensions) {
|
||||
if (ext.enabled && ext.hasDownloadProvider) {
|
||||
providers.add(ext.id);
|
||||
}
|
||||
}
|
||||
return providers;
|
||||
return state.extensions
|
||||
.where((ext) => ext.enabled && ext.hasDownloadProvider)
|
||||
.map((ext) => ext.id)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
List<String> getAllMetadataProviders() {
|
||||
@@ -1837,7 +1664,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
|
||||
return [
|
||||
...primarySearchMetadataExtensions,
|
||||
...builtInMetadataProviderIds,
|
||||
...otherMetadataExtensions,
|
||||
];
|
||||
}
|
||||
@@ -1865,14 +1691,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
final hasPreferredExtension = preferredOrder.any(
|
||||
(provider) => !isBuiltInMetadataProvider(provider),
|
||||
);
|
||||
final hasSavedExtension = result.any(
|
||||
(provider) => !isBuiltInMetadataProvider(provider),
|
||||
);
|
||||
|
||||
if (!hasSavedExtension && hasPreferredExtension) {
|
||||
if (result.isEmpty && preferredOrder.isNotEmpty) {
|
||||
return List<String>.from(preferredOrder);
|
||||
}
|
||||
|
||||
|
||||
@@ -69,12 +69,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
final sanitizedDefaultSearchTab = _normalizeDefaultSearchTab(
|
||||
loaded.defaultSearchTab,
|
||||
);
|
||||
final sanitizedDefaultService = _sanitizeRetiredBuiltInProviderId(
|
||||
loaded.defaultService,
|
||||
);
|
||||
final sanitizedSearchProvider = _sanitizeRetiredBuiltInProviderId(
|
||||
loaded.searchProvider,
|
||||
);
|
||||
state = loaded.copyWith(
|
||||
useExtensionProviders: true,
|
||||
downloadFallbackExtensionIds: sanitizedDownloadFallbackExtensionIds,
|
||||
@@ -82,10 +76,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
loaded.downloadFallbackExtensionIds != null &&
|
||||
sanitizedDownloadFallbackExtensionIds == null,
|
||||
defaultSearchTab: sanitizedDefaultSearchTab,
|
||||
defaultService: sanitizedDefaultService ?? '',
|
||||
searchProvider: sanitizedSearchProvider,
|
||||
clearSearchProvider:
|
||||
loaded.searchProvider != null && sanitizedSearchProvider == null,
|
||||
defaultService: loaded.defaultService,
|
||||
searchProvider: loaded.searchProvider,
|
||||
);
|
||||
|
||||
await _runMigrations(prefs);
|
||||
@@ -166,23 +158,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
);
|
||||
}
|
||||
state = state.copyWith(lastSeenVersion: AppInfo.version);
|
||||
// Migration 7/11: retired built-in services no longer fall back to a
|
||||
// preinstalled provider.
|
||||
final sanitizedDefaultService = _sanitizeRetiredBuiltInProviderId(
|
||||
state.defaultService,
|
||||
);
|
||||
final sanitizedSearchProvider = _sanitizeRetiredBuiltInProviderId(
|
||||
state.searchProvider,
|
||||
);
|
||||
if (sanitizedDefaultService != state.defaultService ||
|
||||
sanitizedSearchProvider != state.searchProvider) {
|
||||
state = state.copyWith(
|
||||
defaultService: sanitizedDefaultService ?? '',
|
||||
searchProvider: sanitizedSearchProvider,
|
||||
clearSearchProvider:
|
||||
state.searchProvider != null && sanitizedSearchProvider == null,
|
||||
);
|
||||
}
|
||||
// Migration 7/11: retired built-in services are now reconciled after
|
||||
// extensions load so manifest-declared replacements can adopt old prefs.
|
||||
if (!state.useExtensionProviders) {
|
||||
state = state.copyWith(useExtensionProviders: true);
|
||||
}
|
||||
|
||||
+144
-398
@@ -195,7 +195,6 @@ class SearchPlaylist {
|
||||
|
||||
class TrackNotifier extends Notifier<TrackState> {
|
||||
int _currentRequestId = 0;
|
||||
static const int _maxPreWarmTracksPerRequest = 80;
|
||||
|
||||
@override
|
||||
TrackState build() {
|
||||
@@ -204,15 +203,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
|
||||
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
|
||||
|
||||
bool _usesBuiltInUrlResolver(String url) {
|
||||
final normalized = url.toLowerCase();
|
||||
return normalized.contains('deezer.com') ||
|
||||
normalized.contains('deezer.page.link') ||
|
||||
normalized.contains('qobuz.com') ||
|
||||
normalized.startsWith('qobuzapp://') ||
|
||||
normalized.contains('tidal.com');
|
||||
}
|
||||
|
||||
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
@@ -220,7 +210,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
|
||||
try {
|
||||
var extensionHandler = await PlatformBridge.findURLHandler(url);
|
||||
if (extensionHandler == null && !_usesBuiltInUrlResolver(url)) {
|
||||
if (extensionHandler == null) {
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
if (!extensionState.isInitialized && extensionState.isLoading) {
|
||||
_log.i(
|
||||
@@ -234,132 +224,124 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
}
|
||||
|
||||
if (extensionHandler != null) {
|
||||
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
|
||||
if (extensionHandler == null) {
|
||||
state = TrackState(
|
||||
isLoading: false,
|
||||
error: 'url_not_recognized',
|
||||
hasSearchText: state.hasSearchText,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, dynamic>? result;
|
||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||
result = await PlatformBridge.handleURLWithExtension(url);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
|
||||
|
||||
if (result != null &&
|
||||
result['type'] == 'track' &&
|
||||
result['track'] != null) {
|
||||
final trackData = result['track'] as Map<String, dynamic>;
|
||||
final name = trackData['name']?.toString() ?? '';
|
||||
if (name.isNotEmpty) {
|
||||
break;
|
||||
}
|
||||
} else if (result != null &&
|
||||
(result['type'] == 'album' || result['type'] == 'playlist')) {
|
||||
break;
|
||||
} else if (result != null && result['type'] == 'artist') {
|
||||
Map<String, dynamic>? result;
|
||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||
result = await PlatformBridge.handleURLWithExtension(url);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
if (result != null &&
|
||||
result['type'] == 'track' &&
|
||||
result['track'] != null) {
|
||||
final trackData = result['track'] as Map<String, dynamic>;
|
||||
final name = trackData['name']?.toString() ?? '';
|
||||
if (name.isNotEmpty) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (attempt < 3) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||
}
|
||||
} else if (result != null &&
|
||||
(result['type'] == 'album' || result['type'] == 'playlist')) {
|
||||
break;
|
||||
} else if (result != null && result['type'] == 'artist') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (result != null) {
|
||||
final type = result['type'] as String?;
|
||||
final extensionId = result['extension_id'] as String?;
|
||||
|
||||
if (type == 'track' && result['track'] != null) {
|
||||
final trackData = result['track'] as Map<String, dynamic>;
|
||||
final track = _parseSearchTrack(trackData, source: extensionId);
|
||||
|
||||
if (track.name.isEmpty) {
|
||||
state = TrackState(
|
||||
isLoading: false,
|
||||
error: 'Failed to load track metadata from extension',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
state = TrackState(
|
||||
tracks: [track],
|
||||
isLoading: false,
|
||||
coverUrl: track.coverUrl,
|
||||
searchExtensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
} else if ((type == 'album' || type == 'playlist') &&
|
||||
result['tracks'] != null) {
|
||||
final trackList = result['tracks'] as List<dynamic>;
|
||||
final tracks = trackList
|
||||
.map(
|
||||
(t) => _parseSearchTrack(
|
||||
t as Map<String, dynamic>,
|
||||
source: extensionId,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
albumId:
|
||||
(result['album'] as Map<String, dynamic>?)?['id'] as String?,
|
||||
albumName:
|
||||
result['name'] as String? ??
|
||||
(result['album'] as Map<String, dynamic>?)?['name']
|
||||
as String?,
|
||||
playlistName: type == 'playlist'
|
||||
? result['name'] as String?
|
||||
: null,
|
||||
coverUrl: normalizeCoverReference(
|
||||
result['cover_url']?.toString(),
|
||||
),
|
||||
searchExtensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
} else if (type == 'artist' && result['artist'] != null) {
|
||||
final artistData = result['artist'] as Map<String, dynamic>;
|
||||
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
|
||||
final albums = albumsList
|
||||
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
final topTracksList =
|
||||
artistData['top_tracks'] as List<dynamic>? ?? [];
|
||||
final topTracks = topTracksList
|
||||
.map(
|
||||
(t) => _parseSearchTrack(
|
||||
t as Map<String, dynamic>,
|
||||
source: extensionId,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
state = TrackState(
|
||||
tracks: [],
|
||||
isLoading: false,
|
||||
artistId: artistData['id'] as String?,
|
||||
artistName: artistData['name'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(
|
||||
(artistData['image_url'] ?? artistData['images'])?.toString(),
|
||||
),
|
||||
headerImageUrl: normalizeRemoteHttpUrl(
|
||||
artistData['header_image']?.toString(),
|
||||
),
|
||||
monthlyListeners: artistData['listeners'] as int?,
|
||||
artistAlbums: albums,
|
||||
artistTopTracks: topTracks.isNotEmpty ? topTracks : null,
|
||||
searchExtensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (attempt < 3) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||
}
|
||||
}
|
||||
|
||||
final handledBuiltInUrl = await _tryResolveBuiltInProviderUrl(
|
||||
url,
|
||||
requestId,
|
||||
);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
if (handledBuiltInUrl) {
|
||||
return;
|
||||
if (result != null) {
|
||||
final type = result['type'] as String?;
|
||||
final extensionId = result['extension_id'] as String?;
|
||||
|
||||
if (type == 'track' && result['track'] != null) {
|
||||
final trackData = result['track'] as Map<String, dynamic>;
|
||||
final track = _parseSearchTrack(trackData, source: extensionId);
|
||||
|
||||
if (track.name.isEmpty) {
|
||||
state = TrackState(
|
||||
isLoading: false,
|
||||
error: 'Failed to load track metadata from extension',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
state = TrackState(
|
||||
tracks: [track],
|
||||
isLoading: false,
|
||||
coverUrl: track.coverUrl,
|
||||
searchExtensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
} else if ((type == 'album' || type == 'playlist') &&
|
||||
result['tracks'] != null) {
|
||||
final trackList = result['tracks'] as List<dynamic>;
|
||||
final tracks = trackList
|
||||
.map(
|
||||
(t) => _parseSearchTrack(
|
||||
t as Map<String, dynamic>,
|
||||
source: extensionId,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
albumId:
|
||||
(result['album'] as Map<String, dynamic>?)?['id'] as String?,
|
||||
albumName:
|
||||
result['name'] as String? ??
|
||||
(result['album'] as Map<String, dynamic>?)?['name'] as String?,
|
||||
playlistName: type == 'playlist' ? result['name'] as String? : null,
|
||||
coverUrl: normalizeCoverReference(result['cover_url']?.toString()),
|
||||
searchExtensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
} else if (type == 'artist' && result['artist'] != null) {
|
||||
final artistData = result['artist'] as Map<String, dynamic>;
|
||||
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
|
||||
final albums = albumsList
|
||||
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
|
||||
final topTracks = topTracksList
|
||||
.map(
|
||||
(t) => _parseSearchTrack(
|
||||
t as Map<String, dynamic>,
|
||||
source: extensionId,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
state = TrackState(
|
||||
tracks: [],
|
||||
isLoading: false,
|
||||
artistId: artistData['id'] as String?,
|
||||
artistName: artistData['name'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(
|
||||
(artistData['image_url'] ?? artistData['images'])?.toString(),
|
||||
),
|
||||
headerImageUrl: normalizeRemoteHttpUrl(
|
||||
artistData['header_image']?.toString(),
|
||||
),
|
||||
monthlyListeners: artistData['listeners'] as int?,
|
||||
artistAlbums: albums,
|
||||
artistTopTracks: topTracks.isNotEmpty ? topTracks : null,
|
||||
searchExtensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
state = TrackState(
|
||||
@@ -377,138 +359,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _tryResolveBuiltInProviderUrl(String url, int requestId) async {
|
||||
Map<String, dynamic> parsed;
|
||||
try {
|
||||
parsed = await PlatformBridge.parseProviderUrl(url);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_isRequestValid(requestId)) return true;
|
||||
|
||||
final providerId = parsed['provider_id']?.toString();
|
||||
final type = parsed['type']?.toString();
|
||||
final id = parsed['id']?.toString();
|
||||
if (providerId == null ||
|
||||
providerId.isEmpty ||
|
||||
type == null ||
|
||||
type.isEmpty ||
|
||||
id == null ||
|
||||
id.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
_log.i('Detected built-in provider URL: $providerId:$type:$id');
|
||||
|
||||
final metadata = await _getResolvedProviderMetadata(providerId, type, id);
|
||||
if (!_isRequestValid(requestId)) return true;
|
||||
|
||||
_applyResolvedProviderMetadata(providerId, type, id, metadata);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _getResolvedProviderMetadata(
|
||||
String providerId,
|
||||
String resourceType,
|
||||
String resourceId,
|
||||
) async {
|
||||
return PlatformBridge.getProviderMetadata(
|
||||
providerId,
|
||||
resourceType,
|
||||
resourceId,
|
||||
);
|
||||
}
|
||||
|
||||
void _applyResolvedProviderMetadata(
|
||||
String providerId,
|
||||
String resourceType,
|
||||
String resourceId,
|
||||
Map<String, dynamic> metadata,
|
||||
) {
|
||||
switch (resourceType) {
|
||||
case 'track':
|
||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||
final track = _parseTrack(trackData);
|
||||
state = TrackState(
|
||||
tracks: [track],
|
||||
isLoading: false,
|
||||
coverUrl: track.coverUrl,
|
||||
);
|
||||
return;
|
||||
case 'album':
|
||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
albumId: _buildResolvedAlbumId(providerId, resourceId),
|
||||
albumName: albumInfo['name'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
|
||||
);
|
||||
_preWarmCacheForTracks(tracks, service: providerId);
|
||||
return;
|
||||
case 'playlist':
|
||||
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
|
||||
final playlistName =
|
||||
(playlistInfo['name'] ?? owner?['name']) as String?;
|
||||
final coverUrl = normalizeRemoteHttpUrl(
|
||||
(playlistInfo['images'] ?? owner?['images'])?.toString(),
|
||||
);
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
playlistName: playlistName,
|
||||
coverUrl: coverUrl,
|
||||
);
|
||||
_preWarmCacheForTracks(tracks, service: providerId);
|
||||
return;
|
||||
case 'artist':
|
||||
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
||||
final albumsList = metadata['albums'] as List<dynamic>;
|
||||
final albums = albumsList
|
||||
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
||||
.toList();
|
||||
final topTracksList = metadata['top_tracks'] as List<dynamic>? ?? [];
|
||||
final topTracks = topTracksList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
state = TrackState(
|
||||
tracks: [],
|
||||
isLoading: false,
|
||||
artistId: artistInfo['id'] as String?,
|
||||
artistName: artistInfo['name'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
|
||||
headerImageUrl: normalizeRemoteHttpUrl(
|
||||
(artistInfo['header_image'] ?? artistInfo['cover_url'])?.toString(),
|
||||
),
|
||||
monthlyListeners: artistInfo['listeners'] as int?,
|
||||
artistAlbums: albums,
|
||||
artistTopTracks: topTracks.isNotEmpty ? topTracks : null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
String _buildResolvedAlbumId(String providerId, String resourceId) {
|
||||
if (providerId == 'deezer') {
|
||||
return resourceId;
|
||||
}
|
||||
return '$providerId:$resourceId';
|
||||
}
|
||||
|
||||
Future<void> search(
|
||||
String query, {
|
||||
String? filterOverride,
|
||||
String? builtInSearchProvider,
|
||||
}) async {
|
||||
final requestId = ++_currentRequestId;
|
||||
final currentFilter = filterOverride ?? state.selectedSearchFilter;
|
||||
@@ -516,33 +369,29 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
|
||||
String? resolvedProvider = builtInSearchProvider;
|
||||
if (resolvedProvider == null || resolvedProvider.isEmpty) {
|
||||
final explicitProvider = settings.searchProvider?.trim();
|
||||
if (explicitProvider != null && explicitProvider.isNotEmpty) {
|
||||
resolvedProvider = explicitProvider;
|
||||
} else {
|
||||
resolvedProvider =
|
||||
extensionState.extensions
|
||||
.where(
|
||||
(ext) =>
|
||||
ext.enabled &&
|
||||
ext.hasCustomSearch &&
|
||||
ext.searchBehavior?.primary == true,
|
||||
)
|
||||
.map((ext) => ext.id)
|
||||
.firstOrNull ??
|
||||
extensionState.extensions
|
||||
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
||||
.map((ext) => ext.id)
|
||||
.firstOrNull;
|
||||
}
|
||||
resolvedProvider ??= defaultBuiltInSearchProviderId;
|
||||
String? resolvedProvider;
|
||||
final explicitProvider = settings.searchProvider?.trim();
|
||||
if (explicitProvider != null && explicitProvider.isNotEmpty) {
|
||||
resolvedProvider = explicitProvider;
|
||||
} else {
|
||||
resolvedProvider =
|
||||
extensionState.extensions
|
||||
.where(
|
||||
(ext) =>
|
||||
ext.enabled &&
|
||||
ext.hasCustomSearch &&
|
||||
ext.searchBehavior?.primary == true,
|
||||
)
|
||||
.map((ext) => ext.id)
|
||||
.firstOrNull ??
|
||||
extensionState.extensions
|
||||
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
||||
.map((ext) => ext.id)
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
if (resolvedProvider != null &&
|
||||
resolvedProvider.isNotEmpty &&
|
||||
!isBuiltInSearchProvider(resolvedProvider) &&
|
||||
!extensionState.extensions.any(
|
||||
(ext) => ext.enabled && ext.id == resolvedProvider,
|
||||
) &&
|
||||
@@ -562,7 +411,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
||||
.map((ext) => ext.id)
|
||||
.firstOrNull;
|
||||
resolvedProvider ??= defaultBuiltInSearchProviderId;
|
||||
}
|
||||
|
||||
final isEnabledExtensionProvider =
|
||||
@@ -571,11 +419,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
extensionState.extensions.any(
|
||||
(ext) => ext.enabled && ext.id == resolvedProvider,
|
||||
);
|
||||
final isBuiltInProvider = isBuiltInSearchProvider(resolvedProvider);
|
||||
|
||||
if (resolvedProvider != null &&
|
||||
resolvedProvider.isNotEmpty &&
|
||||
!isBuiltInProvider &&
|
||||
isEnabledExtensionProvider) {
|
||||
final resolvedFilter = requestFilter ?? 'track';
|
||||
Map<String, dynamic>? options;
|
||||
@@ -589,23 +435,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
return;
|
||||
}
|
||||
|
||||
final fallbackBuiltInProvider = builtInSearchProvider?.isNotEmpty == true
|
||||
? builtInSearchProvider
|
||||
: defaultBuiltInSearchProviderId;
|
||||
final effectiveBuiltInProvider = isBuiltInProvider
|
||||
? resolvedProvider
|
||||
: fallbackBuiltInProvider;
|
||||
|
||||
if (effectiveBuiltInProvider == null || effectiveBuiltInProvider.isEmpty) {
|
||||
state = TrackState(
|
||||
isLoading: false,
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
selectedSearchFilter: currentFilter,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
state = TrackState(
|
||||
isLoading: true,
|
||||
hasSearchText: state.hasSearchText,
|
||||
@@ -614,47 +443,21 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
);
|
||||
|
||||
try {
|
||||
final hasActiveMetadataExtensions = extensionState.extensions.any(
|
||||
(e) => e.enabled && e.hasMetadataProvider,
|
||||
);
|
||||
final includeExtensions =
|
||||
settings.useExtensionProviders && hasActiveMetadataExtensions;
|
||||
|
||||
final effectiveProvider = effectiveBuiltInProvider;
|
||||
final includeExtensions = settings.useExtensionProviders;
|
||||
|
||||
_log.i(
|
||||
'Search started: provider=$effectiveProvider, query="$query", includeExtensions=$includeExtensions, filter=$requestFilter',
|
||||
'Search started: provider=metadata_extensions, query="$query", includeExtensions=$includeExtensions, filter=$requestFilter',
|
||||
);
|
||||
|
||||
Map<String, dynamic> results;
|
||||
List<Map<String, dynamic>> metadataTrackResults = [];
|
||||
|
||||
if (isBuiltInSearchProvider(effectiveProvider)) {
|
||||
_log.d('Calling built-in search API for $effectiveProvider...');
|
||||
results = await PlatformBridge.searchProviderAll(
|
||||
effectiveProvider,
|
||||
query,
|
||||
trackLimit: 20,
|
||||
artistLimit: 2,
|
||||
filter: requestFilter,
|
||||
);
|
||||
} else {
|
||||
_log.d('Calling metadata provider track search API...');
|
||||
metadataTrackResults =
|
||||
await PlatformBridge.searchTracksWithMetadataProviders(
|
||||
query,
|
||||
limit: 20,
|
||||
includeExtensions: includeExtensions,
|
||||
);
|
||||
results = const <String, List<dynamic>>{
|
||||
'tracks': <dynamic>[],
|
||||
'artists': <dynamic>[],
|
||||
'albums': <dynamic>[],
|
||||
'playlists': <dynamic>[],
|
||||
};
|
||||
}
|
||||
_log.d('Calling metadata provider track search API...');
|
||||
final metadataTrackResults =
|
||||
await PlatformBridge.searchTracksWithMetadataProviders(
|
||||
query,
|
||||
limit: 20,
|
||||
includeExtensions: includeExtensions,
|
||||
);
|
||||
_log.i(
|
||||
'$effectiveProvider returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums',
|
||||
'metadata_extensions returned ${metadataTrackResults.length} tracks',
|
||||
);
|
||||
|
||||
if (!_isRequestValid(requestId)) {
|
||||
@@ -662,12 +465,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
return;
|
||||
}
|
||||
|
||||
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
||||
final artistList = results['artists'] as List<dynamic>? ?? [];
|
||||
final albumList = results['albums'] as List<dynamic>? ?? [];
|
||||
final trackSearchResults = metadataTrackResults.isNotEmpty
|
||||
? metadataTrackResults
|
||||
: trackList.whereType<Map<String, dynamic>>().toList();
|
||||
final trackSearchResults = metadataTrackResults;
|
||||
const artistList = <dynamic>[];
|
||||
const albumList = <dynamic>[];
|
||||
|
||||
_log.d(
|
||||
'Raw results: ${trackSearchResults.length} tracks, ${artistList.length} artists, ${albumList.length} albums',
|
||||
@@ -712,7 +512,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
}
|
||||
|
||||
final playlistList = results['playlists'] as List<dynamic>? ?? [];
|
||||
const playlistList = <dynamic>[];
|
||||
final playlists = <SearchPlaylist>[];
|
||||
for (int i = 0; i < playlistList.length; i++) {
|
||||
final p = playlistList[i];
|
||||
@@ -740,7 +540,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
selectedSearchFilter: currentFilter,
|
||||
searchSource: effectiveProvider,
|
||||
searchSource: resolvedProvider,
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
@@ -915,33 +715,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
);
|
||||
}
|
||||
|
||||
Track _parseTrack(Map<String, dynamic> data) {
|
||||
final durationMs = _extractDurationMs(data);
|
||||
final spotifyId = (data['spotify_id'] ?? '').toString();
|
||||
final nativeId = (data['id'] ?? '').toString();
|
||||
return Track(
|
||||
id: spotifyId.isNotEmpty ? spotifyId : nativeId,
|
||||
name: data['name'] as String? ?? '',
|
||||
artistName: data['artists'] as String? ?? '',
|
||||
albumName: data['album_name'] as String? ?? '',
|
||||
albumArtist: data['album_artist'] as String?,
|
||||
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
|
||||
albumId: data['album_id']?.toString(),
|
||||
coverUrl: normalizeCoverReference(data['images']?.toString()),
|
||||
isrc: data['isrc'] as String?,
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
totalDiscs: data['total_discs'] as int?,
|
||||
releaseDate: data['release_date'] as String?,
|
||||
albumType: normalizeOptionalString(data['album_type']?.toString()),
|
||||
totalTracks: data['total_tracks'] as int?,
|
||||
composer: data['composer']?.toString(),
|
||||
audioQuality: data['audio_quality']?.toString(),
|
||||
audioModes: data['audio_modes']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
Track _parseSearchTrack(Map<String, dynamic> data, {String? source}) {
|
||||
final durationMs = _extractDurationMs(data);
|
||||
|
||||
@@ -1054,33 +827,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
);
|
||||
}
|
||||
|
||||
void _preWarmCacheForTracks(List<Track> tracks, {String? service}) {
|
||||
if (tracks.isEmpty) return;
|
||||
final cacheRequests = <Map<String, String>>[];
|
||||
for (final track in tracks) {
|
||||
final isrc = track.isrc;
|
||||
if (isrc == null || isrc.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
final effectiveService =
|
||||
(track.source?.trim().isNotEmpty == true ? track.source : service)
|
||||
?.trim();
|
||||
cacheRequests.add({
|
||||
'isrc': isrc,
|
||||
'track_name': track.name,
|
||||
'artist_name': track.artistName,
|
||||
'spotify_id': track.id,
|
||||
if (effectiveService != null && effectiveService.isNotEmpty)
|
||||
'service': effectiveService,
|
||||
});
|
||||
if (cacheRequests.length >= _maxPreWarmTracksPerRequest) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (cacheRequests.isEmpty) return;
|
||||
|
||||
PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) {});
|
||||
}
|
||||
}
|
||||
|
||||
final trackProvider = NotifierProvider<TrackNotifier, TrackState>(
|
||||
|
||||
@@ -29,7 +29,6 @@ import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
import 'package:spotiflac_android/utils/provider_ui_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
|
||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||
|
||||
@@ -315,26 +314,20 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
final explicit = explicitSearchProvider?.trim();
|
||||
if (explicit != null &&
|
||||
explicit.isNotEmpty &&
|
||||
(isBuiltInSearchProvider(explicit) ||
|
||||
extensions.any(
|
||||
(ext) => ext.enabled && ext.hasCustomSearch && ext.id == explicit,
|
||||
))) {
|
||||
extensions.any(
|
||||
(ext) => ext.enabled && ext.hasCustomSearch && ext.id == explicit,
|
||||
)) {
|
||||
return explicit;
|
||||
}
|
||||
return _defaultSearchExtension(extensions)?.id ??
|
||||
defaultBuiltInSearchProviderId;
|
||||
return _defaultSearchExtension(extensions)?.id;
|
||||
}
|
||||
|
||||
bool _hasSearchProvider(
|
||||
String? explicitSearchProvider,
|
||||
List<Extension> extensions,
|
||||
List<BuiltInProviderSpec> builtInProviders,
|
||||
) {
|
||||
final explicit = explicitSearchProvider?.trim();
|
||||
if (explicit != null && explicit.isNotEmpty) {
|
||||
if (builtInProviders.any((p) => p.supportsSearch && p.id == explicit)) {
|
||||
return true;
|
||||
}
|
||||
if (extensions.any(
|
||||
(ext) => ext.enabled && ext.hasCustomSearch && ext.id == explicit,
|
||||
)) {
|
||||
@@ -342,8 +335,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
}
|
||||
}
|
||||
|
||||
return extensions.any((ext) => ext.enabled && ext.hasCustomSearch) ||
|
||||
builtInProviders.any((provider) => provider.supportsSearch);
|
||||
return extensions.any((ext) => ext.enabled && ext.hasCustomSearch);
|
||||
}
|
||||
|
||||
String? _sanitizeSearchFilterForProvider(
|
||||
@@ -358,8 +350,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
final canonicalFilter = _canonicalSearchFilterId(filter);
|
||||
|
||||
if (currentSearchProvider == null ||
|
||||
currentSearchProvider.isEmpty ||
|
||||
isBuiltInSearchProvider(currentSearchProvider)) {
|
||||
currentSearchProvider.isEmpty) {
|
||||
switch (canonicalFilter) {
|
||||
case 'track':
|
||||
case 'artist':
|
||||
@@ -554,8 +545,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
|
||||
if (searchProvider == null || searchProvider.isEmpty) return false;
|
||||
|
||||
if (isBuiltInSearchProvider(searchProvider)) return true;
|
||||
|
||||
final extension = extState.extensions
|
||||
.where((e) => e.id == searchProvider && e.enabled)
|
||||
.firstOrNull;
|
||||
@@ -655,13 +644,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
_searchSortOption = _SearchSortOption.defaultOrder;
|
||||
_invalidateSearchSortCaches();
|
||||
|
||||
final isBuiltInProvider =
|
||||
searchProvider != null && isBuiltInSearchProvider(searchProvider);
|
||||
|
||||
final isExtensionEnabled =
|
||||
searchProvider != null &&
|
||||
searchProvider.isNotEmpty &&
|
||||
!isBuiltInProvider &&
|
||||
extState.extensions.any((e) => e.id == searchProvider && e.enabled);
|
||||
|
||||
if (isExtensionEnabled) {
|
||||
@@ -677,19 +662,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
options: options,
|
||||
selectedFilter: selectedFilter,
|
||||
);
|
||||
} else if (isBuiltInProvider) {
|
||||
await ref
|
||||
.read(trackProvider.notifier)
|
||||
.search(
|
||||
query,
|
||||
filterOverride: selectedFilter,
|
||||
builtInSearchProvider: searchProvider,
|
||||
);
|
||||
} else {
|
||||
if (searchProvider != null &&
|
||||
searchProvider.isNotEmpty &&
|
||||
!isExtensionEnabled &&
|
||||
!isBuiltInProvider) {
|
||||
!isExtensionEnabled) {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
}
|
||||
await ref
|
||||
@@ -1147,7 +1123,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
(s) => (
|
||||
isInitialized: s.isInitialized,
|
||||
error: s.error,
|
||||
builtInProviders: s.builtInProviders,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1195,7 +1170,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
final hasSearchProvider = _hasSearchProvider(
|
||||
explicitSearchProvider,
|
||||
extensions,
|
||||
extensionReadiness.builtInProviders,
|
||||
);
|
||||
final showSearchBar = hasSearchProvider || isSearchProviderLoading;
|
||||
final hasResults =
|
||||
@@ -3378,11 +3352,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
}
|
||||
|
||||
if (searchProvider != null && searchProvider.isNotEmpty) {
|
||||
final builtIn = builtInProviderSpecForId(searchProvider);
|
||||
if (builtIn != null && builtIn.supportsSearch) {
|
||||
return context.l10n.homeSearchHintProvider(builtIn.displayName);
|
||||
}
|
||||
|
||||
final ext = extState.extensions
|
||||
.where((e) => e.id == searchProvider)
|
||||
.firstOrNull;
|
||||
|
||||
@@ -36,9 +36,7 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
final searchProviders = extensions
|
||||
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
||||
.toList();
|
||||
final builtInProviders = builtInSearchProviderSpecs;
|
||||
final hasAnyProvider =
|
||||
searchProviders.isNotEmpty || builtInProviders.isNotEmpty;
|
||||
final hasAnyProvider = searchProviders.isNotEmpty;
|
||||
final isProviderLoading =
|
||||
!providerReadiness.isInitialized && providerReadiness.error == null;
|
||||
|
||||
@@ -71,11 +69,9 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
final resolvedCurrentProvider =
|
||||
rawCurrentProvider != null &&
|
||||
rawCurrentProvider.isNotEmpty &&
|
||||
(isBuiltInSearchProvider(rawCurrentProvider) ||
|
||||
searchProviders.any((e) => e.id == rawCurrentProvider))
|
||||
searchProviders.any((e) => e.id == rawCurrentProvider)
|
||||
? rawCurrentProvider
|
||||
: _defaultSearchExtension(searchProviders)?.id ??
|
||||
defaultBuiltInSearchProviderId;
|
||||
: _defaultSearchExtension(searchProviders)?.id;
|
||||
final currentProvider =
|
||||
resolvedCurrentProvider != null && resolvedCurrentProvider.isNotEmpty
|
||||
? resolvedCurrentProvider
|
||||
@@ -88,9 +84,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
final isBuiltInProvider =
|
||||
currentProvider != null && isBuiltInSearchProvider(currentProvider);
|
||||
|
||||
IconData displayIcon = Icons.search;
|
||||
String? iconPath;
|
||||
if (currentExt != null) {
|
||||
@@ -98,8 +91,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
if (currentExt.searchBehavior?.icon != null) {
|
||||
displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!);
|
||||
}
|
||||
} else if (isBuiltInProvider) {
|
||||
displayIcon = resolveProviderIcon(currentProvider);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
@@ -137,36 +128,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
onProviderChanged?.call();
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
...builtInProviders.map(
|
||||
(provider) => PopupMenuItem<String>(
|
||||
value: provider.id,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
resolveProviderIcon(provider.id),
|
||||
size: 20,
|
||||
color: currentProvider == provider.id
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
provider.displayName,
|
||||
style: TextStyle(
|
||||
fontWeight: currentProvider == provider.id
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (currentProvider == provider.id)
|
||||
Icon(Icons.check, size: 18, color: colorScheme.primary),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (searchProviders.isNotEmpty) const PopupMenuDivider(),
|
||||
...searchProviders.map(
|
||||
(ext) => PopupMenuItem<String>(
|
||||
value: ext.id,
|
||||
|
||||
@@ -726,29 +726,23 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final builtInProviders = builtInSearchProviderSpecs;
|
||||
|
||||
final searchProviders = extState.extensions
|
||||
.where((e) => e.enabled && e.hasCustomSearch)
|
||||
.toList();
|
||||
|
||||
final hasAnyProvider =
|
||||
searchProviders.isNotEmpty || builtInProviders.isNotEmpty;
|
||||
final hasAnyProvider = searchProviders.isNotEmpty;
|
||||
|
||||
final resolvedProviderId =
|
||||
(settings.searchProvider != null && settings.searchProvider!.isNotEmpty)
|
||||
? settings.searchProvider!
|
||||
: searchProviders.firstOrNull?.id ?? defaultBuiltInSearchProviderId;
|
||||
: searchProviders.firstOrNull?.id;
|
||||
String currentProviderName = context.l10n.optionsPrimaryProviderSubtitle;
|
||||
if (resolvedProviderId != null && resolvedProviderId.isNotEmpty) {
|
||||
if (isBuiltInSearchProvider(resolvedProviderId)) {
|
||||
currentProviderName = resolveProviderDisplayName(resolvedProviderId);
|
||||
} else {
|
||||
final ext = searchProviders
|
||||
.where((e) => e.id == resolvedProviderId)
|
||||
.firstOrNull;
|
||||
currentProviderName = ext?.displayName ?? resolvedProviderId;
|
||||
}
|
||||
final ext = searchProviders
|
||||
.where((e) => e.id == resolvedProviderId)
|
||||
.firstOrNull;
|
||||
currentProviderName = ext?.displayName ?? resolvedProviderId;
|
||||
}
|
||||
|
||||
return Column(
|
||||
@@ -817,7 +811,6 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
List<Extension> searchProviders,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final builtInProviders = builtInSearchProviderSpecs;
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@@ -850,25 +843,6 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
...builtInProviders.map(
|
||||
(provider) => ListTile(
|
||||
leading: Icon(Icons.search, color: colorScheme.tertiary),
|
||||
title: Text(provider.displayName),
|
||||
subtitle: Text(
|
||||
ctx.l10n.extensionsSearchWith(provider.displayName),
|
||||
),
|
||||
trailing: settings.searchProvider == provider.id
|
||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setSearchProvider(provider.id);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (searchProviders.isNotEmpty) const Divider(height: 1),
|
||||
...searchProviders.map(
|
||||
(ext) => ListTile(
|
||||
leading: Icon(Icons.extension, color: colorScheme.secondary),
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/utils/provider_ui_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/priority_settings_scaffold.dart';
|
||||
|
||||
class MetadataProviderPriorityPage extends ConsumerStatefulWidget {
|
||||
@@ -67,6 +66,11 @@ class _MetadataProviderPriorityPageState
|
||||
index: index,
|
||||
isFirst: index == 0,
|
||||
isLast: index == _providers.length - 1,
|
||||
extension: ref
|
||||
.read(extensionProvider)
|
||||
.extensions
|
||||
.where((ext) => ext.id == provider)
|
||||
.firstOrNull,
|
||||
);
|
||||
},
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
@@ -126,6 +130,7 @@ class _MetadataProviderItem extends StatelessWidget {
|
||||
final int index;
|
||||
final bool isFirst;
|
||||
final bool isLast;
|
||||
final Extension? extension;
|
||||
|
||||
const _MetadataProviderItem({
|
||||
super.key,
|
||||
@@ -133,6 +138,7 @@ class _MetadataProviderItem extends StatelessWidget {
|
||||
required this.index,
|
||||
required this.isFirst,
|
||||
required this.isLast,
|
||||
this.extension,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -147,7 +153,7 @@ class _MetadataProviderItem extends StatelessWidget {
|
||||
)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
|
||||
final info = _getProviderInfo(context, provider);
|
||||
final info = _getProviderInfo(context, provider, extension);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
@@ -184,9 +190,7 @@ class _MetadataProviderItem extends StatelessWidget {
|
||||
const SizedBox(width: 16),
|
||||
Icon(
|
||||
info.icon,
|
||||
color: info.isBuiltIn
|
||||
? colorScheme.primary
|
||||
: colorScheme.secondary,
|
||||
color: colorScheme.secondary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
@@ -220,34 +224,12 @@ class _MetadataProviderItem extends StatelessWidget {
|
||||
_MetadataProviderInfo _getProviderInfo(
|
||||
BuildContext context,
|
||||
String provider,
|
||||
Extension? extension,
|
||||
) {
|
||||
final builtIn = builtInProviderSpecForId(provider);
|
||||
if (builtIn != null) {
|
||||
return _MetadataProviderInfo(
|
||||
name: builtIn.displayName,
|
||||
icon: resolveProviderIcon(
|
||||
provider,
|
||||
builtInDefaultIcon: Icons.library_music,
|
||||
),
|
||||
description: context.l10n.providerBuiltIn,
|
||||
isBuiltIn: true,
|
||||
);
|
||||
}
|
||||
|
||||
if (provider == 'deezer') {
|
||||
return _MetadataProviderInfo(
|
||||
name: 'Deezer',
|
||||
icon: Icons.album,
|
||||
description: context.l10n.providerExtension,
|
||||
isBuiltIn: false,
|
||||
);
|
||||
}
|
||||
|
||||
return _MetadataProviderInfo(
|
||||
name: provider,
|
||||
name: extension?.displayName ?? provider,
|
||||
icon: Icons.extension,
|
||||
description: context.l10n.providerExtension,
|
||||
isBuiltIn: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -256,12 +238,10 @@ class _MetadataProviderInfo {
|
||||
final String name;
|
||||
final IconData icon;
|
||||
final String description;
|
||||
final bool isBuiltIn;
|
||||
|
||||
_MetadataProviderInfo({
|
||||
required this.name,
|
||||
required this.icon,
|
||||
required this.description,
|
||||
required this.isBuiltIn,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/utils/provider_ui_utils.dart';
|
||||
|
||||
class ProviderPriorityPage extends ConsumerStatefulWidget {
|
||||
const ProviderPriorityPage({super.key});
|
||||
@@ -139,6 +138,11 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
||||
index: index,
|
||||
isFirst: index == 0,
|
||||
isLast: index == _providers.length - 1,
|
||||
extension: ref
|
||||
.read(extensionProvider)
|
||||
.extensions
|
||||
.where((ext) => ext.id == provider)
|
||||
.firstOrNull,
|
||||
);
|
||||
},
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
@@ -232,6 +236,7 @@ class _ProviderItem extends StatelessWidget {
|
||||
final int index;
|
||||
final bool isFirst;
|
||||
final bool isLast;
|
||||
final Extension? extension;
|
||||
|
||||
const _ProviderItem({
|
||||
super.key,
|
||||
@@ -239,6 +244,7 @@ class _ProviderItem extends StatelessWidget {
|
||||
required this.index,
|
||||
required this.isFirst,
|
||||
required this.isLast,
|
||||
this.extension,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -253,7 +259,7 @@ class _ProviderItem extends StatelessWidget {
|
||||
)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
|
||||
final info = _getProviderInfo(provider);
|
||||
final info = _getProviderInfo(provider, extension);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
@@ -290,9 +296,7 @@ class _ProviderItem extends StatelessWidget {
|
||||
const SizedBox(width: 16),
|
||||
Icon(
|
||||
info.icon,
|
||||
color: info.isBuiltIn
|
||||
? colorScheme.primary
|
||||
: colorScheme.secondary,
|
||||
color: colorScheme.secondary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
@@ -306,9 +310,7 @@ class _ProviderItem extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
info.isBuiltIn
|
||||
? context.l10n.providerBuiltIn
|
||||
: context.l10n.providerExtension,
|
||||
context.l10n.providerExtension,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -325,28 +327,10 @@ class _ProviderItem extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
_ProviderInfo _getProviderInfo(String provider) {
|
||||
final builtIn = builtInProviderSpecForId(provider);
|
||||
if (builtIn != null) {
|
||||
return _ProviderInfo(
|
||||
name: builtIn.displayName,
|
||||
icon: resolveProviderIcon(provider),
|
||||
isBuiltIn: true,
|
||||
);
|
||||
}
|
||||
|
||||
if (provider == 'deezer') {
|
||||
return _ProviderInfo(
|
||||
name: 'Deezer',
|
||||
icon: Icons.graphic_eq,
|
||||
isBuiltIn: false,
|
||||
);
|
||||
}
|
||||
|
||||
_ProviderInfo _getProviderInfo(String provider, Extension? extension) {
|
||||
return _ProviderInfo(
|
||||
name: provider,
|
||||
name: extension?.displayName ?? provider,
|
||||
icon: Icons.extension,
|
||||
isBuiltIn: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -354,11 +338,9 @@ class _ProviderItem extends StatelessWidget {
|
||||
class _ProviderInfo {
|
||||
final String name;
|
||||
final IconData icon;
|
||||
final bool isBuiltIn;
|
||||
|
||||
_ProviderInfo({
|
||||
required this.name,
|
||||
required this.icon,
|
||||
required this.isBuiltIn,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -884,23 +884,6 @@ class PlatformBridge {
|
||||
await _channel.invokeMethod('clearTrackCache');
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> searchProviderAll(
|
||||
String providerId,
|
||||
String query, {
|
||||
int trackLimit = 15,
|
||||
int artistLimit = 2,
|
||||
String? filter,
|
||||
}) async {
|
||||
final result = await _channel.invokeMethod('searchProviderAll', {
|
||||
'provider_id': providerId,
|
||||
'query': query,
|
||||
'track_limit': trackLimit,
|
||||
'artist_limit': artistLimit,
|
||||
'filter': filter ?? '',
|
||||
});
|
||||
return _decodeRequiredMapResult(result, 'searchProviderAll');
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> getDeezerRelatedArtists(
|
||||
String artistId, {
|
||||
int limit = 12,
|
||||
@@ -912,13 +895,6 @@ class PlatformBridge {
|
||||
return _decodeRequiredMapResult(result, 'getDeezerRelatedArtists');
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> parseProviderUrl(String url) async {
|
||||
final result = await _channel.invokeMethod('parseProviderUrl', {
|
||||
'url': url,
|
||||
});
|
||||
return _decodeRequiredMapResult(result, 'parseProviderUrl');
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> getProviderMetadata(
|
||||
String providerId,
|
||||
String resourceType,
|
||||
@@ -1394,11 +1370,6 @@ class PlatformBridge {
|
||||
return _decodeMapListResult(result, 'getSearchProviders');
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> getBuiltInProviders() async {
|
||||
final result = await _channel.invokeMethod('getBuiltInProviders');
|
||||
return _decodeMapListResult(result, 'getBuiltInProviders');
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>?> handleURLWithExtension(
|
||||
String url,
|
||||
) async {
|
||||
|
||||
@@ -61,17 +61,6 @@ Future<List<Map<String, dynamic>>> _searchMetadataProvider(
|
||||
required String filter,
|
||||
required int limit,
|
||||
}) async {
|
||||
if (isBuiltInSearchProvider(providerId)) {
|
||||
final result = await PlatformBridge.searchProviderAll(
|
||||
providerId,
|
||||
query,
|
||||
trackLimit: 0,
|
||||
artistLimit: filter == 'artist' ? limit : 0,
|
||||
filter: filter,
|
||||
);
|
||||
return _extractSearchItems(result, filter);
|
||||
}
|
||||
|
||||
return PlatformBridge.customSearchWithExtension(
|
||||
providerId,
|
||||
query,
|
||||
@@ -79,24 +68,6 @@ Future<List<Map<String, dynamic>>> _searchMetadataProvider(
|
||||
);
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _extractSearchItems(
|
||||
Map<String, dynamic> result,
|
||||
String filter,
|
||||
) {
|
||||
final key = switch (filter) {
|
||||
'artist' => 'artists',
|
||||
'album' => 'albums',
|
||||
_ => '${filter}s',
|
||||
};
|
||||
final items = result[key];
|
||||
if (items is! List) return const [];
|
||||
|
||||
return items
|
||||
.whereType<Map<Object?, Object?>>()
|
||||
.map((item) => Map<String, dynamic>.from(item))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
List<String> _metadataSearchProviderCandidates(
|
||||
BuildContext context, {
|
||||
String? sourceProviderId,
|
||||
@@ -142,10 +113,6 @@ List<String> _metadataSearchProviderCandidates(
|
||||
addProvider(extension.id);
|
||||
}
|
||||
|
||||
for (final providerId in builtInSearchProviderIds) {
|
||||
addProvider(providerId);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
@@ -153,7 +120,6 @@ bool _canSearchMetadataProvider(
|
||||
String providerId,
|
||||
ExtensionState extensionState,
|
||||
) {
|
||||
if (isBuiltInSearchProvider(providerId)) return true;
|
||||
return extensionState.extensions.any(
|
||||
(ext) => ext.enabled && ext.hasCustomSearch && ext.id == providerId,
|
||||
);
|
||||
@@ -333,8 +299,7 @@ void _pushArtistScreen(
|
||||
String? coverUrl,
|
||||
String? extensionId,
|
||||
}) {
|
||||
final isExtension =
|
||||
extensionId != null && !isBuiltInMetadataProvider(extensionId);
|
||||
final isExtension = extensionId != null;
|
||||
final resolvedProviderId = extensionId;
|
||||
|
||||
_pushViaPreferredNavigator(
|
||||
@@ -362,8 +327,7 @@ void _pushAlbumScreen(
|
||||
String? coverUrl,
|
||||
String? extensionId,
|
||||
}) {
|
||||
final isExtension =
|
||||
extensionId != null && !isBuiltInMetadataProvider(extensionId);
|
||||
final isExtension = extensionId != null;
|
||||
final resolvedExtensionId = extensionId;
|
||||
|
||||
_pushViaPreferredNavigator(
|
||||
@@ -677,19 +641,9 @@ bool _canNavigateArtistDirectly({
|
||||
required String? extensionId,
|
||||
}) {
|
||||
if (extensionId != null) return true;
|
||||
final providerPrefix = _resourceProviderPrefix(artistId);
|
||||
if (providerPrefix != null && isBuiltInMetadataProvider(providerPrefix)) {
|
||||
return true;
|
||||
}
|
||||
return _spotifyArtistIdPattern.hasMatch(artistId);
|
||||
}
|
||||
|
||||
String? _resourceProviderPrefix(String resourceId) {
|
||||
final colonIndex = resourceId.indexOf(':');
|
||||
if (colonIndex <= 0) return null;
|
||||
return resourceId.substring(0, colonIndex).trim();
|
||||
}
|
||||
|
||||
final RegExp _spotifyArtistIdPattern = RegExp(r'^[A-Za-z0-9]{22}$');
|
||||
|
||||
class ClickableAlbumName extends StatelessWidget {
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
|
||||
IconData resolveProviderIcon(
|
||||
String providerId, {
|
||||
IconData tidalIcon = Icons.music_note,
|
||||
IconData builtInDefaultIcon = Icons.album,
|
||||
IconData deezerIcon = Icons.graphic_eq,
|
||||
IconData fallbackIcon = Icons.extension,
|
||||
}) {
|
||||
final builtIn = builtInProviderSpecForId(providerId);
|
||||
if (builtIn != null) {
|
||||
if (providerId == 'tidal') {
|
||||
return tidalIcon;
|
||||
}
|
||||
return builtInDefaultIcon;
|
||||
}
|
||||
|
||||
if (providerId == 'deezer') {
|
||||
return deezerIcon;
|
||||
}
|
||||
|
||||
return fallbackIcon;
|
||||
}
|
||||
@@ -5,67 +5,6 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
|
||||
class BuiltInService {
|
||||
final String id;
|
||||
final String label;
|
||||
final List<QualityOption> qualityOptions;
|
||||
final bool isDisabled; // If true, service is grayed out (fallback only)
|
||||
final String? disabledReason;
|
||||
|
||||
const BuiltInService({
|
||||
required this.id,
|
||||
required this.label,
|
||||
required this.qualityOptions,
|
||||
this.isDisabled = false,
|
||||
this.disabledReason,
|
||||
});
|
||||
}
|
||||
|
||||
const _builtInServiceCatalog = {
|
||||
'tidal': BuiltInService(
|
||||
id: 'tidal',
|
||||
label: 'Tidal',
|
||||
qualityOptions: [
|
||||
QualityOption(
|
||||
id: 'LOSSLESS',
|
||||
label: 'FLAC Lossless',
|
||||
description: '16-bit / 44.1kHz',
|
||||
),
|
||||
QualityOption(
|
||||
id: 'HI_RES',
|
||||
label: 'Hi-Res FLAC',
|
||||
description: '24-bit / up to 96kHz',
|
||||
),
|
||||
QualityOption(
|
||||
id: 'HI_RES_LOSSLESS',
|
||||
label: 'Hi-Res FLAC Max',
|
||||
description: '24-bit / up to 192kHz',
|
||||
),
|
||||
],
|
||||
),
|
||||
'qobuz': BuiltInService(
|
||||
id: 'qobuz',
|
||||
label: 'Qobuz',
|
||||
qualityOptions: [
|
||||
QualityOption(
|
||||
id: 'LOSSLESS',
|
||||
label: 'FLAC Lossless',
|
||||
description: '16-bit / 44.1kHz',
|
||||
),
|
||||
QualityOption(
|
||||
id: 'HI_RES',
|
||||
label: 'Hi-Res FLAC',
|
||||
description: '24-bit / up to 96kHz',
|
||||
),
|
||||
QualityOption(
|
||||
id: 'HI_RES_LOSSLESS',
|
||||
label: 'Hi-Res FLAC Max',
|
||||
description: '24-bit / up to 192kHz',
|
||||
),
|
||||
],
|
||||
),
|
||||
};
|
||||
|
||||
class DownloadServicePicker extends ConsumerStatefulWidget {
|
||||
final String? trackName;
|
||||
final String? artistName;
|
||||
@@ -118,18 +57,6 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
|
||||
class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
late String _selectedService;
|
||||
|
||||
List<BuiltInService> _availableBuiltInServices() {
|
||||
final availableIds = builtInDownloadProviderIds.toSet();
|
||||
final services = <BuiltInService>[];
|
||||
for (final id in availableIds) {
|
||||
final service = _builtInServiceCatalog[id];
|
||||
if (service != null) {
|
||||
services.add(service);
|
||||
}
|
||||
}
|
||||
return services;
|
||||
}
|
||||
|
||||
List<Extension> _downloadExtensions() {
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
return extensionState.extensions
|
||||
@@ -137,13 +64,8 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
bool _serviceExists(
|
||||
String serviceId,
|
||||
List<BuiltInService> builtInServices,
|
||||
List<Extension> downloadExtensions,
|
||||
) {
|
||||
bool _serviceExists(String serviceId, List<Extension> downloadExtensions) {
|
||||
if (serviceId.isEmpty) return false;
|
||||
if (builtInServices.any((service) => service.id == serviceId)) return true;
|
||||
return downloadExtensions.any((ext) => ext.id == serviceId);
|
||||
}
|
||||
|
||||
@@ -154,39 +76,21 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
if (!mounted) return;
|
||||
ref.read(extensionProvider.notifier).refreshEnabledExtensionHealth();
|
||||
});
|
||||
final builtInServices = _availableBuiltInServices();
|
||||
final downloadExtensions = _downloadExtensions();
|
||||
final recommended = widget.recommendedService;
|
||||
if (recommended != null &&
|
||||
_serviceExists(recommended, builtInServices, downloadExtensions)) {
|
||||
if (recommended != null && _serviceExists(recommended, downloadExtensions)) {
|
||||
_selectedService = recommended;
|
||||
} else {
|
||||
_selectedService = ref.read(settingsProvider).defaultService;
|
||||
}
|
||||
if (!_serviceExists(
|
||||
_selectedService,
|
||||
builtInServices,
|
||||
downloadExtensions,
|
||||
)) {
|
||||
_selectedService = builtInServices.isNotEmpty
|
||||
? builtInServices.first.id
|
||||
: downloadExtensions.isNotEmpty
|
||||
if (!_serviceExists(_selectedService, downloadExtensions)) {
|
||||
_selectedService = downloadExtensions.isNotEmpty
|
||||
? downloadExtensions.first.id
|
||||
: '';
|
||||
}
|
||||
}
|
||||
|
||||
List<QualityOption> _getQualityOptions(
|
||||
List<BuiltInService> builtInServices,
|
||||
List<Extension> downloadExtensions,
|
||||
) {
|
||||
final builtIn = builtInServices
|
||||
.where((service) => service.id == _selectedService)
|
||||
.firstOrNull;
|
||||
if (builtIn != null) {
|
||||
return builtIn.qualityOptions;
|
||||
}
|
||||
|
||||
List<QualityOption> _getQualityOptions(List<Extension> downloadExtensions) {
|
||||
final ext = downloadExtensions
|
||||
.where((e) => e.id == _selectedService)
|
||||
.firstOrNull;
|
||||
@@ -201,14 +105,9 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final extensionState = ref.watch(extensionProvider);
|
||||
final builtInServices = _availableBuiltInServices();
|
||||
final downloadExtensions = _downloadExtensions();
|
||||
final hasProviders =
|
||||
builtInServices.isNotEmpty || downloadExtensions.isNotEmpty;
|
||||
final qualityOptions = _getQualityOptions(
|
||||
builtInServices,
|
||||
downloadExtensions,
|
||||
);
|
||||
final hasProviders = downloadExtensions.isNotEmpty;
|
||||
final qualityOptions = _getQualityOptions(downloadExtensions);
|
||||
|
||||
return SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
@@ -257,21 +156,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final service in builtInServices)
|
||||
_ServiceChip(
|
||||
label: service.isDisabled
|
||||
? '${service.label} (${service.disabledReason})'
|
||||
: widget.recommendedService == service.id
|
||||
? '${service.label} (Recommended)'
|
||||
: service.label,
|
||||
isSelected: _selectedService == service.id,
|
||||
isDisabled: service.isDisabled,
|
||||
onTap: service.isDisabled
|
||||
? null
|
||||
: () => setState(
|
||||
() => _selectedService = service.id,
|
||||
),
|
||||
),
|
||||
for (final ext in downloadExtensions)
|
||||
_ServiceChip(
|
||||
label: widget.recommendedService == ext.id
|
||||
@@ -302,19 +186,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (builtInServices.any(
|
||||
(service) => service.id == _selectedService,
|
||||
))
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
||||
child: Text(
|
||||
context.l10n.qualityNote,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final quality in qualityOptions)
|
||||
_QualityOption(
|
||||
title: _localizedQualityLabel(context, quality),
|
||||
@@ -429,7 +300,6 @@ class _ServiceChip extends StatelessWidget {
|
||||
final VoidCallback? onTap;
|
||||
final String? iconPath;
|
||||
final String? healthStatus;
|
||||
final bool isDisabled;
|
||||
|
||||
const _ServiceChip({
|
||||
required this.label,
|
||||
@@ -437,21 +307,18 @@ class _ServiceChip extends StatelessWidget {
|
||||
required this.onTap,
|
||||
this.iconPath,
|
||||
this.healthStatus,
|
||||
this.isDisabled = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return GestureDetector(
|
||||
onTap: isDisabled ? null : onTap,
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isDisabled
|
||||
? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5)
|
||||
: isSelected
|
||||
color: isSelected
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@@ -465,7 +332,7 @@ class _ServiceChip extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (healthStatus != null) ...[
|
||||
_ServiceHealthDot(status: healthStatus!, isDisabled: isDisabled),
|
||||
_ServiceHealthDot(status: healthStatus!),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
if (iconPath != null) ...[
|
||||
@@ -479,9 +346,7 @@ class _ServiceChip extends StatelessWidget {
|
||||
errorBuilder: (context, error, stackTrace) => Icon(
|
||||
Icons.extension,
|
||||
size: 18,
|
||||
color: isDisabled
|
||||
? colorScheme.onSurfaceVariant.withValues(alpha: 0.4)
|
||||
: isSelected
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -493,9 +358,7 @@ class _ServiceChip extends StatelessWidget {
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isDisabled
|
||||
? colorScheme.onSurfaceVariant.withValues(alpha: 0.4)
|
||||
: isSelected
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -509,15 +372,12 @@ class _ServiceChip extends StatelessWidget {
|
||||
|
||||
class _ServiceHealthDot extends StatelessWidget {
|
||||
final String status;
|
||||
final bool isDisabled;
|
||||
|
||||
const _ServiceHealthDot({required this.status, required this.isDisabled});
|
||||
const _ServiceHealthDot({required this.status});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = isDisabled
|
||||
? Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.3)
|
||||
: _serviceHealthColor(status);
|
||||
final color = _serviceHealthColor(status);
|
||||
return Tooltip(
|
||||
message: _serviceHealthTooltip(status),
|
||||
child: Container(
|
||||
|
||||
Reference in New Issue
Block a user