mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-06 04:37:59 +02:00
feat: add generic extension provider resolution, progress phases, and instrumental lyrics heuristic
- Replace hardcoded provider prefix checks with resolveEffectiveMetadataProvider using replacesBuiltInProviders manifest capability - Add preparing/downloading/finalizing progress status constants and SetItemPreparing/SetItemDownloading APIs - Expose setDownloadStatus to extension JS runtime for fine-grained progress control - Skip lyrics search for instrumental tracks detected by title heuristic - Pass tidal/qobuz IDs to extension checkAvailability for richer matching - Add shouldAbortCancelledFallback helper for robust cancellation propagation - Add resolvePreferredTrackIDForExtension for intelligent track ID selection per extension - Remove ambiguous Auto/Default search provider option, always resolve to concrete provider - Add tests for shouldAbortCancelledFallback and progress status transitions
This commit is contained in:
@@ -158,6 +158,115 @@ func downloadWithBuiltInProvider(providerID string, req DownloadRequest) (Downlo
|
||||
return spec.Download(req)
|
||||
}
|
||||
|
||||
func manifestCapabilityStringList(manifest *ExtensionManifest, key string) []string {
|
||||
if manifest == nil || manifest.Capabilities == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
raw, ok := manifest.Capabilities[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
values, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
str, ok := value.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
trimmed := strings.ToLower(strings.TrimSpace(str))
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func extensionReplacesBuiltInProvider(ext *loadedExtension, providerID string) bool {
|
||||
if ext == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
normalized := strings.ToLower(strings.TrimSpace(providerID))
|
||||
if normalized == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, replaced := range manifestCapabilityStringList(ext.Manifest, "replacesBuiltInProviders") {
|
||||
if replaced == normalized {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func trimKnownProviderPrefix(trackID, providerID string) string {
|
||||
trimmedID := strings.TrimSpace(trackID)
|
||||
normalizedProvider := strings.ToLower(strings.TrimSpace(providerID))
|
||||
if trimmedID == "" || normalizedProvider == "" {
|
||||
return trimmedID
|
||||
}
|
||||
|
||||
prefix := normalizedProvider + ":"
|
||||
if strings.HasPrefix(strings.ToLower(trimmedID), prefix) {
|
||||
return trimmedID[len(prefix):]
|
||||
}
|
||||
|
||||
return trimmedID
|
||||
}
|
||||
|
||||
func resolvePreferredTrackIDForExtension(ext *loadedExtension, req DownloadRequest, explicitTrackID string) string {
|
||||
candidates := make([]string, 0, 8)
|
||||
appendCandidate := func(value string) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return
|
||||
}
|
||||
for _, existing := range candidates {
|
||||
if existing == trimmed {
|
||||
return
|
||||
}
|
||||
}
|
||||
candidates = append(candidates, trimmed)
|
||||
}
|
||||
|
||||
appendCandidate(explicitTrackID)
|
||||
|
||||
if extensionReplacesBuiltInProvider(ext, "tidal") {
|
||||
appendCandidate(req.TidalID)
|
||||
appendCandidate(trimKnownProviderPrefix(req.SpotifyID, "tidal"))
|
||||
}
|
||||
if extensionReplacesBuiltInProvider(ext, "qobuz") {
|
||||
appendCandidate(req.QobuzID)
|
||||
appendCandidate(trimKnownProviderPrefix(req.SpotifyID, "qobuz"))
|
||||
}
|
||||
if extensionReplacesBuiltInProvider(ext, "deezer") {
|
||||
appendCandidate(req.DeezerID)
|
||||
appendCandidate(trimKnownProviderPrefix(req.SpotifyID, "deezer"))
|
||||
}
|
||||
if extensionReplacesBuiltInProvider(ext, "spotify") {
|
||||
appendCandidate(trimKnownProviderPrefix(req.SpotifyID, "spotify"))
|
||||
appendCandidate(req.SpotifyID)
|
||||
}
|
||||
|
||||
appendCandidate(req.SpotifyID)
|
||||
appendCandidate(req.TidalID)
|
||||
appendCandidate(req.QobuzID)
|
||||
appendCandidate(req.DeezerID)
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return ""
|
||||
}
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
func normalizeExtensionDownloadResult(result *ExtDownloadResult) (DownloadResult, bool) {
|
||||
if result == nil {
|
||||
return DownloadResult{}, false
|
||||
@@ -317,6 +426,13 @@ func buildExtensionFallbackStoppedResponse(providerID string, availability *ExtA
|
||||
}
|
||||
}
|
||||
|
||||
func shouldAbortCancelledFallback(itemID string, err error) bool {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return true
|
||||
}
|
||||
return itemID != "" && isDownloadCancelled(itemID)
|
||||
}
|
||||
|
||||
type DownloadDecryptionInfo struct {
|
||||
Strategy string `json:"strategy,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
@@ -824,11 +940,11 @@ func (p *extensionProviderWrapper) EnrichTrackForItemID(track *ExtTrackMetadata,
|
||||
return &enrichedTrack, nil
|
||||
}
|
||||
|
||||
func (p *extensionProviderWrapper) CheckAvailability(isrc, trackName, artistName, spotifyID, deezerID string) (*ExtAvailabilityResult, error) {
|
||||
return p.CheckAvailabilityForItemID(isrc, trackName, artistName, spotifyID, deezerID, "")
|
||||
func (p *extensionProviderWrapper) CheckAvailability(isrc, trackName, artistName, spotifyID, deezerID, tidalID, qobuzID string) (*ExtAvailabilityResult, error) {
|
||||
return p.CheckAvailabilityForItemID(isrc, trackName, artistName, spotifyID, deezerID, tidalID, qobuzID, "")
|
||||
}
|
||||
|
||||
func (p *extensionProviderWrapper) CheckAvailabilityForItemID(isrc, trackName, artistName, spotifyID, deezerID string, itemID string) (*ExtAvailabilityResult, error) {
|
||||
func (p *extensionProviderWrapper) CheckAvailabilityForItemID(isrc, trackName, artistName, spotifyID, deezerID, tidalID, qobuzID string, itemID string) (*ExtAvailabilityResult, error) {
|
||||
if !p.extension.Manifest.IsDownloadProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
||||
}
|
||||
@@ -855,11 +971,16 @@ func (p *extensionProviderWrapper) CheckAvailabilityForItemID(isrc, trackName, a
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.checkAvailability === 'function') {
|
||||
return extension.checkAvailability(%q, %q, %q, {spotify_id: %q, deezer_id: %q});
|
||||
return extension.checkAvailability(%q, %q, %q, {
|
||||
spotify_id: %q,
|
||||
deezer_id: %q,
|
||||
tidal_id: %q,
|
||||
qobuz_id: %q
|
||||
});
|
||||
}
|
||||
return null;
|
||||
})()
|
||||
`, isrc, trackName, artistName, spotifyID, deezerID)
|
||||
`, isrc, trackName, artistName, spotifyID, deezerID, tidalID, qobuzID)
|
||||
|
||||
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
|
||||
if err != nil {
|
||||
@@ -966,6 +1087,7 @@ func (p *extensionProviderWrapper) Download(trackID, quality, outputPath, itemID
|
||||
if itemID != "" {
|
||||
initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
SetItemPreparing(itemID)
|
||||
}
|
||||
|
||||
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
|
||||
@@ -1585,8 +1707,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
ext, err := extManager.GetExtension(req.Source)
|
||||
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() {
|
||||
provider := newExtensionProviderWrapper(ext)
|
||||
availability, availErr := provider.CheckAvailabilityForItemID(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID, req.ItemID)
|
||||
if errors.Is(availErr, ErrDownloadCancelled) {
|
||||
availability, availErr := provider.CheckAvailabilityForItemID(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID, req.TidalID, req.QobuzID, req.ItemID)
|
||||
if shouldAbortCancelledFallback(req.ItemID, availErr) {
|
||||
return nil, ErrDownloadCancelled
|
||||
}
|
||||
if availErr != nil {
|
||||
@@ -1624,7 +1746,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
|
||||
enrichedTrack, err := provider.EnrichTrackForItemID(trackMeta, req.ItemID)
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
if shouldAbortCancelledFallback(req.ItemID, err) {
|
||||
return nil, ErrDownloadCancelled
|
||||
}
|
||||
if err == nil && enrichedTrack != nil {
|
||||
@@ -1716,7 +1838,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
GoLog("[DownloadWithExtensionFallback] Metadata incomplete, searching providers for: %s\n", searchQuery)
|
||||
|
||||
tracks, searchErr := extManager.SearchTracksWithMetadataProvidersForItemID(searchQuery, 5, true, req.ItemID)
|
||||
if errors.Is(searchErr, ErrDownloadCancelled) {
|
||||
if shouldAbortCancelledFallback(req.ItemID, searchErr) {
|
||||
return nil, ErrDownloadCancelled
|
||||
}
|
||||
if searchErr == nil && len(tracks) > 0 {
|
||||
@@ -1770,6 +1892,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
if req.ISRC != "" &&
|
||||
(req.Genre == "" || req.Label == "" || req.Copyright == "") {
|
||||
enrichExtraMetadataByISRC("DownloadWithExtensionFallback", req.ISRC, &req.Genre, &req.Label, &req.Copyright)
|
||||
if isDownloadCancelled(req.ItemID) {
|
||||
return nil, ErrDownloadCancelled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1793,10 +1918,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
|
||||
provider := newExtensionProviderWrapper(ext)
|
||||
|
||||
trackID := req.SpotifyID
|
||||
if sourceExtensionTrackID != "" {
|
||||
trackID = sourceExtensionTrackID
|
||||
}
|
||||
trackID := resolvePreferredTrackIDForExtension(ext, req, sourceExtensionTrackID)
|
||||
|
||||
GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn)
|
||||
|
||||
@@ -1824,6 +1946,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
RemoveItemProgress(req.ItemID)
|
||||
}
|
||||
}
|
||||
if shouldAbortCancelledFallback(req.ItemID, err) {
|
||||
return nil, ErrDownloadCancelled
|
||||
}
|
||||
|
||||
if err == nil && result.Success {
|
||||
normalizedResult, alreadyExists := normalizeExtensionDownloadResult(result)
|
||||
@@ -1994,8 +2119,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
|
||||
provider := newExtensionProviderWrapper(ext)
|
||||
|
||||
availability, err := provider.CheckAvailabilityForItemID(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID, req.ItemID)
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
availability, err := provider.CheckAvailabilityForItemID(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID, req.TidalID, req.QobuzID, req.ItemID)
|
||||
if shouldAbortCancelledFallback(req.ItemID, err) {
|
||||
return nil, ErrDownloadCancelled
|
||||
}
|
||||
terminalAvailability := shouldStopProviderFallback(availability)
|
||||
@@ -2036,6 +2161,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
RemoveItemProgress(req.ItemID)
|
||||
}
|
||||
}
|
||||
if shouldAbortCancelledFallback(req.ItemID, err) {
|
||||
return nil, ErrDownloadCancelled
|
||||
}
|
||||
|
||||
if err == nil && result.Success {
|
||||
normalizedResult, alreadyExists := normalizeExtensionDownloadResult(result)
|
||||
|
||||
@@ -211,6 +211,24 @@ func TestBuildExtensionFallbackStoppedResponseFallsBackToError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldAbortCancelledFallbackWithCancelledError(t *testing.T) {
|
||||
if !shouldAbortCancelledFallback("", ErrDownloadCancelled) {
|
||||
t.Fatal("expected cancelled error to abort fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldAbortCancelledFallbackWithCancelledItemState(t *testing.T) {
|
||||
const itemID = "cancelled-item"
|
||||
initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
|
||||
cancelDownload(itemID)
|
||||
|
||||
if !shouldAbortCancelledFallback(itemID, errors.New("generic failure")) {
|
||||
t.Fatal("expected cancelled item state to abort fallback even for generic errors")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanEmbedGenreLabelRequiresExistingAbsoluteLocalFile(t *testing.T) {
|
||||
tempFile := filepath.Join(t.TempDir(), "track.flac")
|
||||
if err := os.WriteFile(tempFile, []byte("fLaC"), 0644); err != nil {
|
||||
|
||||
@@ -479,6 +479,7 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
utilsObj.Set("appUserAgent", r.appUserAgent)
|
||||
utilsObj.Set("sleep", r.sleep)
|
||||
utilsObj.Set("isDownloadCancelled", r.isDownloadCancelled)
|
||||
utilsObj.Set("setDownloadStatus", r.setDownloadStatus)
|
||||
vm.Set("utils", utilsObj)
|
||||
|
||||
logObj := vm.NewObject()
|
||||
|
||||
@@ -205,14 +205,19 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
contentLength := resp.ContentLength
|
||||
activeItemID := r.getActiveDownloadItemID()
|
||||
if activeItemID != "" && contentLength > 0 {
|
||||
if activeItemID != "" {
|
||||
SetItemDownloading(activeItemID)
|
||||
}
|
||||
|
||||
contentLength := resp.ContentLength
|
||||
shouldTrackItemBytes := activeItemID != "" && onProgress == nil
|
||||
if shouldTrackItemBytes && contentLength > 0 {
|
||||
SetItemBytesTotal(activeItemID, contentLength)
|
||||
}
|
||||
|
||||
var progressWriter interface{ Write([]byte) (int, error) } = out
|
||||
if activeItemID != "" {
|
||||
if shouldTrackItemBytes {
|
||||
progressWriter = NewItemProgressWriter(out, activeItemID)
|
||||
}
|
||||
|
||||
|
||||
@@ -312,6 +312,25 @@ func (r *extensionRuntime) isDownloadCancelled(call goja.FunctionCall) goja.Valu
|
||||
return r.vm.ToValue(isDownloadCancelled(itemID))
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) setDownloadStatus(call goja.FunctionCall) goja.Value {
|
||||
itemID := r.getActiveDownloadItemID()
|
||||
if itemID == "" || len(call.Arguments) < 1 {
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
status := strings.ToLower(strings.TrimSpace(call.Arguments[0].String()))
|
||||
switch status {
|
||||
case itemProgressStatusPreparing:
|
||||
SetItemPreparing(itemID)
|
||||
case itemProgressStatusDownloading:
|
||||
SetItemDownloading(itemID)
|
||||
case itemProgressStatusFinalizing:
|
||||
SetItemFinalizing(itemID)
|
||||
}
|
||||
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
|
||||
msg := r.formatLogArgs(call.Arguments)
|
||||
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
|
||||
|
||||
@@ -81,6 +81,8 @@ var defaultLyricsFetchOptions = LyricsFetchOptions{
|
||||
MusixmatchLanguage: "",
|
||||
}
|
||||
|
||||
var instrumentalTrackPattern = regexp.MustCompile(`(?i)(?:^|[\s\[(\-])(?:instrumental|inst\.?)(?:[\s\])]|$)`)
|
||||
|
||||
var (
|
||||
lyricsFetchOptionsMu sync.RWMutex
|
||||
lyricsFetchOptions = defaultLyricsFetchOptions
|
||||
@@ -411,6 +413,16 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
||||
primaryArtist := normalizeArtistName(artistName)
|
||||
fetchOptions := GetLyricsFetchOptions()
|
||||
|
||||
if isLikelyInstrumentalTrack(trackName) {
|
||||
GoLog("[Lyrics] Track marked instrumental by title heuristic, skipping lyrics search: %s - %s\n", artistName, trackName)
|
||||
instrumental := &LyricsResponse{
|
||||
Instrumental: true,
|
||||
Source: "Heuristic: Instrumental",
|
||||
}
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, instrumental)
|
||||
return instrumental, nil
|
||||
}
|
||||
|
||||
extManager := getExtensionManager()
|
||||
var extensionProviders []*extensionProviderWrapper
|
||||
if extManager != nil {
|
||||
@@ -847,6 +859,15 @@ func normalizeArtistName(name string) string {
|
||||
return strings.TrimSpace(result)
|
||||
}
|
||||
|
||||
func isLikelyInstrumentalTrack(name string) bool {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
return instrumentalTrackPattern.MatchString(trimmed)
|
||||
}
|
||||
|
||||
func SaveLRCFile(audioFilePath, lrcContent string) (string, error) {
|
||||
if lrcContent == "" {
|
||||
return "", fmt.Errorf("empty LRC content")
|
||||
|
||||
+36
-3
@@ -26,6 +26,13 @@ type ItemProgress struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
const (
|
||||
itemProgressStatusPreparing = "preparing"
|
||||
itemProgressStatusDownloading = "downloading"
|
||||
itemProgressStatusCompleted = "completed"
|
||||
itemProgressStatusFinalizing = "finalizing"
|
||||
)
|
||||
|
||||
type MultiProgress struct {
|
||||
Items map[string]*ItemProgress `json:"items"`
|
||||
}
|
||||
@@ -106,11 +113,37 @@ func StartItemProgress(itemID string) {
|
||||
BytesReceived: 0,
|
||||
Progress: 0,
|
||||
IsDownloading: true,
|
||||
Status: "downloading",
|
||||
Status: itemProgressStatusDownloading,
|
||||
}
|
||||
markMultiProgressDirtyLocked()
|
||||
}
|
||||
|
||||
func SetItemPreparing(itemID string) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
item.Progress = 0
|
||||
item.BytesReceived = 0
|
||||
item.BytesTotal = 0
|
||||
item.SpeedMBps = 0
|
||||
item.IsDownloading = true
|
||||
item.Status = itemProgressStatusPreparing
|
||||
markMultiProgressDirtyLocked()
|
||||
}
|
||||
}
|
||||
|
||||
func SetItemDownloading(itemID string) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
item.IsDownloading = true
|
||||
item.Status = itemProgressStatusDownloading
|
||||
markMultiProgressDirtyLocked()
|
||||
}
|
||||
}
|
||||
|
||||
func SetItemBytesTotal(itemID string, total int64) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
@@ -155,7 +188,7 @@ func CompleteItemProgress(itemID string) {
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
item.Progress = 1.0
|
||||
item.IsDownloading = false
|
||||
item.Status = "completed"
|
||||
item.Status = itemProgressStatusCompleted
|
||||
markMultiProgressDirtyLocked()
|
||||
}
|
||||
}
|
||||
@@ -182,7 +215,7 @@ func SetItemFinalizing(itemID string) {
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
item.Progress = 1.0
|
||||
item.Status = "finalizing"
|
||||
item.Status = itemProgressStatusFinalizing
|
||||
markMultiProgressDirtyLocked()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestItemProgressPreparingAndDownloadingStatuses(t *testing.T) {
|
||||
const itemID = "progress-phase-item"
|
||||
RemoveItemProgress(itemID)
|
||||
defer RemoveItemProgress(itemID)
|
||||
|
||||
StartItemProgress(itemID)
|
||||
SetItemPreparing(itemID)
|
||||
|
||||
if item := multiProgress.Items[itemID]; item == nil {
|
||||
t.Fatal("expected item progress entry to exist")
|
||||
} else {
|
||||
if item.Status != itemProgressStatusPreparing {
|
||||
t.Fatalf("status = %q, want %q", item.Status, itemProgressStatusPreparing)
|
||||
}
|
||||
if item.Progress != 0 {
|
||||
t.Fatalf("progress = %v, want 0", item.Progress)
|
||||
}
|
||||
}
|
||||
|
||||
SetItemProgress(itemID, 0.37, 0, 0)
|
||||
if item := multiProgress.Items[itemID]; item == nil {
|
||||
t.Fatal("expected item progress entry to exist after update")
|
||||
} else if item.Status != itemProgressStatusPreparing {
|
||||
t.Fatalf("status after progress update = %q, want %q", item.Status, itemProgressStatusPreparing)
|
||||
}
|
||||
|
||||
SetItemDownloading(itemID)
|
||||
if item := multiProgress.Items[itemID]; item == nil {
|
||||
t.Fatal("expected item progress entry to exist after downloading status")
|
||||
} else if item.Status != itemProgressStatusDownloading {
|
||||
t.Fatalf("status after download start = %q, want %q", item.Status, itemProgressStatusDownloading)
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemProgressFinalizingAndCompletedStatuses(t *testing.T) {
|
||||
const itemID = "progress-finalizing-item"
|
||||
RemoveItemProgress(itemID)
|
||||
defer RemoveItemProgress(itemID)
|
||||
|
||||
StartItemProgress(itemID)
|
||||
SetItemFinalizing(itemID)
|
||||
|
||||
if item := multiProgress.Items[itemID]; item == nil {
|
||||
t.Fatal("expected item progress entry to exist")
|
||||
} else if item.Status != itemProgressStatusFinalizing {
|
||||
t.Fatalf("status = %q, want %q", item.Status, itemProgressStatusFinalizing)
|
||||
}
|
||||
|
||||
CompleteItemProgress(itemID)
|
||||
if item := multiProgress.Items[itemID]; item == nil {
|
||||
t.Fatal("expected item progress entry to exist after completion")
|
||||
} else if item.Status != itemProgressStatusCompleted {
|
||||
t.Fatalf("status = %q, want %q", item.Status, itemProgressStatusCompleted)
|
||||
}
|
||||
}
|
||||
@@ -1711,6 +1711,21 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (status == 'preparing') {
|
||||
progressUpdates[itemId] = const _ProgressUpdate(
|
||||
status: DownloadStatus.downloading,
|
||||
progress: 0.0,
|
||||
speedMBps: 0,
|
||||
bytesReceived: 0,
|
||||
bytesTotal: 0,
|
||||
);
|
||||
|
||||
if (LogBuffer.loggingEnabled) {
|
||||
_log.d('Preparing [$itemId]: waiting for real download bytes');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
final progressFromBackend =
|
||||
(itemProgress['progress'] as num?)?.toDouble() ?? 0.0;
|
||||
|
||||
@@ -1823,6 +1838,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final bytesReceived =
|
||||
(firstProgress['bytes_received'] as num?)?.toInt() ?? 0;
|
||||
final bytesTotal = (firstProgress['bytes_total'] as num?)?.toInt() ?? 0;
|
||||
final backendStatus = firstProgress['status'] as String? ?? 'downloading';
|
||||
|
||||
if (downloadingCount > 0 && firstDownloading != null) {
|
||||
final trackName = downloadingCount == 1
|
||||
@@ -1835,7 +1851,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
int notifProgress = bytesReceived;
|
||||
int notifTotal = bytesTotal;
|
||||
|
||||
if (bytesTotal <= 0) {
|
||||
if (backendStatus == 'preparing') {
|
||||
notifProgress = 0;
|
||||
notifTotal = 100;
|
||||
} else if (bytesTotal <= 0) {
|
||||
final progressPercent =
|
||||
(firstProgress['progress'] as num?)?.toDouble() ?? 0.0;
|
||||
notifProgress = (progressPercent * 100).toInt();
|
||||
@@ -2620,11 +2639,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
try {
|
||||
final colonIdx = track.id.indexOf(':');
|
||||
final provider = track.id.substring(0, colonIdx);
|
||||
final effectiveProvider = resolveEffectiveMetadataProvider(
|
||||
provider,
|
||||
ref.read(extensionProvider),
|
||||
);
|
||||
final providerTrackId = track.id.substring(colonIdx + 1);
|
||||
|
||||
_log.d('No ISRC, fetching from $provider API: $providerTrackId');
|
||||
_log.d(
|
||||
'No ISRC, fetching from ${effectiveProvider.isEmpty ? provider : effectiveProvider} API: $providerTrackId',
|
||||
);
|
||||
final providerData = await PlatformBridge.getProviderMetadata(
|
||||
provider,
|
||||
effectiveProvider.isEmpty ? provider : effectiveProvider,
|
||||
'track',
|
||||
providerTrackId,
|
||||
);
|
||||
@@ -2647,7 +2672,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
);
|
||||
}
|
||||
|
||||
_log.d('Resolved ISRC from $provider: $resolvedIsrc');
|
||||
_log.d(
|
||||
'Resolved ISRC from ${effectiveProvider.isEmpty ? provider : effectiveProvider}: $resolvedIsrc',
|
||||
);
|
||||
|
||||
final updatedTrack = _copyTrackWithResolvedMetadata(
|
||||
track,
|
||||
@@ -2661,7 +2688,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
);
|
||||
final deezerTrackId = await _searchDeezerTrackIdByIsrc(
|
||||
resolvedIsrc,
|
||||
lookupContext: '$provider ISRC',
|
||||
lookupContext:
|
||||
'${effectiveProvider.isEmpty ? provider : effectiveProvider} ISRC',
|
||||
itemId: itemId,
|
||||
);
|
||||
|
||||
|
||||
@@ -345,6 +345,50 @@ String resolveEffectiveDownloadService(
|
||||
'';
|
||||
}
|
||||
|
||||
String resolveEffectiveMetadataProvider(
|
||||
String requestedProvider,
|
||||
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;
|
||||
if (matchingExtension != null) {
|
||||
return matchingExtension.id;
|
||||
}
|
||||
|
||||
final replacementExtension = enabledMetadataExtensions
|
||||
.where(
|
||||
(ext) => ext.replacesBuiltInProviders.contains(normalizedRequested),
|
||||
)
|
||||
.firstOrNull;
|
||||
if (replacementExtension != null) {
|
||||
return replacementExtension.id;
|
||||
}
|
||||
}
|
||||
|
||||
return enabledMetadataExtensions.firstOrNull?.id ??
|
||||
extensionState.builtInProviders
|
||||
.where((provider) => provider.supportsMetadata)
|
||||
.map((provider) => provider.id)
|
||||
.firstOrNull ??
|
||||
'';
|
||||
}
|
||||
|
||||
bool isDeezerCompatibleDownloadService(
|
||||
String service,
|
||||
ExtensionState extensionState,
|
||||
@@ -1024,6 +1068,23 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
String? _firstEnabledSearchProviderId() {
|
||||
return state.extensions
|
||||
.where(
|
||||
(ext) =>
|
||||
ext.enabled &&
|
||||
ext.hasCustomSearch &&
|
||||
ext.searchBehavior?.primary == true,
|
||||
)
|
||||
.map((ext) => ext.id)
|
||||
.firstOrNull ??
|
||||
state.extensions
|
||||
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
||||
.map((ext) => ext.id)
|
||||
.firstOrNull ??
|
||||
defaultBuiltInSearchProviderId;
|
||||
}
|
||||
|
||||
String? replacedBuiltInDownloadProviderFor(String providerId) {
|
||||
final normalized = providerId.trim().toLowerCase();
|
||||
if (normalized.isEmpty) return null;
|
||||
@@ -1137,8 +1198,18 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
|
||||
void _reconcileSearchProvider() {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final currentSearchProvider = settings.searchProvider?.trim();
|
||||
if (currentSearchProvider == null || currentSearchProvider.isEmpty) {
|
||||
final currentSearchProvider = settings.searchProvider?.trim() ?? '';
|
||||
final preferredSearchProvider = _firstEnabledSearchProviderId() ?? '';
|
||||
|
||||
if (currentSearchProvider.isEmpty) {
|
||||
if (preferredSearchProvider.isNotEmpty) {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setSearchProvider(preferredSearchProvider);
|
||||
_log.d(
|
||||
'Adopted first enabled search provider as default: $preferredSearchProvider',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1161,9 +1232,15 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
);
|
||||
if (!isBuiltInSearchProvider(currentSearchProvider) &&
|
||||
!hasMatchingExtension) {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setSearchProvider(
|
||||
preferredSearchProvider.isNotEmpty ? preferredSearchProvider : null,
|
||||
);
|
||||
_log.d(
|
||||
'Cleared stale search provider because $currentSearchProvider is no longer available',
|
||||
preferredSearchProvider.isNotEmpty
|
||||
? 'Reset stale search provider $currentSearchProvider to $preferredSearchProvider'
|
||||
: 'Cleared stale search provider because $currentSearchProvider is no longer available',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,32 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
int? _albumTotalTracks;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
String _legacyProviderIdFromResourceId(String value) {
|
||||
if (value.startsWith('deezer:')) return 'deezer';
|
||||
if (value.startsWith('qobuz:')) return 'qobuz';
|
||||
if (value.startsWith('tidal:')) return 'tidal';
|
||||
if (value.startsWith('spotify:')) return 'spotify';
|
||||
return 'spotify';
|
||||
}
|
||||
|
||||
String _effectiveMetadataProviderIdFromAlbumId() {
|
||||
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
|
||||
return widget.extensionId!;
|
||||
}
|
||||
return resolveEffectiveMetadataProvider(
|
||||
_legacyProviderIdFromResourceId(widget.albumId),
|
||||
ref.read(extensionProvider),
|
||||
);
|
||||
}
|
||||
|
||||
String _stripPrefixedResourceId(String value) {
|
||||
final colonIndex = value.indexOf(':');
|
||||
if (colonIndex <= 0 || colonIndex == value.length - 1) {
|
||||
return value;
|
||||
}
|
||||
return value.substring(colonIndex + 1);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -87,14 +113,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
_scrollController.addListener(_onScroll);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final providerId =
|
||||
widget.extensionId ??
|
||||
(() {
|
||||
if (widget.albumId.startsWith('deezer:')) return 'deezer';
|
||||
if (widget.albumId.startsWith('qobuz:')) return 'qobuz';
|
||||
if (widget.albumId.startsWith('tidal:')) return 'tidal';
|
||||
return 'spotify';
|
||||
})();
|
||||
final providerId = _effectiveMetadataProviderIdFromAlbumId();
|
||||
ref
|
||||
.read(recentAccessProvider.notifier)
|
||||
.recordAlbumAccess(
|
||||
@@ -263,21 +282,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
}
|
||||
|
||||
String? _directMetadataProviderId() {
|
||||
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
|
||||
return widget.extensionId;
|
||||
}
|
||||
if (widget.albumId.startsWith('deezer:')) return 'deezer';
|
||||
if (widget.albumId.startsWith('qobuz:')) return 'qobuz';
|
||||
if (widget.albumId.startsWith('tidal:')) return 'tidal';
|
||||
return null;
|
||||
final providerId = _effectiveMetadataProviderIdFromAlbumId();
|
||||
return providerId.isEmpty ? null : providerId;
|
||||
}
|
||||
|
||||
String _metadataResourceId(String providerId) {
|
||||
final prefixed = '$providerId:';
|
||||
if (widget.albumId.startsWith(prefixed)) {
|
||||
return widget.albumId.substring(prefixed.length);
|
||||
}
|
||||
return widget.albumId;
|
||||
return _stripPrefixedResourceId(widget.albumId);
|
||||
}
|
||||
|
||||
Track _parseTrack(
|
||||
@@ -656,9 +666,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addToQueue(track, service);
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
|
||||
);
|
||||
|
||||
@@ -158,22 +158,39 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
return _directMetadataProviderId();
|
||||
}
|
||||
|
||||
String? _directMetadataProviderId() {
|
||||
String _legacyProviderIdFromResourceId(String value) {
|
||||
if (value.startsWith('deezer:')) return 'deezer';
|
||||
if (value.startsWith('qobuz:')) return 'qobuz';
|
||||
if (value.startsWith('tidal:')) return 'tidal';
|
||||
if (value.startsWith('spotify:')) return 'spotify';
|
||||
return 'spotify';
|
||||
}
|
||||
|
||||
String _effectiveMetadataProviderIdFromArtistId() {
|
||||
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
|
||||
return widget.extensionId;
|
||||
return widget.extensionId!;
|
||||
}
|
||||
if (widget.artistId.startsWith('deezer:')) return 'deezer';
|
||||
if (widget.artistId.startsWith('qobuz:')) return 'qobuz';
|
||||
if (widget.artistId.startsWith('tidal:')) return 'tidal';
|
||||
return null;
|
||||
return resolveEffectiveMetadataProvider(
|
||||
_legacyProviderIdFromResourceId(widget.artistId),
|
||||
ref.read(extensionProvider),
|
||||
);
|
||||
}
|
||||
|
||||
String _stripPrefixedResourceId(String value) {
|
||||
final colonIndex = value.indexOf(':');
|
||||
if (colonIndex <= 0 || colonIndex == value.length - 1) {
|
||||
return value;
|
||||
}
|
||||
return value.substring(colonIndex + 1);
|
||||
}
|
||||
|
||||
String? _directMetadataProviderId() {
|
||||
final providerId = _effectiveMetadataProviderIdFromArtistId();
|
||||
return providerId.isEmpty ? null : providerId;
|
||||
}
|
||||
|
||||
String _metadataResourceId(String providerId) {
|
||||
final prefixed = '$providerId:';
|
||||
if (widget.artistId.startsWith(prefixed)) {
|
||||
return widget.artistId.substring(prefixed.length);
|
||||
}
|
||||
return widget.artistId;
|
||||
return _stripPrefixedResourceId(widget.artistId);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -183,14 +200,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
_scrollController.addListener(_onScroll);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final providerId =
|
||||
widget.extensionId ??
|
||||
(() {
|
||||
if (widget.artistId.startsWith('deezer:')) return 'deezer';
|
||||
if (widget.artistId.startsWith('qobuz:')) return 'qobuz';
|
||||
if (widget.artistId.startsWith('tidal:')) return 'tidal';
|
||||
return 'spotify';
|
||||
})();
|
||||
final providerId = _effectiveMetadataProviderIdFromArtistId();
|
||||
ref
|
||||
.read(recentAccessProvider.notifier)
|
||||
.recordArtistAccess(
|
||||
@@ -1034,9 +1044,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
Future<List<Track>> _fetchAlbumTracks(ArtistAlbum album) async {
|
||||
final providerId = album.providerId;
|
||||
if (providerId != null && providerId.isNotEmpty) {
|
||||
final resourceId = album.id.startsWith('$providerId:')
|
||||
? album.id.substring(providerId.length + 1)
|
||||
: album.id;
|
||||
final resourceId = _stripPrefixedResourceId(album.id);
|
||||
final metadata = await PlatformBridge.getProviderMetadata(
|
||||
providerId,
|
||||
'album',
|
||||
|
||||
@@ -3643,20 +3643,17 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
||||
.toList();
|
||||
final builtInProviders = builtInSearchProviderSpecs;
|
||||
final primarySearchExtension = _defaultSearchExtension(searchProviders);
|
||||
final defaultProviderTarget =
|
||||
primarySearchExtension?.displayName ??
|
||||
defaultBuiltInSearchProviderDisplayName ??
|
||||
context.l10n.extensionDefaultProvider;
|
||||
final defaultProviderLabel =
|
||||
'${context.l10n.extensionsHomeFeedAuto} ($defaultProviderTarget)';
|
||||
final defaultProviderIconPath = primarySearchExtension?.iconPath;
|
||||
final currentProvider =
|
||||
final resolvedCurrentProvider =
|
||||
rawCurrentProvider != null &&
|
||||
rawCurrentProvider.isNotEmpty &&
|
||||
(isBuiltInSearchProvider(rawCurrentProvider) ||
|
||||
searchProviders.any((e) => e.id == rawCurrentProvider))
|
||||
? rawCurrentProvider
|
||||
: _defaultSearchExtension(searchProviders)?.id ??
|
||||
defaultBuiltInSearchProviderId;
|
||||
final currentProvider =
|
||||
resolvedCurrentProvider != null && resolvedCurrentProvider.isNotEmpty
|
||||
? resolvedCurrentProvider
|
||||
: null;
|
||||
|
||||
Extension? currentExt;
|
||||
@@ -3676,19 +3673,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
if (currentExt.searchBehavior?.icon != null) {
|
||||
displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!);
|
||||
}
|
||||
} else if (primarySearchExtension?.searchBehavior?.icon != null) {
|
||||
displayIcon = _getIconFromName(
|
||||
primarySearchExtension!.searchBehavior!.icon!,
|
||||
);
|
||||
iconPath = defaultProviderIconPath;
|
||||
} else if (defaultProviderIconPath != null &&
|
||||
defaultProviderIconPath.isNotEmpty) {
|
||||
iconPath = defaultProviderIconPath;
|
||||
if (primarySearchExtension?.searchBehavior?.icon != null) {
|
||||
displayIcon = _getIconFromName(
|
||||
primarySearchExtension!.searchBehavior!.icon!,
|
||||
);
|
||||
}
|
||||
} else if (isBuiltInProvider) {
|
||||
displayIcon = resolveProviderIcon(currentProvider);
|
||||
}
|
||||
@@ -3724,39 +3708,10 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
offset: const Offset(0, 40),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
onSelected: (String providerId) {
|
||||
final provider = providerId.isEmpty ? null : providerId;
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(provider);
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(providerId);
|
||||
onProviderChanged?.call();
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem<String>(
|
||||
value: '',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.music_note,
|
||||
size: 20,
|
||||
color: currentProvider == null || currentProvider.isEmpty
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
defaultProviderLabel,
|
||||
style: TextStyle(
|
||||
fontWeight:
|
||||
currentProvider == null || currentProvider.isEmpty
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (currentProvider == null || currentProvider.isEmpty)
|
||||
Icon(Icons.check, size: 18, color: colorScheme.primary),
|
||||
],
|
||||
),
|
||||
),
|
||||
...builtInProviders.map(
|
||||
(provider) => PopupMenuItem<String>(
|
||||
value: provider.id,
|
||||
|
||||
@@ -52,19 +52,33 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
String get _playlistName => _resolvedPlaylistName ?? widget.playlistName;
|
||||
String? get _coverUrl => _resolvedCoverUrl ?? widget.coverUrl;
|
||||
|
||||
String? _metadataProviderId(String playlistId) {
|
||||
if (playlistId.startsWith('deezer:')) return 'deezer';
|
||||
if (playlistId.startsWith('qobuz:')) return 'qobuz';
|
||||
if (playlistId.startsWith('tidal:')) return 'tidal';
|
||||
String? _legacyProviderIdFromResourceId(String value) {
|
||||
if (value.startsWith('deezer:')) return 'deezer';
|
||||
if (value.startsWith('qobuz:')) return 'qobuz';
|
||||
if (value.startsWith('tidal:')) return 'tidal';
|
||||
return null;
|
||||
}
|
||||
|
||||
String _metadataResourceId(String providerId, String playlistId) {
|
||||
final prefixed = '$providerId:';
|
||||
if (playlistId.startsWith(prefixed)) {
|
||||
return playlistId.substring(prefixed.length);
|
||||
String _stripPrefixedResourceId(String value) {
|
||||
final colonIndex = value.indexOf(':');
|
||||
if (colonIndex <= 0 || colonIndex == value.length - 1) {
|
||||
return value;
|
||||
}
|
||||
return playlistId;
|
||||
return value.substring(colonIndex + 1);
|
||||
}
|
||||
|
||||
String? _metadataProviderId(String playlistId) {
|
||||
final providerId = _legacyProviderIdFromResourceId(playlistId);
|
||||
if (providerId == null) return null;
|
||||
final effective = resolveEffectiveMetadataProvider(
|
||||
providerId,
|
||||
ref.read(extensionProvider),
|
||||
);
|
||||
return effective.isEmpty ? null : effective;
|
||||
}
|
||||
|
||||
String _metadataResourceId(String providerId, String playlistId) {
|
||||
return _stripPrefixedResourceId(playlistId);
|
||||
}
|
||||
|
||||
String? _recommendedDownloadService() {
|
||||
@@ -75,8 +89,13 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
|
||||
final playlistId = widget.playlistId;
|
||||
if (playlistId != null) {
|
||||
if (playlistId.startsWith('tidal:')) return 'tidal';
|
||||
if (playlistId.startsWith('qobuz:')) return 'qobuz';
|
||||
final providerId = _metadataProviderId(playlistId);
|
||||
if (providerId != null && providerId.isNotEmpty) {
|
||||
return resolveEffectiveDownloadService(
|
||||
providerId,
|
||||
ref.read(extensionProvider),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final source = _tracks.firstOrNull?.source;
|
||||
@@ -85,8 +104,13 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
}
|
||||
|
||||
final trackId = _tracks.firstOrNull?.id ?? '';
|
||||
if (trackId.startsWith('tidal:')) return 'tidal';
|
||||
if (trackId.startsWith('qobuz:')) return 'qobuz';
|
||||
final trackProviderId = _legacyProviderIdFromResourceId(trackId);
|
||||
if (trackProviderId != null) {
|
||||
return resolveEffectiveDownloadService(
|
||||
trackProviderId,
|
||||
ref.read(extensionProvider),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -509,11 +533,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
}
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addToQueue(
|
||||
track,
|
||||
service,
|
||||
playlistName: _playlistName,
|
||||
);
|
||||
.addToQueue(track, service, playlistName: _playlistName);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
|
||||
);
|
||||
|
||||
@@ -674,18 +674,19 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
final hasAnyProvider =
|
||||
searchProviders.isNotEmpty || builtInProviders.isNotEmpty;
|
||||
|
||||
String currentProviderName = context.l10n.extensionDefaultProvider;
|
||||
if (settings.searchProvider != null &&
|
||||
settings.searchProvider!.isNotEmpty) {
|
||||
if (isBuiltInSearchProvider(settings.searchProvider)) {
|
||||
currentProviderName = resolveProviderDisplayName(
|
||||
settings.searchProvider!,
|
||||
);
|
||||
final resolvedProviderId =
|
||||
(settings.searchProvider != null && settings.searchProvider!.isNotEmpty)
|
||||
? settings.searchProvider!
|
||||
: searchProviders.firstOrNull?.id ?? defaultBuiltInSearchProviderId;
|
||||
String currentProviderName = context.l10n.optionsPrimaryProviderSubtitle;
|
||||
if (resolvedProviderId != null && resolvedProviderId.isNotEmpty) {
|
||||
if (isBuiltInSearchProvider(resolvedProviderId)) {
|
||||
currentProviderName = resolveProviderDisplayName(resolvedProviderId);
|
||||
} else {
|
||||
final ext = searchProviders
|
||||
.where((e) => e.id == settings.searchProvider)
|
||||
.where((e) => e.id == resolvedProviderId)
|
||||
.firstOrNull;
|
||||
currentProviderName = ext?.displayName ?? settings.searchProvider!;
|
||||
currentProviderName = ext?.displayName ?? resolvedProviderId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -788,20 +789,6 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.music_note, color: colorScheme.primary),
|
||||
title: Text(ctx.l10n.extensionDefaultProvider),
|
||||
subtitle: Text(ctx.l10n.extensionDefaultProviderSubtitle),
|
||||
trailing:
|
||||
(settings.searchProvider == null ||
|
||||
settings.searchProvider!.isEmpty)
|
||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
...builtInProviders.map(
|
||||
(provider) => ListTile(
|
||||
leading: Icon(Icons.search, color: colorScheme.tertiary),
|
||||
|
||||
@@ -741,13 +741,6 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
|
||||
final rawSearchProvider = settings.searchProvider?.trim() ?? '';
|
||||
final isValidBuiltIn = isBuiltInSearchProvider(rawSearchProvider);
|
||||
final primarySearchExtension = _defaultSearchExtension(extState.extensions);
|
||||
final defaultProviderTarget =
|
||||
primarySearchExtension?.displayName ??
|
||||
defaultBuiltInSearchProviderDisplayName ??
|
||||
context.l10n.extensionDefaultProvider;
|
||||
final defaultProviderLabel =
|
||||
'${context.l10n.extensionsHomeFeedAuto} ($defaultProviderTarget)';
|
||||
final searchProvider =
|
||||
isValidBuiltIn ||
|
||||
extState.extensions.any(
|
||||
@@ -755,7 +748,9 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
e.enabled && e.hasCustomSearch && e.id == rawSearchProvider,
|
||||
)
|
||||
? rawSearchProvider
|
||||
: '';
|
||||
: _defaultSearchExtension(extState.extensions)?.id ??
|
||||
defaultBuiltInSearchProviderId ??
|
||||
'';
|
||||
final isBuiltIn = isBuiltInSearchProvider(searchProvider);
|
||||
|
||||
Extension? activeExtension;
|
||||
@@ -764,7 +759,6 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
.where((e) => e.id == searchProvider && e.enabled)
|
||||
.firstOrNull;
|
||||
}
|
||||
final hasNonDefaultProvider = isBuiltIn || activeExtension != null;
|
||||
|
||||
String subtitle;
|
||||
if (isBuiltIn) {
|
||||
@@ -792,7 +786,7 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
Text(
|
||||
subtitle,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: hasNonDefaultProvider
|
||||
color: searchProvider.isNotEmpty
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -802,16 +796,6 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_SourceChip(
|
||||
icon: Icons.graphic_eq,
|
||||
label: defaultProviderLabel,
|
||||
isSelected: searchProvider.isEmpty,
|
||||
onTap: () {
|
||||
if (hasNonDefaultProvider) {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
}
|
||||
},
|
||||
),
|
||||
for (final provider in builtInProviders)
|
||||
_SourceChip(
|
||||
icon: resolveProviderIcon(
|
||||
@@ -840,7 +824,7 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Tap $defaultProviderLabel to switch back from extension',
|
||||
context.l10n.optionsSwitchBack,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user