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:
zarzet
2026-05-05 03:55:24 +07:00
parent cfc8e699f3
commit 101ab3f521
49 changed files with 275 additions and 1502 deletions
@@ -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
View File
@@ -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")
-16
View File
@@ -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)
}
}
+16 -180
View File
@@ -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
}
-11
View File
@@ -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) {
-23
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 =>
+1 -1
View File
@@ -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 =>
+1 -1
View File
@@ -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 =>
+1 -1
View File
@@ -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 =>
+1 -1
View File
@@ -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 =>
+1 -1
View File
@@ -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 =>
+1 -1
View File
@@ -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 =>
+1 -1
View File
@@ -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 =>
+1 -1
View File
@@ -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 =>
+2 -2
View File
@@ -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 =>
+1 -1
View File
@@ -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 =>
+3 -3
View File
@@ -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 =>
+1 -1
View File
@@ -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"
},
+1 -1
View File
@@ -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"
},
+1 -1
View File
@@ -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"
},
+1 -1
View File
@@ -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"
},
+1 -1
View File
@@ -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"
},
+1 -1
View File
@@ -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"
},
+1 -1
View File
@@ -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"
},
+1 -1
View File
@@ -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"
},
+1 -1
View File
@@ -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"
},
+1 -1
View File
@@ -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"
},
+1 -1
View File
@@ -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"
},
+1 -1
View File
@@ -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"
},
+1 -1
View File
@@ -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"
},
+1 -1
View File
@@ -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"
},
+1 -1
View File
@@ -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(
+10 -191
View File
@@ -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);
}
+4 -27
View File
@@ -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
View File
@@ -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>(
+7 -38
View File
@@ -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;
+3 -42
View File
@@ -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,
+6 -32
View File
@@ -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,
});
}
-29
View File
@@ -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 {
+2 -48
View File
@@ -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 {
-24
View File
@@ -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;
}
+14 -154
View File
@@ -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(