mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-13 04:24:45 +02:00
refactor: abstract built-in providers into generic registry and unify platform bridge API
Replace hardcoded Tidal/Qobuz switch/case with builtInProviderSpec registry pattern. Unify searchTidalAll/searchQobuzAll into searchProviderAll, getDeezerMetadata/getTidalMetadata/getQobuzMetadata into getProviderMetadata, and parseDeezerUrl/parseQobuzUrl/parseTidalUrl into parseProviderUrl. Remove extension-specific getAlbum/Playlist/ArtistWithExtension in favor of generic getProviderMetadata routing. Extract provider UI helpers into provider_ui_utils.dart. Preserve track_number fallback for zero-value TrackNumber in album/playlist track lists.
This commit is contained in:
@@ -2864,23 +2864,20 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"searchTidalAll" -> {
|
||||
"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.searchTidalAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
|
||||
Gobackend.searchProviderAllJSON(providerId, query, trackLimit.toLong(), artistLimit.toLong(), filter)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"searchQobuzAll" -> {
|
||||
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") ?: ""
|
||||
"getBuiltInProviders" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.searchQobuzAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
|
||||
Gobackend.getBuiltInProvidersJSON()
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
@@ -2892,14 +2889,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getDeezerMetadata" -> {
|
||||
val resourceType = call.argument<String>("resource_type") ?: ""
|
||||
val resourceId = call.argument<String>("resource_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getDeezerMetadata(resourceType, resourceId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getQobuzMetadata" -> {
|
||||
val resourceType = call.argument<String>("resource_type") ?: ""
|
||||
val resourceId = call.argument<String>("resource_id") ?: ""
|
||||
@@ -2916,24 +2905,19 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"parseDeezerUrl" -> {
|
||||
val url = call.argument<String>("url") ?: ""
|
||||
"getProviderMetadata" -> {
|
||||
val providerId = call.argument<String>("provider_id") ?: ""
|
||||
val resourceType = call.argument<String>("resource_type") ?: ""
|
||||
val resourceId = call.argument<String>("resource_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.parseDeezerURLExport(url)
|
||||
Gobackend.getProviderMetadataJSON(providerId, resourceType, resourceId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"parseQobuzUrl" -> {
|
||||
"parseProviderUrl" -> {
|
||||
val url = call.argument<String>("url") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.parseQobuzURLExport(url)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"parseTidalUrl" -> {
|
||||
val url = call.argument<String>("url") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.parseTidalURLExport(url)
|
||||
Gobackend.parseProviderURLJSON(url)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
@@ -3291,30 +3275,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getAlbumWithExtension" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val albumId = call.argument<String>("album_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getAlbumWithExtensionJSON(extensionId, albumId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getPlaylistWithExtension" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val playlistId = call.argument<String>("playlist_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getPlaylistWithExtensionJSON(extensionId, playlistId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getArtistWithExtension" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val artistId = call.argument<String>("artist_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getArtistWithExtensionJSON(extensionId, artistId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"runPostProcessing" -> {
|
||||
val filePath = call.argument<String>("file_path") ?: ""
|
||||
val metadataJson = call.argument<String>("metadata") ?: ""
|
||||
|
||||
+290
-369
@@ -1051,51 +1051,12 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
return errorResponse("Download cancelled")
|
||||
}
|
||||
|
||||
var result DownloadResult
|
||||
var err error
|
||||
|
||||
switch req.Service {
|
||||
case "tidal":
|
||||
tidalResult, tidalErr := downloadFromTidal(req)
|
||||
if tidalErr == nil {
|
||||
result = DownloadResult{
|
||||
FilePath: tidalResult.FilePath,
|
||||
BitDepth: tidalResult.BitDepth,
|
||||
SampleRate: tidalResult.SampleRate,
|
||||
Title: tidalResult.Title,
|
||||
Artist: tidalResult.Artist,
|
||||
Album: tidalResult.Album,
|
||||
ReleaseDate: tidalResult.ReleaseDate,
|
||||
TrackNumber: tidalResult.TrackNumber,
|
||||
DiscNumber: tidalResult.DiscNumber,
|
||||
ISRC: tidalResult.ISRC,
|
||||
LyricsLRC: tidalResult.LyricsLRC,
|
||||
}
|
||||
}
|
||||
err = tidalErr
|
||||
case "qobuz":
|
||||
qobuzResult, qobuzErr := downloadFromQobuz(req)
|
||||
if qobuzErr == nil {
|
||||
result = DownloadResult{
|
||||
FilePath: qobuzResult.FilePath,
|
||||
BitDepth: qobuzResult.BitDepth,
|
||||
SampleRate: qobuzResult.SampleRate,
|
||||
Title: qobuzResult.Title,
|
||||
Artist: qobuzResult.Artist,
|
||||
Album: qobuzResult.Album,
|
||||
ReleaseDate: qobuzResult.ReleaseDate,
|
||||
TrackNumber: qobuzResult.TrackNumber,
|
||||
DiscNumber: qobuzResult.DiscNumber,
|
||||
ISRC: qobuzResult.ISRC,
|
||||
CoverURL: qobuzResult.CoverURL,
|
||||
LyricsLRC: qobuzResult.LyricsLRC,
|
||||
}
|
||||
}
|
||||
err = qobuzErr
|
||||
default:
|
||||
if !isBuiltInDownloadProvider(req.Service) {
|
||||
return errorResponse("Unknown service: " + req.Service)
|
||||
}
|
||||
|
||||
result, err := downloadWithBuiltInProvider(req.Service, req)
|
||||
|
||||
if err != nil {
|
||||
return errorResponse(err.Error())
|
||||
}
|
||||
@@ -1227,51 +1188,9 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
GoLog("[DownloadWithFallback] Trying service: %s\n", service)
|
||||
req.Service = service
|
||||
|
||||
var result DownloadResult
|
||||
var err error
|
||||
|
||||
switch service {
|
||||
case "tidal":
|
||||
tidalResult, tidalErr := downloadFromTidal(req)
|
||||
if tidalErr == nil {
|
||||
result = DownloadResult{
|
||||
FilePath: tidalResult.FilePath,
|
||||
BitDepth: tidalResult.BitDepth,
|
||||
SampleRate: tidalResult.SampleRate,
|
||||
Title: tidalResult.Title,
|
||||
Artist: tidalResult.Artist,
|
||||
Album: tidalResult.Album,
|
||||
ReleaseDate: tidalResult.ReleaseDate,
|
||||
TrackNumber: tidalResult.TrackNumber,
|
||||
DiscNumber: tidalResult.DiscNumber,
|
||||
ISRC: tidalResult.ISRC,
|
||||
LyricsLRC: tidalResult.LyricsLRC,
|
||||
}
|
||||
} else if !errors.Is(tidalErr, ErrDownloadCancelled) {
|
||||
GoLog("[DownloadWithFallback] Tidal error: %v\n", tidalErr)
|
||||
}
|
||||
err = tidalErr
|
||||
case "qobuz":
|
||||
qobuzResult, qobuzErr := downloadFromQobuz(req)
|
||||
if qobuzErr == nil {
|
||||
result = DownloadResult{
|
||||
FilePath: qobuzResult.FilePath,
|
||||
BitDepth: qobuzResult.BitDepth,
|
||||
SampleRate: qobuzResult.SampleRate,
|
||||
Title: qobuzResult.Title,
|
||||
Artist: qobuzResult.Artist,
|
||||
Album: qobuzResult.Album,
|
||||
ReleaseDate: qobuzResult.ReleaseDate,
|
||||
TrackNumber: qobuzResult.TrackNumber,
|
||||
DiscNumber: qobuzResult.DiscNumber,
|
||||
ISRC: qobuzResult.ISRC,
|
||||
CoverURL: qobuzResult.CoverURL,
|
||||
LyricsLRC: qobuzResult.LyricsLRC,
|
||||
}
|
||||
} else if !errors.Is(qobuzErr, ErrDownloadCancelled) {
|
||||
GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
|
||||
}
|
||||
err = qobuzErr
|
||||
result, err := downloadWithBuiltInProvider(service, req)
|
||||
if err != nil && !errors.Is(err, ErrDownloadCancelled) {
|
||||
GoLog("[DownloadWithFallback] %s error: %v\n", service, err)
|
||||
}
|
||||
|
||||
if err != nil && errors.Is(err, ErrDownloadCancelled) {
|
||||
@@ -2058,6 +1977,28 @@ func SearchQobuzAll(query string, trackLimit, artistLimit int, filter string) (s
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -2141,6 +2082,208 @@ func GetQobuzMetadata(resourceType, resourceID string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func normalizeExtensionTrackMetadataMap(
|
||||
track ExtTrackMetadata,
|
||||
fallbackCover string,
|
||||
fallbackTrackNumber int,
|
||||
) map[string]interface{} {
|
||||
coverURL := track.ResolvedCoverURL()
|
||||
if coverURL == "" {
|
||||
coverURL = fallbackCover
|
||||
}
|
||||
|
||||
trackNum := track.TrackNumber
|
||||
if trackNum == 0 && fallbackTrackNumber > 0 {
|
||||
trackNum = fallbackTrackNumber
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"id": track.ID,
|
||||
"name": track.Name,
|
||||
"artists": track.Artists,
|
||||
"album_name": track.AlbumName,
|
||||
"album_artist": track.AlbumArtist,
|
||||
"duration_ms": track.DurationMS,
|
||||
"images": coverURL,
|
||||
"cover_url": coverURL,
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": trackNum,
|
||||
"total_tracks": track.TotalTracks,
|
||||
"disc_number": track.DiscNumber,
|
||||
"total_discs": track.TotalDiscs,
|
||||
"isrc": track.ISRC,
|
||||
"provider_id": track.ProviderID,
|
||||
"item_type": track.ItemType,
|
||||
"album_type": track.AlbumType,
|
||||
"spotify_id": track.SpotifyID,
|
||||
"composer": track.Composer,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeExtensionAlbumInfoMap(album *ExtAlbumMetadata) map[string]interface{} {
|
||||
if album == nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"id": album.ID,
|
||||
"name": album.Name,
|
||||
"artists": album.Artists,
|
||||
"artist_id": album.ArtistID,
|
||||
"images": album.CoverURL,
|
||||
"cover_url": album.CoverURL,
|
||||
"release_date": album.ReleaseDate,
|
||||
"total_tracks": album.TotalTracks,
|
||||
"album_type": album.AlbumType,
|
||||
"provider_id": album.ProviderID,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeExtensionArtistAlbumMap(album ExtAlbumMetadata) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"id": album.ID,
|
||||
"name": album.Name,
|
||||
"artists": album.Artists,
|
||||
"images": album.CoverURL,
|
||||
"cover_url": album.CoverURL,
|
||||
"release_date": album.ReleaseDate,
|
||||
"total_tracks": album.TotalTracks,
|
||||
"album_type": album.AlbumType,
|
||||
"provider_id": album.ProviderID,
|
||||
}
|
||||
}
|
||||
|
||||
func getExtensionProviderMetadataResponse(
|
||||
providerID,
|
||||
resourceType,
|
||||
resourceID string,
|
||||
) (map[string]interface{}, error) {
|
||||
manager := getExtensionManager()
|
||||
ext, err := manager.GetExtension(providerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !ext.Manifest.IsMetadataProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", providerID)
|
||||
}
|
||||
if !ext.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", providerID)
|
||||
}
|
||||
|
||||
provider := newExtensionProviderWrapper(ext)
|
||||
|
||||
switch resourceType {
|
||||
case "track":
|
||||
track, err := provider.GetTrack(resourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if track == nil {
|
||||
return nil, fmt.Errorf("track not found")
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"track": normalizeExtensionTrackMetadataMap(*track, "", 0),
|
||||
}, nil
|
||||
case "album":
|
||||
album, err := provider.GetAlbum(resourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if album == nil {
|
||||
return nil, fmt.Errorf("album not found")
|
||||
}
|
||||
|
||||
tracks := make([]map[string]interface{}, len(album.Tracks))
|
||||
for i, track := range album.Tracks {
|
||||
tracks[i] = normalizeExtensionTrackMetadataMap(track, album.CoverURL, i+1)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"album_info": normalizeExtensionAlbumInfoMap(album),
|
||||
"track_list": tracks,
|
||||
}, nil
|
||||
case "playlist":
|
||||
playlist, err := provider.GetPlaylist(resourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if playlist == nil {
|
||||
return nil, fmt.Errorf("playlist not found")
|
||||
}
|
||||
|
||||
tracks := make([]map[string]interface{}, len(playlist.Tracks))
|
||||
for i, track := range playlist.Tracks {
|
||||
tracks[i] = normalizeExtensionTrackMetadataMap(track, playlist.CoverURL, i+1)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"playlist_info": map[string]interface{}{
|
||||
"id": playlist.ID,
|
||||
"name": playlist.Name,
|
||||
"images": playlist.CoverURL,
|
||||
"cover_url": playlist.CoverURL,
|
||||
"provider_id": playlist.ProviderID,
|
||||
"owner": map[string]interface{}{
|
||||
"name": playlist.Artists,
|
||||
"images": playlist.CoverURL,
|
||||
},
|
||||
},
|
||||
"track_list": tracks,
|
||||
}, nil
|
||||
case "artist":
|
||||
artist, err := provider.GetArtist(resourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if artist == nil {
|
||||
return nil, fmt.Errorf("artist not found")
|
||||
}
|
||||
|
||||
albums := make([]map[string]interface{}, len(artist.Albums))
|
||||
for i, album := range artist.Albums {
|
||||
albums[i] = normalizeExtensionArtistAlbumMap(album)
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"artist_info": map[string]interface{}{
|
||||
"id": artist.ID,
|
||||
"name": artist.Name,
|
||||
"images": tidalFirstNonEmpty(artist.HeaderImage, artist.ImageURL),
|
||||
"cover_url": artist.ImageURL,
|
||||
"header_image": artist.HeaderImage,
|
||||
"provider_id": artist.ProviderID,
|
||||
},
|
||||
"albums": albums,
|
||||
}
|
||||
|
||||
if len(artist.Releases) > 0 {
|
||||
releases := make([]map[string]interface{}, len(artist.Releases))
|
||||
for i, release := range artist.Releases {
|
||||
releases[i] = normalizeExtensionArtistAlbumMap(release)
|
||||
}
|
||||
response["releases"] = releases
|
||||
}
|
||||
|
||||
if artist.Listeners > 0 {
|
||||
artistInfo := response["artist_info"].(map[string]interface{})
|
||||
artistInfo["listeners"] = artist.Listeners
|
||||
}
|
||||
|
||||
if len(artist.TopTracks) > 0 {
|
||||
topTracks := make([]map[string]interface{}, len(artist.TopTracks))
|
||||
for i, track := range artist.TopTracks {
|
||||
topTracks[i] = normalizeExtensionTrackMetadataMap(track, artist.ImageURL, i+1)
|
||||
}
|
||||
response["top_tracks"] = topTracks
|
||||
}
|
||||
|
||||
return response, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported provider resource type: %s", resourceType)
|
||||
}
|
||||
}
|
||||
|
||||
func GetTidalMetadata(resourceType, resourceID string) (string, error) {
|
||||
downloader := NewTidalDownloader()
|
||||
|
||||
@@ -2171,6 +2314,34 @@ func GetTidalMetadata(resourceType, resourceID string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func GetProviderMetadataJSON(providerID, resourceType, resourceID string) (string, error) {
|
||||
trimmedProviderID := strings.TrimSpace(providerID)
|
||||
if trimmedProviderID == "" {
|
||||
return "", fmt.Errorf("empty provider ID")
|
||||
}
|
||||
|
||||
normalizedProviderID := strings.ToLower(trimmedProviderID)
|
||||
if isBuiltInMetadataProvider(normalizedProviderID) {
|
||||
return getBuiltInProviderMetadata(normalizedProviderID, resourceType, resourceID)
|
||||
}
|
||||
|
||||
switch normalizedProviderID {
|
||||
case "deezer":
|
||||
return GetDeezerMetadata(resourceType, resourceID)
|
||||
default:
|
||||
response, err := getExtensionProviderMetadataResponse(trimmedProviderID, resourceType, resourceID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
}
|
||||
|
||||
func ParseDeezerURLExport(url string) (string, error) {
|
||||
resourceType, resourceID, err := parseDeezerURL(url)
|
||||
if err != nil {
|
||||
@@ -2228,6 +2399,38 @@ func ParseTidalURLExport(url string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func ParseProviderURLJSON(url string) (string, error) {
|
||||
parsers := []struct {
|
||||
providerID string
|
||||
parse func(string) (string, string, error)
|
||||
}{
|
||||
{providerID: "deezer", parse: parseDeezerURL},
|
||||
{providerID: "qobuz", parse: parseQobuzURL},
|
||||
{providerID: "tidal", parse: parseTidalURL},
|
||||
}
|
||||
|
||||
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 ConvertTidalToSpotifyDeezer(tidalURL string) (string, error) {
|
||||
client := NewSongLinkClient()
|
||||
availability, err := client.CheckAvailabilityFromURL(tidalURL)
|
||||
@@ -3599,288 +3802,6 @@ func FindURLHandlerJSON(url string) string {
|
||||
return handler.extension.ID
|
||||
}
|
||||
|
||||
func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
|
||||
manager := getExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !ext.Manifest.IsMetadataProvider() {
|
||||
return "", fmt.Errorf("extension '%s' is not a metadata provider", extensionID)
|
||||
}
|
||||
if !ext.Enabled {
|
||||
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
|
||||
}
|
||||
|
||||
provider := newExtensionProviderWrapper(ext)
|
||||
album, err := provider.GetAlbum(albumID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if album == nil {
|
||||
return "", fmt.Errorf("album not found")
|
||||
}
|
||||
|
||||
tracks := make([]map[string]interface{}, len(album.Tracks))
|
||||
for i, track := range album.Tracks {
|
||||
trackCover := track.ResolvedCoverURL()
|
||||
if trackCover == "" {
|
||||
trackCover = album.CoverURL
|
||||
}
|
||||
trackNum := track.TrackNumber
|
||||
if trackNum == 0 {
|
||||
trackNum = i + 1
|
||||
}
|
||||
tracks[i] = map[string]interface{}{
|
||||
"id": track.ID,
|
||||
"name": track.Name,
|
||||
"artists": track.Artists,
|
||||
"album_name": track.AlbumName,
|
||||
"album_artist": track.AlbumArtist,
|
||||
"duration_ms": track.DurationMS,
|
||||
"cover_url": trackCover,
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": trackNum,
|
||||
"total_tracks": track.TotalTracks,
|
||||
"disc_number": track.DiscNumber,
|
||||
"total_discs": track.TotalDiscs,
|
||||
"isrc": track.ISRC,
|
||||
"provider_id": track.ProviderID,
|
||||
"item_type": track.ItemType,
|
||||
"album_type": track.AlbumType,
|
||||
"composer": track.Composer,
|
||||
}
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"id": album.ID,
|
||||
"name": album.Name,
|
||||
"artists": album.Artists,
|
||||
"artist_id": album.ArtistID,
|
||||
"cover_url": album.CoverURL,
|
||||
"release_date": album.ReleaseDate,
|
||||
"total_tracks": album.TotalTracks,
|
||||
"album_type": album.AlbumType,
|
||||
"tracks": tracks,
|
||||
"provider_id": album.ProviderID,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error) {
|
||||
manager := getExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !ext.Manifest.IsMetadataProvider() {
|
||||
return "", fmt.Errorf("extension '%s' is not a metadata provider", extensionID)
|
||||
}
|
||||
if !ext.Enabled {
|
||||
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
|
||||
}
|
||||
|
||||
vm, err := ext.lockReadyVM()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer ext.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.getPlaylist === 'function') {
|
||||
return extension.getPlaylist(%q);
|
||||
}
|
||||
if (typeof extension !== 'undefined' && typeof extension.getAlbum === 'function') {
|
||||
return extension.getAlbum(%q);
|
||||
}
|
||||
return null;
|
||||
})()
|
||||
`, playlistID, playlistID)
|
||||
|
||||
result, err := RunWithTimeoutAndRecover(vm, script, DefaultJSTimeout)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("getPlaylist failed: %w", err)
|
||||
}
|
||||
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
return "", fmt.Errorf("playlist not found")
|
||||
}
|
||||
|
||||
exported := result.Export()
|
||||
jsonBytes, err := json.Marshal(exported)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal result: %w", err)
|
||||
}
|
||||
|
||||
var album ExtAlbumMetadata
|
||||
if err := json.Unmarshal(jsonBytes, &album); err != nil {
|
||||
return "", fmt.Errorf("failed to parse playlist: %w", err)
|
||||
}
|
||||
album.ProviderID = ext.ID
|
||||
for i := range album.Tracks {
|
||||
album.Tracks[i].ProviderID = ext.ID
|
||||
}
|
||||
|
||||
tracks := make([]map[string]interface{}, len(album.Tracks))
|
||||
for i, track := range album.Tracks {
|
||||
trackCover := track.ResolvedCoverURL()
|
||||
if trackCover == "" {
|
||||
trackCover = album.CoverURL
|
||||
}
|
||||
tracks[i] = map[string]interface{}{
|
||||
"id": track.ID,
|
||||
"name": track.Name,
|
||||
"artists": track.Artists,
|
||||
"album_name": track.AlbumName,
|
||||
"album_artist": track.AlbumArtist,
|
||||
"duration_ms": track.DurationMS,
|
||||
"cover_url": trackCover,
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": track.TrackNumber,
|
||||
"total_tracks": track.TotalTracks,
|
||||
"disc_number": track.DiscNumber,
|
||||
"total_discs": track.TotalDiscs,
|
||||
"isrc": track.ISRC,
|
||||
"provider_id": track.ProviderID,
|
||||
"item_type": track.ItemType,
|
||||
"album_type": track.AlbumType,
|
||||
"composer": track.Composer,
|
||||
}
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"id": album.ID,
|
||||
"name": album.Name,
|
||||
"owner": album.Artists,
|
||||
"cover_url": album.CoverURL,
|
||||
"total_tracks": album.TotalTracks,
|
||||
"tracks": tracks,
|
||||
"provider_id": album.ProviderID,
|
||||
}
|
||||
|
||||
jsonBytes, err = json.Marshal(response)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
|
||||
manager := getExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !ext.Manifest.IsMetadataProvider() {
|
||||
return "", fmt.Errorf("extension '%s' is not a metadata provider", extensionID)
|
||||
}
|
||||
|
||||
provider := newExtensionProviderWrapper(ext)
|
||||
artist, err := provider.GetArtist(artistID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if artist == nil {
|
||||
return "", fmt.Errorf("artist not found")
|
||||
}
|
||||
|
||||
albums := make([]map[string]interface{}, len(artist.Albums))
|
||||
for i, album := range artist.Albums {
|
||||
albums[i] = map[string]interface{}{
|
||||
"id": album.ID,
|
||||
"name": album.Name,
|
||||
"artists": album.Artists,
|
||||
"cover_url": album.CoverURL,
|
||||
"release_date": album.ReleaseDate,
|
||||
"total_tracks": album.TotalTracks,
|
||||
"album_type": album.AlbumType,
|
||||
"provider_id": album.ProviderID,
|
||||
}
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"id": artist.ID,
|
||||
"name": artist.Name,
|
||||
"cover_url": artist.ImageURL,
|
||||
"albums": albums,
|
||||
"provider_id": artist.ProviderID,
|
||||
}
|
||||
|
||||
if len(artist.Releases) > 0 {
|
||||
releases := make([]map[string]interface{}, len(artist.Releases))
|
||||
for i, release := range artist.Releases {
|
||||
releaseType := release.AlbumType
|
||||
if releaseType == "" {
|
||||
releaseType = "album"
|
||||
}
|
||||
releases[i] = map[string]interface{}{
|
||||
"id": release.ID,
|
||||
"name": release.Name,
|
||||
"artists": release.Artists,
|
||||
"cover_url": release.CoverURL,
|
||||
"release_date": release.ReleaseDate,
|
||||
"total_tracks": release.TotalTracks,
|
||||
"album_type": releaseType,
|
||||
"provider_id": release.ProviderID,
|
||||
}
|
||||
}
|
||||
response["releases"] = releases
|
||||
}
|
||||
|
||||
if artist.HeaderImage != "" {
|
||||
response["header_image"] = artist.HeaderImage
|
||||
}
|
||||
|
||||
if artist.Listeners > 0 {
|
||||
response["listeners"] = artist.Listeners
|
||||
}
|
||||
|
||||
if len(artist.TopTracks) > 0 {
|
||||
topTracks := make([]map[string]interface{}, len(artist.TopTracks))
|
||||
for i, track := range artist.TopTracks {
|
||||
topTracks[i] = map[string]interface{}{
|
||||
"id": track.ID,
|
||||
"name": track.Name,
|
||||
"artists": track.Artists,
|
||||
"album_name": track.AlbumName,
|
||||
"album_artist": track.AlbumArtist,
|
||||
"duration_ms": track.DurationMS,
|
||||
"images": track.ResolvedCoverURL(),
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": track.TrackNumber,
|
||||
"total_tracks": track.TotalTracks,
|
||||
"disc_number": track.DiscNumber,
|
||||
"total_discs": track.TotalDiscs,
|
||||
"isrc": track.ISRC,
|
||||
"provider_id": track.ProviderID,
|
||||
"spotify_id": track.SpotifyID,
|
||||
"composer": track.Composer,
|
||||
}
|
||||
}
|
||||
response["top_tracks"] = topTracks
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func GetURLHandlersJSON() (string, error) {
|
||||
manager := getExtensionManager()
|
||||
handlers := manager.GetURLHandlers()
|
||||
|
||||
@@ -96,6 +96,136 @@ 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"`
|
||||
SupportsDownload bool `json:"supports_download"`
|
||||
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:"-"`
|
||||
Download func(req DownloadRequest) (DownloadResult, error) `json:"-"`
|
||||
}
|
||||
|
||||
var builtInProviderRegistry = []builtInProviderSpec{
|
||||
{
|
||||
ID: "tidal",
|
||||
DisplayName: "Tidal",
|
||||
SupportsMetadata: true,
|
||||
SupportsDownload: true,
|
||||
SupportsSearch: true,
|
||||
GetMetadata: GetTidalMetadata,
|
||||
SearchAll: SearchTidalAll,
|
||||
SearchTracks: func(query string, limit int) ([]ExtTrackMetadata, error) {
|
||||
return NewTidalDownloader().SearchTracks(query, limit)
|
||||
},
|
||||
Download: downloadWithBuiltInTidal,
|
||||
},
|
||||
{
|
||||
ID: "qobuz",
|
||||
DisplayName: "Qobuz",
|
||||
SupportsMetadata: true,
|
||||
SupportsDownload: true,
|
||||
SupportsSearch: true,
|
||||
GetMetadata: GetQobuzMetadata,
|
||||
SearchAll: SearchQobuzAll,
|
||||
SearchTracks: func(query string, limit int) ([]ExtTrackMetadata, error) {
|
||||
return NewQobuzDownloader().SearchTracks(query, limit)
|
||||
},
|
||||
Download: downloadWithBuiltInQobuz,
|
||||
},
|
||||
}
|
||||
|
||||
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 downloadWithBuiltInProvider(providerID string, req DownloadRequest) (DownloadResult, error) {
|
||||
spec, ok := getBuiltInProviderSpec(providerID)
|
||||
if !ok || !spec.SupportsDownload || spec.Download == nil {
|
||||
return DownloadResult{}, fmt.Errorf("unknown built-in provider: %s", providerID)
|
||||
}
|
||||
return spec.Download(req)
|
||||
}
|
||||
|
||||
func downloadWithBuiltInTidal(req DownloadRequest) (DownloadResult, error) {
|
||||
result, err := downloadFromTidal(req)
|
||||
if err != nil {
|
||||
return DownloadResult{}, err
|
||||
}
|
||||
return DownloadResult{
|
||||
FilePath: result.FilePath,
|
||||
BitDepth: result.BitDepth,
|
||||
SampleRate: result.SampleRate,
|
||||
Title: result.Title,
|
||||
Artist: result.Artist,
|
||||
Album: result.Album,
|
||||
ReleaseDate: result.ReleaseDate,
|
||||
TrackNumber: result.TrackNumber,
|
||||
DiscNumber: result.DiscNumber,
|
||||
ISRC: result.ISRC,
|
||||
LyricsLRC: result.LyricsLRC,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func downloadWithBuiltInQobuz(req DownloadRequest) (DownloadResult, error) {
|
||||
result, err := downloadFromQobuz(req)
|
||||
if err != nil {
|
||||
return DownloadResult{}, err
|
||||
}
|
||||
return DownloadResult{
|
||||
FilePath: result.FilePath,
|
||||
BitDepth: result.BitDepth,
|
||||
SampleRate: result.SampleRate,
|
||||
Title: result.Title,
|
||||
Artist: result.Artist,
|
||||
Album: result.Album,
|
||||
ReleaseDate: result.ReleaseDate,
|
||||
TrackNumber: result.TrackNumber,
|
||||
DiscNumber: result.DiscNumber,
|
||||
ISRC: result.ISRC,
|
||||
CoverURL: result.CoverURL,
|
||||
LyricsLRC: result.LyricsLRC,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func shouldStopProviderFallback(availability *ExtAvailabilityResult) bool {
|
||||
return availability != nil && availability.SkipFallback
|
||||
}
|
||||
@@ -435,6 +565,61 @@ func (p *extensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
|
||||
return &album, nil
|
||||
}
|
||||
|
||||
func (p *extensionProviderWrapper) GetPlaylist(playlistID string) (*ExtAlbumMetadata, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||
}
|
||||
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.getPlaylist === 'function') {
|
||||
return extension.getPlaylist(%q);
|
||||
}
|
||||
if (typeof extension !== 'undefined' && typeof extension.getAlbum === 'function') {
|
||||
return extension.getAlbum(%q);
|
||||
}
|
||||
return null;
|
||||
})()
|
||||
`, playlistID, playlistID)
|
||||
|
||||
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
|
||||
if err != nil {
|
||||
if IsTimeoutError(err) {
|
||||
return nil, fmt.Errorf("getPlaylist timeout: extension took too long to respond")
|
||||
}
|
||||
return nil, fmt.Errorf("getPlaylist failed: %w", err)
|
||||
}
|
||||
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
return nil, fmt.Errorf("getPlaylist returned null")
|
||||
}
|
||||
|
||||
exported := result.Export()
|
||||
jsonBytes, err := json.Marshal(exported)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal result: %w", err)
|
||||
}
|
||||
|
||||
var playlist ExtAlbumMetadata
|
||||
if err := json.Unmarshal(jsonBytes, &playlist); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse playlist: %w", err)
|
||||
}
|
||||
|
||||
playlist.ProviderID = p.extension.ID
|
||||
for i := range playlist.Tracks {
|
||||
playlist.Tracks[i].ProviderID = p.extension.ID
|
||||
}
|
||||
return &playlist, nil
|
||||
}
|
||||
|
||||
func (p *extensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadata, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||
@@ -919,7 +1104,7 @@ func GetProviderPriority() []string {
|
||||
defer providerPriorityMu.RUnlock()
|
||||
|
||||
if len(providerPriority) == 0 {
|
||||
return []string{"tidal", "qobuz"}
|
||||
return []string{}
|
||||
}
|
||||
|
||||
result := make([]string, len(providerPriority))
|
||||
@@ -928,7 +1113,7 @@ func GetProviderPriority() []string {
|
||||
}
|
||||
|
||||
func sanitizeDownloadProviderPriority(providerIDs []string) []string {
|
||||
sanitized := make([]string, 0, len(providerIDs)+2)
|
||||
sanitized := make([]string, 0, len(providerIDs))
|
||||
seen := map[string]struct{}{}
|
||||
|
||||
for _, providerID := range providerIDs {
|
||||
@@ -953,14 +1138,6 @@ func sanitizeDownloadProviderPriority(providerIDs []string) []string {
|
||||
sanitized = append(sanitized, providerID)
|
||||
}
|
||||
|
||||
for _, providerID := range []string{"tidal", "qobuz"} {
|
||||
if _, exists := seen[providerID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[providerID] = struct{}{}
|
||||
sanitized = append(sanitized, providerID)
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
@@ -1027,7 +1204,7 @@ func SetMetadataProviderPriority(providerIDs []string) {
|
||||
metadataProviderPriorityMu.Lock()
|
||||
defer metadataProviderPriorityMu.Unlock()
|
||||
|
||||
sanitized := make([]string, 0, len(providerIDs)+2)
|
||||
sanitized := make([]string, 0, len(providerIDs))
|
||||
seen := map[string]struct{}{}
|
||||
for _, providerID := range providerIDs {
|
||||
providerID = strings.TrimSpace(providerID)
|
||||
@@ -1040,14 +1217,6 @@ func SetMetadataProviderPriority(providerIDs []string) {
|
||||
seen[providerID] = struct{}{}
|
||||
sanitized = append(sanitized, providerID)
|
||||
}
|
||||
for _, providerID := range []string{"qobuz", "tidal"} {
|
||||
if _, exists := seen[providerID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[providerID] = struct{}{}
|
||||
sanitized = append(sanitized, providerID)
|
||||
}
|
||||
|
||||
metadataProviderPriority = sanitized
|
||||
GoLog("[Extension] Metadata provider priority set: %v\n", sanitized)
|
||||
}
|
||||
@@ -1057,7 +1226,7 @@ func GetMetadataProviderPriority() []string {
|
||||
defer metadataProviderPriorityMu.RUnlock()
|
||||
|
||||
if len(metadataProviderPriority) == 0 {
|
||||
return []string{"qobuz", "tidal"}
|
||||
return []string{}
|
||||
}
|
||||
|
||||
result := make([]string, len(metadataProviderPriority))
|
||||
@@ -1066,21 +1235,23 @@ func GetMetadataProviderPriority() []string {
|
||||
}
|
||||
|
||||
func isBuiltInProvider(providerID string) bool {
|
||||
switch providerID {
|
||||
case "tidal", "qobuz":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
_, 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 isBuiltInDownloadProvider(providerID string) bool {
|
||||
switch providerID {
|
||||
case "tidal", "qobuz":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
spec, ok := getBuiltInProviderSpec(providerID)
|
||||
return ok && spec.SupportsDownload
|
||||
}
|
||||
|
||||
func normalizeQualityForBuiltIn(quality string) string {
|
||||
@@ -1150,14 +1321,7 @@ func metadataTrackDedupKey(track ExtTrackMetadata) string {
|
||||
}
|
||||
|
||||
func searchBuiltInMetadataTracks(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
|
||||
switch providerID {
|
||||
case "qobuz":
|
||||
return NewQobuzDownloader().SearchTracks(query, limit)
|
||||
case "tidal":
|
||||
return NewTidalDownloader().SearchTracks(query, limit)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported built-in metadata provider: %s", providerID)
|
||||
}
|
||||
return searchBuiltInProviderTracks(providerID, query, limit)
|
||||
}
|
||||
|
||||
func (m *extensionManager) SearchTracksWithMetadataProviders(query string, limit int, includeExtensions bool) ([]ExtTrackMetadata, error) {
|
||||
@@ -1958,49 +2122,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadResponse, error) {
|
||||
req.Service = providerID
|
||||
|
||||
var result DownloadResult
|
||||
var err error
|
||||
|
||||
switch providerID {
|
||||
case "tidal":
|
||||
tidalResult, tidalErr := downloadFromTidal(req)
|
||||
if tidalErr == nil {
|
||||
result = DownloadResult{
|
||||
FilePath: tidalResult.FilePath,
|
||||
BitDepth: tidalResult.BitDepth,
|
||||
SampleRate: tidalResult.SampleRate,
|
||||
Title: tidalResult.Title,
|
||||
Artist: tidalResult.Artist,
|
||||
Album: tidalResult.Album,
|
||||
ReleaseDate: tidalResult.ReleaseDate,
|
||||
TrackNumber: tidalResult.TrackNumber,
|
||||
DiscNumber: tidalResult.DiscNumber,
|
||||
ISRC: tidalResult.ISRC,
|
||||
}
|
||||
}
|
||||
err = tidalErr
|
||||
case "qobuz":
|
||||
qobuzResult, qobuzErr := downloadFromQobuz(req)
|
||||
if qobuzErr == nil {
|
||||
result = DownloadResult{
|
||||
FilePath: qobuzResult.FilePath,
|
||||
BitDepth: qobuzResult.BitDepth,
|
||||
SampleRate: qobuzResult.SampleRate,
|
||||
Title: qobuzResult.Title,
|
||||
Artist: qobuzResult.Artist,
|
||||
Album: qobuzResult.Album,
|
||||
ReleaseDate: qobuzResult.ReleaseDate,
|
||||
TrackNumber: qobuzResult.TrackNumber,
|
||||
DiscNumber: qobuzResult.DiscNumber,
|
||||
ISRC: qobuzResult.ISRC,
|
||||
CoverURL: qobuzResult.CoverURL,
|
||||
}
|
||||
}
|
||||
err = qobuzErr
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown built-in provider: %s", providerID)
|
||||
}
|
||||
|
||||
result, err := downloadWithBuiltInProvider(providerID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -7,13 +7,13 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
|
||||
func TestSetMetadataProviderPriorityPreservesExplicitProvidersOnly(t *testing.T) {
|
||||
original := GetMetadataProviderPriority()
|
||||
defer SetMetadataProviderPriority(original)
|
||||
|
||||
SetMetadataProviderPriority([]string{"tidal"})
|
||||
got := GetMetadataProviderPriority()
|
||||
want := []string{"tidal", "qobuz"}
|
||||
want := []string{"tidal"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("unexpected priority length: got %v want %v", got, want)
|
||||
}
|
||||
@@ -80,7 +80,7 @@ func TestSetProviderPriorityRemovesRetiredDeezerDownloader(t *testing.T) {
|
||||
SetProviderPriority([]string{"deezer", "qobuz", "custom-ext"})
|
||||
|
||||
got := GetProviderPriority()
|
||||
want := []string{"qobuz", "custom-ext", "tidal"}
|
||||
want := []string{"qobuz", "custom-ext"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("unexpected priority length: got %v want %v", got, want)
|
||||
}
|
||||
|
||||
@@ -424,23 +424,19 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "searchTidalAll":
|
||||
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 = GobackendSearchTidalAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
|
||||
let response = GobackendSearchProviderAllJSON(providerId, query, Int(trackLimit), Int(artistLimit), filter, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "searchQobuzAll":
|
||||
let args = call.arguments as! [String: Any]
|
||||
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 = GobackendSearchQobuzAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
|
||||
case "getBuiltInProviders":
|
||||
let response = GobackendGetBuiltInProvidersJSON(&error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
@@ -452,14 +448,6 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getDeezerMetadata":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let resourceType = args["resource_type"] as! String
|
||||
let resourceId = args["resource_id"] as! String
|
||||
let response = GobackendGetDeezerMetadata(resourceType, resourceId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getQobuzMetadata":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let resourceType = args["resource_type"] as! String
|
||||
@@ -476,24 +464,19 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "parseDeezerUrl":
|
||||
case "getProviderMetadata":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let url = args["url"] as! String
|
||||
let response = GobackendParseDeezerURLExport(url, &error)
|
||||
let providerId = args["provider_id"] as! String
|
||||
let resourceType = args["resource_type"] as! String
|
||||
let resourceId = args["resource_id"] as! String
|
||||
let response = GobackendGetProviderMetadataJSON(providerId, resourceType, resourceId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "parseQobuzUrl":
|
||||
case "parseProviderUrl":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let url = args["url"] as! String
|
||||
let response = GobackendParseQobuzURLExport(url, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "parseTidalUrl":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let url = args["url"] as! String
|
||||
let response = GobackendParseTidalURLExport(url, &error)
|
||||
let response = GobackendParseProviderURLJSON(url, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
@@ -852,30 +835,6 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getAlbumWithExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let albumId = args["album_id"] as! String
|
||||
let response = GobackendGetAlbumWithExtensionJSON(extensionId, albumId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getPlaylistWithExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let playlistId = args["playlist_id"] as! String
|
||||
let response = GobackendGetPlaylistWithExtensionJSON(extensionId, playlistId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getArtistWithExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let artistId = args["artist_id"] as! String
|
||||
let response = GobackendGetArtistWithExtensionJSON(extensionId, artistId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
// Extension Post-Processing API
|
||||
case "runPostProcessing":
|
||||
let args = call.arguments as! [String: Any]
|
||||
|
||||
@@ -2575,9 +2575,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final providerTrackId = track.id.substring(colonIdx + 1);
|
||||
|
||||
_log.d('No ISRC, fetching from $provider API: $providerTrackId');
|
||||
final providerData = provider == 'tidal'
|
||||
? await PlatformBridge.getTidalMetadata('track', providerTrackId)
|
||||
: await PlatformBridge.getQobuzMetadata('track', providerTrackId);
|
||||
final providerData = await PlatformBridge.getProviderMetadata(
|
||||
provider,
|
||||
'track',
|
||||
providerTrackId,
|
||||
);
|
||||
|
||||
final trackData = providerData['track'] as Map<String, dynamic>?;
|
||||
if (trackData == null) {
|
||||
@@ -4448,7 +4450,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
);
|
||||
final rawId = trackToDownload.id.split(':')[1];
|
||||
_log.d('Fetching full metadata for Deezer ID: $rawId');
|
||||
final fullData = await PlatformBridge.getDeezerMetadata(
|
||||
final fullData = await PlatformBridge.getProviderMetadata(
|
||||
'deezer',
|
||||
'track',
|
||||
rawId,
|
||||
);
|
||||
|
||||
@@ -15,6 +15,44 @@ const _metadataProviderPriorityKey = 'metadata_provider_priority';
|
||||
const _providerPriorityKey = 'provider_priority';
|
||||
const _spotifyWebExtensionId = 'spotify-web';
|
||||
|
||||
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;
|
||||
@@ -195,6 +233,83 @@ 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),
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return providerId;
|
||||
}
|
||||
|
||||
class SearchFilter {
|
||||
final String id;
|
||||
final String? label;
|
||||
@@ -460,6 +575,7 @@ class ExtensionSetting {
|
||||
|
||||
class ExtensionState {
|
||||
final List<Extension> extensions;
|
||||
final List<BuiltInProviderSpec> builtInProviders;
|
||||
final List<String> providerPriority;
|
||||
final List<String> metadataProviderPriority;
|
||||
final bool isLoading;
|
||||
@@ -468,6 +584,7 @@ class ExtensionState {
|
||||
|
||||
const ExtensionState({
|
||||
this.extensions = const [],
|
||||
this.builtInProviders = const [],
|
||||
this.providerPriority = const [],
|
||||
this.metadataProviderPriority = const [],
|
||||
this.isLoading = false,
|
||||
@@ -477,6 +594,7 @@ class ExtensionState {
|
||||
|
||||
ExtensionState copyWith({
|
||||
List<Extension>? extensions,
|
||||
List<BuiltInProviderSpec>? builtInProviders,
|
||||
List<String>? providerPriority,
|
||||
List<String>? metadataProviderPriority,
|
||||
bool? isLoading,
|
||||
@@ -485,6 +603,7 @@ class ExtensionState {
|
||||
}) {
|
||||
return ExtensionState(
|
||||
extensions: extensions ?? this.extensions,
|
||||
builtInProviders: builtInProviders ?? this.builtInProviders,
|
||||
providerPriority: providerPriority ?? this.providerPriority,
|
||||
metadataProviderPriority:
|
||||
metadataProviderPriority ?? this.metadataProviderPriority,
|
||||
@@ -496,7 +615,6 @@ class ExtensionState {
|
||||
}
|
||||
|
||||
class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
static const _builtInMetadataProviders = ['qobuz', 'tidal'];
|
||||
AppLifecycleListener? _appLifecycleListener;
|
||||
bool _cleanupInFlight = false;
|
||||
Completer<void>? _initializationCompleter;
|
||||
@@ -547,6 +665,12 @@ 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,
|
||||
@@ -634,6 +758,16 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -727,10 +861,16 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
|
||||
if (ext.hasDownloadProvider && settings.defaultService == extensionId) {
|
||||
ref.read(settingsProvider.notifier).setDefaultService('tidal');
|
||||
_log.d(
|
||||
'Reset default service to Tidal because extension $extensionId was disabled',
|
||||
);
|
||||
final availableProviders = getAllDownloadProviders();
|
||||
if (availableProviders.isNotEmpty) {
|
||||
final fallbackService = availableProviders.first;
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setDefaultService(fallbackService);
|
||||
_log.d(
|
||||
'Reset default service to $fallbackService because extension $extensionId was disabled',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -973,7 +1113,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
|
||||
List<String> getAllDownloadProviders() {
|
||||
final providers = ['tidal', 'qobuz'];
|
||||
final providers = List<String>.from(builtInDownloadProviderIds);
|
||||
for (final ext in state.extensions) {
|
||||
if (ext.enabled && ext.hasDownloadProvider) {
|
||||
providers.add(ext.id);
|
||||
@@ -995,7 +1135,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
|
||||
return [
|
||||
...primarySearchMetadataExtensions,
|
||||
..._builtInMetadataProviders,
|
||||
...builtInMetadataProviderIds,
|
||||
...otherMetadataExtensions,
|
||||
];
|
||||
}
|
||||
@@ -1012,10 +1152,10 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
|
||||
final hasPreferredExtension = preferredOrder.any(
|
||||
(provider) => !_builtInMetadataProviders.contains(provider),
|
||||
(provider) => !isBuiltInMetadataProvider(provider),
|
||||
);
|
||||
final hasSavedExtension = result.any(
|
||||
(provider) => !_builtInMetadataProviders.contains(provider),
|
||||
(provider) => !isBuiltInMetadataProvider(provider),
|
||||
);
|
||||
|
||||
if (!hasSavedExtension && hasPreferredExtension) {
|
||||
|
||||
+178
-259
@@ -353,212 +353,12 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
}
|
||||
|
||||
if (url.contains('deezer.com') || url.contains('deezer.page.link')) {
|
||||
_log.i('Detected Deezer URL, parsing...');
|
||||
final parsed = await PlatformBridge.parseDeezerUrl(url);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
final type = parsed['type'] as String;
|
||||
final id = parsed['id'] as String;
|
||||
|
||||
final metadata = await PlatformBridge.getDeezerMetadata(type, id);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
if (type == 'track') {
|
||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||
final track = _parseTrack(trackData);
|
||||
state = TrackState(
|
||||
tracks: [track],
|
||||
isLoading: false,
|
||||
coverUrl: track.coverUrl,
|
||||
);
|
||||
} else if (type == '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: id,
|
||||
albumName: albumInfo['name'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
|
||||
);
|
||||
_preWarmCacheForTracks(tracks);
|
||||
} else if (type == '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();
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
playlistName: playlistInfo['name'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(
|
||||
playlistInfo['images']?.toString(),
|
||||
),
|
||||
);
|
||||
_preWarmCacheForTracks(tracks);
|
||||
} else if (type == '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();
|
||||
state = TrackState(
|
||||
tracks: [],
|
||||
isLoading: false,
|
||||
artistId: artistInfo['id'] as String?,
|
||||
artistName: artistInfo['name'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
|
||||
artistAlbums: albums,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.contains('qobuz.com') || url.startsWith('qobuzapp://')) {
|
||||
_log.i('Detected Qobuz URL, parsing...');
|
||||
final parsed = await PlatformBridge.parseQobuzUrl(url);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
final type = parsed['type'] as String;
|
||||
final id = parsed['id'] as String;
|
||||
|
||||
final metadata = await PlatformBridge.getQobuzMetadata(type, id);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
if (type == 'track') {
|
||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||
final track = _parseTrack(trackData);
|
||||
state = TrackState(
|
||||
tracks: [track],
|
||||
isLoading: false,
|
||||
coverUrl: track.coverUrl,
|
||||
);
|
||||
} else if (type == '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: 'qobuz:$id',
|
||||
albumName: albumInfo['name'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
|
||||
);
|
||||
_preWarmCacheForTracks(tracks);
|
||||
} else if (type == '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);
|
||||
} else if (type == '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();
|
||||
state = TrackState(
|
||||
tracks: [],
|
||||
isLoading: false,
|
||||
artistId: artistInfo['id'] as String?,
|
||||
artistName: artistInfo['name'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
|
||||
artistAlbums: albums,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.contains('tidal.com')) {
|
||||
_log.i('Detected Tidal URL, parsing...');
|
||||
final parsed = await PlatformBridge.parseTidalUrl(url);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
final type = parsed['type'] as String;
|
||||
final id = parsed['id'] as String;
|
||||
|
||||
final metadata = await PlatformBridge.getTidalMetadata(type, id);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
if (type == 'track') {
|
||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||
final track = _parseTrack(trackData);
|
||||
state = TrackState(
|
||||
tracks: [track],
|
||||
isLoading: false,
|
||||
coverUrl: track.coverUrl,
|
||||
);
|
||||
} else if (type == '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: 'tidal:$id',
|
||||
albumName: albumInfo['name'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
|
||||
);
|
||||
_preWarmCacheForTracks(tracks);
|
||||
} else if (type == '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);
|
||||
} else if (type == '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();
|
||||
state = TrackState(
|
||||
tracks: [],
|
||||
isLoading: false,
|
||||
artistId: artistInfo['id'] as String?,
|
||||
artistName: artistInfo['name'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
|
||||
artistAlbums: albums,
|
||||
);
|
||||
}
|
||||
final handledBuiltInUrl = await _tryResolveBuiltInProviderUrl(
|
||||
url,
|
||||
requestId,
|
||||
);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
if (handledBuiltInUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -577,6 +377,134 @@ 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);
|
||||
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);
|
||||
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,
|
||||
@@ -609,19 +537,15 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
.map((ext) => ext.id)
|
||||
.firstOrNull;
|
||||
}
|
||||
resolvedProvider ??= 'tidal';
|
||||
resolvedProvider ??= defaultBuiltInSearchProviderId;
|
||||
}
|
||||
|
||||
final isEnabledExtensionProvider =
|
||||
if (resolvedProvider != null &&
|
||||
resolvedProvider.isNotEmpty &&
|
||||
extensionState.extensions.any(
|
||||
!isBuiltInSearchProvider(resolvedProvider) &&
|
||||
!extensionState.extensions.any(
|
||||
(ext) => ext.enabled && ext.id == resolvedProvider,
|
||||
);
|
||||
|
||||
if (resolvedProvider.isNotEmpty &&
|
||||
resolvedProvider != 'tidal' &&
|
||||
resolvedProvider != 'qobuz' &&
|
||||
!isEnabledExtensionProvider &&
|
||||
) &&
|
||||
settings.searchProvider?.trim() == resolvedProvider) {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
resolvedProvider =
|
||||
@@ -638,15 +562,21 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
||||
.map((ext) => ext.id)
|
||||
.firstOrNull;
|
||||
resolvedProvider ??= 'tidal';
|
||||
resolvedProvider ??= defaultBuiltInSearchProviderId;
|
||||
}
|
||||
|
||||
if (resolvedProvider.isNotEmpty &&
|
||||
resolvedProvider != 'tidal' &&
|
||||
resolvedProvider != 'qobuz' &&
|
||||
final isEnabledExtensionProvider =
|
||||
resolvedProvider != null &&
|
||||
resolvedProvider.isNotEmpty &&
|
||||
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;
|
||||
options = {'filter': resolvedFilter};
|
||||
@@ -659,12 +589,12 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
return;
|
||||
}
|
||||
|
||||
final effectiveBuiltInProvider =
|
||||
resolvedProvider == 'tidal' || resolvedProvider == 'qobuz'
|
||||
final fallbackBuiltInProvider = builtInSearchProvider?.isNotEmpty == true
|
||||
? builtInSearchProvider
|
||||
: defaultBuiltInSearchProviderId;
|
||||
final effectiveBuiltInProvider = isBuiltInProvider
|
||||
? resolvedProvider
|
||||
: (builtInSearchProvider?.isNotEmpty == true
|
||||
? builtInSearchProvider
|
||||
: 'tidal');
|
||||
: fallbackBuiltInProvider;
|
||||
|
||||
if (effectiveBuiltInProvider == null || effectiveBuiltInProvider.isEmpty) {
|
||||
state = TrackState(
|
||||
@@ -700,40 +630,29 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
Map<String, dynamic> results;
|
||||
List<Map<String, dynamic>> metadataTrackResults = [];
|
||||
|
||||
switch (effectiveProvider) {
|
||||
case 'tidal':
|
||||
_log.d('Calling Tidal search API...');
|
||||
results = await PlatformBridge.searchTidalAll(
|
||||
query,
|
||||
trackLimit: 20,
|
||||
artistLimit: 2,
|
||||
filter: requestFilter,
|
||||
);
|
||||
break;
|
||||
case 'qobuz':
|
||||
_log.d('Calling Qobuz search API...');
|
||||
results = await PlatformBridge.searchQobuzAll(
|
||||
query,
|
||||
trackLimit: 20,
|
||||
artistLimit: 2,
|
||||
filter: requestFilter,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
_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>[],
|
||||
};
|
||||
break;
|
||||
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.i(
|
||||
'$effectiveProvider returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums',
|
||||
|
||||
@@ -176,83 +176,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
Future<void> _fetchTracks() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
if (widget.albumId.startsWith('deezer:')) {
|
||||
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
|
||||
final metadata = await PlatformBridge.getDeezerMetadata(
|
||||
final directProviderId = _directMetadataProviderId();
|
||||
if (directProviderId != null) {
|
||||
final metadata = await PlatformBridge.getProviderMetadata(
|
||||
directProviderId,
|
||||
'album',
|
||||
deezerAlbumId,
|
||||
);
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
|
||||
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
|
||||
?.toString();
|
||||
final albumType = normalizeOptionalString(
|
||||
albumInfo?['album_type']?.toString(),
|
||||
);
|
||||
final totalTracks = albumInfo?['total_tracks'] as int?;
|
||||
final tracks = trackList
|
||||
.map(
|
||||
(t) => _parseTrack(
|
||||
t as Map<String, dynamic>,
|
||||
albumTypeFallback: albumType,
|
||||
totalTracksFallback: totalTracks,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
_AlbumCache.set(widget.albumId, tracks);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_tracks = tracks;
|
||||
_artistId = artistId;
|
||||
_albumType = albumType;
|
||||
_albumTotalTracks = totalTracks;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
} else if (widget.albumId.startsWith('qobuz:')) {
|
||||
final qobuzAlbumId = widget.albumId.replaceFirst('qobuz:', '');
|
||||
final metadata = await PlatformBridge.getQobuzMetadata(
|
||||
'album',
|
||||
qobuzAlbumId,
|
||||
);
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
|
||||
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
|
||||
?.toString();
|
||||
final albumType = normalizeOptionalString(
|
||||
albumInfo?['album_type']?.toString(),
|
||||
);
|
||||
final totalTracks = albumInfo?['total_tracks'] as int?;
|
||||
final tracks = trackList
|
||||
.map(
|
||||
(t) => _parseTrack(
|
||||
t as Map<String, dynamic>,
|
||||
albumTypeFallback: albumType,
|
||||
totalTracksFallback: totalTracks,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
_AlbumCache.set(widget.albumId, tracks);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_tracks = tracks;
|
||||
_artistId = artistId;
|
||||
_albumType = albumType;
|
||||
_albumTotalTracks = totalTracks;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
} else if (widget.albumId.startsWith('tidal:')) {
|
||||
final tidalAlbumId = widget.albumId.replaceFirst('tidal:', '');
|
||||
final metadata = await PlatformBridge.getTidalMetadata(
|
||||
'album',
|
||||
tidalAlbumId,
|
||||
_metadataResourceId(directProviderId),
|
||||
);
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
|
||||
@@ -332,6 +261,24 @@ 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;
|
||||
}
|
||||
|
||||
String _metadataResourceId(String providerId) {
|
||||
final prefixed = '$providerId:';
|
||||
if (widget.albumId.startsWith(prefixed)) {
|
||||
return widget.albumId.substring(prefixed.length);
|
||||
}
|
||||
return widget.albumId;
|
||||
}
|
||||
|
||||
Track _parseTrack(
|
||||
Map<String, dynamic> data, {
|
||||
String? albumTypeFallback,
|
||||
@@ -366,12 +313,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
}
|
||||
|
||||
String? _recommendedDownloadService() {
|
||||
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
|
||||
return widget.extensionId;
|
||||
}
|
||||
if (widget.albumId.startsWith('tidal:')) return 'tidal';
|
||||
if (widget.albumId.startsWith('qobuz:')) return 'qobuz';
|
||||
return null;
|
||||
return _directMetadataProviderId();
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
+33
-114
@@ -154,14 +154,27 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
}
|
||||
|
||||
String? _recommendedDownloadService() {
|
||||
return _directMetadataProviderId();
|
||||
}
|
||||
|
||||
String? _directMetadataProviderId() {
|
||||
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
|
||||
return widget.extensionId;
|
||||
}
|
||||
if (widget.artistId.startsWith('tidal:')) return 'tidal';
|
||||
if (widget.artistId.startsWith('deezer:')) return 'deezer';
|
||||
if (widget.artistId.startsWith('qobuz:')) return 'qobuz';
|
||||
if (widget.artistId.startsWith('tidal:')) return 'tidal';
|
||||
return null;
|
||||
}
|
||||
|
||||
String _metadataResourceId(String providerId) {
|
||||
final prefixed = '$providerId:';
|
||||
if (widget.artistId.startsWith(prefixed)) {
|
||||
return widget.artistId.substring(prefixed.length);
|
||||
}
|
||||
return widget.artistId;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -250,51 +263,13 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
String? headerImage;
|
||||
int? listeners;
|
||||
|
||||
if (widget.artistId.startsWith('deezer:')) {
|
||||
final deezerArtistId = widget.artistId.replaceFirst('deezer:', '');
|
||||
final metadata = await PlatformBridge.getDeezerMetadata(
|
||||
if (_directMetadataProviderId() != null) {
|
||||
final providerId = _directMetadataProviderId()!;
|
||||
final artistData = await PlatformBridge.getProviderMetadata(
|
||||
providerId,
|
||||
'artist',
|
||||
deezerArtistId,
|
||||
_metadataResourceId(providerId),
|
||||
);
|
||||
final albumsList = metadata['albums'] as List<dynamic>;
|
||||
albums = albumsList
|
||||
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
||||
.toList();
|
||||
} else if (widget.artistId.startsWith('qobuz:')) {
|
||||
final qobuzArtistId = widget.artistId.replaceFirst('qobuz:', '');
|
||||
final metadata = await PlatformBridge.getQobuzMetadata(
|
||||
'artist',
|
||||
qobuzArtistId,
|
||||
);
|
||||
final albumsList = metadata['albums'] as List<dynamic>;
|
||||
albums = albumsList
|
||||
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
||||
.toList();
|
||||
final artistInfo = metadata['artist_info'] as Map<String, dynamic>?;
|
||||
headerImage = artistInfo?['images'] as String?;
|
||||
} else if (widget.artistId.startsWith('tidal:')) {
|
||||
final tidalArtistId = widget.artistId.replaceFirst('tidal:', '');
|
||||
final metadata = await PlatformBridge.getTidalMetadata(
|
||||
'artist',
|
||||
tidalArtistId,
|
||||
);
|
||||
final albumsList = metadata['albums'] as List<dynamic>;
|
||||
albums = albumsList
|
||||
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
||||
.toList();
|
||||
final artistInfo = metadata['artist_info'] as Map<String, dynamic>?;
|
||||
headerImage = artistInfo?['images'] as String?;
|
||||
} else if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
|
||||
final result = await PlatformBridge.getArtistWithExtension(
|
||||
widget.extensionId!,
|
||||
widget.artistId,
|
||||
);
|
||||
|
||||
if (result == null) {
|
||||
throw Exception('Failed to load artist from extension');
|
||||
}
|
||||
|
||||
final artistData = result;
|
||||
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
|
||||
albums = albumsList
|
||||
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
||||
@@ -314,11 +289,16 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
.toList();
|
||||
}
|
||||
|
||||
final artistInfo = artistData['artist_info'] as Map<String, dynamic>?;
|
||||
headerImage =
|
||||
artistInfo?['images'] as String? ??
|
||||
artistInfo?['header_image'] as String? ??
|
||||
artistInfo?['cover_url'] as String? ??
|
||||
artistData['header_image'] as String? ??
|
||||
artistData['cover_url'] as String? ??
|
||||
artistData['image_url'] as String?;
|
||||
listeners = artistData['listeners'] as int?;
|
||||
listeners =
|
||||
artistInfo?['listeners'] as int? ?? artistData['listeners'] as int?;
|
||||
} else {
|
||||
final url = 'https://open.spotify.com/artist/${widget.artistId}';
|
||||
final result = await PlatformBridge.handleURLWithExtension(url);
|
||||
@@ -1051,42 +1031,16 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
}
|
||||
|
||||
Future<List<Track>> _fetchAlbumTracks(ArtistAlbum album) async {
|
||||
if (album.providerId != null && album.providerId!.isNotEmpty) {
|
||||
final result = await PlatformBridge.getAlbumWithExtension(
|
||||
album.providerId!,
|
||||
album.id,
|
||||
);
|
||||
if (result != null && result['tracks'] != null) {
|
||||
final tracksList = result['tracks'] as List<dynamic>;
|
||||
final parsedTracks = tracksList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
|
||||
.toList();
|
||||
return parsedTracks;
|
||||
}
|
||||
} else if (album.id.startsWith('deezer:')) {
|
||||
final deezerId = album.id.replaceFirst('deezer:', '');
|
||||
final metadata = await PlatformBridge.getDeezerMetadata(
|
||||
final providerId = album.providerId;
|
||||
if (providerId != null && providerId.isNotEmpty) {
|
||||
final resourceId = album.id.startsWith('$providerId:')
|
||||
? album.id.substring(providerId.length + 1)
|
||||
: album.id;
|
||||
final metadata = await PlatformBridge.getProviderMetadata(
|
||||
providerId,
|
||||
'album',
|
||||
deezerId,
|
||||
resourceId,
|
||||
);
|
||||
if (metadata['tracks'] != null) {
|
||||
final tracksList = metadata['tracks'] as List<dynamic>;
|
||||
return tracksList
|
||||
.map((t) => _parseTrackFromDeezer(t as Map<String, dynamic>, album))
|
||||
.toList();
|
||||
}
|
||||
} else if (album.id.startsWith('qobuz:')) {
|
||||
final qobuzId = album.id.replaceFirst('qobuz:', '');
|
||||
final metadata = await PlatformBridge.getQobuzMetadata('album', qobuzId);
|
||||
if (metadata['track_list'] != null) {
|
||||
final tracksList = metadata['track_list'] as List<dynamic>;
|
||||
return tracksList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
|
||||
.toList();
|
||||
}
|
||||
} else if (album.id.startsWith('tidal:')) {
|
||||
final tidalId = album.id.replaceFirst('tidal:', '');
|
||||
final metadata = await PlatformBridge.getTidalMetadata('album', tidalId);
|
||||
if (metadata['track_list'] != null) {
|
||||
final tracksList = metadata['track_list'] as List<dynamic>;
|
||||
return tracksList
|
||||
@@ -1106,41 +1060,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
return [];
|
||||
}
|
||||
|
||||
Track _parseTrackFromDeezer(Map<String, dynamic> data, ArtistAlbum album) {
|
||||
int durationMs = 0;
|
||||
final durationValue = data['duration'];
|
||||
final artistData = data['artist'];
|
||||
final artistName = artistData is Map<String, dynamic>
|
||||
? (artistData['name'] as String? ?? widget.artistName)
|
||||
: (artistData?.toString() ?? widget.artistName);
|
||||
if (durationValue is int) {
|
||||
durationMs = durationValue * 1000; // Deezer returns seconds
|
||||
} else if (durationValue is double) {
|
||||
durationMs = (durationValue * 1000).toInt();
|
||||
}
|
||||
|
||||
return Track(
|
||||
id: 'deezer:${data['id']}',
|
||||
name: (data['title'] ?? data['name'] ?? '').toString(),
|
||||
artistName: artistName,
|
||||
albumName: album.name,
|
||||
albumArtist: null,
|
||||
artistId: widget.artistId,
|
||||
albumId: album.id.isNotEmpty ? album.id : null,
|
||||
coverUrl: album.coverUrl,
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber:
|
||||
data['track_position'] as int? ?? data['track_number'] as int?,
|
||||
discNumber: data['disk_number'] as int? ?? data['disc_number'] as int?,
|
||||
totalDiscs: data['total_discs'] as int?,
|
||||
releaseDate: album.releaseDate,
|
||||
albumType: album.albumType,
|
||||
totalTracks: album.totalTracks,
|
||||
composer: data['composer']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme, {
|
||||
|
||||
+71
-104
@@ -31,6 +31,7 @@ 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';
|
||||
|
||||
class HomeTab extends ConsumerStatefulWidget {
|
||||
const HomeTab({super.key});
|
||||
@@ -481,13 +482,14 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
final explicit = explicitSearchProvider?.trim();
|
||||
if (explicit != null &&
|
||||
explicit.isNotEmpty &&
|
||||
(_builtInSearchProviders.contains(explicit) ||
|
||||
(isBuiltInSearchProvider(explicit) ||
|
||||
extensions.any(
|
||||
(ext) => ext.enabled && ext.hasCustomSearch && ext.id == explicit,
|
||||
))) {
|
||||
return explicit;
|
||||
}
|
||||
return _defaultSearchExtension(extensions)?.id ?? 'tidal';
|
||||
return _defaultSearchExtension(extensions)?.id ??
|
||||
defaultBuiltInSearchProviderId;
|
||||
}
|
||||
|
||||
String? _sanitizeSearchFilterForProvider(
|
||||
@@ -503,7 +505,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
|
||||
if (currentSearchProvider == null ||
|
||||
currentSearchProvider.isEmpty ||
|
||||
_builtInSearchProviders.contains(currentSearchProvider)) {
|
||||
isBuiltInSearchProvider(currentSearchProvider)) {
|
||||
switch (canonicalFilter) {
|
||||
case 'track':
|
||||
case 'artist':
|
||||
@@ -695,8 +697,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
|
||||
if (searchProvider == null || searchProvider.isEmpty) return false;
|
||||
|
||||
// Built-in providers (tidal, qobuz) also support live search
|
||||
if (_builtInSearchProviders.contains(searchProvider)) return true;
|
||||
if (isBuiltInSearchProvider(searchProvider)) return true;
|
||||
|
||||
final extension = extState.extensions
|
||||
.where((e) => e.id == searchProvider && e.enabled)
|
||||
@@ -755,8 +756,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
}
|
||||
}
|
||||
|
||||
static const _builtInSearchProviders = {'tidal', 'qobuz'};
|
||||
|
||||
Future<void> _performSearch(String query, {String? filterOverride}) async {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final extState = ref.read(extensionProvider);
|
||||
@@ -795,8 +794,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
_invalidateSearchSortCaches();
|
||||
|
||||
final isBuiltInProvider =
|
||||
searchProvider != null &&
|
||||
_builtInSearchProviders.contains(searchProvider);
|
||||
searchProvider != null && isBuiltInSearchProvider(searchProvider);
|
||||
|
||||
final isExtensionEnabled =
|
||||
searchProvider != null &&
|
||||
@@ -3347,11 +3345,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
}
|
||||
|
||||
if (searchProvider != null && searchProvider.isNotEmpty) {
|
||||
if (searchProvider == 'tidal') {
|
||||
return 'Search with Tidal...';
|
||||
}
|
||||
if (searchProvider == 'qobuz') {
|
||||
return 'Search with Qobuz...';
|
||||
final builtIn = builtInProviderSpecForId(searchProvider);
|
||||
if (builtIn != null && builtIn.supportsSearch) {
|
||||
return 'Search with ${builtIn.displayName}...';
|
||||
}
|
||||
|
||||
final ext = extState.extensions
|
||||
@@ -3566,16 +3562,19 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
final searchProviders = extensions
|
||||
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
||||
.toList();
|
||||
final builtInProviders = builtInSearchProviderSpecs;
|
||||
final primarySearchExtension = _defaultSearchExtension(searchProviders);
|
||||
final defaultProviderTarget =
|
||||
primarySearchExtension?.displayName ?? 'Tidal';
|
||||
primarySearchExtension?.displayName ??
|
||||
defaultBuiltInSearchProviderDisplayName ??
|
||||
context.l10n.extensionDefaultProvider;
|
||||
final defaultProviderLabel =
|
||||
'${context.l10n.extensionsHomeFeedAuto} ($defaultProviderTarget)';
|
||||
final defaultProviderIconPath = primarySearchExtension?.iconPath;
|
||||
final currentProvider =
|
||||
rawCurrentProvider != null &&
|
||||
rawCurrentProvider.isNotEmpty &&
|
||||
({'tidal', 'qobuz'}.contains(rawCurrentProvider) ||
|
||||
(isBuiltInSearchProvider(rawCurrentProvider) ||
|
||||
searchProviders.any((e) => e.id == rawCurrentProvider))
|
||||
? rawCurrentProvider
|
||||
: null;
|
||||
@@ -3587,9 +3586,8 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
const builtInProviders = {'tidal', 'qobuz'};
|
||||
final isBuiltInProvider =
|
||||
currentProvider != null && builtInProviders.contains(currentProvider);
|
||||
currentProvider != null && isBuiltInSearchProvider(currentProvider);
|
||||
|
||||
IconData displayIcon = Icons.search;
|
||||
String? iconPath;
|
||||
@@ -3612,7 +3610,7 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
} else if (isBuiltInProvider) {
|
||||
displayIcon = Icons.music_note;
|
||||
displayIcon = resolveProviderIcon(currentProvider);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
@@ -3679,58 +3677,33 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'tidal',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.music_note,
|
||||
size: 20,
|
||||
color: currentProvider == 'tidal'
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Tidal',
|
||||
style: TextStyle(
|
||||
fontWeight: currentProvider == 'tidal'
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
...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 == 'tidal')
|
||||
Icon(Icons.check, size: 18, color: colorScheme.primary),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'qobuz',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.music_note,
|
||||
size: 20,
|
||||
color: currentProvider == 'qobuz'
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Qobuz',
|
||||
style: TextStyle(
|
||||
fontWeight: currentProvider == 'qobuz'
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (currentProvider == 'qobuz')
|
||||
Icon(Icons.check, size: 18, color: colorScheme.primary),
|
||||
],
|
||||
if (currentProvider == provider.id)
|
||||
Icon(Icons.check, size: 18, color: colorScheme.primary),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (searchProviders.isNotEmpty) const PopupMenuDivider(),
|
||||
@@ -4627,21 +4600,17 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
||||
});
|
||||
|
||||
try {
|
||||
final result = await PlatformBridge.getAlbumWithExtension(
|
||||
final result = await PlatformBridge.getProviderMetadata(
|
||||
widget.extensionId,
|
||||
'album',
|
||||
widget.albumId,
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
if (result == null) {
|
||||
setState(() {
|
||||
_error = context.l10n.errorLoadAlbum;
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final trackList = result['tracks'] as List<dynamic>?;
|
||||
final albumInfo = result['album_info'] as Map<String, dynamic>? ?? result;
|
||||
final trackList =
|
||||
result['track_list'] as List<dynamic>? ??
|
||||
result['tracks'] as List<dynamic>?;
|
||||
if (trackList == null) {
|
||||
setState(() {
|
||||
_error = context.l10n.errorNoTracksFound;
|
||||
@@ -4650,12 +4619,15 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final artistId = (result['artist_id'] ?? result['artistId'])?.toString();
|
||||
final artistName = result['artists'] as String?;
|
||||
final artistId = (albumInfo['artist_id'] ?? albumInfo['artistId'])
|
||||
?.toString();
|
||||
final artistName = (albumInfo['artists'] ?? albumInfo['artist'])
|
||||
?.toString();
|
||||
final albumType =
|
||||
normalizeOptionalString(result['album_type']?.toString()) ??
|
||||
normalizeOptionalString(albumInfo['album_type']?.toString()) ??
|
||||
_albumType;
|
||||
final totalTracks = result['total_tracks'] as int? ?? _albumTotalTracks;
|
||||
final totalTracks =
|
||||
albumInfo['total_tracks'] as int? ?? _albumTotalTracks;
|
||||
final tracks = trackList
|
||||
.map(
|
||||
(t) => _parseTrack(
|
||||
@@ -4818,21 +4790,16 @@ class _ExtensionPlaylistScreenState
|
||||
});
|
||||
|
||||
try {
|
||||
final result = await PlatformBridge.getPlaylistWithExtension(
|
||||
final result = await PlatformBridge.getProviderMetadata(
|
||||
widget.extensionId,
|
||||
'playlist',
|
||||
widget.playlistId,
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
if (result == null) {
|
||||
setState(() {
|
||||
_error = context.l10n.errorLoadPlaylist;
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final trackList = result['tracks'] as List<dynamic>?;
|
||||
final trackList =
|
||||
result['track_list'] as List<dynamic>? ??
|
||||
result['tracks'] as List<dynamic>?;
|
||||
if (trackList == null) {
|
||||
setState(() {
|
||||
_error = context.l10n.errorNoTracksFound;
|
||||
@@ -4975,20 +4942,15 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
|
||||
});
|
||||
|
||||
try {
|
||||
final result = await PlatformBridge.getArtistWithExtension(
|
||||
final result = await PlatformBridge.getProviderMetadata(
|
||||
widget.extensionId,
|
||||
'artist',
|
||||
widget.artistId,
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
if (result == null) {
|
||||
setState(() {
|
||||
_error = context.l10n.errorLoadArtist;
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final artistInfo =
|
||||
result['artist_info'] as Map<String, dynamic>? ?? result;
|
||||
final albumList = result['albums'] as List<dynamic>?;
|
||||
final albums =
|
||||
albumList
|
||||
@@ -5004,8 +4966,13 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
|
||||
.toList();
|
||||
}
|
||||
|
||||
final headerImage = result['header_image'] as String?;
|
||||
final listeners = result['listeners'] as int?;
|
||||
final headerImage =
|
||||
artistInfo['images'] as String? ??
|
||||
artistInfo['header_image'] as String? ??
|
||||
artistInfo['cover_url'] as String? ??
|
||||
result['header_image'] as String?;
|
||||
final listeners =
|
||||
artistInfo['listeners'] as int? ?? result['listeners'] as int?;
|
||||
|
||||
setState(() {
|
||||
_albums = albums;
|
||||
|
||||
@@ -51,6 +51,21 @@ 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';
|
||||
return null;
|
||||
}
|
||||
|
||||
String _metadataResourceId(String providerId, String playlistId) {
|
||||
final prefixed = '$providerId:';
|
||||
if (playlistId.startsWith(prefixed)) {
|
||||
return playlistId.substring(prefixed.length);
|
||||
}
|
||||
return playlistId;
|
||||
}
|
||||
|
||||
String? _recommendedDownloadService() {
|
||||
final explicit = widget.recommendedService;
|
||||
if (explicit != null && explicit.isNotEmpty) {
|
||||
@@ -99,17 +114,19 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
try {
|
||||
String playlistId = widget.playlistId!;
|
||||
late final Map<String, dynamic> result;
|
||||
if (playlistId.startsWith('deezer:')) {
|
||||
playlistId = playlistId.substring(7);
|
||||
result = await PlatformBridge.getDeezerMetadata('playlist', playlistId);
|
||||
} else if (playlistId.startsWith('qobuz:')) {
|
||||
playlistId = playlistId.substring(6);
|
||||
result = await PlatformBridge.getQobuzMetadata('playlist', playlistId);
|
||||
} else if (playlistId.startsWith('tidal:')) {
|
||||
playlistId = playlistId.substring(6);
|
||||
result = await PlatformBridge.getTidalMetadata('playlist', playlistId);
|
||||
final providerId = _metadataProviderId(playlistId);
|
||||
if (providerId != null) {
|
||||
result = await PlatformBridge.getProviderMetadata(
|
||||
providerId,
|
||||
'playlist',
|
||||
_metadataResourceId(providerId, playlistId),
|
||||
);
|
||||
} else {
|
||||
result = await PlatformBridge.getDeezerMetadata('playlist', playlistId);
|
||||
result = await PlatformBridge.getProviderMetadata(
|
||||
'deezer',
|
||||
'playlist',
|
||||
playlistId,
|
||||
);
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/provider_ui_utils.dart';
|
||||
import 'package:spotiflac_android/screens/settings/lyrics_provider_priority_page.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
@@ -24,7 +25,6 @@ class DownloadSettingsPage extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
static const _builtInServices = ['tidal', 'qobuz'];
|
||||
static const _songLinkRegions = [
|
||||
'AD',
|
||||
'AE',
|
||||
@@ -300,7 +300,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
|
||||
final isBuiltInService = _builtInServices.contains(settings.defaultService);
|
||||
final isBuiltInService = isBuiltInDownloadProvider(settings.defaultService);
|
||||
final isTidalService = settings.defaultService == 'tidal';
|
||||
|
||||
return PopScope(
|
||||
@@ -2053,13 +2053,13 @@ class _ServiceSelector extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final builtInServiceIds = ['tidal', 'qobuz'];
|
||||
final builtInProviders = builtInDownloadProviderSpecs;
|
||||
|
||||
final extensionProviders = extState.extensions
|
||||
.where((e) => e.enabled && e.hasDownloadProvider)
|
||||
.toList();
|
||||
|
||||
final isExtensionService = !builtInServiceIds.contains(currentService);
|
||||
final isExtensionService = !isBuiltInDownloadProvider(currentService);
|
||||
final isCurrentExtensionEnabled = isExtensionService
|
||||
? extensionProviders.any((e) => e.id == currentService)
|
||||
: true;
|
||||
@@ -2070,25 +2070,17 @@ class _ServiceSelector extends ConsumerWidget {
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _ServiceChip(
|
||||
icon: Icons.music_note,
|
||||
label: 'Tidal',
|
||||
isSelected: effectiveService == 'tidal',
|
||||
onTap: () => onChanged('tidal'),
|
||||
for (final provider in builtInProviders)
|
||||
_ServiceChip(
|
||||
icon: resolveProviderIcon(provider.id),
|
||||
label: provider.displayName,
|
||||
isSelected: effectiveService == provider.id,
|
||||
onTap: () => onChanged(provider.id),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _ServiceChip(
|
||||
icon: Icons.album,
|
||||
label: 'Qobuz',
|
||||
isSelected: effectiveService == 'qobuz',
|
||||
onTap: () => onChanged('qobuz'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (extensionProviders.isNotEmpty) ...[
|
||||
|
||||
@@ -660,26 +660,27 @@ class _DownloadFallbackItem extends ConsumerWidget {
|
||||
class _SearchProviderSelector extends ConsumerWidget {
|
||||
const _SearchProviderSelector();
|
||||
|
||||
static const _builtInProviders = {'tidal': 'Tidal', 'qobuz': 'Qobuz'};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
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;
|
||||
searchProviders.isNotEmpty || builtInProviders.isNotEmpty;
|
||||
|
||||
String currentProviderName = context.l10n.extensionDefaultProvider;
|
||||
if (settings.searchProvider != null &&
|
||||
settings.searchProvider!.isNotEmpty) {
|
||||
if (_builtInProviders.containsKey(settings.searchProvider)) {
|
||||
currentProviderName = _builtInProviders[settings.searchProvider]!;
|
||||
if (isBuiltInSearchProvider(settings.searchProvider)) {
|
||||
currentProviderName = resolveProviderDisplayName(
|
||||
settings.searchProvider!,
|
||||
);
|
||||
} else {
|
||||
final ext = searchProviders
|
||||
.where((e) => e.id == settings.searchProvider)
|
||||
@@ -754,6 +755,7 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
List<Extension> searchProviders,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final builtInProviders = builtInSearchProviderSpecs;
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@@ -800,18 +802,20 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
..._builtInProviders.entries.map(
|
||||
(entry) => ListTile(
|
||||
...builtInProviders.map(
|
||||
(provider) => ListTile(
|
||||
leading: Icon(Icons.search, color: colorScheme.tertiary),
|
||||
title: Text(entry.value),
|
||||
subtitle: Text(ctx.l10n.extensionsSearchWith(entry.value)),
|
||||
trailing: settings.searchProvider == entry.key
|
||||
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(entry.key);
|
||||
.setSearchProvider(provider.id);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 {
|
||||
@@ -220,36 +221,34 @@ class _MetadataProviderItem extends StatelessWidget {
|
||||
BuildContext context,
|
||||
String provider,
|
||||
) {
|
||||
switch (provider) {
|
||||
case 'deezer':
|
||||
return _MetadataProviderInfo(
|
||||
name: 'Deezer',
|
||||
icon: Icons.album,
|
||||
description: context.l10n.providerExtension,
|
||||
isBuiltIn: false,
|
||||
);
|
||||
case 'qobuz':
|
||||
return _MetadataProviderInfo(
|
||||
name: 'Qobuz',
|
||||
icon: Icons.library_music,
|
||||
description: context.l10n.providerBuiltIn,
|
||||
isBuiltIn: true,
|
||||
);
|
||||
case 'tidal':
|
||||
return _MetadataProviderInfo(
|
||||
name: 'Tidal',
|
||||
icon: Icons.music_note,
|
||||
description: context.l10n.providerBuiltIn,
|
||||
isBuiltIn: true,
|
||||
);
|
||||
default:
|
||||
return _MetadataProviderInfo(
|
||||
name: provider,
|
||||
icon: Icons.extension,
|
||||
description: context.l10n.providerExtension,
|
||||
isBuiltIn: false,
|
||||
);
|
||||
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,
|
||||
icon: Icons.extension,
|
||||
description: context.l10n.providerExtension,
|
||||
isBuiltIn: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||
import 'package:spotiflac_android/utils/provider_ui_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class OptionsSettingsPage extends ConsumerWidget {
|
||||
@@ -717,8 +718,6 @@ class _ChannelChip extends StatelessWidget {
|
||||
class _MetadataSourceSelector extends ConsumerWidget {
|
||||
const _MetadataSourceSelector();
|
||||
|
||||
static const _builtInProviders = {'tidal': 'Tidal', 'qobuz': 'Qobuz'};
|
||||
|
||||
Extension? _defaultSearchExtension(List<Extension> extensions) {
|
||||
return extensions
|
||||
.where(
|
||||
@@ -738,12 +737,15 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final builtInProviders = builtInSearchProviderSpecs;
|
||||
|
||||
final rawSearchProvider = settings.searchProvider?.trim() ?? '';
|
||||
final isValidBuiltIn = _builtInProviders.containsKey(rawSearchProvider);
|
||||
final isValidBuiltIn = isBuiltInSearchProvider(rawSearchProvider);
|
||||
final primarySearchExtension = _defaultSearchExtension(extState.extensions);
|
||||
final defaultProviderTarget =
|
||||
primarySearchExtension?.displayName ?? 'Tidal';
|
||||
primarySearchExtension?.displayName ??
|
||||
defaultBuiltInSearchProviderDisplayName ??
|
||||
context.l10n.extensionDefaultProvider;
|
||||
final defaultProviderLabel =
|
||||
'${context.l10n.extensionsHomeFeedAuto} ($defaultProviderTarget)';
|
||||
final searchProvider =
|
||||
@@ -754,7 +756,7 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
)
|
||||
? rawSearchProvider
|
||||
: '';
|
||||
final isBuiltIn = _builtInProviders.containsKey(searchProvider);
|
||||
final isBuiltIn = isBuiltInSearchProvider(searchProvider);
|
||||
|
||||
Extension? activeExtension;
|
||||
if (searchProvider.isNotEmpty && !isBuiltIn) {
|
||||
@@ -766,7 +768,7 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
|
||||
String subtitle;
|
||||
if (isBuiltIn) {
|
||||
subtitle = 'Using ${_builtInProviders[searchProvider]}';
|
||||
subtitle = 'Using ${resolveProviderDisplayName(searchProvider)}';
|
||||
} else if (activeExtension != null) {
|
||||
subtitle = context.l10n.optionsUsingExtension(
|
||||
activeExtension.displayName,
|
||||
@@ -796,48 +798,34 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SourceChip(
|
||||
icon: Icons.graphic_eq,
|
||||
label: defaultProviderLabel,
|
||||
isSelected: searchProvider.isEmpty,
|
||||
onTap: () {
|
||||
if (hasNonDefaultProvider) {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setSearchProvider(null);
|
||||
}
|
||||
},
|
||||
),
|
||||
_SourceChip(
|
||||
icon: Icons.graphic_eq,
|
||||
label: defaultProviderLabel,
|
||||
isSelected: searchProvider.isEmpty,
|
||||
onTap: () {
|
||||
if (hasNonDefaultProvider) {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _SourceChip(
|
||||
icon: Icons.waves,
|
||||
label: 'Tidal',
|
||||
isSelected: searchProvider == 'tidal',
|
||||
for (final provider in builtInProviders)
|
||||
_SourceChip(
|
||||
icon: resolveProviderIcon(
|
||||
provider.id,
|
||||
tidalIcon: Icons.waves,
|
||||
),
|
||||
label: provider.displayName,
|
||||
isSelected: searchProvider == provider.id,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setSearchProvider('tidal');
|
||||
.setSearchProvider(provider.id);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _SourceChip(
|
||||
icon: Icons.album,
|
||||
label: 'Qobuz',
|
||||
isSelected: searchProvider == 'qobuz',
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setSearchProvider('qobuz');
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (activeExtension != null) ...[
|
||||
|
||||
@@ -3,6 +3,7 @@ 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});
|
||||
@@ -325,28 +326,28 @@ class _ProviderItem extends StatelessWidget {
|
||||
}
|
||||
|
||||
_ProviderInfo _getProviderInfo(String provider) {
|
||||
switch (provider) {
|
||||
case 'tidal':
|
||||
return _ProviderInfo(
|
||||
name: 'Tidal',
|
||||
icon: Icons.music_note,
|
||||
isBuiltIn: true,
|
||||
);
|
||||
case 'qobuz':
|
||||
return _ProviderInfo(name: 'Qobuz', icon: Icons.album, isBuiltIn: true);
|
||||
case 'deezer':
|
||||
return _ProviderInfo(
|
||||
name: 'Deezer',
|
||||
icon: Icons.graphic_eq,
|
||||
isBuiltIn: true,
|
||||
);
|
||||
default:
|
||||
return _ProviderInfo(
|
||||
name: provider,
|
||||
icon: Icons.extension,
|
||||
isBuiltIn: false,
|
||||
);
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
return _ProviderInfo(
|
||||
name: provider,
|
||||
icon: Icons.extension,
|
||||
isBuiltIn: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5040,7 +5040,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
|
||||
final deezerId = _extractRawDeezerTrackIdFromValue(sourceTrackId);
|
||||
if (deezerId != null) {
|
||||
final deezerTrack = await PlatformBridge.getDeezerMetadata(
|
||||
final deezerTrack = await PlatformBridge.getProviderMetadata(
|
||||
'deezer',
|
||||
'track',
|
||||
deezerId,
|
||||
);
|
||||
@@ -5291,7 +5292,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
(enriched['isrc'] ?? '').trim().isEmpty &&
|
||||
deezerId != null) {
|
||||
try {
|
||||
final deezerMeta = await PlatformBridge.getDeezerMetadata(
|
||||
final deezerMeta = await PlatformBridge.getProviderMetadata(
|
||||
'deezer',
|
||||
'track',
|
||||
deezerId,
|
||||
);
|
||||
|
||||
@@ -496,28 +496,15 @@ class PlatformBridge {
|
||||
await _channel.invokeMethod('clearTrackCache');
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> searchTidalAll(
|
||||
static Future<Map<String, dynamic>> searchProviderAll(
|
||||
String providerId,
|
||||
String query, {
|
||||
int trackLimit = 15,
|
||||
int artistLimit = 2,
|
||||
String? filter,
|
||||
}) async {
|
||||
final result = await _channel.invokeMethod('searchTidalAll', {
|
||||
'query': query,
|
||||
'track_limit': trackLimit,
|
||||
'artist_limit': artistLimit,
|
||||
'filter': filter ?? '',
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> searchQobuzAll(
|
||||
String query, {
|
||||
int trackLimit = 15,
|
||||
int artistLimit = 2,
|
||||
String? filter,
|
||||
}) async {
|
||||
final result = await _channel.invokeMethod('searchQobuzAll', {
|
||||
final result = await _channel.invokeMethod('searchProviderAll', {
|
||||
'provider_id': providerId,
|
||||
'query': query,
|
||||
'track_limit': trackLimit,
|
||||
'artist_limit': artistLimit,
|
||||
@@ -537,27 +524,6 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> getDeezerMetadata(
|
||||
String resourceType,
|
||||
String resourceId,
|
||||
) async {
|
||||
final result = await _channel.invokeMethod('getDeezerMetadata', {
|
||||
'resource_type': resourceType,
|
||||
'resource_id': resourceId,
|
||||
});
|
||||
if (result == null) {
|
||||
throw Exception(
|
||||
'getDeezerMetadata returned null for $resourceType:$resourceId',
|
||||
);
|
||||
}
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> parseDeezerUrl(String url) async {
|
||||
final result = await _channel.invokeMethod('parseDeezerUrl', {'url': url});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> getQobuzMetadata(
|
||||
String resourceType,
|
||||
String resourceId,
|
||||
@@ -574,13 +540,10 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> parseQobuzUrl(String url) async {
|
||||
final result = await _channel.invokeMethod('parseQobuzUrl', {'url': url});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> parseTidalUrl(String url) async {
|
||||
final result = await _channel.invokeMethod('parseTidalUrl', {'url': url});
|
||||
static Future<Map<String, dynamic>> parseProviderUrl(String url) async {
|
||||
final result = await _channel.invokeMethod('parseProviderUrl', {
|
||||
'url': url,
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
@@ -600,6 +563,24 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> getProviderMetadata(
|
||||
String providerId,
|
||||
String resourceType,
|
||||
String resourceId,
|
||||
) async {
|
||||
final result = await _channel.invokeMethod('getProviderMetadata', {
|
||||
'provider_id': providerId,
|
||||
'resource_type': resourceType,
|
||||
'resource_id': resourceId,
|
||||
});
|
||||
if (result == null) {
|
||||
throw Exception(
|
||||
'getProviderMetadata returned null for $providerId:$resourceType:$resourceId',
|
||||
);
|
||||
}
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> convertTidalToSpotifyDeezer(
|
||||
String tidalUrl,
|
||||
) async {
|
||||
@@ -969,6 +950,12 @@ class PlatformBridge {
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> getBuiltInProviders() async {
|
||||
final result = await _channel.invokeMethod('getBuiltInProviders');
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>?> handleURLWithExtension(
|
||||
String url,
|
||||
) async {
|
||||
@@ -995,57 +982,6 @@ class PlatformBridge {
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>?> getAlbumWithExtension(
|
||||
String extensionId,
|
||||
String albumId,
|
||||
) async {
|
||||
try {
|
||||
final result = await _channel.invokeMethod('getAlbumWithExtension', {
|
||||
'extension_id': extensionId,
|
||||
'album_id': albumId,
|
||||
});
|
||||
if (result == null || result == '') return null;
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
_log.e('getAlbumWithExtension failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>?> getPlaylistWithExtension(
|
||||
String extensionId,
|
||||
String playlistId,
|
||||
) async {
|
||||
try {
|
||||
final result = await _channel.invokeMethod('getPlaylistWithExtension', {
|
||||
'extension_id': extensionId,
|
||||
'playlist_id': playlistId,
|
||||
});
|
||||
if (result == null || result == '') return null;
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
_log.e('getPlaylistWithExtension failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>?> getArtistWithExtension(
|
||||
String extensionId,
|
||||
String artistId,
|
||||
) async {
|
||||
try {
|
||||
final result = await _channel.invokeMethod('getArtistWithExtension', {
|
||||
'extension_id': extensionId,
|
||||
'artist_id': artistId,
|
||||
});
|
||||
if (result == null || result == '') return null;
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
_log.e('getArtistWithExtension failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>?> getExtensionHomeFeed(
|
||||
String extensionId,
|
||||
) async {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/home_tab.dart'
|
||||
@@ -216,9 +217,8 @@ void _pushAlbumScreen(
|
||||
String? coverUrl,
|
||||
String? extensionId,
|
||||
}) {
|
||||
const builtInProviders = {'tidal', 'qobuz'};
|
||||
final isExtension =
|
||||
extensionId != null && !builtInProviders.contains(extensionId);
|
||||
extensionId != null && !isBuiltInMetadataProvider(extensionId);
|
||||
final resolvedExtensionId = extensionId;
|
||||
|
||||
_pushViaPreferredNavigator(
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user