feat: remove Tidal built-in provider, add extension download dedup/ISRC/Lyrics APIs, and expand l10n/a11y

Remove Tidal from built-in provider registry (metadata, search, download,
URL parsing) and delete tidal.go. Introduce extension runtime APIs for
lyrics lookup (getLyricsLRC), ISRC existence check (checkISRCExists), and
ISRC index management (addToISRCIndex). Refactor extension download response
construction into normalizeExtensionDownloadResult/overlayExtensionDownloadMetadata
helpers with AlreadyExists support and ISRC indexing. Switch download mirrors
to DoRequestWithUserAgent for ISP blocking detection. Add 50+ new
localization keys and accessibility labels across all supported locales.
This commit is contained in:
zarzet
2026-04-18 22:12:14 +07:00
parent 6895e45f2c
commit 16ce6089fb
49 changed files with 4928 additions and 3384 deletions
@@ -2897,14 +2897,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"getTidalMetadata" -> {
val resourceType = call.argument<String>("resource_type") ?: ""
val resourceId = call.argument<String>("resource_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getTidalMetadata(resourceType, resourceId)
}
result.success(response)
}
"getProviderMetadata" -> {
val providerId = call.argument<String>("provider_id") ?: ""
val resourceType = call.argument<String>("resource_type") ?: ""
@@ -2921,13 +2913,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"convertTidalToSpotifyDeezer" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.convertTidalToSpotifyDeezer(url)
}
result.success(response)
}
"searchDeezerByISRC" -> {
val isrc = call.argument<String>("isrc") ?: ""
val itemId = call.argument<String>("item_id") ?: ""
+17 -91
View File
@@ -1165,10 +1165,18 @@ func DownloadWithFallback(requestJSON string) (string, error) {
return errorResponse("Download cancelled")
}
allServices := []string{"tidal", "qobuz"}
allServices := make([]string, 0, len(getBuiltInProviderSpecs()))
for _, spec := range getBuiltInProviderSpecs() {
if spec.SupportsDownload {
allServices = append(allServices, spec.ID)
}
}
if len(allServices) == 0 {
return errorResponse("No built-in download providers available")
}
preferredService := req.Service
if !isBuiltInDownloadProvider(preferredService) {
preferredService = "tidal"
preferredService = allServices[0]
}
GoLog("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service)
@@ -1947,21 +1955,6 @@ func ClearTrackIDCache() {
ClearTrackCache()
}
func SearchTidalAll(query string, trackLimit, artistLimit int, filter string) (string, error) {
downloader := NewTidalDownloader()
results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func SearchQobuzAll(query string, trackLimit, artistLimit int, filter string) (string, error) {
downloader := NewQobuzDownloader()
results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter)
@@ -2249,7 +2242,7 @@ func getExtensionProviderMetadataResponse(
"artist_info": map[string]interface{}{
"id": artist.ID,
"name": artist.Name,
"images": tidalFirstNonEmpty(artist.HeaderImage, artist.ImageURL),
"images": firstNonEmptyTrimmed(artist.HeaderImage, artist.ImageURL),
"cover_url": artist.ImageURL,
"header_image": artist.HeaderImage,
"provider_id": artist.ProviderID,
@@ -2284,34 +2277,13 @@ func getExtensionProviderMetadataResponse(
}
}
func GetTidalMetadata(resourceType, resourceID string) (string, error) {
downloader := NewTidalDownloader()
var data interface{}
var err error
switch resourceType {
case "track":
data, err = downloader.GetTrackMetadata(resourceID)
case "album":
data, err = downloader.GetAlbumMetadata(resourceID)
case "artist":
data, err = downloader.GetArtistMetadata(resourceID)
case "playlist":
data, err = downloader.GetPlaylistMetadata(resourceID)
default:
return "", fmt.Errorf("unsupported Tidal resource type: %s", resourceType)
func firstNonEmptyTrimmed(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(data)
if err != nil {
return "", err
}
return string(jsonBytes), nil
return ""
}
func GetProviderMetadataJSON(providerID, resourceType, resourceID string) (string, error) {
@@ -2380,25 +2352,6 @@ func ParseQobuzURLExport(url string) (string, error) {
return string(jsonBytes), nil
}
func ParseTidalURLExport(url string) (string, error) {
resourceType, resourceID, err := parseTidalURL(url)
if err != nil {
return "", err
}
result := map[string]string{
"type": resourceType,
"id": resourceID,
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func ParseProviderURLJSON(url string) (string, error) {
parsers := []struct {
providerID string
@@ -2406,7 +2359,6 @@ func ParseProviderURLJSON(url string) (string, error) {
}{
{providerID: "deezer", parse: parseDeezerURL},
{providerID: "qobuz", parse: parseQobuzURL},
{providerID: "tidal", parse: parseTidalURL},
}
for _, parser := range parsers {
@@ -2431,32 +2383,6 @@ func ParseProviderURLJSON(url string) (string, error) {
return "", fmt.Errorf("unsupported provider URL")
}
func ConvertTidalToSpotifyDeezer(tidalURL string) (string, error) {
client := NewSongLinkClient()
availability, err := client.CheckAvailabilityFromURL(tidalURL)
if err != nil {
return "", err
}
result := map[string]string{
"spotify_id": availability.SpotifyID,
"deezer_id": availability.DeezerID,
"deezer_url": availability.DeezerURL,
"spotify_url": "",
}
if availability.SpotifyID != "" {
result["spotify_url"] = "https://open.spotify.com/track/" + availability.SpotifyID
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func GetDeezerExtendedMetadata(trackID string) (string, error) {
if trackID == "" {
return "", fmt.Errorf("empty track ID")
+204 -203
View File
@@ -109,19 +109,6 @@ type builtInProviderSpec struct {
}
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",
@@ -185,26 +172,6 @@ func downloadWithBuiltInProvider(providerID string, req DownloadRequest) (Downlo
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 {
@@ -226,6 +193,139 @@ func downloadWithBuiltInQobuz(req DownloadRequest) (DownloadResult, error) {
}, nil
}
func normalizeExtensionDownloadResult(result *ExtDownloadResult) (DownloadResult, bool) {
if result == nil {
return DownloadResult{}, false
}
downloadResult := DownloadResult{
FilePath: strings.TrimSpace(result.FilePath),
BitDepth: result.BitDepth,
SampleRate: result.SampleRate,
Title: result.Title,
Artist: result.Artist,
Album: result.Album,
ReleaseDate: result.ReleaseDate,
TrackNumber: result.TrackNumber,
TotalTracks: result.TotalTracks,
DiscNumber: result.DiscNumber,
TotalDiscs: result.TotalDiscs,
ISRC: result.ISRC,
CoverURL: result.CoverURL,
Genre: result.Genre,
Label: result.Label,
Copyright: result.Copyright,
Composer: result.Composer,
LyricsLRC: result.LyricsLRC,
DecryptionKey: result.DecryptionKey,
Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey),
}
alreadyExists := result.AlreadyExists
if strings.HasPrefix(downloadResult.FilePath, "EXISTS:") {
alreadyExists = true
downloadResult.FilePath = strings.TrimPrefix(downloadResult.FilePath, "EXISTS:")
}
enrichResultQualityFromFile(&downloadResult)
return downloadResult, alreadyExists
}
func overlayExtensionDownloadMetadata(resp *DownloadResponse, result *ExtDownloadResult) {
if resp == nil || result == nil {
return
}
if strings.TrimSpace(resp.Title) == "" && result.Title != "" {
resp.Title = result.Title
}
if strings.TrimSpace(resp.Artist) == "" && result.Artist != "" {
resp.Artist = result.Artist
}
if strings.TrimSpace(resp.Album) == "" && result.Album != "" {
resp.Album = result.Album
}
if strings.TrimSpace(resp.AlbumArtist) == "" && result.AlbumArtist != "" {
resp.AlbumArtist = result.AlbumArtist
}
if resp.TrackNumber == 0 && result.TrackNumber > 0 {
resp.TrackNumber = result.TrackNumber
}
if resp.DiscNumber == 0 && result.DiscNumber > 0 {
resp.DiscNumber = result.DiscNumber
}
if resp.TotalTracks == 0 && result.TotalTracks > 0 {
resp.TotalTracks = result.TotalTracks
}
if resp.TotalDiscs == 0 && result.TotalDiscs > 0 {
resp.TotalDiscs = result.TotalDiscs
}
if strings.TrimSpace(resp.ReleaseDate) == "" && result.ReleaseDate != "" {
resp.ReleaseDate = result.ReleaseDate
}
if strings.TrimSpace(resp.CoverURL) == "" && result.CoverURL != "" {
resp.CoverURL = result.CoverURL
}
if strings.TrimSpace(resp.ISRC) == "" && result.ISRC != "" {
resp.ISRC = result.ISRC
}
if strings.TrimSpace(resp.Genre) == "" && result.Genre != "" {
resp.Genre = result.Genre
}
if strings.TrimSpace(resp.Label) == "" && result.Label != "" {
resp.Label = result.Label
}
if strings.TrimSpace(resp.Copyright) == "" && result.Copyright != "" {
resp.Copyright = result.Copyright
}
if strings.TrimSpace(resp.Composer) == "" && result.Composer != "" {
resp.Composer = result.Composer
}
if result.LyricsLRC != "" {
resp.LyricsLRC = result.LyricsLRC
}
if result.DecryptionKey != "" {
resp.DecryptionKey = result.DecryptionKey
}
if normalized := normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey); normalized != nil {
resp.Decryption = normalized
}
}
func applyExtensionRequestFallbacks(resp *DownloadResponse, req DownloadRequest) {
if resp == nil {
return
}
if req.AlbumName != "" && resp.Album == "" {
resp.Album = req.AlbumName
}
if req.AlbumArtist != "" && resp.AlbumArtist == "" {
resp.AlbumArtist = req.AlbumArtist
}
if req.ReleaseDate != "" && resp.ReleaseDate == "" {
resp.ReleaseDate = req.ReleaseDate
}
if req.ISRC != "" && resp.ISRC == "" {
resp.ISRC = req.ISRC
}
if req.TrackNumber > 0 && resp.TrackNumber == 0 {
resp.TrackNumber = req.TrackNumber
}
if req.TotalTracks > 0 && resp.TotalTracks == 0 {
resp.TotalTracks = req.TotalTracks
}
if req.DiscNumber > 0 && resp.DiscNumber == 0 {
resp.DiscNumber = req.DiscNumber
}
if req.TotalDiscs > 0 && resp.TotalDiscs == 0 {
resp.TotalDiscs = req.TotalDiscs
}
if req.CoverURL != "" && resp.CoverURL == "" {
resp.CoverURL = req.CoverURL
}
}
func shouldStopProviderFallback(availability *ExtAvailabilityResult) bool {
return availability != nil && availability.SkipFallback
}
@@ -262,12 +362,13 @@ type DownloadDecryptionInfo struct {
}
type ExtDownloadResult struct {
Success bool `json:"success"`
FilePath string `json:"file_path,omitempty"`
BitDepth int `json:"bit_depth,omitempty"`
SampleRate int `json:"sample_rate,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
ErrorType string `json:"error_type,omitempty"`
Success bool `json:"success"`
FilePath string `json:"file_path,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
BitDepth int `json:"bit_depth,omitempty"`
SampleRate int `json:"sample_rate,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
ErrorType string `json:"error_type,omitempty"`
Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"`
@@ -1728,81 +1829,25 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
if err == nil && result.Success {
resp := &DownloadResponse{
Success: true,
Message: "Downloaded from " + req.Source,
FilePath: result.FilePath,
ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate,
Service: req.Source,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
DecryptionKey: result.DecryptionKey,
Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey),
}
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(result.FilePath) {
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
} else {
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
}
} else if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
GoLog("[DownloadWithExtensionFallback] Skipping genre/label embed for non-local output path: %q\n", result.FilePath)
normalizedResult, alreadyExists := normalizeExtensionDownloadResult(result)
message := "Downloaded from " + req.Source
if alreadyExists {
message = "File already exists"
}
resp := buildDownloadSuccessResponse(
req,
normalizedResult,
req.Source,
message,
normalizedResult.FilePath,
alreadyExists,
)
overlayExtensionDownloadMetadata(&resp, result)
if ext.Manifest.SkipMetadataEnrichment {
resp.SkipMetadataEnrichment = true
if result.Title != "" {
resp.Title = result.Title
}
if result.Artist != "" {
resp.Artist = result.Artist
}
if result.Album != "" {
resp.Album = result.Album
}
if result.AlbumArtist != "" {
resp.AlbumArtist = result.AlbumArtist
}
if result.TrackNumber > 0 {
resp.TrackNumber = result.TrackNumber
}
if result.DiscNumber > 0 {
resp.DiscNumber = result.DiscNumber
}
if result.TotalTracks > 0 {
resp.TotalTracks = result.TotalTracks
}
if result.TotalDiscs > 0 {
resp.TotalDiscs = result.TotalDiscs
}
if result.ReleaseDate != "" {
resp.ReleaseDate = result.ReleaseDate
}
if result.CoverURL != "" {
resp.CoverURL = result.CoverURL
}
if result.ISRC != "" {
resp.ISRC = result.ISRC
}
if result.Genre != "" {
resp.Genre = result.Genre
}
if result.Label != "" {
resp.Label = result.Label
}
if result.Copyright != "" {
resp.Copyright = result.Copyright
}
if result.Composer != "" {
resp.Composer = result.Composer
}
if result.LyricsLRC != "" {
resp.LyricsLRC = result.LyricsLRC
}
}
applyExtensionRequestFallbacks(&resp, req)
if req.TrackName != "" && resp.Title == "" {
resp.Title = req.TrackName
@@ -1810,38 +1855,31 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if req.ArtistName != "" && resp.Artist == "" {
resp.Artist = req.ArtistName
}
if req.AlbumName != "" && resp.Album == "" {
resp.Album = req.AlbumName
}
if req.AlbumArtist != "" && resp.AlbumArtist == "" {
resp.AlbumArtist = req.AlbumArtist
}
if req.ReleaseDate != "" && resp.ReleaseDate == "" {
resp.ReleaseDate = req.ReleaseDate
}
if req.ISRC != "" && resp.ISRC == "" {
resp.ISRC = req.ISRC
}
if req.TrackNumber > 0 && resp.TrackNumber == 0 {
resp.TrackNumber = req.TrackNumber
}
if req.DiscNumber > 0 && resp.DiscNumber == 0 {
resp.DiscNumber = req.DiscNumber
}
if req.TotalTracks > 0 && resp.TotalTracks == 0 {
resp.TotalTracks = req.TotalTracks
}
if req.TotalDiscs > 0 && resp.TotalDiscs == 0 {
resp.TotalDiscs = req.TotalDiscs
}
if req.CoverURL != "" && resp.CoverURL == "" {
resp.CoverURL = req.CoverURL
}
if req.Composer != "" && resp.Composer == "" {
resp.Composer = req.Composer
}
return resp, nil
if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(normalizedResult.FilePath) {
if err := EmbedGenreLabel(normalizedResult.FilePath, req.Genre, req.Label); err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
} else {
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
}
} else if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
GoLog("[DownloadWithExtensionFallback] Skipping genre/label embed for non-local output path: %q\n", normalizedResult.FilePath)
}
if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" {
indexISRC := strings.TrimSpace(resp.ISRC)
if indexISRC == "" {
indexISRC = strings.TrimSpace(req.ISRC)
}
if indexISRC != "" && strings.TrimSpace(resp.FilePath) != "" {
AddToISRCIndex(req.OutputDir, indexISRC, resp.FilePath)
}
}
return &resp, nil
}
if err != nil {
@@ -2003,84 +2041,47 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
if err == nil && result.Success {
resp := &DownloadResponse{
Success: true,
Message: "Downloaded from " + providerID,
FilePath: result.FilePath,
ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate,
Service: providerID,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
DecryptionKey: result.DecryptionKey,
Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey),
normalizedResult, alreadyExists := normalizeExtensionDownloadResult(result)
message := "Downloaded from " + providerID
if alreadyExists {
message = "File already exists"
}
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(result.FilePath) {
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
resp := buildDownloadSuccessResponse(
req,
normalizedResult,
providerID,
message,
normalizedResult.FilePath,
alreadyExists,
)
overlayExtensionDownloadMetadata(&resp, result)
if ext.Manifest.SkipMetadataEnrichment {
resp.SkipMetadataEnrichment = true
}
applyExtensionRequestFallbacks(&resp, req)
if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(normalizedResult.FilePath) {
if err := EmbedGenreLabel(normalizedResult.FilePath, req.Genre, req.Label); err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
} else {
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
}
} else if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
GoLog("[DownloadWithExtensionFallback] Skipping genre/label embed for non-local output path: %q\n", result.FilePath)
} else if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
GoLog("[DownloadWithExtensionFallback] Skipping genre/label embed for non-local output path: %q\n", normalizedResult.FilePath)
}
if ext.Manifest.SkipMetadataEnrichment {
resp.SkipMetadataEnrichment = true
if result.Title != "" {
resp.Title = result.Title
if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" {
indexISRC := strings.TrimSpace(resp.ISRC)
if indexISRC == "" {
indexISRC = strings.TrimSpace(req.ISRC)
}
if result.Artist != "" {
resp.Artist = result.Artist
}
if result.Album != "" {
resp.Album = result.Album
}
if result.AlbumArtist != "" {
resp.AlbumArtist = result.AlbumArtist
}
if result.TrackNumber > 0 {
resp.TrackNumber = result.TrackNumber
}
if result.DiscNumber > 0 {
resp.DiscNumber = result.DiscNumber
}
if result.ReleaseDate != "" {
resp.ReleaseDate = result.ReleaseDate
}
if result.CoverURL != "" {
resp.CoverURL = result.CoverURL
}
if result.ISRC != "" {
resp.ISRC = result.ISRC
if indexISRC != "" && strings.TrimSpace(resp.FilePath) != "" {
AddToISRCIndex(req.OutputDir, indexISRC, resp.FilePath)
}
}
if req.AlbumName != "" && resp.Album == "" {
resp.Album = req.AlbumName
}
if req.AlbumArtist != "" && resp.AlbumArtist == "" {
resp.AlbumArtist = req.AlbumArtist
}
if req.ReleaseDate != "" && resp.ReleaseDate == "" {
resp.ReleaseDate = req.ReleaseDate
}
if req.ISRC != "" && resp.ISRC == "" {
resp.ISRC = req.ISRC
}
if req.TrackNumber > 0 && resp.TrackNumber == 0 {
resp.TrackNumber = req.TrackNumber
}
if req.DiscNumber > 0 && resp.DiscNumber == 0 {
resp.DiscNumber = req.DiscNumber
}
if req.CoverURL != "" && resp.CoverURL == "" {
resp.CoverURL = req.CoverURL
}
return resp, nil
return &resp, nil
}
if err != nil {
+8 -11
View File
@@ -11,9 +11,9 @@ func TestSetMetadataProviderPriorityPreservesExplicitProvidersOnly(t *testing.T)
original := GetMetadataProviderPriority()
defer SetMetadataProviderPriority(original)
SetMetadataProviderPriority([]string{"tidal"})
SetMetadataProviderPriority([]string{"qobuz"})
got := GetMetadataProviderPriority()
want := []string{"tidal"}
want := []string{"qobuz"}
if len(got) != len(want) {
t.Fatalf("unexpected priority length: got %v want %v", got, want)
}
@@ -28,7 +28,7 @@ func TestSetExtensionFallbackProviderIDsSkipsBuiltInsAndDuplicates(t *testing.T)
original := GetExtensionFallbackProviderIDs()
defer SetExtensionFallbackProviderIDs(original)
SetExtensionFallbackProviderIDs([]string{"ext-a", "tidal", "ext-a", " ext-b "})
SetExtensionFallbackProviderIDs([]string{"ext-a", "qobuz", "ext-a", " ext-b "})
got := GetExtensionFallbackProviderIDs()
want := []string{"ext-a", "ext-b"}
@@ -255,7 +255,7 @@ func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
searchBuiltInMetadataTracksFunc = originalSearch
}()
SetMetadataProviderPriority([]string{"qobuz", "tidal"})
SetMetadataProviderPriority([]string{"qobuz"})
var calls []string
searchBuiltInMetadataTracksFunc = func(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
@@ -264,11 +264,8 @@ func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
case "qobuz":
return []ExtTrackMetadata{
{ProviderID: "qobuz", SpotifyID: "qobuz:1", ISRC: "AAA111", Name: "First"},
}, nil
case "tidal":
return []ExtTrackMetadata{
{ProviderID: "tidal", SpotifyID: "tidal:2", ISRC: "AAA111", Name: "Duplicate"},
{ProviderID: "tidal", SpotifyID: "tidal:3", ISRC: "BBB222", Name: "Second"},
{ProviderID: "qobuz", SpotifyID: "qobuz:2", ISRC: "AAA111", Name: "Duplicate"},
{ProviderID: "qobuz", SpotifyID: "qobuz:3", ISRC: "BBB222", Name: "Second"},
}, nil
default:
return nil, nil
@@ -283,10 +280,10 @@ func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
if len(tracks) != 2 {
t.Fatalf("unexpected track count: got %d want 2", len(tracks))
}
if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "tidal" {
if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "qobuz" {
t.Fatalf("unexpected track provider order: %+v", tracks)
}
if len(calls) != 2 || calls[0] != "qobuz" || calls[1] != "tidal" {
if len(calls) != 1 || calls[0] != "qobuz" {
t.Fatalf("unexpected provider call order: %v", calls)
}
}
+75
View File
@@ -390,6 +390,81 @@ func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
})
})
obj.Set("getLyricsLRC", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 3 {
return vm.ToValue(map[string]interface{}{
"error": "spotifyID, trackName, and artistName are required",
})
}
spotifyID := strings.TrimSpace(call.Arguments[0].String())
trackName := strings.TrimSpace(call.Arguments[1].String())
artistName := strings.TrimSpace(call.Arguments[2].String())
filePath := ""
if len(call.Arguments) > 3 && !goja.IsUndefined(call.Arguments[3]) && !goja.IsNull(call.Arguments[3]) {
filePath = strings.TrimSpace(call.Arguments[3].String())
}
var durationMs int64
if len(call.Arguments) > 4 && !goja.IsUndefined(call.Arguments[4]) && !goja.IsNull(call.Arguments[4]) {
durationMs = call.Arguments[4].ToInteger()
}
lyrics, err := GetLyricsLRC(spotifyID, trackName, artistName, filePath, durationMs)
if err != nil {
return vm.ToValue(map[string]interface{}{
"error": err.Error(),
})
}
return vm.ToValue(map[string]interface{}{
"lyrics": lyrics,
})
})
obj.Set("checkISRCExists", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return vm.ToValue(map[string]interface{}{
"error": "outputDir and isrc are required",
})
}
outputDir := strings.TrimSpace(call.Arguments[0].String())
isrc := strings.TrimSpace(call.Arguments[1].String())
if outputDir == "" || isrc == "" {
return vm.ToValue(map[string]interface{}{
"error": "outputDir and isrc are required",
})
}
filePath, exists := checkISRCExistsInternal(outputDir, isrc)
return vm.ToValue(map[string]interface{}{
"exists": exists,
"filePath": filePath,
})
})
obj.Set("addToISRCIndex", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 3 {
return vm.ToValue(map[string]interface{}{
"error": "outputDir, isrc, and filePath are required",
})
}
outputDir := strings.TrimSpace(call.Arguments[0].String())
isrc := strings.TrimSpace(call.Arguments[1].String())
filePath := strings.TrimSpace(call.Arguments[2].String())
if outputDir == "" || isrc == "" || filePath == "" {
return vm.ToValue(map[string]interface{}{
"error": "outputDir, isrc, and filePath are required",
})
}
AddToISRCIndex(outputDir, isrc, filePath)
return vm.ToValue(map[string]interface{}{
"success": true,
})
})
obj.Set("buildFilename", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return vm.ToValue("")
+2 -32
View File
@@ -8,9 +8,8 @@ import (
)
type TrackIDCacheEntry struct {
TidalTrackID int64
QobuzTrackID int64
ExpiresAt time.Time
QobuzTrackID int64
ExpiresAt time.Time
}
type TrackIDCache struct {
@@ -68,25 +67,6 @@ func (c *TrackIDCache) pruneExpiredLocked(now time.Time) {
}
}
func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
c.mu.Lock()
defer c.mu.Unlock()
entry, exists := c.cache[isrc]
if !exists {
entry = &TrackIDCacheEntry{}
c.cache[isrc] = entry
}
entry.TidalTrackID = trackID
now := time.Now()
entry.ExpiresAt = now.Add(c.ttl)
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
c.pruneExpiredLocked(now)
c.lastCleanup = now
}
}
func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
c.mu.Lock()
defer c.mu.Unlock()
@@ -211,8 +191,6 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
defer func() { <-semaphore }()
switch r.Service {
case "tidal":
preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName)
case "qobuz":
preWarmQobuzCache(r.ISRC, r.SpotifyID)
}
@@ -222,14 +200,6 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
wg.Wait()
}
func preWarmTidalCache(isrc, _, _ string) {
downloader := NewTidalDownloader()
track, err := downloader.SearchTrackByISRC(isrc)
if err == nil && track != nil {
GetTrackIDCache().SetTidal(isrc, track.ID)
}
}
// preWarmQobuzCache tries to get Qobuz Track ID in the following order:
// 1. From SongLink (fast, no Qobuz API call needed)
// 2. Direct ISRC search on Qobuz API (slower, may fail if ISRC not in Qobuz database)
-2547
View File
File diff suppressed because it is too large Load Diff
-222
View File
@@ -1,222 +0,0 @@
package gobackend
import "testing"
func TestParseTidalURL(t *testing.T) {
tests := []struct {
name string
input string
wantType string
wantID string
expectErr bool
}{
{
name: "track url",
input: "https://tidal.com/track/77616174",
wantType: "track",
wantID: "77616174",
},
{
name: "browse album url",
input: "https://listen.tidal.com/browse/album/77616169",
wantType: "album",
wantID: "77616169",
},
{
name: "artist url",
input: "https://www.tidal.com/artist/3852143",
wantType: "artist",
wantID: "3852143",
},
{
name: "playlist url",
input: "https://tidal.com/playlist/edf3b7d2-cb42-41d7-93c0-afa2a395521b",
wantType: "playlist",
wantID: "edf3b7d2-cb42-41d7-93c0-afa2a395521b",
},
{
name: "unsupported host",
input: "https://example.com/track/123",
expectErr: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
gotType, gotID, err := parseTidalURL(test.input)
if test.expectErr {
if err == nil {
t.Fatalf("expected error, got none")
}
return
}
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if gotType != test.wantType || gotID != test.wantID {
t.Fatalf("parseTidalURL(%q) = (%q, %q), want (%q, %q)", test.input, gotType, gotID, test.wantType, test.wantID)
}
})
}
}
func TestParseTidalRequestTrackID(t *testing.T) {
tests := []struct {
input string
want int64
ok bool
}{
{input: "40681594", want: 40681594, ok: true},
{input: "tidal:40681594", want: 40681594, ok: true},
{input: " tidal:40681594 ", want: 40681594, ok: true},
{input: "", want: 0, ok: false},
{input: "tidal:not-a-number", want: 0, ok: false},
}
for _, test := range tests {
got, ok := parseTidalRequestTrackID(test.input)
if got != test.want || ok != test.ok {
t.Fatalf("parseTidalRequestTrackID(%q) = (%d, %v), want (%d, %v)", test.input, got, ok, test.want, test.ok)
}
}
}
func TestTidalImageURL(t *testing.T) {
got := tidalImageURL("fc18a64b-d76b-4582-962a-224cb05193f3", "1280x1280")
want := "https://resources.tidal.com/images/fc18a64b/d76b/4582/962a/224cb05193f3/1280x1280.jpg"
if got != want {
t.Fatalf("tidalImageURL() = %q, want %q", got, want)
}
}
func TestTidalTrackToTrackMetadata(t *testing.T) {
track := &TidalTrack{
ID: 77616174,
Title: "Bruckner: Symphony No. 5",
ISRC: "GBUM71507433",
Duration: 1172,
TrackNumber: 5,
VolumeNumber: 1,
URL: "http://www.tidal.com/track/77616174",
}
track.Artist.ID = 3852143
track.Artist.Name = "Staatskapelle Berlin"
track.Artists = []struct {
ID int64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Picture string `json:"picture"`
}{
{ID: 3852143, Name: "Staatskapelle Berlin", Type: "MAIN"},
{ID: 12430, Name: "Daniel Barenboim", Type: "FEATURED"},
}
track.Album.ID = 77616169
track.Album.Title = "Bruckner: Symphonies 4-9"
track.Album.Cover = "fc18a64b-d76b-4582-962a-224cb05193f3"
track.Album.ReleaseDate = "2016-02-26"
got := tidalTrackToTrackMetadata(track)
if got.SpotifyID != "tidal:77616174" {
t.Fatalf("unexpected track ID: %q", got.SpotifyID)
}
if got.Artists != "Staatskapelle Berlin, Daniel Barenboim" {
t.Fatalf("unexpected artists: %q", got.Artists)
}
if got.AlbumID != "tidal:77616169" {
t.Fatalf("unexpected album ID: %q", got.AlbumID)
}
if got.ArtistID != "tidal:3852143" {
t.Fatalf("unexpected artist ID: %q", got.ArtistID)
}
if got.Images == "" || got.ExternalURL != "https://www.tidal.com/track/77616174" {
t.Fatalf("unexpected image/url: %q / %q", got.Images, got.ExternalURL)
}
}
func TestTidalAlbumToArtistAlbum(t *testing.T) {
album := &tidalPublicAlbum{
ID: 77616169,
Title: "Bruckner: Symphonies 4-9",
Type: "ALBUM",
Cover: "fc18a64b-d76b-4582-962a-224cb05193f3",
ReleaseDate: "2016-02-26",
NumberOfTracks: 23,
Artists: []tidalPublicArtist{
{ID: 3852143, Name: "Staatskapelle Berlin", Type: "MAIN"},
{ID: 12430, Name: "Daniel Barenboim", Type: "FEATURED"},
},
}
got := tidalAlbumToArtistAlbum(album)
if got.ID != "tidal:77616169" {
t.Fatalf("unexpected album ID: %q", got.ID)
}
if got.AlbumType != "album" {
t.Fatalf("unexpected album type: %q", got.AlbumType)
}
if got.Artists != "Staatskapelle Berlin, Daniel Barenboim" {
t.Fatalf("unexpected artists: %q", got.Artists)
}
if got.Images == "" {
t.Fatalf("expected image URL, got empty string")
}
}
func TestTidalAlbumToArtistAlbumWithFallbackType(t *testing.T) {
album := &tidalPublicAlbum{
ID: 490623904,
Title: "LET 'EM KNOW",
Cover: "fc18a64b-d76b-4582-962a-224cb05193f3",
NumberOfTracks: 1,
}
got := tidalAlbumToArtistAlbumWithType(album, "single")
if got.AlbumType != "single" {
t.Fatalf("unexpected fallback album type: %q", got.AlbumType)
}
}
func TestTidalArtistAlbumTypeFromModuleTitle(t *testing.T) {
tests := []struct {
title string
want string
}{
{title: "Albums", want: "album"},
{title: "EP & Singles", want: "single"},
{title: "Compilations", want: "album"},
{title: "Appears On", want: "album"},
{title: "Unknown", want: ""},
}
for _, test := range tests {
if got := tidalArtistAlbumTypeFromModuleTitle(test.title); got != test.want {
t.Fatalf("tidalArtistAlbumTypeFromModuleTitle(%q) = %q, want %q", test.title, got, test.want)
}
}
}
func TestTidalPlaylistImageUsesOrigin(t *testing.T) {
got := tidalImageURL("e6b59fd3-6995-40f0-8a32-174db3a8f4f2", "origin")
want := "https://resources.tidal.com/images/e6b59fd3/6995/40f0/8a32/174db3a8f4f2/origin.jpg"
if got != want {
t.Fatalf("unexpected origin playlist image URL: %q", got)
}
}
func TestTidalPlaylistOwnerName(t *testing.T) {
editorial := &tidalPublicPlaylist{Type: "EDITORIAL"}
if got := tidalPlaylistOwnerName(editorial); got != "TIDAL" {
t.Fatalf("unexpected editorial owner: %q", got)
}
artist := &tidalPublicPlaylist{Type: "ARTIST"}
if got := tidalPlaylistOwnerName(artist); got != "Artist" {
t.Fatalf("unexpected artist owner: %q", got)
}
user := &tidalPublicPlaylist{}
user.Creator.Name = "djtest"
if got := tidalPlaylistOwnerName(user); got != "djtest" {
t.Fatalf("unexpected creator owner: %q", got)
}
}
+257
View File
@@ -107,6 +107,263 @@ func normalizeSymbolOnlyTitle(title string) string {
return b.String()
}
func artistsMatch(expectedArtist, foundArtist string) bool {
normExpected := normalizeLooseArtistName(expectedArtist)
normFound := normalizeLooseArtistName(foundArtist)
if normExpected == normFound {
return true
}
if strings.Contains(normExpected, normFound) ||
strings.Contains(normFound, normExpected) {
return true
}
expectedArtists := splitArtists(normExpected)
foundArtists := splitArtists(normFound)
for _, expected := range expectedArtists {
for _, found := range foundArtists {
if expected == found {
return true
}
if strings.Contains(expected, found) ||
strings.Contains(found, expected) {
return true
}
if sameWordsUnordered(expected, found) {
return true
}
}
}
if isLatinScript(expectedArtist) != isLatinScript(foundArtist) {
return true
}
return false
}
func splitArtists(artists string) []string {
normalized := artists
normalized = strings.ReplaceAll(normalized, " feat. ", "|")
normalized = strings.ReplaceAll(normalized, " feat ", "|")
normalized = strings.ReplaceAll(normalized, " ft. ", "|")
normalized = strings.ReplaceAll(normalized, " ft ", "|")
normalized = strings.ReplaceAll(normalized, " & ", "|")
normalized = strings.ReplaceAll(normalized, " and ", "|")
normalized = strings.ReplaceAll(normalized, ", ", "|")
normalized = strings.ReplaceAll(normalized, " x ", "|")
parts := strings.Split(normalized, "|")
result := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
func sameWordsUnordered(a, b string) bool {
wordsA := strings.Fields(a)
wordsB := strings.Fields(b)
if len(wordsA) != len(wordsB) || len(wordsA) == 0 {
return false
}
sortedA := make([]string, len(wordsA))
sortedB := make([]string, len(wordsB))
copy(sortedA, wordsA)
copy(sortedB, wordsB)
for i := 0; i < len(sortedA)-1; i++ {
for j := i + 1; j < len(sortedA); j++ {
if sortedA[i] > sortedA[j] {
sortedA[i], sortedA[j] = sortedA[j], sortedA[i]
}
if sortedB[i] > sortedB[j] {
sortedB[i], sortedB[j] = sortedB[j], sortedB[i]
}
}
}
for i := range sortedA {
if sortedA[i] != sortedB[i] {
return false
}
}
return true
}
func titlesMatch(expectedTitle, foundTitle string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
normFound := strings.ToLower(strings.TrimSpace(foundTitle))
if normExpected == normFound {
return true
}
if strings.Contains(normExpected, normFound) ||
strings.Contains(normFound, normExpected) {
return true
}
cleanExpected := cleanTitle(normExpected)
cleanFound := cleanTitle(normFound)
if cleanExpected == cleanFound {
return true
}
if cleanExpected != "" && cleanFound != "" {
if strings.Contains(cleanExpected, cleanFound) ||
strings.Contains(cleanFound, cleanExpected) {
return true
}
}
coreExpected := extractCoreTitle(normExpected)
coreFound := extractCoreTitle(normFound)
if coreExpected != "" && coreFound != "" && coreExpected == coreFound {
return true
}
looseExpected := normalizeLooseTitle(normExpected)
looseFound := normalizeLooseTitle(normFound)
if looseExpected != "" && looseFound != "" {
if looseExpected == looseFound {
return true
}
if strings.Contains(looseExpected, looseFound) ||
strings.Contains(looseFound, looseExpected) {
return true
}
}
if (!hasAlphaNumericRunes(expectedTitle) || !hasAlphaNumericRunes(foundTitle)) &&
strings.TrimSpace(expectedTitle) != "" &&
strings.TrimSpace(foundTitle) != "" {
expectedSymbols := normalizeSymbolOnlyTitle(expectedTitle)
foundSymbols := normalizeSymbolOnlyTitle(foundTitle)
if expectedSymbols != "" &&
foundSymbols != "" &&
expectedSymbols == foundSymbols {
return true
}
}
return false
}
func extractCoreTitle(title string) string {
parenIdx := strings.Index(title, "(")
bracketIdx := strings.Index(title, "[")
dashIdx := strings.Index(title, " - ")
cutIdx := len(title)
if parenIdx > 0 && parenIdx < cutIdx {
cutIdx = parenIdx
}
if bracketIdx > 0 && bracketIdx < cutIdx {
cutIdx = bracketIdx
}
if dashIdx > 0 && dashIdx < cutIdx {
cutIdx = dashIdx
}
return strings.TrimSpace(title[:cutIdx])
}
func cleanTitle(title string) string {
cleaned := title
versionPatterns := []string{
"remaster", "remastered", "deluxe", "bonus", "single",
"album version", "radio edit", "original mix", "extended",
"club mix", "remix", "live", "acoustic", "demo",
}
for {
startParen := strings.LastIndex(cleaned, "(")
endParen := strings.LastIndex(cleaned, ")")
if startParen >= 0 && endParen > startParen {
content := strings.ToLower(cleaned[startParen+1 : endParen])
isVersionIndicator := false
for _, pattern := range versionPatterns {
if strings.Contains(content, pattern) {
isVersionIndicator = true
break
}
}
if isVersionIndicator {
cleaned = strings.TrimSpace(cleaned[:startParen]) + cleaned[endParen+1:]
continue
}
}
break
}
for {
startBracket := strings.LastIndex(cleaned, "[")
endBracket := strings.LastIndex(cleaned, "]")
if startBracket >= 0 && endBracket > startBracket {
content := strings.ToLower(cleaned[startBracket+1 : endBracket])
isVersionIndicator := false
for _, pattern := range versionPatterns {
if strings.Contains(content, pattern) {
isVersionIndicator = true
break
}
}
if isVersionIndicator {
cleaned = strings.TrimSpace(cleaned[:startBracket]) + cleaned[endBracket+1:]
continue
}
}
break
}
dashPatterns := []string{
" - remaster", " - remastered", " - single version", " - radio edit",
" - live", " - acoustic", " - demo", " - remix",
}
for _, pattern := range dashPatterns {
if strings.HasSuffix(strings.ToLower(cleaned), pattern) {
cleaned = cleaned[:len(cleaned)-len(pattern)]
}
}
for strings.Contains(cleaned, " ") {
cleaned = strings.ReplaceAll(cleaned, " ", " ")
}
return strings.TrimSpace(cleaned)
}
func isLatinScript(value string) bool {
for _, r := range value {
if r < 128 {
continue
}
if (r >= 0x0100 && r <= 0x024F) ||
(r >= 0x1E00 && r <= 0x1EFF) ||
(r >= 0x00C0 && r <= 0x00FF) {
continue
}
if (r >= 0x4E00 && r <= 0x9FFF) ||
(r >= 0x3040 && r <= 0x309F) ||
(r >= 0x30A0 && r <= 0x30FF) ||
(r >= 0xAC00 && r <= 0xD7AF) ||
(r >= 0x0600 && r <= 0x06FF) ||
(r >= 0x0400 && r <= 0x04FF) {
return false
}
}
return true
}
type resolvedTrackInfo struct {
Title string
ArtistName string
-15
View File
@@ -456,14 +456,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "getTidalMetadata":
let args = call.arguments as! [String: Any]
let resourceType = args["resource_type"] as! String
let resourceId = args["resource_id"] as! String
let response = GobackendGetTidalMetadata(resourceType, resourceId, &error)
if let error = error { throw error }
return response
case "getProviderMetadata":
let args = call.arguments as! [String: Any]
let providerId = args["provider_id"] as! String
@@ -480,13 +472,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "convertTidalToSpotifyDeezer":
let args = call.arguments as! [String: Any]
let url = args["url"] as! String
let response = GobackendConvertTidalToSpotifyDeezer(url, &error)
if let error = error { throw error }
return response
case "searchDeezerByISRC":
let args = call.arguments as! [String: Any]
let isrc = args["isrc"] as! String
+370
View File
@@ -166,6 +166,18 @@ abstract class AppLocalizations {
/// **'Paste a supported URL or search by name'**
String get homeSubtitle;
/// Title shown on home when no providers are available yet
///
/// In en, this message translates to:
/// **'Home is empty'**
String get homeEmptyTitle;
/// Subtitle shown on home when no providers are available yet
///
/// In en, this message translates to:
/// **'Install your first extension to unlock search and browsing.'**
String get homeEmptySubtitle;
/// Info text about supported URL types
///
/// In en, this message translates to:
@@ -5844,6 +5856,364 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Could not download update. Try again later.'**
String get notifUpdateFailedBody;
/// Search filter label - tracks
///
/// In en, this message translates to:
/// **'Tracks'**
String get searchTracks;
/// Default placeholder for the main search field on Home
///
/// In en, this message translates to:
/// **'Paste supported URL or search...'**
String get homeSearchHintDefault;
/// Placeholder for the main search field when a provider is selected
///
/// In en, this message translates to:
/// **'Search with {providerName}...'**
String homeSearchHintProvider(String providerName);
/// Tooltip for importing a CSV file into Home search
///
/// In en, this message translates to:
/// **'Import CSV'**
String get homeImportCsvTooltip;
/// Tooltip for the Home search provider picker
///
/// In en, this message translates to:
/// **'Change search provider'**
String get homeChangeSearchProviderTooltip;
/// Generic action - paste from clipboard
///
/// In en, this message translates to:
/// **'Paste'**
String get actionPaste;
/// Placeholder for the search screen input
///
/// In en, this message translates to:
/// **'Search tracks...'**
String get searchTracksHint;
/// Empty-state prompt on the search screen
///
/// In en, this message translates to:
/// **'Search for tracks'**
String get searchTracksEmptyPrompt;
/// Placeholder shown in the tutorial search demo
///
/// In en, this message translates to:
/// **'Paste or search...'**
String get tutorialSearchHint;
/// Accessibility label for completed download state in tutorial demo
///
/// In en, this message translates to:
/// **'Download completed'**
String get tutorialDownloadCompletedSemantics;
/// Accessibility label for active download state in tutorial demo
///
/// In en, this message translates to:
/// **'Download in progress'**
String get tutorialDownloadInProgressSemantics;
/// Accessibility label for idle download button in tutorial demo
///
/// In en, this message translates to:
/// **'Start download'**
String get tutorialStartDownloadSemantics;
/// Settings toggle title for writing metadata into downloaded files
///
/// In en, this message translates to:
/// **'Embed Metadata'**
String get optionsEmbedMetadata;
/// Subtitle when metadata embedding is enabled
///
/// In en, this message translates to:
/// **'Write metadata, cover art, and embedded lyrics to files'**
String get optionsEmbedMetadataSubtitleOn;
/// Subtitle when metadata embedding is disabled
///
/// In en, this message translates to:
/// **'Disabled (advanced): skip all metadata embedding'**
String get optionsEmbedMetadataSubtitleOff;
/// Subtitle for max quality cover when metadata embedding is disabled
///
/// In en, this message translates to:
/// **'Disabled when metadata embedding is off'**
String get optionsMaxQualityCoverSubtitleDisabled;
/// Example placeholder for the download filename format input
///
/// In en, this message translates to:
/// **'{artist} - {title}'**
String downloadFilenameHintExample(Object artist, Object title);
/// Message shown when a track file has no embedded cover art
///
/// In en, this message translates to:
/// **'No embedded album art found'**
String get trackCoverNoEmbeddedArt;
/// Button label for replacing selected cover art
///
/// In en, this message translates to:
/// **'Replace Cover'**
String get trackCoverReplace;
/// Button label for selecting cover art
///
/// In en, this message translates to:
/// **'Pick Cover'**
String get trackCoverPick;
/// Tooltip for clearing the newly selected cover art
///
/// In en, this message translates to:
/// **'Clear selected cover'**
String get trackCoverClearSelected;
/// Label for the currently embedded cover preview
///
/// In en, this message translates to:
/// **'Current cover'**
String get trackCoverCurrent;
/// Label for the newly selected cover preview
///
/// In en, this message translates to:
/// **'Selected cover'**
String get trackCoverSelected;
/// Notice shown when a new cover has been selected but not saved yet
///
/// In en, this message translates to:
/// **'The selected cover will replace the current embedded cover when you tap Save.'**
String get trackCoverReplaceNotice;
/// Generic action - stop
///
/// In en, this message translates to:
/// **'Stop'**
String get actionStop;
/// Accessibility label for a queue item that is finalizing
///
/// In en, this message translates to:
/// **'Finalizing download'**
String get queueFinalizingDownload;
/// Accessibility label when a downloaded file is missing from disk
///
/// In en, this message translates to:
/// **'Downloaded file missing'**
String get queueDownloadedFileMissing;
/// Accessibility label for completed download state in queue
///
/// In en, this message translates to:
/// **'Download completed'**
String get queueDownloadCompleted;
/// Accessibility label for picking an accent color
///
/// In en, this message translates to:
/// **'Select accent color {hex}'**
String appearanceSelectAccentColor(String hex);
/// Tooltip when auto-scroll is enabled on the log screen
///
/// In en, this message translates to:
/// **'Auto-scroll ON'**
String get logAutoScrollOn;
/// Tooltip when auto-scroll is disabled on the log screen
///
/// In en, this message translates to:
/// **'Auto-scroll OFF'**
String get logAutoScrollOff;
/// Tooltip for copying logs
///
/// In en, this message translates to:
/// **'Copy logs'**
String get logCopyLogs;
/// Tooltip for clearing the log search field
///
/// In en, this message translates to:
/// **'Clear search'**
String get logClearSearch;
/// Diagnostic badge label when ISP blocking is detected
///
/// In en, this message translates to:
/// **'ISP BLOCKING DETECTED'**
String get logIssueIspBlockingLabel;
/// Diagnostic badge description for ISP blocking
///
/// In en, this message translates to:
/// **'Your ISP may be blocking access to download services'**
String get logIssueIspBlockingDescription;
/// Diagnostic badge suggestion for ISP blocking
///
/// In en, this message translates to:
/// **'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'**
String get logIssueIspBlockingSuggestion;
/// Diagnostic badge label when the service rate limits requests
///
/// In en, this message translates to:
/// **'RATE LIMITED'**
String get logIssueRateLimitedLabel;
/// Diagnostic badge description for rate limiting
///
/// In en, this message translates to:
/// **'Too many requests to the service'**
String get logIssueRateLimitedDescription;
/// Diagnostic badge suggestion for rate limiting
///
/// In en, this message translates to:
/// **'Wait a few minutes before trying again'**
String get logIssueRateLimitedSuggestion;
/// Diagnostic badge label for generic network errors
///
/// In en, this message translates to:
/// **'NETWORK ERROR'**
String get logIssueNetworkErrorLabel;
/// Diagnostic badge description for generic network errors
///
/// In en, this message translates to:
/// **'Connection issues detected'**
String get logIssueNetworkErrorDescription;
/// Diagnostic badge suggestion for generic network errors
///
/// In en, this message translates to:
/// **'Check your internet connection'**
String get logIssueNetworkErrorSuggestion;
/// Diagnostic badge label when a track is unavailable
///
/// In en, this message translates to:
/// **'TRACK NOT FOUND'**
String get logIssueTrackNotFoundLabel;
/// Diagnostic badge description when a track is unavailable
///
/// In en, this message translates to:
/// **'Some tracks could not be found on download services'**
String get logIssueTrackNotFoundDescription;
/// Diagnostic badge suggestion when a track is unavailable
///
/// In en, this message translates to:
/// **'The track may not be available in lossless quality'**
String get logIssueTrackNotFoundSuggestion;
/// Snackbar shown while clickable artist metadata is being resolved
///
/// In en, this message translates to:
/// **'Looking up artist...'**
String get clickableLookingUpArtist;
/// Snackbar shown when clickable metadata cannot open a destination
///
/// In en, this message translates to:
/// **'{type} information not available'**
String clickableInformationUnavailable(String type);
/// Section title for extension tags
///
/// In en, this message translates to:
/// **'Tags'**
String get extensionDetailsTags;
/// Section title for extension metadata information
///
/// In en, this message translates to:
/// **'Information'**
String get extensionDetailsInformation;
/// Capability label for utility-only extensions
///
/// In en, this message translates to:
/// **'Utility Functions'**
String get extensionUtilityFunctions;
/// Generic action - dismiss
///
/// In en, this message translates to:
/// **'Dismiss'**
String get actionDismiss;
/// Tooltip for editing the selected download folder
///
/// In en, this message translates to:
/// **'Change folder'**
String get setupChangeFolderTooltip;
/// Accessibility label for opening a track item
///
/// In en, this message translates to:
/// **'Open track {trackName} by {artistName}'**
String a11yOpenTrackByArtist(String trackName, String artistName);
/// Accessibility label for opening a generic item
///
/// In en, this message translates to:
/// **'Open {itemType} {name}'**
String a11yOpenItem(String itemType, String name);
/// Accessibility label for opening a grouped item with count
///
/// In en, this message translates to:
/// **'Open {title}, {count} {count, plural, =1{item} other{items}}'**
String a11yOpenItemCount(String title, int count);
/// Accessibility label for opening an album item with track count
///
/// In en, this message translates to:
/// **'Open album {albumName} by {artistName}, {trackCount} tracks'**
String a11yOpenAlbumByArtistTrackCount(
String albumName,
String artistName,
int trackCount,
);
/// Accessibility label for a queue or list track item
///
/// In en, this message translates to:
/// **'{trackName} by {artistName}'**
String a11yTrackByArtist(String trackName, String artistName);
/// Accessibility label for selecting an album
///
/// In en, this message translates to:
/// **'Select album {albumName}'**
String a11ySelectAlbum(String albumName);
/// Accessibility label for opening an album
///
/// In en, this message translates to:
/// **'Open album {albumName}'**
String a11yOpenAlbum(String albumName);
}
class _AppLocalizationsDelegate
+226
View File
@@ -29,6 +29,13 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get homeSubtitle => 'Spotify-Link einfügen oder nach Namen suchen';
@override
String get homeEmptyTitle => 'Home is empty';
@override
String get homeEmptySubtitle =>
'Install your first extension to unlock search and browsing.';
@override
String get homeSupports =>
'Unterstützt: Titel, Album, Playlist, Künstler-URLs';
@@ -3456,4 +3463,223 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
@override
String get searchTracks => 'Tracks';
@override
String get homeSearchHintDefault => 'Paste supported URL or search...';
@override
String homeSearchHintProvider(String providerName) {
return 'Search with $providerName...';
}
@override
String get homeImportCsvTooltip => 'Import CSV';
@override
String get homeChangeSearchProviderTooltip => 'Change search provider';
@override
String get actionPaste => 'Paste';
@override
String get searchTracksHint => 'Search tracks...';
@override
String get searchTracksEmptyPrompt => 'Search for tracks';
@override
String get tutorialSearchHint => 'Paste or search...';
@override
String get tutorialDownloadCompletedSemantics => 'Download completed';
@override
String get tutorialDownloadInProgressSemantics => 'Download in progress';
@override
String get tutorialStartDownloadSemantics => 'Start download';
@override
String get optionsEmbedMetadata => 'Embed Metadata';
@override
String get optionsEmbedMetadataSubtitleOn =>
'Write metadata, cover art, and embedded lyrics to files';
@override
String get optionsEmbedMetadataSubtitleOff =>
'Disabled (advanced): skip all metadata embedding';
@override
String get optionsMaxQualityCoverSubtitleDisabled =>
'Disabled when metadata embedding is off';
@override
String downloadFilenameHintExample(Object artist, Object title) {
return '$artist - $title';
}
@override
String get trackCoverNoEmbeddedArt => 'No embedded album art found';
@override
String get trackCoverReplace => 'Replace Cover';
@override
String get trackCoverPick => 'Pick Cover';
@override
String get trackCoverClearSelected => 'Clear selected cover';
@override
String get trackCoverCurrent => 'Current cover';
@override
String get trackCoverSelected => 'Selected cover';
@override
String get trackCoverReplaceNotice =>
'The selected cover will replace the current embedded cover when you tap Save.';
@override
String get actionStop => 'Stop';
@override
String get queueFinalizingDownload => 'Finalizing download';
@override
String get queueDownloadedFileMissing => 'Downloaded file missing';
@override
String get queueDownloadCompleted => 'Download completed';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
}
@override
String get logAutoScrollOn => 'Auto-scroll ON';
@override
String get logAutoScrollOff => 'Auto-scroll OFF';
@override
String get logCopyLogs => 'Copy logs';
@override
String get logClearSearch => 'Clear search';
@override
String get logIssueIspBlockingLabel => 'ISP BLOCKING DETECTED';
@override
String get logIssueIspBlockingDescription =>
'Your ISP may be blocking access to download services';
@override
String get logIssueIspBlockingSuggestion =>
'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8';
@override
String get logIssueRateLimitedLabel => 'RATE LIMITED';
@override
String get logIssueRateLimitedDescription =>
'Too many requests to the service';
@override
String get logIssueRateLimitedSuggestion =>
'Wait a few minutes before trying again';
@override
String get logIssueNetworkErrorLabel => 'NETWORK ERROR';
@override
String get logIssueNetworkErrorDescription => 'Connection issues detected';
@override
String get logIssueNetworkErrorSuggestion => 'Check your internet connection';
@override
String get logIssueTrackNotFoundLabel => 'TRACK NOT FOUND';
@override
String get logIssueTrackNotFoundDescription =>
'Some tracks could not be found on download services';
@override
String get logIssueTrackNotFoundSuggestion =>
'The track may not be available in lossless quality';
@override
String get clickableLookingUpArtist => 'Looking up artist...';
@override
String clickableInformationUnavailable(String type) {
return '$type information not available';
}
@override
String get extensionDetailsTags => 'Tags';
@override
String get extensionDetailsInformation => 'Information';
@override
String get extensionUtilityFunctions => 'Utility Functions';
@override
String get actionDismiss => 'Dismiss';
@override
String get setupChangeFolderTooltip => 'Change folder';
@override
String a11yOpenTrackByArtist(String trackName, String artistName) {
return 'Open track $trackName by $artistName';
}
@override
String a11yOpenItem(String itemType, String name) {
return 'Open $itemType $name';
}
@override
String a11yOpenItemCount(String title, int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return 'Open $title, $count $_temp0';
}
@override
String a11yOpenAlbumByArtistTrackCount(
String albumName,
String artistName,
int trackCount,
) {
return 'Open album $albumName by $artistName, $trackCount tracks';
}
@override
String a11yTrackByArtist(String trackName, String artistName) {
return '$trackName by $artistName';
}
@override
String a11ySelectAlbum(String albumName) {
return 'Select album $albumName';
}
@override
String a11yOpenAlbum(String albumName) {
return 'Open album $albumName';
}
}
+226
View File
@@ -29,6 +29,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get homeSubtitle => 'Paste a supported URL or search by name';
@override
String get homeEmptyTitle => 'Home is empty';
@override
String get homeEmptySubtitle =>
'Install your first extension to unlock search and browsing.';
@override
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
@@ -3424,4 +3431,223 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
@override
String get searchTracks => 'Tracks';
@override
String get homeSearchHintDefault => 'Paste supported URL or search...';
@override
String homeSearchHintProvider(String providerName) {
return 'Search with $providerName...';
}
@override
String get homeImportCsvTooltip => 'Import CSV';
@override
String get homeChangeSearchProviderTooltip => 'Change search provider';
@override
String get actionPaste => 'Paste';
@override
String get searchTracksHint => 'Search tracks...';
@override
String get searchTracksEmptyPrompt => 'Search for tracks';
@override
String get tutorialSearchHint => 'Paste or search...';
@override
String get tutorialDownloadCompletedSemantics => 'Download completed';
@override
String get tutorialDownloadInProgressSemantics => 'Download in progress';
@override
String get tutorialStartDownloadSemantics => 'Start download';
@override
String get optionsEmbedMetadata => 'Embed Metadata';
@override
String get optionsEmbedMetadataSubtitleOn =>
'Write metadata, cover art, and embedded lyrics to files';
@override
String get optionsEmbedMetadataSubtitleOff =>
'Disabled (advanced): skip all metadata embedding';
@override
String get optionsMaxQualityCoverSubtitleDisabled =>
'Disabled when metadata embedding is off';
@override
String downloadFilenameHintExample(Object artist, Object title) {
return '$artist - $title';
}
@override
String get trackCoverNoEmbeddedArt => 'No embedded album art found';
@override
String get trackCoverReplace => 'Replace Cover';
@override
String get trackCoverPick => 'Pick Cover';
@override
String get trackCoverClearSelected => 'Clear selected cover';
@override
String get trackCoverCurrent => 'Current cover';
@override
String get trackCoverSelected => 'Selected cover';
@override
String get trackCoverReplaceNotice =>
'The selected cover will replace the current embedded cover when you tap Save.';
@override
String get actionStop => 'Stop';
@override
String get queueFinalizingDownload => 'Finalizing download';
@override
String get queueDownloadedFileMissing => 'Downloaded file missing';
@override
String get queueDownloadCompleted => 'Download completed';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
}
@override
String get logAutoScrollOn => 'Auto-scroll ON';
@override
String get logAutoScrollOff => 'Auto-scroll OFF';
@override
String get logCopyLogs => 'Copy logs';
@override
String get logClearSearch => 'Clear search';
@override
String get logIssueIspBlockingLabel => 'ISP BLOCKING DETECTED';
@override
String get logIssueIspBlockingDescription =>
'Your ISP may be blocking access to download services';
@override
String get logIssueIspBlockingSuggestion =>
'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8';
@override
String get logIssueRateLimitedLabel => 'RATE LIMITED';
@override
String get logIssueRateLimitedDescription =>
'Too many requests to the service';
@override
String get logIssueRateLimitedSuggestion =>
'Wait a few minutes before trying again';
@override
String get logIssueNetworkErrorLabel => 'NETWORK ERROR';
@override
String get logIssueNetworkErrorDescription => 'Connection issues detected';
@override
String get logIssueNetworkErrorSuggestion => 'Check your internet connection';
@override
String get logIssueTrackNotFoundLabel => 'TRACK NOT FOUND';
@override
String get logIssueTrackNotFoundDescription =>
'Some tracks could not be found on download services';
@override
String get logIssueTrackNotFoundSuggestion =>
'The track may not be available in lossless quality';
@override
String get clickableLookingUpArtist => 'Looking up artist...';
@override
String clickableInformationUnavailable(String type) {
return '$type information not available';
}
@override
String get extensionDetailsTags => 'Tags';
@override
String get extensionDetailsInformation => 'Information';
@override
String get extensionUtilityFunctions => 'Utility Functions';
@override
String get actionDismiss => 'Dismiss';
@override
String get setupChangeFolderTooltip => 'Change folder';
@override
String a11yOpenTrackByArtist(String trackName, String artistName) {
return 'Open track $trackName by $artistName';
}
@override
String a11yOpenItem(String itemType, String name) {
return 'Open $itemType $name';
}
@override
String a11yOpenItemCount(String title, int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return 'Open $title, $count $_temp0';
}
@override
String a11yOpenAlbumByArtistTrackCount(
String albumName,
String artistName,
int trackCount,
) {
return 'Open album $albumName by $artistName, $trackCount tracks';
}
@override
String a11yTrackByArtist(String trackName, String artistName) {
return '$trackName by $artistName';
}
@override
String a11ySelectAlbum(String albumName) {
return 'Select album $albumName';
}
@override
String a11yOpenAlbum(String albumName) {
return 'Open album $albumName';
}
}
+226
View File
@@ -29,6 +29,13 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get homeSubtitle => 'Paste a Spotify link or search by name';
@override
String get homeEmptyTitle => 'Home is empty';
@override
String get homeEmptySubtitle =>
'Install your first extension to unlock search and browsing.';
@override
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
@@ -3424,6 +3431,225 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
@override
String get searchTracks => 'Tracks';
@override
String get homeSearchHintDefault => 'Paste supported URL or search...';
@override
String homeSearchHintProvider(String providerName) {
return 'Search with $providerName...';
}
@override
String get homeImportCsvTooltip => 'Import CSV';
@override
String get homeChangeSearchProviderTooltip => 'Change search provider';
@override
String get actionPaste => 'Paste';
@override
String get searchTracksHint => 'Search tracks...';
@override
String get searchTracksEmptyPrompt => 'Search for tracks';
@override
String get tutorialSearchHint => 'Paste or search...';
@override
String get tutorialDownloadCompletedSemantics => 'Download completed';
@override
String get tutorialDownloadInProgressSemantics => 'Download in progress';
@override
String get tutorialStartDownloadSemantics => 'Start download';
@override
String get optionsEmbedMetadata => 'Embed Metadata';
@override
String get optionsEmbedMetadataSubtitleOn =>
'Write metadata, cover art, and embedded lyrics to files';
@override
String get optionsEmbedMetadataSubtitleOff =>
'Disabled (advanced): skip all metadata embedding';
@override
String get optionsMaxQualityCoverSubtitleDisabled =>
'Disabled when metadata embedding is off';
@override
String downloadFilenameHintExample(Object artist, Object title) {
return '$artist - $title';
}
@override
String get trackCoverNoEmbeddedArt => 'No embedded album art found';
@override
String get trackCoverReplace => 'Replace Cover';
@override
String get trackCoverPick => 'Pick Cover';
@override
String get trackCoverClearSelected => 'Clear selected cover';
@override
String get trackCoverCurrent => 'Current cover';
@override
String get trackCoverSelected => 'Selected cover';
@override
String get trackCoverReplaceNotice =>
'The selected cover will replace the current embedded cover when you tap Save.';
@override
String get actionStop => 'Stop';
@override
String get queueFinalizingDownload => 'Finalizing download';
@override
String get queueDownloadedFileMissing => 'Downloaded file missing';
@override
String get queueDownloadCompleted => 'Download completed';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
}
@override
String get logAutoScrollOn => 'Auto-scroll ON';
@override
String get logAutoScrollOff => 'Auto-scroll OFF';
@override
String get logCopyLogs => 'Copy logs';
@override
String get logClearSearch => 'Clear search';
@override
String get logIssueIspBlockingLabel => 'ISP BLOCKING DETECTED';
@override
String get logIssueIspBlockingDescription =>
'Your ISP may be blocking access to download services';
@override
String get logIssueIspBlockingSuggestion =>
'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8';
@override
String get logIssueRateLimitedLabel => 'RATE LIMITED';
@override
String get logIssueRateLimitedDescription =>
'Too many requests to the service';
@override
String get logIssueRateLimitedSuggestion =>
'Wait a few minutes before trying again';
@override
String get logIssueNetworkErrorLabel => 'NETWORK ERROR';
@override
String get logIssueNetworkErrorDescription => 'Connection issues detected';
@override
String get logIssueNetworkErrorSuggestion => 'Check your internet connection';
@override
String get logIssueTrackNotFoundLabel => 'TRACK NOT FOUND';
@override
String get logIssueTrackNotFoundDescription =>
'Some tracks could not be found on download services';
@override
String get logIssueTrackNotFoundSuggestion =>
'The track may not be available in lossless quality';
@override
String get clickableLookingUpArtist => 'Looking up artist...';
@override
String clickableInformationUnavailable(String type) {
return '$type information not available';
}
@override
String get extensionDetailsTags => 'Tags';
@override
String get extensionDetailsInformation => 'Information';
@override
String get extensionUtilityFunctions => 'Utility Functions';
@override
String get actionDismiss => 'Dismiss';
@override
String get setupChangeFolderTooltip => 'Change folder';
@override
String a11yOpenTrackByArtist(String trackName, String artistName) {
return 'Open track $trackName by $artistName';
}
@override
String a11yOpenItem(String itemType, String name) {
return 'Open $itemType $name';
}
@override
String a11yOpenItemCount(String title, int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return 'Open $title, $count $_temp0';
}
@override
String a11yOpenAlbumByArtistTrackCount(
String albumName,
String artistName,
int trackCount,
) {
return 'Open album $albumName by $artistName, $trackCount tracks';
}
@override
String a11yTrackByArtist(String trackName, String artistName) {
return '$trackName by $artistName';
}
@override
String a11ySelectAlbum(String albumName) {
return 'Select album $albumName';
}
@override
String a11yOpenAlbum(String albumName) {
return 'Open album $albumName';
}
}
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
+226
View File
@@ -29,6 +29,13 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get homeSubtitle => 'Coller un lien Spotify ou rechercher par nom';
@override
String get homeEmptyTitle => 'Home is empty';
@override
String get homeEmptySubtitle =>
'Install your first extension to unlock search and browsing.';
@override
String get homeSupports => 'Supports: Piste, Album, Playlist, Artiste URLs';
@@ -3425,4 +3432,223 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
@override
String get searchTracks => 'Tracks';
@override
String get homeSearchHintDefault => 'Paste supported URL or search...';
@override
String homeSearchHintProvider(String providerName) {
return 'Search with $providerName...';
}
@override
String get homeImportCsvTooltip => 'Import CSV';
@override
String get homeChangeSearchProviderTooltip => 'Change search provider';
@override
String get actionPaste => 'Paste';
@override
String get searchTracksHint => 'Search tracks...';
@override
String get searchTracksEmptyPrompt => 'Search for tracks';
@override
String get tutorialSearchHint => 'Paste or search...';
@override
String get tutorialDownloadCompletedSemantics => 'Download completed';
@override
String get tutorialDownloadInProgressSemantics => 'Download in progress';
@override
String get tutorialStartDownloadSemantics => 'Start download';
@override
String get optionsEmbedMetadata => 'Embed Metadata';
@override
String get optionsEmbedMetadataSubtitleOn =>
'Write metadata, cover art, and embedded lyrics to files';
@override
String get optionsEmbedMetadataSubtitleOff =>
'Disabled (advanced): skip all metadata embedding';
@override
String get optionsMaxQualityCoverSubtitleDisabled =>
'Disabled when metadata embedding is off';
@override
String downloadFilenameHintExample(Object artist, Object title) {
return '$artist - $title';
}
@override
String get trackCoverNoEmbeddedArt => 'No embedded album art found';
@override
String get trackCoverReplace => 'Replace Cover';
@override
String get trackCoverPick => 'Pick Cover';
@override
String get trackCoverClearSelected => 'Clear selected cover';
@override
String get trackCoverCurrent => 'Current cover';
@override
String get trackCoverSelected => 'Selected cover';
@override
String get trackCoverReplaceNotice =>
'The selected cover will replace the current embedded cover when you tap Save.';
@override
String get actionStop => 'Stop';
@override
String get queueFinalizingDownload => 'Finalizing download';
@override
String get queueDownloadedFileMissing => 'Downloaded file missing';
@override
String get queueDownloadCompleted => 'Download completed';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
}
@override
String get logAutoScrollOn => 'Auto-scroll ON';
@override
String get logAutoScrollOff => 'Auto-scroll OFF';
@override
String get logCopyLogs => 'Copy logs';
@override
String get logClearSearch => 'Clear search';
@override
String get logIssueIspBlockingLabel => 'ISP BLOCKING DETECTED';
@override
String get logIssueIspBlockingDescription =>
'Your ISP may be blocking access to download services';
@override
String get logIssueIspBlockingSuggestion =>
'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8';
@override
String get logIssueRateLimitedLabel => 'RATE LIMITED';
@override
String get logIssueRateLimitedDescription =>
'Too many requests to the service';
@override
String get logIssueRateLimitedSuggestion =>
'Wait a few minutes before trying again';
@override
String get logIssueNetworkErrorLabel => 'NETWORK ERROR';
@override
String get logIssueNetworkErrorDescription => 'Connection issues detected';
@override
String get logIssueNetworkErrorSuggestion => 'Check your internet connection';
@override
String get logIssueTrackNotFoundLabel => 'TRACK NOT FOUND';
@override
String get logIssueTrackNotFoundDescription =>
'Some tracks could not be found on download services';
@override
String get logIssueTrackNotFoundSuggestion =>
'The track may not be available in lossless quality';
@override
String get clickableLookingUpArtist => 'Looking up artist...';
@override
String clickableInformationUnavailable(String type) {
return '$type information not available';
}
@override
String get extensionDetailsTags => 'Tags';
@override
String get extensionDetailsInformation => 'Information';
@override
String get extensionUtilityFunctions => 'Utility Functions';
@override
String get actionDismiss => 'Dismiss';
@override
String get setupChangeFolderTooltip => 'Change folder';
@override
String a11yOpenTrackByArtist(String trackName, String artistName) {
return 'Open track $trackName by $artistName';
}
@override
String a11yOpenItem(String itemType, String name) {
return 'Open $itemType $name';
}
@override
String a11yOpenItemCount(String title, int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return 'Open $title, $count $_temp0';
}
@override
String a11yOpenAlbumByArtistTrackCount(
String albumName,
String artistName,
int trackCount,
) {
return 'Open album $albumName by $artistName, $trackCount tracks';
}
@override
String a11yTrackByArtist(String trackName, String artistName) {
return '$trackName by $artistName';
}
@override
String a11ySelectAlbum(String albumName) {
return 'Select album $albumName';
}
@override
String a11yOpenAlbum(String albumName) {
return 'Open album $albumName';
}
}
+226
View File
@@ -29,6 +29,13 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get homeSubtitle => 'Paste a Spotify link or search by name';
@override
String get homeEmptyTitle => 'Home is empty';
@override
String get homeEmptySubtitle =>
'Install your first extension to unlock search and browsing.';
@override
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
@@ -3423,4 +3430,223 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
@override
String get searchTracks => 'Tracks';
@override
String get homeSearchHintDefault => 'Paste supported URL or search...';
@override
String homeSearchHintProvider(String providerName) {
return 'Search with $providerName...';
}
@override
String get homeImportCsvTooltip => 'Import CSV';
@override
String get homeChangeSearchProviderTooltip => 'Change search provider';
@override
String get actionPaste => 'Paste';
@override
String get searchTracksHint => 'Search tracks...';
@override
String get searchTracksEmptyPrompt => 'Search for tracks';
@override
String get tutorialSearchHint => 'Paste or search...';
@override
String get tutorialDownloadCompletedSemantics => 'Download completed';
@override
String get tutorialDownloadInProgressSemantics => 'Download in progress';
@override
String get tutorialStartDownloadSemantics => 'Start download';
@override
String get optionsEmbedMetadata => 'Embed Metadata';
@override
String get optionsEmbedMetadataSubtitleOn =>
'Write metadata, cover art, and embedded lyrics to files';
@override
String get optionsEmbedMetadataSubtitleOff =>
'Disabled (advanced): skip all metadata embedding';
@override
String get optionsMaxQualityCoverSubtitleDisabled =>
'Disabled when metadata embedding is off';
@override
String downloadFilenameHintExample(Object artist, Object title) {
return '$artist - $title';
}
@override
String get trackCoverNoEmbeddedArt => 'No embedded album art found';
@override
String get trackCoverReplace => 'Replace Cover';
@override
String get trackCoverPick => 'Pick Cover';
@override
String get trackCoverClearSelected => 'Clear selected cover';
@override
String get trackCoverCurrent => 'Current cover';
@override
String get trackCoverSelected => 'Selected cover';
@override
String get trackCoverReplaceNotice =>
'The selected cover will replace the current embedded cover when you tap Save.';
@override
String get actionStop => 'Stop';
@override
String get queueFinalizingDownload => 'Finalizing download';
@override
String get queueDownloadedFileMissing => 'Downloaded file missing';
@override
String get queueDownloadCompleted => 'Download completed';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
}
@override
String get logAutoScrollOn => 'Auto-scroll ON';
@override
String get logAutoScrollOff => 'Auto-scroll OFF';
@override
String get logCopyLogs => 'Copy logs';
@override
String get logClearSearch => 'Clear search';
@override
String get logIssueIspBlockingLabel => 'ISP BLOCKING DETECTED';
@override
String get logIssueIspBlockingDescription =>
'Your ISP may be blocking access to download services';
@override
String get logIssueIspBlockingSuggestion =>
'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8';
@override
String get logIssueRateLimitedLabel => 'RATE LIMITED';
@override
String get logIssueRateLimitedDescription =>
'Too many requests to the service';
@override
String get logIssueRateLimitedSuggestion =>
'Wait a few minutes before trying again';
@override
String get logIssueNetworkErrorLabel => 'NETWORK ERROR';
@override
String get logIssueNetworkErrorDescription => 'Connection issues detected';
@override
String get logIssueNetworkErrorSuggestion => 'Check your internet connection';
@override
String get logIssueTrackNotFoundLabel => 'TRACK NOT FOUND';
@override
String get logIssueTrackNotFoundDescription =>
'Some tracks could not be found on download services';
@override
String get logIssueTrackNotFoundSuggestion =>
'The track may not be available in lossless quality';
@override
String get clickableLookingUpArtist => 'Looking up artist...';
@override
String clickableInformationUnavailable(String type) {
return '$type information not available';
}
@override
String get extensionDetailsTags => 'Tags';
@override
String get extensionDetailsInformation => 'Information';
@override
String get extensionUtilityFunctions => 'Utility Functions';
@override
String get actionDismiss => 'Dismiss';
@override
String get setupChangeFolderTooltip => 'Change folder';
@override
String a11yOpenTrackByArtist(String trackName, String artistName) {
return 'Open track $trackName by $artistName';
}
@override
String a11yOpenItem(String itemType, String name) {
return 'Open $itemType $name';
}
@override
String a11yOpenItemCount(String title, int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return 'Open $title, $count $_temp0';
}
@override
String a11yOpenAlbumByArtistTrackCount(
String albumName,
String artistName,
int trackCount,
) {
return 'Open album $albumName by $artistName, $trackCount tracks';
}
@override
String a11yTrackByArtist(String trackName, String artistName) {
return '$trackName by $artistName';
}
@override
String a11ySelectAlbum(String albumName) {
return 'Select album $albumName';
}
@override
String a11yOpenAlbum(String albumName) {
return 'Open album $albumName';
}
}
+226
View File
@@ -30,6 +30,13 @@ class AppLocalizationsId extends AppLocalizations {
String get homeSubtitle =>
'Tempel URL yang didukung atau cari berdasarkan nama';
@override
String get homeEmptyTitle => 'Home is empty';
@override
String get homeEmptySubtitle =>
'Install your first extension to unlock search and browsing.';
@override
String get homeSupports => 'Mendukung: URL Track, Album, Playlist, Artis';
@@ -3434,4 +3441,223 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
@override
String get searchTracks => 'Tracks';
@override
String get homeSearchHintDefault => 'Paste supported URL or search...';
@override
String homeSearchHintProvider(String providerName) {
return 'Search with $providerName...';
}
@override
String get homeImportCsvTooltip => 'Import CSV';
@override
String get homeChangeSearchProviderTooltip => 'Change search provider';
@override
String get actionPaste => 'Paste';
@override
String get searchTracksHint => 'Search tracks...';
@override
String get searchTracksEmptyPrompt => 'Search for tracks';
@override
String get tutorialSearchHint => 'Paste or search...';
@override
String get tutorialDownloadCompletedSemantics => 'Download completed';
@override
String get tutorialDownloadInProgressSemantics => 'Download in progress';
@override
String get tutorialStartDownloadSemantics => 'Start download';
@override
String get optionsEmbedMetadata => 'Embed Metadata';
@override
String get optionsEmbedMetadataSubtitleOn =>
'Write metadata, cover art, and embedded lyrics to files';
@override
String get optionsEmbedMetadataSubtitleOff =>
'Disabled (advanced): skip all metadata embedding';
@override
String get optionsMaxQualityCoverSubtitleDisabled =>
'Disabled when metadata embedding is off';
@override
String downloadFilenameHintExample(Object artist, Object title) {
return '$artist - $title';
}
@override
String get trackCoverNoEmbeddedArt => 'No embedded album art found';
@override
String get trackCoverReplace => 'Replace Cover';
@override
String get trackCoverPick => 'Pick Cover';
@override
String get trackCoverClearSelected => 'Clear selected cover';
@override
String get trackCoverCurrent => 'Current cover';
@override
String get trackCoverSelected => 'Selected cover';
@override
String get trackCoverReplaceNotice =>
'The selected cover will replace the current embedded cover when you tap Save.';
@override
String get actionStop => 'Stop';
@override
String get queueFinalizingDownload => 'Finalizing download';
@override
String get queueDownloadedFileMissing => 'Downloaded file missing';
@override
String get queueDownloadCompleted => 'Download completed';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
}
@override
String get logAutoScrollOn => 'Auto-scroll ON';
@override
String get logAutoScrollOff => 'Auto-scroll OFF';
@override
String get logCopyLogs => 'Copy logs';
@override
String get logClearSearch => 'Clear search';
@override
String get logIssueIspBlockingLabel => 'ISP BLOCKING DETECTED';
@override
String get logIssueIspBlockingDescription =>
'Your ISP may be blocking access to download services';
@override
String get logIssueIspBlockingSuggestion =>
'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8';
@override
String get logIssueRateLimitedLabel => 'RATE LIMITED';
@override
String get logIssueRateLimitedDescription =>
'Too many requests to the service';
@override
String get logIssueRateLimitedSuggestion =>
'Wait a few minutes before trying again';
@override
String get logIssueNetworkErrorLabel => 'NETWORK ERROR';
@override
String get logIssueNetworkErrorDescription => 'Connection issues detected';
@override
String get logIssueNetworkErrorSuggestion => 'Check your internet connection';
@override
String get logIssueTrackNotFoundLabel => 'TRACK NOT FOUND';
@override
String get logIssueTrackNotFoundDescription =>
'Some tracks could not be found on download services';
@override
String get logIssueTrackNotFoundSuggestion =>
'The track may not be available in lossless quality';
@override
String get clickableLookingUpArtist => 'Looking up artist...';
@override
String clickableInformationUnavailable(String type) {
return '$type information not available';
}
@override
String get extensionDetailsTags => 'Tags';
@override
String get extensionDetailsInformation => 'Information';
@override
String get extensionUtilityFunctions => 'Utility Functions';
@override
String get actionDismiss => 'Dismiss';
@override
String get setupChangeFolderTooltip => 'Change folder';
@override
String a11yOpenTrackByArtist(String trackName, String artistName) {
return 'Open track $trackName by $artistName';
}
@override
String a11yOpenItem(String itemType, String name) {
return 'Open $itemType $name';
}
@override
String a11yOpenItemCount(String title, int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return 'Open $title, $count $_temp0';
}
@override
String a11yOpenAlbumByArtistTrackCount(
String albumName,
String artistName,
int trackCount,
) {
return 'Open album $albumName by $artistName, $trackCount tracks';
}
@override
String a11yTrackByArtist(String trackName, String artistName) {
return '$trackName by $artistName';
}
@override
String a11ySelectAlbum(String albumName) {
return 'Select album $albumName';
}
@override
String a11yOpenAlbum(String albumName) {
return 'Open album $albumName';
}
}
+226
View File
@@ -29,6 +29,13 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get homeSubtitle => 'Spotify のリンクを貼り付けるか、名前で検索します';
@override
String get homeEmptyTitle => 'Home is empty';
@override
String get homeEmptySubtitle =>
'Install your first extension to unlock search and browsing.';
@override
String get homeSupports => 'サポート: トラック、アルバム、プレイリスト、アーティスト、URL';
@@ -3410,4 +3417,223 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
@override
String get searchTracks => 'Tracks';
@override
String get homeSearchHintDefault => 'Paste supported URL or search...';
@override
String homeSearchHintProvider(String providerName) {
return 'Search with $providerName...';
}
@override
String get homeImportCsvTooltip => 'Import CSV';
@override
String get homeChangeSearchProviderTooltip => 'Change search provider';
@override
String get actionPaste => 'Paste';
@override
String get searchTracksHint => 'Search tracks...';
@override
String get searchTracksEmptyPrompt => 'Search for tracks';
@override
String get tutorialSearchHint => 'Paste or search...';
@override
String get tutorialDownloadCompletedSemantics => 'Download completed';
@override
String get tutorialDownloadInProgressSemantics => 'Download in progress';
@override
String get tutorialStartDownloadSemantics => 'Start download';
@override
String get optionsEmbedMetadata => 'Embed Metadata';
@override
String get optionsEmbedMetadataSubtitleOn =>
'Write metadata, cover art, and embedded lyrics to files';
@override
String get optionsEmbedMetadataSubtitleOff =>
'Disabled (advanced): skip all metadata embedding';
@override
String get optionsMaxQualityCoverSubtitleDisabled =>
'Disabled when metadata embedding is off';
@override
String downloadFilenameHintExample(Object artist, Object title) {
return '$artist - $title';
}
@override
String get trackCoverNoEmbeddedArt => 'No embedded album art found';
@override
String get trackCoverReplace => 'Replace Cover';
@override
String get trackCoverPick => 'Pick Cover';
@override
String get trackCoverClearSelected => 'Clear selected cover';
@override
String get trackCoverCurrent => 'Current cover';
@override
String get trackCoverSelected => 'Selected cover';
@override
String get trackCoverReplaceNotice =>
'The selected cover will replace the current embedded cover when you tap Save.';
@override
String get actionStop => 'Stop';
@override
String get queueFinalizingDownload => 'Finalizing download';
@override
String get queueDownloadedFileMissing => 'Downloaded file missing';
@override
String get queueDownloadCompleted => 'Download completed';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
}
@override
String get logAutoScrollOn => 'Auto-scroll ON';
@override
String get logAutoScrollOff => 'Auto-scroll OFF';
@override
String get logCopyLogs => 'Copy logs';
@override
String get logClearSearch => 'Clear search';
@override
String get logIssueIspBlockingLabel => 'ISP BLOCKING DETECTED';
@override
String get logIssueIspBlockingDescription =>
'Your ISP may be blocking access to download services';
@override
String get logIssueIspBlockingSuggestion =>
'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8';
@override
String get logIssueRateLimitedLabel => 'RATE LIMITED';
@override
String get logIssueRateLimitedDescription =>
'Too many requests to the service';
@override
String get logIssueRateLimitedSuggestion =>
'Wait a few minutes before trying again';
@override
String get logIssueNetworkErrorLabel => 'NETWORK ERROR';
@override
String get logIssueNetworkErrorDescription => 'Connection issues detected';
@override
String get logIssueNetworkErrorSuggestion => 'Check your internet connection';
@override
String get logIssueTrackNotFoundLabel => 'TRACK NOT FOUND';
@override
String get logIssueTrackNotFoundDescription =>
'Some tracks could not be found on download services';
@override
String get logIssueTrackNotFoundSuggestion =>
'The track may not be available in lossless quality';
@override
String get clickableLookingUpArtist => 'Looking up artist...';
@override
String clickableInformationUnavailable(String type) {
return '$type information not available';
}
@override
String get extensionDetailsTags => 'Tags';
@override
String get extensionDetailsInformation => 'Information';
@override
String get extensionUtilityFunctions => 'Utility Functions';
@override
String get actionDismiss => 'Dismiss';
@override
String get setupChangeFolderTooltip => 'Change folder';
@override
String a11yOpenTrackByArtist(String trackName, String artistName) {
return 'Open track $trackName by $artistName';
}
@override
String a11yOpenItem(String itemType, String name) {
return 'Open $itemType $name';
}
@override
String a11yOpenItemCount(String title, int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return 'Open $title, $count $_temp0';
}
@override
String a11yOpenAlbumByArtistTrackCount(
String albumName,
String artistName,
int trackCount,
) {
return 'Open album $albumName by $artistName, $trackCount tracks';
}
@override
String a11yTrackByArtist(String trackName, String artistName) {
return '$trackName by $artistName';
}
@override
String a11ySelectAlbum(String albumName) {
return 'Select album $albumName';
}
@override
String a11yOpenAlbum(String albumName) {
return 'Open album $albumName';
}
}
+226
View File
@@ -29,6 +29,13 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get homeSubtitle => 'Spotify URL을 붙여 넣거나 검색';
@override
String get homeEmptyTitle => 'Home is empty';
@override
String get homeEmptySubtitle =>
'Install your first extension to unlock search and browsing.';
@override
String get homeSupports => '지원 항목: 트랙, 앨범, 플레이리스트, 아티스트 URLs';
@@ -3403,4 +3410,223 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
@override
String get searchTracks => 'Tracks';
@override
String get homeSearchHintDefault => 'Paste supported URL or search...';
@override
String homeSearchHintProvider(String providerName) {
return 'Search with $providerName...';
}
@override
String get homeImportCsvTooltip => 'Import CSV';
@override
String get homeChangeSearchProviderTooltip => 'Change search provider';
@override
String get actionPaste => 'Paste';
@override
String get searchTracksHint => 'Search tracks...';
@override
String get searchTracksEmptyPrompt => 'Search for tracks';
@override
String get tutorialSearchHint => 'Paste or search...';
@override
String get tutorialDownloadCompletedSemantics => 'Download completed';
@override
String get tutorialDownloadInProgressSemantics => 'Download in progress';
@override
String get tutorialStartDownloadSemantics => 'Start download';
@override
String get optionsEmbedMetadata => 'Embed Metadata';
@override
String get optionsEmbedMetadataSubtitleOn =>
'Write metadata, cover art, and embedded lyrics to files';
@override
String get optionsEmbedMetadataSubtitleOff =>
'Disabled (advanced): skip all metadata embedding';
@override
String get optionsMaxQualityCoverSubtitleDisabled =>
'Disabled when metadata embedding is off';
@override
String downloadFilenameHintExample(Object artist, Object title) {
return '$artist - $title';
}
@override
String get trackCoverNoEmbeddedArt => 'No embedded album art found';
@override
String get trackCoverReplace => 'Replace Cover';
@override
String get trackCoverPick => 'Pick Cover';
@override
String get trackCoverClearSelected => 'Clear selected cover';
@override
String get trackCoverCurrent => 'Current cover';
@override
String get trackCoverSelected => 'Selected cover';
@override
String get trackCoverReplaceNotice =>
'The selected cover will replace the current embedded cover when you tap Save.';
@override
String get actionStop => 'Stop';
@override
String get queueFinalizingDownload => 'Finalizing download';
@override
String get queueDownloadedFileMissing => 'Downloaded file missing';
@override
String get queueDownloadCompleted => 'Download completed';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
}
@override
String get logAutoScrollOn => 'Auto-scroll ON';
@override
String get logAutoScrollOff => 'Auto-scroll OFF';
@override
String get logCopyLogs => 'Copy logs';
@override
String get logClearSearch => 'Clear search';
@override
String get logIssueIspBlockingLabel => 'ISP BLOCKING DETECTED';
@override
String get logIssueIspBlockingDescription =>
'Your ISP may be blocking access to download services';
@override
String get logIssueIspBlockingSuggestion =>
'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8';
@override
String get logIssueRateLimitedLabel => 'RATE LIMITED';
@override
String get logIssueRateLimitedDescription =>
'Too many requests to the service';
@override
String get logIssueRateLimitedSuggestion =>
'Wait a few minutes before trying again';
@override
String get logIssueNetworkErrorLabel => 'NETWORK ERROR';
@override
String get logIssueNetworkErrorDescription => 'Connection issues detected';
@override
String get logIssueNetworkErrorSuggestion => 'Check your internet connection';
@override
String get logIssueTrackNotFoundLabel => 'TRACK NOT FOUND';
@override
String get logIssueTrackNotFoundDescription =>
'Some tracks could not be found on download services';
@override
String get logIssueTrackNotFoundSuggestion =>
'The track may not be available in lossless quality';
@override
String get clickableLookingUpArtist => 'Looking up artist...';
@override
String clickableInformationUnavailable(String type) {
return '$type information not available';
}
@override
String get extensionDetailsTags => 'Tags';
@override
String get extensionDetailsInformation => 'Information';
@override
String get extensionUtilityFunctions => 'Utility Functions';
@override
String get actionDismiss => 'Dismiss';
@override
String get setupChangeFolderTooltip => 'Change folder';
@override
String a11yOpenTrackByArtist(String trackName, String artistName) {
return 'Open track $trackName by $artistName';
}
@override
String a11yOpenItem(String itemType, String name) {
return 'Open $itemType $name';
}
@override
String a11yOpenItemCount(String title, int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return 'Open $title, $count $_temp0';
}
@override
String a11yOpenAlbumByArtistTrackCount(
String albumName,
String artistName,
int trackCount,
) {
return 'Open album $albumName by $artistName, $trackCount tracks';
}
@override
String a11yTrackByArtist(String trackName, String artistName) {
return '$trackName by $artistName';
}
@override
String a11ySelectAlbum(String albumName) {
return 'Select album $albumName';
}
@override
String a11yOpenAlbum(String albumName) {
return 'Open album $albumName';
}
}
+226
View File
@@ -29,6 +29,13 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get homeSubtitle => 'Paste a Spotify link or search by name';
@override
String get homeEmptyTitle => 'Home is empty';
@override
String get homeEmptySubtitle =>
'Install your first extension to unlock search and browsing.';
@override
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
@@ -3423,4 +3430,223 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
@override
String get searchTracks => 'Tracks';
@override
String get homeSearchHintDefault => 'Paste supported URL or search...';
@override
String homeSearchHintProvider(String providerName) {
return 'Search with $providerName...';
}
@override
String get homeImportCsvTooltip => 'Import CSV';
@override
String get homeChangeSearchProviderTooltip => 'Change search provider';
@override
String get actionPaste => 'Paste';
@override
String get searchTracksHint => 'Search tracks...';
@override
String get searchTracksEmptyPrompt => 'Search for tracks';
@override
String get tutorialSearchHint => 'Paste or search...';
@override
String get tutorialDownloadCompletedSemantics => 'Download completed';
@override
String get tutorialDownloadInProgressSemantics => 'Download in progress';
@override
String get tutorialStartDownloadSemantics => 'Start download';
@override
String get optionsEmbedMetadata => 'Embed Metadata';
@override
String get optionsEmbedMetadataSubtitleOn =>
'Write metadata, cover art, and embedded lyrics to files';
@override
String get optionsEmbedMetadataSubtitleOff =>
'Disabled (advanced): skip all metadata embedding';
@override
String get optionsMaxQualityCoverSubtitleDisabled =>
'Disabled when metadata embedding is off';
@override
String downloadFilenameHintExample(Object artist, Object title) {
return '$artist - $title';
}
@override
String get trackCoverNoEmbeddedArt => 'No embedded album art found';
@override
String get trackCoverReplace => 'Replace Cover';
@override
String get trackCoverPick => 'Pick Cover';
@override
String get trackCoverClearSelected => 'Clear selected cover';
@override
String get trackCoverCurrent => 'Current cover';
@override
String get trackCoverSelected => 'Selected cover';
@override
String get trackCoverReplaceNotice =>
'The selected cover will replace the current embedded cover when you tap Save.';
@override
String get actionStop => 'Stop';
@override
String get queueFinalizingDownload => 'Finalizing download';
@override
String get queueDownloadedFileMissing => 'Downloaded file missing';
@override
String get queueDownloadCompleted => 'Download completed';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
}
@override
String get logAutoScrollOn => 'Auto-scroll ON';
@override
String get logAutoScrollOff => 'Auto-scroll OFF';
@override
String get logCopyLogs => 'Copy logs';
@override
String get logClearSearch => 'Clear search';
@override
String get logIssueIspBlockingLabel => 'ISP BLOCKING DETECTED';
@override
String get logIssueIspBlockingDescription =>
'Your ISP may be blocking access to download services';
@override
String get logIssueIspBlockingSuggestion =>
'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8';
@override
String get logIssueRateLimitedLabel => 'RATE LIMITED';
@override
String get logIssueRateLimitedDescription =>
'Too many requests to the service';
@override
String get logIssueRateLimitedSuggestion =>
'Wait a few minutes before trying again';
@override
String get logIssueNetworkErrorLabel => 'NETWORK ERROR';
@override
String get logIssueNetworkErrorDescription => 'Connection issues detected';
@override
String get logIssueNetworkErrorSuggestion => 'Check your internet connection';
@override
String get logIssueTrackNotFoundLabel => 'TRACK NOT FOUND';
@override
String get logIssueTrackNotFoundDescription =>
'Some tracks could not be found on download services';
@override
String get logIssueTrackNotFoundSuggestion =>
'The track may not be available in lossless quality';
@override
String get clickableLookingUpArtist => 'Looking up artist...';
@override
String clickableInformationUnavailable(String type) {
return '$type information not available';
}
@override
String get extensionDetailsTags => 'Tags';
@override
String get extensionDetailsInformation => 'Information';
@override
String get extensionUtilityFunctions => 'Utility Functions';
@override
String get actionDismiss => 'Dismiss';
@override
String get setupChangeFolderTooltip => 'Change folder';
@override
String a11yOpenTrackByArtist(String trackName, String artistName) {
return 'Open track $trackName by $artistName';
}
@override
String a11yOpenItem(String itemType, String name) {
return 'Open $itemType $name';
}
@override
String a11yOpenItemCount(String title, int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return 'Open $title, $count $_temp0';
}
@override
String a11yOpenAlbumByArtistTrackCount(
String albumName,
String artistName,
int trackCount,
) {
return 'Open album $albumName by $artistName, $trackCount tracks';
}
@override
String a11yTrackByArtist(String trackName, String artistName) {
return '$trackName by $artistName';
}
@override
String a11ySelectAlbum(String albumName) {
return 'Select album $albumName';
}
@override
String a11yOpenAlbum(String albumName) {
return 'Open album $albumName';
}
}
+226
View File
@@ -29,6 +29,13 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get homeSubtitle => 'Paste a Spotify link or search by name';
@override
String get homeEmptyTitle => 'Home is empty';
@override
String get homeEmptySubtitle =>
'Install your first extension to unlock search and browsing.';
@override
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
@@ -3424,6 +3431,225 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
@override
String get searchTracks => 'Tracks';
@override
String get homeSearchHintDefault => 'Paste supported URL or search...';
@override
String homeSearchHintProvider(String providerName) {
return 'Search with $providerName...';
}
@override
String get homeImportCsvTooltip => 'Import CSV';
@override
String get homeChangeSearchProviderTooltip => 'Change search provider';
@override
String get actionPaste => 'Paste';
@override
String get searchTracksHint => 'Search tracks...';
@override
String get searchTracksEmptyPrompt => 'Search for tracks';
@override
String get tutorialSearchHint => 'Paste or search...';
@override
String get tutorialDownloadCompletedSemantics => 'Download completed';
@override
String get tutorialDownloadInProgressSemantics => 'Download in progress';
@override
String get tutorialStartDownloadSemantics => 'Start download';
@override
String get optionsEmbedMetadata => 'Embed Metadata';
@override
String get optionsEmbedMetadataSubtitleOn =>
'Write metadata, cover art, and embedded lyrics to files';
@override
String get optionsEmbedMetadataSubtitleOff =>
'Disabled (advanced): skip all metadata embedding';
@override
String get optionsMaxQualityCoverSubtitleDisabled =>
'Disabled when metadata embedding is off';
@override
String downloadFilenameHintExample(Object artist, Object title) {
return '$artist - $title';
}
@override
String get trackCoverNoEmbeddedArt => 'No embedded album art found';
@override
String get trackCoverReplace => 'Replace Cover';
@override
String get trackCoverPick => 'Pick Cover';
@override
String get trackCoverClearSelected => 'Clear selected cover';
@override
String get trackCoverCurrent => 'Current cover';
@override
String get trackCoverSelected => 'Selected cover';
@override
String get trackCoverReplaceNotice =>
'The selected cover will replace the current embedded cover when you tap Save.';
@override
String get actionStop => 'Stop';
@override
String get queueFinalizingDownload => 'Finalizing download';
@override
String get queueDownloadedFileMissing => 'Downloaded file missing';
@override
String get queueDownloadCompleted => 'Download completed';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
}
@override
String get logAutoScrollOn => 'Auto-scroll ON';
@override
String get logAutoScrollOff => 'Auto-scroll OFF';
@override
String get logCopyLogs => 'Copy logs';
@override
String get logClearSearch => 'Clear search';
@override
String get logIssueIspBlockingLabel => 'ISP BLOCKING DETECTED';
@override
String get logIssueIspBlockingDescription =>
'Your ISP may be blocking access to download services';
@override
String get logIssueIspBlockingSuggestion =>
'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8';
@override
String get logIssueRateLimitedLabel => 'RATE LIMITED';
@override
String get logIssueRateLimitedDescription =>
'Too many requests to the service';
@override
String get logIssueRateLimitedSuggestion =>
'Wait a few minutes before trying again';
@override
String get logIssueNetworkErrorLabel => 'NETWORK ERROR';
@override
String get logIssueNetworkErrorDescription => 'Connection issues detected';
@override
String get logIssueNetworkErrorSuggestion => 'Check your internet connection';
@override
String get logIssueTrackNotFoundLabel => 'TRACK NOT FOUND';
@override
String get logIssueTrackNotFoundDescription =>
'Some tracks could not be found on download services';
@override
String get logIssueTrackNotFoundSuggestion =>
'The track may not be available in lossless quality';
@override
String get clickableLookingUpArtist => 'Looking up artist...';
@override
String clickableInformationUnavailable(String type) {
return '$type information not available';
}
@override
String get extensionDetailsTags => 'Tags';
@override
String get extensionDetailsInformation => 'Information';
@override
String get extensionUtilityFunctions => 'Utility Functions';
@override
String get actionDismiss => 'Dismiss';
@override
String get setupChangeFolderTooltip => 'Change folder';
@override
String a11yOpenTrackByArtist(String trackName, String artistName) {
return 'Open track $trackName by $artistName';
}
@override
String a11yOpenItem(String itemType, String name) {
return 'Open $itemType $name';
}
@override
String a11yOpenItemCount(String title, int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return 'Open $title, $count $_temp0';
}
@override
String a11yOpenAlbumByArtistTrackCount(
String albumName,
String artistName,
int trackCount,
) {
return 'Open album $albumName by $artistName, $trackCount tracks';
}
@override
String a11yTrackByArtist(String trackName, String artistName) {
return '$trackName by $artistName';
}
@override
String a11ySelectAlbum(String albumName) {
return 'Select album $albumName';
}
@override
String a11yOpenAlbum(String albumName) {
return 'Open album $albumName';
}
}
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
+226
View File
@@ -29,6 +29,13 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get homeSubtitle => 'Вставьте ссылку Spotify или ищите по названию';
@override
String get homeEmptyTitle => 'Home is empty';
@override
String get homeEmptySubtitle =>
'Install your first extension to unlock search and browsing.';
@override
String get homeSupports =>
'Поддерживается: Трек, Альбом, Плейлист, URL исполнителя';
@@ -3483,4 +3490,223 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
@override
String get searchTracks => 'Tracks';
@override
String get homeSearchHintDefault => 'Paste supported URL or search...';
@override
String homeSearchHintProvider(String providerName) {
return 'Search with $providerName...';
}
@override
String get homeImportCsvTooltip => 'Import CSV';
@override
String get homeChangeSearchProviderTooltip => 'Change search provider';
@override
String get actionPaste => 'Paste';
@override
String get searchTracksHint => 'Search tracks...';
@override
String get searchTracksEmptyPrompt => 'Search for tracks';
@override
String get tutorialSearchHint => 'Paste or search...';
@override
String get tutorialDownloadCompletedSemantics => 'Download completed';
@override
String get tutorialDownloadInProgressSemantics => 'Download in progress';
@override
String get tutorialStartDownloadSemantics => 'Start download';
@override
String get optionsEmbedMetadata => 'Embed Metadata';
@override
String get optionsEmbedMetadataSubtitleOn =>
'Write metadata, cover art, and embedded lyrics to files';
@override
String get optionsEmbedMetadataSubtitleOff =>
'Disabled (advanced): skip all metadata embedding';
@override
String get optionsMaxQualityCoverSubtitleDisabled =>
'Disabled when metadata embedding is off';
@override
String downloadFilenameHintExample(Object artist, Object title) {
return '$artist - $title';
}
@override
String get trackCoverNoEmbeddedArt => 'No embedded album art found';
@override
String get trackCoverReplace => 'Replace Cover';
@override
String get trackCoverPick => 'Pick Cover';
@override
String get trackCoverClearSelected => 'Clear selected cover';
@override
String get trackCoverCurrent => 'Current cover';
@override
String get trackCoverSelected => 'Selected cover';
@override
String get trackCoverReplaceNotice =>
'The selected cover will replace the current embedded cover when you tap Save.';
@override
String get actionStop => 'Stop';
@override
String get queueFinalizingDownload => 'Finalizing download';
@override
String get queueDownloadedFileMissing => 'Downloaded file missing';
@override
String get queueDownloadCompleted => 'Download completed';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
}
@override
String get logAutoScrollOn => 'Auto-scroll ON';
@override
String get logAutoScrollOff => 'Auto-scroll OFF';
@override
String get logCopyLogs => 'Copy logs';
@override
String get logClearSearch => 'Clear search';
@override
String get logIssueIspBlockingLabel => 'ISP BLOCKING DETECTED';
@override
String get logIssueIspBlockingDescription =>
'Your ISP may be blocking access to download services';
@override
String get logIssueIspBlockingSuggestion =>
'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8';
@override
String get logIssueRateLimitedLabel => 'RATE LIMITED';
@override
String get logIssueRateLimitedDescription =>
'Too many requests to the service';
@override
String get logIssueRateLimitedSuggestion =>
'Wait a few minutes before trying again';
@override
String get logIssueNetworkErrorLabel => 'NETWORK ERROR';
@override
String get logIssueNetworkErrorDescription => 'Connection issues detected';
@override
String get logIssueNetworkErrorSuggestion => 'Check your internet connection';
@override
String get logIssueTrackNotFoundLabel => 'TRACK NOT FOUND';
@override
String get logIssueTrackNotFoundDescription =>
'Some tracks could not be found on download services';
@override
String get logIssueTrackNotFoundSuggestion =>
'The track may not be available in lossless quality';
@override
String get clickableLookingUpArtist => 'Looking up artist...';
@override
String clickableInformationUnavailable(String type) {
return '$type information not available';
}
@override
String get extensionDetailsTags => 'Tags';
@override
String get extensionDetailsInformation => 'Information';
@override
String get extensionUtilityFunctions => 'Utility Functions';
@override
String get actionDismiss => 'Dismiss';
@override
String get setupChangeFolderTooltip => 'Change folder';
@override
String a11yOpenTrackByArtist(String trackName, String artistName) {
return 'Open track $trackName by $artistName';
}
@override
String a11yOpenItem(String itemType, String name) {
return 'Open $itemType $name';
}
@override
String a11yOpenItemCount(String title, int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return 'Open $title, $count $_temp0';
}
@override
String a11yOpenAlbumByArtistTrackCount(
String albumName,
String artistName,
int trackCount,
) {
return 'Open album $albumName by $artistName, $trackCount tracks';
}
@override
String a11yTrackByArtist(String trackName, String artistName) {
return '$trackName by $artistName';
}
@override
String a11ySelectAlbum(String albumName) {
return 'Select album $albumName';
}
@override
String a11yOpenAlbum(String albumName) {
return 'Open album $albumName';
}
}
+226
View File
@@ -30,6 +30,13 @@ class AppLocalizationsTr extends AppLocalizations {
String get homeSubtitle =>
'Bir Spotify bağlantısı yapıştırın veya şarkı arayın';
@override
String get homeEmptyTitle => 'Home is empty';
@override
String get homeEmptySubtitle =>
'Install your first extension to unlock search and browsing.';
@override
String get homeSupports =>
'Desteklenenler: Şarkı, Albüm, Çalma Listesi, Sanatçı bağlantıları';
@@ -3481,4 +3488,223 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
@override
String get searchTracks => 'Tracks';
@override
String get homeSearchHintDefault => 'Paste supported URL or search...';
@override
String homeSearchHintProvider(String providerName) {
return 'Search with $providerName...';
}
@override
String get homeImportCsvTooltip => 'Import CSV';
@override
String get homeChangeSearchProviderTooltip => 'Change search provider';
@override
String get actionPaste => 'Paste';
@override
String get searchTracksHint => 'Search tracks...';
@override
String get searchTracksEmptyPrompt => 'Search for tracks';
@override
String get tutorialSearchHint => 'Paste or search...';
@override
String get tutorialDownloadCompletedSemantics => 'Download completed';
@override
String get tutorialDownloadInProgressSemantics => 'Download in progress';
@override
String get tutorialStartDownloadSemantics => 'Start download';
@override
String get optionsEmbedMetadata => 'Embed Metadata';
@override
String get optionsEmbedMetadataSubtitleOn =>
'Write metadata, cover art, and embedded lyrics to files';
@override
String get optionsEmbedMetadataSubtitleOff =>
'Disabled (advanced): skip all metadata embedding';
@override
String get optionsMaxQualityCoverSubtitleDisabled =>
'Disabled when metadata embedding is off';
@override
String downloadFilenameHintExample(Object artist, Object title) {
return '$artist - $title';
}
@override
String get trackCoverNoEmbeddedArt => 'No embedded album art found';
@override
String get trackCoverReplace => 'Replace Cover';
@override
String get trackCoverPick => 'Pick Cover';
@override
String get trackCoverClearSelected => 'Clear selected cover';
@override
String get trackCoverCurrent => 'Current cover';
@override
String get trackCoverSelected => 'Selected cover';
@override
String get trackCoverReplaceNotice =>
'The selected cover will replace the current embedded cover when you tap Save.';
@override
String get actionStop => 'Stop';
@override
String get queueFinalizingDownload => 'Finalizing download';
@override
String get queueDownloadedFileMissing => 'Downloaded file missing';
@override
String get queueDownloadCompleted => 'Download completed';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
}
@override
String get logAutoScrollOn => 'Auto-scroll ON';
@override
String get logAutoScrollOff => 'Auto-scroll OFF';
@override
String get logCopyLogs => 'Copy logs';
@override
String get logClearSearch => 'Clear search';
@override
String get logIssueIspBlockingLabel => 'ISP BLOCKING DETECTED';
@override
String get logIssueIspBlockingDescription =>
'Your ISP may be blocking access to download services';
@override
String get logIssueIspBlockingSuggestion =>
'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8';
@override
String get logIssueRateLimitedLabel => 'RATE LIMITED';
@override
String get logIssueRateLimitedDescription =>
'Too many requests to the service';
@override
String get logIssueRateLimitedSuggestion =>
'Wait a few minutes before trying again';
@override
String get logIssueNetworkErrorLabel => 'NETWORK ERROR';
@override
String get logIssueNetworkErrorDescription => 'Connection issues detected';
@override
String get logIssueNetworkErrorSuggestion => 'Check your internet connection';
@override
String get logIssueTrackNotFoundLabel => 'TRACK NOT FOUND';
@override
String get logIssueTrackNotFoundDescription =>
'Some tracks could not be found on download services';
@override
String get logIssueTrackNotFoundSuggestion =>
'The track may not be available in lossless quality';
@override
String get clickableLookingUpArtist => 'Looking up artist...';
@override
String clickableInformationUnavailable(String type) {
return '$type information not available';
}
@override
String get extensionDetailsTags => 'Tags';
@override
String get extensionDetailsInformation => 'Information';
@override
String get extensionUtilityFunctions => 'Utility Functions';
@override
String get actionDismiss => 'Dismiss';
@override
String get setupChangeFolderTooltip => 'Change folder';
@override
String a11yOpenTrackByArtist(String trackName, String artistName) {
return 'Open track $trackName by $artistName';
}
@override
String a11yOpenItem(String itemType, String name) {
return 'Open $itemType $name';
}
@override
String a11yOpenItemCount(String title, int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return 'Open $title, $count $_temp0';
}
@override
String a11yOpenAlbumByArtistTrackCount(
String albumName,
String artistName,
int trackCount,
) {
return 'Open album $albumName by $artistName, $trackCount tracks';
}
@override
String a11yTrackByArtist(String trackName, String artistName) {
return '$trackName by $artistName';
}
@override
String a11ySelectAlbum(String albumName) {
return 'Select album $albumName';
}
@override
String a11yOpenAlbum(String albumName) {
return 'Open album $albumName';
}
}
+226
View File
@@ -29,6 +29,13 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get homeSubtitle => 'Paste a Spotify link or search by name';
@override
String get homeEmptyTitle => 'Home is empty';
@override
String get homeEmptySubtitle =>
'Install your first extension to unlock search and browsing.';
@override
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
@@ -3424,6 +3431,225 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
@override
String get searchTracks => 'Tracks';
@override
String get homeSearchHintDefault => 'Paste supported URL or search...';
@override
String homeSearchHintProvider(String providerName) {
return 'Search with $providerName...';
}
@override
String get homeImportCsvTooltip => 'Import CSV';
@override
String get homeChangeSearchProviderTooltip => 'Change search provider';
@override
String get actionPaste => 'Paste';
@override
String get searchTracksHint => 'Search tracks...';
@override
String get searchTracksEmptyPrompt => 'Search for tracks';
@override
String get tutorialSearchHint => 'Paste or search...';
@override
String get tutorialDownloadCompletedSemantics => 'Download completed';
@override
String get tutorialDownloadInProgressSemantics => 'Download in progress';
@override
String get tutorialStartDownloadSemantics => 'Start download';
@override
String get optionsEmbedMetadata => 'Embed Metadata';
@override
String get optionsEmbedMetadataSubtitleOn =>
'Write metadata, cover art, and embedded lyrics to files';
@override
String get optionsEmbedMetadataSubtitleOff =>
'Disabled (advanced): skip all metadata embedding';
@override
String get optionsMaxQualityCoverSubtitleDisabled =>
'Disabled when metadata embedding is off';
@override
String downloadFilenameHintExample(Object artist, Object title) {
return '$artist - $title';
}
@override
String get trackCoverNoEmbeddedArt => 'No embedded album art found';
@override
String get trackCoverReplace => 'Replace Cover';
@override
String get trackCoverPick => 'Pick Cover';
@override
String get trackCoverClearSelected => 'Clear selected cover';
@override
String get trackCoverCurrent => 'Current cover';
@override
String get trackCoverSelected => 'Selected cover';
@override
String get trackCoverReplaceNotice =>
'The selected cover will replace the current embedded cover when you tap Save.';
@override
String get actionStop => 'Stop';
@override
String get queueFinalizingDownload => 'Finalizing download';
@override
String get queueDownloadedFileMissing => 'Downloaded file missing';
@override
String get queueDownloadCompleted => 'Download completed';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
}
@override
String get logAutoScrollOn => 'Auto-scroll ON';
@override
String get logAutoScrollOff => 'Auto-scroll OFF';
@override
String get logCopyLogs => 'Copy logs';
@override
String get logClearSearch => 'Clear search';
@override
String get logIssueIspBlockingLabel => 'ISP BLOCKING DETECTED';
@override
String get logIssueIspBlockingDescription =>
'Your ISP may be blocking access to download services';
@override
String get logIssueIspBlockingSuggestion =>
'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8';
@override
String get logIssueRateLimitedLabel => 'RATE LIMITED';
@override
String get logIssueRateLimitedDescription =>
'Too many requests to the service';
@override
String get logIssueRateLimitedSuggestion =>
'Wait a few minutes before trying again';
@override
String get logIssueNetworkErrorLabel => 'NETWORK ERROR';
@override
String get logIssueNetworkErrorDescription => 'Connection issues detected';
@override
String get logIssueNetworkErrorSuggestion => 'Check your internet connection';
@override
String get logIssueTrackNotFoundLabel => 'TRACK NOT FOUND';
@override
String get logIssueTrackNotFoundDescription =>
'Some tracks could not be found on download services';
@override
String get logIssueTrackNotFoundSuggestion =>
'The track may not be available in lossless quality';
@override
String get clickableLookingUpArtist => 'Looking up artist...';
@override
String clickableInformationUnavailable(String type) {
return '$type information not available';
}
@override
String get extensionDetailsTags => 'Tags';
@override
String get extensionDetailsInformation => 'Information';
@override
String get extensionUtilityFunctions => 'Utility Functions';
@override
String get actionDismiss => 'Dismiss';
@override
String get setupChangeFolderTooltip => 'Change folder';
@override
String a11yOpenTrackByArtist(String trackName, String artistName) {
return 'Open track $trackName by $artistName';
}
@override
String a11yOpenItem(String itemType, String name) {
return 'Open $itemType $name';
}
@override
String a11yOpenItemCount(String title, int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return 'Open $title, $count $_temp0';
}
@override
String a11yOpenAlbumByArtistTrackCount(
String albumName,
String artistName,
int trackCount,
) {
return 'Open album $albumName by $artistName, $trackCount tracks';
}
@override
String a11yTrackByArtist(String trackName, String artistName) {
return '$trackName by $artistName';
}
@override
String a11ySelectAlbum(String albumName) {
return 'Select album $albumName';
}
@override
String a11yOpenAlbum(String albumName) {
return 'Open album $albumName';
}
}
/// The translations for Chinese, as used in China (`zh_CN`).
+312
View File
@@ -29,6 +29,14 @@
"@homeSubtitle": {
"description": "Subtitle shown below search box"
},
"homeEmptyTitle": "Home is empty",
"@homeEmptyTitle": {
"description": "Title shown on home when no providers are available yet"
},
"homeEmptySubtitle": "Install your first extension to unlock search and browsing.",
"@homeEmptySubtitle": {
"description": "Subtitle shown on home when no providers are available yet"
},
"homeSupports": "Supports: Track, Album, Playlist, Artist URLs",
"@homeSupports": {
"description": "Info text about supported URL types"
@@ -4548,5 +4556,309 @@
"notifUpdateFailedBody": "Could not download update. Try again later.",
"@notifUpdateFailedBody": {
"description": "Notification body when app update download fails"
},
"searchTracks": "Tracks",
"@searchTracks": {
"description": "Search filter label - tracks"
},
"homeSearchHintDefault": "Paste supported URL or search...",
"@homeSearchHintDefault": {
"description": "Default placeholder for the main search field on Home"
},
"homeSearchHintProvider": "Search with {providerName}...",
"@homeSearchHintProvider": {
"description": "Placeholder for the main search field when a provider is selected",
"placeholders": {
"providerName": {
"type": "String"
}
}
},
"homeImportCsvTooltip": "Import CSV",
"@homeImportCsvTooltip": {
"description": "Tooltip for importing a CSV file into Home search"
},
"homeChangeSearchProviderTooltip": "Change search provider",
"@homeChangeSearchProviderTooltip": {
"description": "Tooltip for the Home search provider picker"
},
"actionPaste": "Paste",
"@actionPaste": {
"description": "Generic action - paste from clipboard"
},
"searchTracksHint": "Search tracks...",
"@searchTracksHint": {
"description": "Placeholder for the search screen input"
},
"searchTracksEmptyPrompt": "Search for tracks",
"@searchTracksEmptyPrompt": {
"description": "Empty-state prompt on the search screen"
},
"tutorialSearchHint": "Paste or search...",
"@tutorialSearchHint": {
"description": "Placeholder shown in the tutorial search demo"
},
"tutorialDownloadCompletedSemantics": "Download completed",
"@tutorialDownloadCompletedSemantics": {
"description": "Accessibility label for completed download state in tutorial demo"
},
"tutorialDownloadInProgressSemantics": "Download in progress",
"@tutorialDownloadInProgressSemantics": {
"description": "Accessibility label for active download state in tutorial demo"
},
"tutorialStartDownloadSemantics": "Start download",
"@tutorialStartDownloadSemantics": {
"description": "Accessibility label for idle download button in tutorial demo"
},
"optionsEmbedMetadata": "Embed Metadata",
"@optionsEmbedMetadata": {
"description": "Settings toggle title for writing metadata into downloaded files"
},
"optionsEmbedMetadataSubtitleOn": "Write metadata, cover art, and embedded lyrics to files",
"@optionsEmbedMetadataSubtitleOn": {
"description": "Subtitle when metadata embedding is enabled"
},
"optionsEmbedMetadataSubtitleOff": "Disabled (advanced): skip all metadata embedding",
"@optionsEmbedMetadataSubtitleOff": {
"description": "Subtitle when metadata embedding is disabled"
},
"optionsMaxQualityCoverSubtitleDisabled": "Disabled when metadata embedding is off",
"@optionsMaxQualityCoverSubtitleDisabled": {
"description": "Subtitle for max quality cover when metadata embedding is disabled"
},
"downloadFilenameHintExample": "{artist} - {title}",
"@downloadFilenameHintExample": {
"description": "Example placeholder for the download filename format input"
},
"trackCoverNoEmbeddedArt": "No embedded album art found",
"@trackCoverNoEmbeddedArt": {
"description": "Message shown when a track file has no embedded cover art"
},
"trackCoverReplace": "Replace Cover",
"@trackCoverReplace": {
"description": "Button label for replacing selected cover art"
},
"trackCoverPick": "Pick Cover",
"@trackCoverPick": {
"description": "Button label for selecting cover art"
},
"trackCoverClearSelected": "Clear selected cover",
"@trackCoverClearSelected": {
"description": "Tooltip for clearing the newly selected cover art"
},
"trackCoverCurrent": "Current cover",
"@trackCoverCurrent": {
"description": "Label for the currently embedded cover preview"
},
"trackCoverSelected": "Selected cover",
"@trackCoverSelected": {
"description": "Label for the newly selected cover preview"
},
"trackCoverReplaceNotice": "The selected cover will replace the current embedded cover when you tap Save.",
"@trackCoverReplaceNotice": {
"description": "Notice shown when a new cover has been selected but not saved yet"
},
"actionStop": "Stop",
"@actionStop": {
"description": "Generic action - stop"
},
"queueFinalizingDownload": "Finalizing download",
"@queueFinalizingDownload": {
"description": "Accessibility label for a queue item that is finalizing"
},
"queueDownloadedFileMissing": "Downloaded file missing",
"@queueDownloadedFileMissing": {
"description": "Accessibility label when a downloaded file is missing from disk"
},
"queueDownloadCompleted": "Download completed",
"@queueDownloadCompleted": {
"description": "Accessibility label for completed download state in queue"
},
"appearanceSelectAccentColor": "Select accent color {hex}",
"@appearanceSelectAccentColor": {
"description": "Accessibility label for picking an accent color",
"placeholders": {
"hex": {
"type": "String"
}
}
},
"logAutoScrollOn": "Auto-scroll ON",
"@logAutoScrollOn": {
"description": "Tooltip when auto-scroll is enabled on the log screen"
},
"logAutoScrollOff": "Auto-scroll OFF",
"@logAutoScrollOff": {
"description": "Tooltip when auto-scroll is disabled on the log screen"
},
"logCopyLogs": "Copy logs",
"@logCopyLogs": {
"description": "Tooltip for copying logs"
},
"logClearSearch": "Clear search",
"@logClearSearch": {
"description": "Tooltip for clearing the log search field"
},
"logIssueIspBlockingLabel": "ISP BLOCKING DETECTED",
"@logIssueIspBlockingLabel": {
"description": "Diagnostic badge label when ISP blocking is detected"
},
"logIssueIspBlockingDescription": "Your ISP may be blocking access to download services",
"@logIssueIspBlockingDescription": {
"description": "Diagnostic badge description for ISP blocking"
},
"logIssueIspBlockingSuggestion": "Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8",
"@logIssueIspBlockingSuggestion": {
"description": "Diagnostic badge suggestion for ISP blocking"
},
"logIssueRateLimitedLabel": "RATE LIMITED",
"@logIssueRateLimitedLabel": {
"description": "Diagnostic badge label when the service rate limits requests"
},
"logIssueRateLimitedDescription": "Too many requests to the service",
"@logIssueRateLimitedDescription": {
"description": "Diagnostic badge description for rate limiting"
},
"logIssueRateLimitedSuggestion": "Wait a few minutes before trying again",
"@logIssueRateLimitedSuggestion": {
"description": "Diagnostic badge suggestion for rate limiting"
},
"logIssueNetworkErrorLabel": "NETWORK ERROR",
"@logIssueNetworkErrorLabel": {
"description": "Diagnostic badge label for generic network errors"
},
"logIssueNetworkErrorDescription": "Connection issues detected",
"@logIssueNetworkErrorDescription": {
"description": "Diagnostic badge description for generic network errors"
},
"logIssueNetworkErrorSuggestion": "Check your internet connection",
"@logIssueNetworkErrorSuggestion": {
"description": "Diagnostic badge suggestion for generic network errors"
},
"logIssueTrackNotFoundLabel": "TRACK NOT FOUND",
"@logIssueTrackNotFoundLabel": {
"description": "Diagnostic badge label when a track is unavailable"
},
"logIssueTrackNotFoundDescription": "Some tracks could not be found on download services",
"@logIssueTrackNotFoundDescription": {
"description": "Diagnostic badge description when a track is unavailable"
},
"logIssueTrackNotFoundSuggestion": "The track may not be available in lossless quality",
"@logIssueTrackNotFoundSuggestion": {
"description": "Diagnostic badge suggestion when a track is unavailable"
},
"clickableLookingUpArtist": "Looking up artist...",
"@clickableLookingUpArtist": {
"description": "Snackbar shown while clickable artist metadata is being resolved"
},
"clickableInformationUnavailable": "{type} information not available",
"@clickableInformationUnavailable": {
"description": "Snackbar shown when clickable metadata cannot open a destination",
"placeholders": {
"type": {
"type": "String"
}
}
},
"extensionDetailsTags": "Tags",
"@extensionDetailsTags": {
"description": "Section title for extension tags"
},
"extensionDetailsInformation": "Information",
"@extensionDetailsInformation": {
"description": "Section title for extension metadata information"
},
"extensionUtilityFunctions": "Utility Functions",
"@extensionUtilityFunctions": {
"description": "Capability label for utility-only extensions"
},
"actionDismiss": "Dismiss",
"@actionDismiss": {
"description": "Generic action - dismiss"
},
"setupChangeFolderTooltip": "Change folder",
"@setupChangeFolderTooltip": {
"description": "Tooltip for editing the selected download folder"
},
"a11yOpenTrackByArtist": "Open track {trackName} by {artistName}",
"@a11yOpenTrackByArtist": {
"description": "Accessibility label for opening a track item",
"placeholders": {
"trackName": {
"type": "String"
},
"artistName": {
"type": "String"
}
}
},
"a11yOpenItem": "Open {itemType} {name}",
"@a11yOpenItem": {
"description": "Accessibility label for opening a generic item",
"placeholders": {
"itemType": {
"type": "String"
},
"name": {
"type": "String"
}
}
},
"a11yOpenItemCount": "Open {title}, {count} {count, plural, =1{item} other{items}}",
"@a11yOpenItemCount": {
"description": "Accessibility label for opening a grouped item with count",
"placeholders": {
"title": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"a11yOpenAlbumByArtistTrackCount": "Open album {albumName} by {artistName}, {trackCount} tracks",
"@a11yOpenAlbumByArtistTrackCount": {
"description": "Accessibility label for opening an album item with track count",
"placeholders": {
"albumName": {
"type": "String"
},
"artistName": {
"type": "String"
},
"trackCount": {
"type": "int"
}
}
},
"a11yTrackByArtist": "{trackName} by {artistName}",
"@a11yTrackByArtist": {
"description": "Accessibility label for a queue or list track item",
"placeholders": {
"trackName": {
"type": "String"
},
"artistName": {
"type": "String"
}
}
},
"a11ySelectAlbum": "Select album {albumName}",
"@a11ySelectAlbum": {
"description": "Accessibility label for selecting an album",
"placeholders": {
"albumName": {
"type": "String"
}
}
},
"a11yOpenAlbum": "Open album {albumName}",
"@a11yOpenAlbum": {
"description": "Accessibility label for opening an album",
"placeholders": {
"albumName": {
"type": "String"
}
}
}
}
+1 -1
View File
@@ -85,7 +85,7 @@ class AppSettings {
lastSeenVersion; // Last app version the user has acknowledged (e.g. '3.7.0')
const AppSettings({
this.defaultService = 'tidal',
this.defaultService = '',
this.audioQuality = 'LOSSLESS',
this.filenameFormat = '{title} - {artist}',
this.downloadDirectory = '',
+1 -1
View File
@@ -7,7 +7,7 @@ part of 'settings.dart';
// **************************************************************************
AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
defaultService: json['defaultService'] as String? ?? 'tidal',
defaultService: json['defaultService'] as String? ?? '',
audioQuality: json['audioQuality'] as String? ?? 'LOSSLESS',
filenameFormat: json['filenameFormat'] as String? ?? '{title} - {artist}',
downloadDirectory: json['downloadDirectory'] as String? ?? '',
+78 -5
View File
@@ -1455,6 +1455,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final decoded = jsonDecode(itemJson);
if (decoded is! Map) continue;
var item = DownloadItem.fromJson(Map<String, dynamic>.from(decoded));
final normalizedService = _normalizeQueuedService(item.service);
if (normalizedService != item.service) {
item = item.copyWith(service: normalizedService);
}
if (item.status == DownloadStatus.downloading) {
item = item.copyWith(status: DownloadStatus.queued, progress: 0);
}
@@ -2395,7 +2399,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (extensionPreferred != null) {
return extensionPreferred;
}
if (service.toLowerCase() == 'tidal' && quality == 'HIGH') {
if (_usesBuiltInCompatibleDownloadProvider(service, 'tidal') &&
quality == 'HIGH') {
return '.m4a';
}
final q = quality.toLowerCase();
@@ -2405,6 +2410,49 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return '.flac';
}
bool _usesBuiltInCompatibleDownloadProvider(
String service,
String builtInProviderId,
) {
return ref
.read(extensionProvider.notifier)
.downloadProviderMatchesBuiltIn(service, builtInProviderId);
}
String _normalizeQueuedService(String service) {
final normalized = service.trim();
if (normalized.isEmpty) {
return normalized;
}
final replacement = ref
.read(extensionProvider.notifier)
.replacedBuiltInDownloadProviderFor(normalized);
if (replacement != null && replacement.isNotEmpty) {
return replacement;
}
return normalized;
}
bool _hasActiveDownloadProvider(String service) {
final normalized = service.trim();
if (normalized.isEmpty) {
return false;
}
if (isBuiltInDownloadProvider(normalized)) {
return true;
}
final extensionState = ref.read(extensionProvider);
return extensionState.extensions.any(
(ext) =>
ext.enabled &&
ext.hasDownloadProvider &&
ext.id.toLowerCase() == normalized.toLowerCase(),
);
}
String _mimeTypeForExt(String ext) {
switch (ext.toLowerCase()) {
case '.m4a':
@@ -2837,7 +2885,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final item = DownloadItem(
id: id,
track: track,
service: service,
service: _normalizeQueuedService(service),
createdAt: DateTime.now(),
qualityOverride: qualityOverride,
playlistName: playlistName,
@@ -2869,7 +2917,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return DownloadItem(
id: id,
track: track,
service: service,
service: _normalizeQueuedService(service),
createdAt: DateTime.now(),
qualityOverride: qualityOverride,
playlistName: playlistName,
@@ -4388,6 +4436,31 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
Future<void> _downloadSingleItem(DownloadItem item) async {
final normalizedService = _normalizeQueuedService(item.service);
if (normalizedService != item.service) {
item = item.copyWith(service: normalizedService);
state = state.copyWith(
items: [
for (final existing in state.items)
if (existing.id == item.id) item else existing,
],
currentDownload: state.currentDownload?.id == item.id
? item
: state.currentDownload,
);
_saveQueueToStorage();
}
if (!_hasActiveDownloadProvider(item.service)) {
updateItemStatus(
item.id,
DownloadStatus.failed,
error: 'Download provider is no longer available',
errorType: DownloadErrorType.notFound,
);
return;
}
_log.d('Processing: ${item.track.name} by ${item.track.artistName}');
_log.d('Cover URL: ${item.track.coverUrl}');
var pausedDuringThisRun = false;
@@ -4748,7 +4821,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
if (trackToDownload.id.startsWith('tidal:')) {
payloadTidalId = trackToDownload.id.substring(6);
if (item.service == 'tidal') {
if (_usesBuiltInCompatibleDownloadProvider(item.service, 'tidal')) {
payloadSpotifyId = '';
}
}
@@ -5051,7 +5124,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
!wasExisting &&
isContentUriPath &&
effectiveSafMode &&
actualService == 'tidal' &&
_usesBuiltInCompatibleDownloadProvider(actualService, 'tidal') &&
filePath.endsWith('.flac') &&
(mimeType == null || mimeType.contains('flac'));
+164 -10
View File
@@ -211,6 +211,20 @@ class Extension {
bool get hasPostProcessing => postProcessing?.enabled ?? false;
bool get hasHomeFeed => capabilities['homeFeed'] == true;
bool get hasBrowseCategories => capabilities['browseCategories'] == true;
List<String> get replacesBuiltInProviders {
final value = capabilities['replacesBuiltInProviders'];
if (value is! List) return const [];
final normalized = <String>[];
for (final item in value) {
if (item is! String) continue;
final trimmed = item.trim().toLowerCase();
if (trimmed.isEmpty || normalized.contains(trimmed)) continue;
normalized.add(trimmed);
}
return normalized;
}
String? get preferredDownloadOutputExtension {
final value = capabilities['downloadOutputExtension'];
if (value is! String) return null;
@@ -743,6 +757,8 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
final extensions = list.map((e) => Extension.fromJson(e)).toList();
state = state.copyWith(extensions: extensions);
await _reconcileDownloadProviderPriority();
await _reconcileDefaultDownloadService();
_reconcileSearchProvider();
_log.d('Loaded ${extensions.length} extensions');
for (final ext in extensions) {
@@ -849,6 +865,8 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
state = state.copyWith(extensions: extensions);
await _reconcileDownloadProviderPriority();
await _reconcileDefaultDownloadService();
_reconcileSearchProvider();
if (!enabled && ext != null) {
final settings = ref.read(settingsProvider);
@@ -861,16 +879,16 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
if (ext.hasDownloadProvider && settings.defaultService == extensionId) {
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',
);
}
final fallbackService =
_firstEnabledExtensionDownloadProviderId() ?? '';
ref
.read(settingsProvider.notifier)
.setDefaultService(fallbackService);
_log.d(
fallbackService.isEmpty
? 'Cleared default service because extension $extensionId was disabled'
: 'Reset default service to $fallbackService because extension $extensionId was disabled',
);
}
}
} catch (e) {
@@ -896,6 +914,142 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
_log.d('Reconciled provider priority after extension update: $sanitized');
}
String? _firstEnabledExtensionDownloadProviderId() {
return state.extensions
.where((ext) => ext.enabled && ext.hasDownloadProvider)
.map((ext) => ext.id)
.firstOrNull;
}
String? replacedBuiltInDownloadProviderFor(String providerId) {
final normalized = providerId.trim().toLowerCase();
if (normalized.isEmpty) return null;
return state.extensions
.where(
(ext) =>
ext.enabled &&
ext.hasDownloadProvider &&
ext.replacesBuiltInProviders.contains(normalized),
)
.map((ext) => ext.id)
.firstOrNull;
}
String? replacedBuiltInSearchProviderFor(String providerId) {
final normalized = providerId.trim().toLowerCase();
if (normalized.isEmpty) return null;
return state.extensions
.where(
(ext) =>
ext.enabled &&
ext.hasCustomSearch &&
ext.replacesBuiltInProviders.contains(normalized),
)
.map((ext) => ext.id)
.firstOrNull;
}
bool downloadProviderMatchesBuiltIn(
String providerId,
String builtInProviderId,
) {
final normalizedProvider = providerId.trim().toLowerCase();
final normalizedBuiltIn = builtInProviderId.trim().toLowerCase();
if (normalizedProvider.isEmpty || normalizedBuiltIn.isEmpty) return false;
if (normalizedProvider == normalizedBuiltIn) return true;
final extension = state.extensions
.where((ext) => ext.enabled && ext.hasDownloadProvider)
.where((ext) => ext.id.toLowerCase() == normalizedProvider)
.firstOrNull;
return extension?.replacesBuiltInProviders.contains(normalizedBuiltIn) ??
false;
}
Future<void> _reconcileDefaultDownloadService() async {
final settings = ref.read(settingsProvider);
final preferredExtensionId = _firstEnabledExtensionDownloadProviderId();
final currentService = settings.defaultService.trim();
if (currentService.isEmpty) {
if (preferredExtensionId != null) {
ref
.read(settingsProvider.notifier)
.setDefaultService(preferredExtensionId);
_log.d(
'Adopted first enabled download extension as default service: $preferredExtensionId',
);
}
return;
}
final replacementExtensionId = replacedBuiltInDownloadProviderFor(
currentService,
);
if (replacementExtensionId != null) {
ref
.read(settingsProvider.notifier)
.setDefaultService(replacementExtensionId);
_log.d(
'Migrated retired built-in service $currentService to $replacementExtensionId',
);
return;
}
final currentExtension = state.extensions
.where((ext) => ext.id == currentService)
.firstOrNull;
final isMissingOrInvalidExtension =
currentExtension == null ||
!currentExtension.enabled ||
!currentExtension.hasDownloadProvider;
if (!isBuiltInDownloadProvider(currentService) &&
isMissingOrInvalidExtension) {
final fallbackService = preferredExtensionId ?? '';
ref.read(settingsProvider.notifier).setDefaultService(fallbackService);
_log.d(
fallbackService.isEmpty
? 'Cleared default service because $currentService is no longer available'
: 'Reset default service to $fallbackService because $currentService is no longer available',
);
}
}
void _reconcileSearchProvider() {
final settings = ref.read(settingsProvider);
final currentSearchProvider = settings.searchProvider?.trim();
if (currentSearchProvider == null || currentSearchProvider.isEmpty) {
return;
}
final replacementExtensionId = replacedBuiltInSearchProviderFor(
currentSearchProvider,
);
if (replacementExtensionId != null) {
ref
.read(settingsProvider.notifier)
.setSearchProvider(replacementExtensionId);
_log.d(
'Migrated retired built-in search provider $currentSearchProvider to $replacementExtensionId',
);
return;
}
final hasMatchingExtension = state.extensions.any(
(ext) =>
ext.enabled && ext.hasCustomSearch && ext.id == currentSearchProvider,
);
if (!isBuiltInSearchProvider(currentSearchProvider) &&
!hasMatchingExtension) {
ref.read(settingsProvider.notifier).setSearchProvider(null);
_log.d(
'Cleared stale search provider because $currentSearchProvider is no longer available',
);
}
}
Future<bool> ensureSpotifyWebExtensionReady({
bool setAsSearchProvider = true,
}) async {
+10 -4
View File
@@ -12,13 +12,18 @@ import 'package:spotiflac_android/utils/logger.dart';
const _settingsKey = 'app_settings';
const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 10;
const _currentMigrationVersion = 11;
const _spotifyClientSecretKey = 'spotify_client_secret';
final _log = AppLogger('SettingsProvider');
class SettingsNotifier extends Notifier<AppSettings> {
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$');
static const Set<String> _searchTabValues = {'all', 'track', 'artist', 'album'};
static const Set<String> _searchTabValues = {
'all',
'track',
'artist',
'album',
};
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
@@ -137,10 +142,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
);
}
state = state.copyWith(lastSeenVersion: AppInfo.version);
// Migration 7/10: retired built-in services reset back to Tidal
// Migration 7/11: retired built-in services no longer fall back to a
// preinstalled provider.
if (state.defaultService == 'youtube' ||
state.defaultService == 'deezer') {
state = state.copyWith(defaultService: 'tidal');
state = state.copyWith(defaultService: '');
}
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
await _saveSettings();
+2 -2
View File
@@ -1689,8 +1689,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
button: true,
selected: _isSelectionMode && isSelected,
label: _isSelectionMode
? 'Select album ${album.name}'
: 'Open album ${album.name}',
? context.l10n.a11ySelectAlbum(album.name)
: context.l10n.a11yOpenAlbum(album.name),
child: GestureDetector(
onTap: () {
if (_isSelectionMode) {
+1 -1
View File
@@ -866,7 +866,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
trailing: _isSelectionMode
? null
: IconButton(
tooltip: 'Play track',
tooltip: context.l10n.tooltipPlay,
onPressed: () => _openFile(track),
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
style: IconButton.styleFrom(
+71 -28
View File
@@ -431,6 +431,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
List<SearchFilter> _resolveSearchFilters(
BuildContext context,
String? currentSearchProvider,
List<Extension> extensions,
) {
@@ -453,11 +454,27 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
}
return const [
SearchFilter(id: 'track', label: 'Tracks', icon: 'music'),
SearchFilter(id: 'artist', label: 'Artists', icon: 'artist'),
SearchFilter(id: 'album', label: 'Albums', icon: 'album'),
SearchFilter(id: 'playlist', label: 'Playlists', icon: 'playlist'),
return [
SearchFilter(
id: 'track',
label: context.l10n.searchTracks,
icon: 'music',
),
SearchFilter(
id: 'artist',
label: context.l10n.searchArtists,
icon: 'artist',
),
SearchFilter(
id: 'album',
label: context.l10n.searchAlbums,
icon: 'album',
),
SearchFilter(
id: 'playlist',
label: context.l10n.searchPlaylists,
icon: 'playlist',
),
];
}
@@ -1249,9 +1266,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
final hasSearchedBefore = ref.watch(
settingsProvider.select((s) => s.hasSearchedBefore),
);
final explicitSearchProvider = ref.watch(
settingsProvider.select((s) => s.searchProvider),
);
final defaultSearchTab = ref.watch(
settingsProvider.select((s) => s.defaultSearchTab),
);
final extensions = ref.watch(extensionProvider.select((s) => s.extensions));
final hasExploreContent = ref.watch(
exploreProvider.select((s) => s.sections.isNotEmpty),
@@ -1289,6 +1310,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
recentModeRequested &&
(!hasSearchInput || hasShortSearchInput || !hasActualResults) &&
!isLoading;
final hasSearchProvider =
(_resolveSearchProvider(explicitSearchProvider, extensions) ?? '')
.isNotEmpty;
final hasResults =
hasSearchInput || hasActualResults || isLoading || showRecentAccess;
final showExplore =
@@ -1298,6 +1322,12 @@ class _HomeTabState extends ConsumerState<HomeTab>
!homeFeedDisabled &&
(hasHomeFeedExtension || hasExploreContent) &&
hasExploreContent;
final showEmptyHomeState =
!hasSearchProvider &&
!hasHomeFeedExtension &&
!hasExploreContent &&
!hasResults &&
historyItems.isEmpty;
ref.listen<String>(settingsProvider.select((s) => s.defaultSearchTab), (
previous,
@@ -1413,13 +1443,17 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
const SizedBox(height: 16),
Text(
'SpotiFLAC',
showEmptyHomeState
? context.l10n.homeEmptyTitle
: 'SpotiFLAC Mobile',
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
context.l10n.homeSubtitle,
showEmptyHomeState
? context.l10n.homeEmptySubtitle
: context.l10n.homeSubtitle,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
@@ -1431,17 +1465,18 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.fromLTRB(
16,
(hasResults || showExplore) ? 8 : 32,
16,
(hasResults || showExplore) ? 8 : 16,
if (hasSearchProvider)
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.fromLTRB(
16,
(hasResults || showExplore) ? 8 : 32,
16,
(hasResults || showExplore) ? 8 : 16,
),
child: _buildSearchBar(colorScheme),
),
child: _buildSearchBar(colorScheme),
),
),
if (hasActualResults && !showRecentAccess)
Consumer(
@@ -1456,6 +1491,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
trackProvider.select((s) => s.selectedSearchFilter),
);
final searchFilters = _resolveSearchFilters(
context,
currentSearchProvider,
extensions,
);
@@ -1493,7 +1529,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
child: AnimatedSize(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
child: (hasResults || showRecentAccess || showExplore)
child:
(hasResults ||
showRecentAccess ||
showExplore ||
showEmptyHomeState)
? const SizedBox.shrink()
: Column(
children: [
@@ -1666,7 +1706,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
key: ValueKey(item.id),
child: Semantics(
button: true,
label: 'Open track ${item.trackName} by ${item.artistName}',
label: context.l10n.a11yOpenTrackByArtist(
item.trackName,
item.artistName,
),
child: GestureDetector(
onTap: () => _navigateToMetadataScreen(
item,
@@ -1810,7 +1853,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
return Semantics(
button: true,
label: 'Open ${item.type} ${item.name}',
label: context.l10n.a11yOpenItem(item.type, item.name),
child: GestureDetector(
onTap: () => _navigateToExploreItem(item),
child: SizedBox(
@@ -2294,7 +2337,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
IconButton(
tooltip: 'Dismiss',
tooltip: context.l10n.actionDismiss,
icon: Icon(
Icons.close,
size: 20,
@@ -3341,13 +3384,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
if (!extState.isInitialized) {
return 'Paste supported URL or search...';
return context.l10n.homeSearchHintDefault;
}
if (searchProvider != null && searchProvider.isNotEmpty) {
final builtIn = builtInProviderSpecForId(searchProvider);
if (builtIn != null && builtIn.supportsSearch) {
return 'Search with ${builtIn.displayName}...';
return context.l10n.homeSearchHintProvider(builtIn.displayName);
}
final ext = extState.extensions
@@ -3357,10 +3400,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (ext.searchBehavior?.placeholder != null) {
return ext.searchBehavior!.placeholder!;
}
return 'Search with ${ext.displayName}...';
return context.l10n.homeSearchHintProvider(ext.displayName);
}
}
return 'Paste supported URL or search...';
return context.l10n.homeSearchHintDefault;
}
Widget _buildSearchFilterBar(
@@ -3481,7 +3524,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
IconButton(
icon: const Icon(Icons.clear),
onPressed: _clearAndRefresh,
tooltip: 'Clear',
tooltip: context.l10n.dialogClear,
)
else ...[
IconButton(
@@ -3489,12 +3532,12 @@ class _HomeTabState extends ConsumerState<HomeTab>
onPressed: _isCsvImporting
? null
: () => _importCsv(context, ref),
tooltip: 'Import CSV',
tooltip: context.l10n.homeImportCsvTooltip,
),
IconButton(
icon: const Icon(Icons.paste),
onPressed: _pasteFromClipboard,
tooltip: 'Paste',
tooltip: context.l10n.actionPaste,
),
],
],
@@ -3640,7 +3683,7 @@ class _SearchProviderDropdown extends ConsumerWidget {
),
],
),
tooltip: 'Change search provider',
tooltip: context.l10n.homeChangeSearchProviderTooltip,
offset: const Offset(0, 40),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onSelected: (String providerId) {
+1 -1
View File
@@ -737,7 +737,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
trailing: _isSelectionMode
? null
: IconButton(
tooltip: 'Play track',
tooltip: context.l10n.tooltipPlay,
onPressed: () => _openFile(track),
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
style: IconButton.styleFrom(
+19 -12
View File
@@ -3537,7 +3537,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
tooltip: 'Clear',
tooltip: context.l10n.dialogClear,
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
@@ -3954,7 +3954,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return Semantics(
button: true,
label: 'Open $title, $count ${count == 1 ? 'item' : 'items'}',
label: context.l10n.a11yOpenItemCount(title, count),
child: GestureDetector(
onTap: onTap,
onLongPress: onLongPress,
@@ -4909,7 +4909,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}) {
return Semantics(
button: true,
label: 'Open album $albumName by $artistName, $trackCount tracks',
label: context.l10n.a11yOpenAlbumByArtistTrackCount(
albumName,
artistName,
trackCount,
),
child: GestureDetector(
onTap: onTap,
child: Column(
@@ -6444,7 +6448,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
onPressed: () =>
ref.read(downloadQueueProvider.notifier).cancelItem(item.id),
icon: Icon(Icons.close, color: colorScheme.error),
tooltip: 'Cancel',
tooltip: context.l10n.dialogCancel,
style: IconButton.styleFrom(
backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3),
),
@@ -6454,14 +6458,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
onPressed: () =>
ref.read(downloadQueueProvider.notifier).cancelItem(item.id),
icon: Icon(Icons.stop, color: colorScheme.error),
tooltip: 'Stop',
tooltip: context.l10n.actionStop,
style: IconButton.styleFrom(
backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3),
),
);
case DownloadStatus.finalizing:
return Semantics(
label: 'Finalizing download',
label: context.l10n.queueFinalizingDownload,
child: SizedBox(
width: 40,
height: 40,
@@ -6500,7 +6504,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
coverUrl: item.track.coverUrl ?? '',
),
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
tooltip: 'Play',
tooltip: context.l10n.tooltipPlay,
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer.withValues(
alpha: 0.3,
@@ -6509,7 +6513,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
)
else
Semantics(
label: 'Downloaded file missing',
label: context.l10n.queueDownloadedFileMissing,
child: ExcludeSemantics(
child: Icon(
Icons.error_outline,
@@ -6520,7 +6524,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
const SizedBox(width: 4),
Semantics(
label: 'Download completed',
label: context.l10n.queueDownloadCompleted,
child: ExcludeSemantics(
child: Container(
padding: const EdgeInsets.all(8),
@@ -6549,7 +6553,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
onPressed: () =>
ref.read(downloadQueueProvider.notifier).retryItem(item.id),
icon: Icon(Icons.refresh, color: colorScheme.primary),
tooltip: 'Retry',
tooltip: context.l10n.dialogRetry,
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer.withValues(
alpha: 0.3,
@@ -6566,7 +6570,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
? colorScheme.error
: colorScheme.onSurfaceVariant,
),
tooltip: 'Remove',
tooltip: context.l10n.dialogRemove,
style: item.status == DownloadStatus.failed
? IconButton.styleFrom(
backgroundColor: colorScheme.errorContainer.withValues(
@@ -6743,7 +6747,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
: colorScheme.onSecondaryContainer;
return Semantics(
label: '${item.trackName} by ${item.artistName}',
label: context.l10n.a11yTrackByArtist(
item.trackName,
item.artistName,
),
selected: isSelected,
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
+1 -1
View File
@@ -142,7 +142,7 @@ class _RepoTabState extends ConsumerState<RepoTab> {
prefixIcon: const Icon(Icons.search),
suffixIcon: value.text.isNotEmpty
? IconButton(
tooltip: 'Clear',
tooltip: context.l10n.dialogClear,
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
+3 -3
View File
@@ -70,7 +70,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
controller: _searchController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: 'Search tracks...',
hintText: context.l10n.searchTracksHint,
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
border: InputBorder.none,
enabledBorder: InputBorder.none,
@@ -124,7 +124,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
Icon(Icons.search, size: 64, color: colorScheme.onSurfaceVariant),
const SizedBox(height: 16),
Text(
'Search for tracks',
context.l10n.searchTracksEmptyPrompt,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -194,7 +194,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
children: [
IconButton(
icon: const Icon(Icons.download_rounded),
tooltip: 'Download',
tooltip: context.l10n.dialogDownload,
onPressed: () => _downloadTrack(track),
),
],
@@ -358,7 +358,7 @@ class _ColorPalettePicker extends StatelessWidget {
child: Semantics(
button: true,
selected: isSelected,
label: 'Select accent color $colorHex',
label: context.l10n.appearanceSelectAccentColor(colorHex),
child: GestureDetector(
onTap: () => onColorSelected(color),
child: _ColorPaletteItem(color: color, isSelected: isSelected),
+125 -26
View File
@@ -297,11 +297,34 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
@override
Widget build(BuildContext context) {
final settings = ref.watch(settingsProvider);
final extensionState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = normalizedHeaderTopPadding(context);
final extensionDownloadProviders = extensionState.extensions
.where(
(extension) => extension.enabled && extension.hasDownloadProvider,
)
.toList(growable: false);
final extensionNotifier = ref.read(extensionProvider.notifier);
final hasDownloadProviders =
builtInDownloadProviderSpecs.isNotEmpty ||
extensionDownloadProviders.isNotEmpty;
final isBuiltInService = isBuiltInDownloadProvider(settings.defaultService);
final isTidalService = settings.defaultService == 'tidal';
final replacedBuiltInServiceId = builtInDownloadProviderSpecs
.map((provider) => provider.id)
.where(
(providerId) => extensionNotifier.downloadProviderMatchesBuiltIn(
settings.defaultService,
providerId,
),
)
.firstOrNull;
final effectiveBuiltInServiceId = isBuiltInService
? settings.defaultService
: replacedBuiltInServiceId;
final isBuiltInCompatibleService = effectiveBuiltInServiceId != null;
final isTidalService = effectiveBuiltInServiceId == 'tidal';
return PopScope(
canPop: true,
@@ -375,17 +398,19 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
SettingsSwitchItem(
icon: Icons.tune,
title: context.l10n.downloadAskBeforeDownload,
subtitle: isBuiltInService
subtitle: !hasDownloadProviders
? context.l10n.extensionsNoDownloadProvider
: isBuiltInCompatibleService
? context.l10n.downloadAskQualitySubtitle
: context.l10n.downloadSelectServiceToEnable,
value: settings.askQualityBeforeDownload,
enabled: isBuiltInService,
enabled: hasDownloadProviders && isBuiltInCompatibleService,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setAskQualityBeforeDownload(value),
),
if (!settings.askQualityBeforeDownload &&
isBuiltInService) ...[
isBuiltInCompatibleService) ...[
_QualityOption(
title: context.l10n.qualityFlacLossless,
subtitle: context.l10n.qualityFlacLosslessSubtitle,
@@ -441,7 +466,13 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
showDivider: false,
),
],
if (!isBuiltInService) ...[
if (!hasDownloadProviders) ...[
_InlineInfoMessage(
icon: Icons.extension_outlined,
text: context.l10n.extensionsNoDownloadProvider,
secondaryText: context.l10n.storeAddRepoDescription,
),
] else if (!isBuiltInCompatibleService) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Row(
@@ -1076,7 +1107,10 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
TextField(
controller: controller,
decoration: InputDecoration(
hintText: '{artist} - {title}',
hintText: context.l10n.downloadFilenameHintExample(
'{artist}',
'{title}',
),
filled: true,
fillColor: colorScheme.surfaceContainerHighest
.withValues(alpha: 0.3),
@@ -2070,34 +2104,42 @@ class _ServiceSelector extends ConsumerWidget {
padding: const EdgeInsets.all(12),
child: Column(
children: [
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final provider in builtInProviders)
_ServiceChip(
icon: resolveProviderIcon(provider.id),
label: provider.displayName,
isSelected: effectiveService == provider.id,
onTap: () => onChanged(provider.id),
),
],
),
if (extensionProviders.isNotEmpty) ...[
const SizedBox(height: 8),
if (builtInProviders.isEmpty && extensionProviders.isEmpty)
_InlineInfoMessage(
icon: Icons.extension_outlined,
text: context.l10n.extensionsNoDownloadProvider,
secondaryText: context.l10n.storeAddRepoDescription,
)
else ...[
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final extension in extensionProviders)
for (final provider in builtInProviders)
_ServiceChip(
icon: Icons.extension,
label: extension.displayName,
isSelected: effectiveService == extension.id,
onTap: () => onChanged(extension.id),
icon: resolveProviderIcon(provider.id),
label: provider.displayName,
isSelected: effectiveService == provider.id,
onTap: () => onChanged(provider.id),
),
],
),
if (extensionProviders.isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final extension in extensionProviders)
_ServiceChip(
icon: Icons.extension,
label: extension.displayName,
isSelected: effectiveService == extension.id,
onTap: () => onChanged(extension.id),
),
],
),
],
],
],
),
@@ -2105,6 +2147,63 @@ class _ServiceSelector extends ConsumerWidget {
}
}
class _InlineInfoMessage extends StatelessWidget {
final IconData icon;
final String text;
final String? secondaryText;
const _InlineInfoMessage({
required this.icon,
required this.text,
this.secondaryText,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.4),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 18, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
text,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
if (secondaryText != null && secondaryText!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
secondaryText!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
],
),
),
],
),
);
}
}
class _ServiceChip extends StatelessWidget {
final IconData icon;
final String label;
+17 -19
View File
@@ -160,12 +160,14 @@ class _LogScreenState extends State<LogScreen> {
? Icons.vertical_align_bottom
: Icons.vertical_align_center,
),
tooltip: _autoScroll ? 'Auto-scroll ON' : 'Auto-scroll OFF',
tooltip: _autoScroll
? context.l10n.logAutoScrollOn
: context.l10n.logAutoScrollOff,
onPressed: () => setState(() => _autoScroll = !_autoScroll),
),
IconButton(
icon: const Icon(Icons.copy),
tooltip: 'Copy logs',
tooltip: context.l10n.logCopyLogs,
onPressed: _copyLogs,
),
PopupMenuButton<String>(
@@ -327,7 +329,7 @@ class _LogScreenState extends State<LogScreen> {
fillColor: colorScheme.surfaceContainerHighest,
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
tooltip: 'Clear search',
tooltip: context.l10n.logClearSearch,
icon: const Icon(Icons.clear, size: 20),
onPressed: () {
_searchController.clear();
@@ -609,11 +611,9 @@ class _LogSummaryCard extends StatelessWidget {
if (analysis.hasISPBlocking) ...[
_IssueBadge(
icon: Icons.block,
label: 'ISP BLOCKING DETECTED',
description:
'Your ISP may be blocking access to download services',
suggestion:
'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8',
label: context.l10n.logIssueIspBlockingLabel,
description: context.l10n.logIssueIspBlockingDescription,
suggestion: context.l10n.logIssueIspBlockingSuggestion,
color: colorScheme.error,
domains: analysis.blockedDomains,
),
@@ -623,9 +623,9 @@ class _LogSummaryCard extends StatelessWidget {
if (analysis.hasRateLimit) ...[
_IssueBadge(
icon: Icons.speed,
label: 'RATE LIMITED',
description: 'Too many requests to the service',
suggestion: 'Wait a few minutes before trying again',
label: context.l10n.logIssueRateLimitedLabel,
description: context.l10n.logIssueRateLimitedDescription,
suggestion: context.l10n.logIssueRateLimitedSuggestion,
color: Colors.orange,
),
const SizedBox(height: 8),
@@ -634,9 +634,9 @@ class _LogSummaryCard extends StatelessWidget {
if (analysis.hasNetworkError && !analysis.hasISPBlocking) ...[
_IssueBadge(
icon: Icons.wifi_off,
label: 'NETWORK ERROR',
description: 'Connection issues detected',
suggestion: 'Check your internet connection',
label: context.l10n.logIssueNetworkErrorLabel,
description: context.l10n.logIssueNetworkErrorDescription,
suggestion: context.l10n.logIssueNetworkErrorSuggestion,
color: colorScheme.tertiary,
),
const SizedBox(height: 8),
@@ -645,11 +645,9 @@ class _LogSummaryCard extends StatelessWidget {
if (analysis.hasNotFound) ...[
_IssueBadge(
icon: Icons.search_off,
label: 'TRACK NOT FOUND',
description:
'Some tracks could not be found on download services',
suggestion:
'The track may not be available in lossless quality',
label: context.l10n.logIssueTrackNotFoundLabel,
description: context.l10n.logIssueTrackNotFoundDescription,
suggestion: context.l10n.logIssueTrackNotFoundSuggestion,
color: colorScheme.onSurfaceVariant,
),
],
@@ -107,10 +107,10 @@ class OptionsSettingsPage extends ConsumerWidget {
),
SettingsSwitchItem(
icon: Icons.sell_outlined,
title: 'Embed Metadata',
title: context.l10n.optionsEmbedMetadata,
subtitle: settings.embedMetadata
? 'Write metadata, cover art, and embedded lyrics to files'
: 'Disabled (advanced): skip all metadata embedding',
? context.l10n.optionsEmbedMetadataSubtitleOn
: context.l10n.optionsEmbedMetadataSubtitleOff,
value: settings.embedMetadata,
onChanged: (v) =>
ref.read(settingsProvider.notifier).setEmbedMetadata(v),
@@ -135,7 +135,7 @@ class OptionsSettingsPage extends ConsumerWidget {
title: context.l10n.optionsMaxQualityCover,
subtitle: settings.embedMetadata
? context.l10n.optionsMaxQualityCoverSubtitle
: 'Disabled when metadata embedding is off',
: context.l10n.optionsMaxQualityCoverSubtitleDisabled,
value: settings.maxQualityCover,
enabled: settings.embedMetadata,
onChanged: (v) => ref
+1 -1
View File
@@ -749,7 +749,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
overflow: TextOverflow.ellipsis,
),
trailing: IconButton(
tooltip: 'Change folder',
tooltip: context.l10n.setupChangeFolderTooltip,
icon: const Icon(Icons.edit),
onPressed: _selectDirectory,
),
@@ -44,13 +44,18 @@ class _ExtensionDetailsScreenState
_buildDescription(context, liveExtension, colorScheme),
if (liveExtension.tags.isNotEmpty) ...[
_buildSectionHeader(context, 'Tags', Icons.tag, colorScheme),
_buildSectionHeader(
context,
context.l10n.extensionDetailsTags,
Icons.tag,
colorScheme,
),
_buildTags(context, liveExtension, colorScheme),
],
_buildSectionHeader(
context,
'Information',
context.l10n.extensionDetailsInformation,
Icons.table_chart_outlined,
colorScheme,
),
@@ -438,7 +443,7 @@ class _ExtensionDetailsScreenState
),
_CapabilityRow(
icon: Icons.build,
label: 'Utility Functions',
label: context.l10n.extensionUtilityFunctions,
enabled: isUtility,
colorScheme: colorScheme,
isLast: true,
+10 -6
View File
@@ -6037,7 +6037,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
const LinearProgressIndicator(minHeight: 2)
else if (!hasCurrentCover)
Text(
'No embedded album art found',
context.l10n.trackCoverNoEmbeddedArt,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
@@ -6050,14 +6050,16 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
onPressed: _saving ? null : _pickCoverImage,
icon: const Icon(Icons.image_outlined),
label: Text(
hasSelectedCover ? 'Replace Cover' : 'Pick Cover',
hasSelectedCover
? context.l10n.trackCoverReplace
: context.l10n.trackCoverPick,
),
),
),
if (hasSelectedCover) ...[
const SizedBox(width: 8),
IconButton(
tooltip: 'Clear selected cover',
tooltip: context.l10n.trackCoverClearSelected,
onPressed: _saving
? null
: () async {
@@ -6079,7 +6081,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
child: _buildCoverPreviewTile(
cs: cs,
path: _currentCoverPath!,
label: 'Current cover',
label: context.l10n.trackCoverCurrent,
),
),
if (hasCurrentCover && hasSelectedCover)
@@ -6089,7 +6091,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
child: _buildCoverPreviewTile(
cs: cs,
path: _selectedCoverPath!,
label: _selectedCoverName ?? 'Selected cover',
label:
_selectedCoverName ??
context.l10n.trackCoverSelected,
),
),
],
@@ -6097,7 +6101,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
if (hasSelectedCover) ...[
const SizedBox(height: 8),
Text(
'The selected cover will replace the current embedded cover when you tap Save.',
context.l10n.trackCoverReplaceNotice,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
+4 -4
View File
@@ -408,7 +408,7 @@ class _InteractiveSearchExampleState extends State<_InteractiveSearchExample> {
},
style: TextStyle(color: colorScheme.onSurface, fontSize: 16),
decoration: InputDecoration(
hintText: 'Paste or search...',
hintText: context.l10n.tutorialSearchHint,
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
prefixIcon: Icon(Icons.search, color: colorScheme.primary),
filled: true,
@@ -619,10 +619,10 @@ class _InteractiveDownloadExampleState
Semantics(
button: true,
label: _isCompleted
? 'Download completed'
? context.l10n.tutorialDownloadCompletedSemantics
: _isDownloading
? 'Download in progress'
: 'Start download',
? context.l10n.tutorialDownloadInProgressSemantics
: context.l10n.tutorialStartDownloadSemantics,
child: GestureDetector(
onTap: _startDownload,
child: AnimatedContainer(
@@ -108,7 +108,7 @@ class LocalTrackRedownloadService {
case 'deezer':
return settings.defaultService.toLowerCase();
default:
return 'tidal';
return '';
}
}
-25
View File
@@ -547,22 +547,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> getTidalMetadata(
String resourceType,
String resourceId,
) async {
final result = await _channel.invokeMethod('getTidalMetadata', {
'resource_type': resourceType,
'resource_id': resourceId,
});
if (result == null) {
throw Exception(
'getTidalMetadata returned null for $resourceType:$resourceId',
);
}
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> getProviderMetadata(
String providerId,
String resourceType,
@@ -581,15 +565,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> convertTidalToSpotifyDeezer(
String tidalUrl,
) async {
final result = await _channel.invokeMethod('convertTidalToSpotifyDeezer', {
'url': tidalUrl,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> searchDeezerByISRC(
String isrc, {
String? itemId,
+8 -5
View File
@@ -1,5 +1,6 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:spotiflac_android/l10n/l10n.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';
@@ -51,7 +52,7 @@ Future<void> navigateToArtist(
return;
}
_showLoadingSnackBar(context, 'Looking up artist...');
_showLoadingSnackBar(context, context.l10n.clickableLookingUpArtist);
try {
final artistList = await _searchDeezerExtension(
artistName,
@@ -62,7 +63,7 @@ Future<void> navigateToArtist(
ScaffoldMessenger.of(context).hideCurrentSnackBar();
if (artistList.isEmpty) {
_showUnavailable(context, 'Artist');
_showUnavailable(context, context.l10n.trackArtist);
return;
}
@@ -82,7 +83,7 @@ Future<void> navigateToArtist(
final resolvedImage = bestMatch['images'] as String?;
if (resolvedId.isEmpty) {
_showUnavailable(context, 'Artist');
_showUnavailable(context, context.l10n.trackArtist);
return;
}
@@ -98,7 +99,7 @@ Future<void> navigateToArtist(
_log.e('Failed to look up artist "$artistName": $e', e);
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
_showUnavailable(context, 'Artist');
_showUnavailable(context, context.l10n.trackArtist);
}
}
@@ -290,7 +291,9 @@ void _showLoadingSnackBar(BuildContext context, String message) {
void _showUnavailable(BuildContext context, String type) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('$type information not available')));
).showSnackBar(
SnackBar(content: Text(context.l10n.clickableInformationUnavailable(type))),
);
}
class ClickableArtistName extends StatefulWidget {
+214 -84
View File
@@ -21,8 +21,8 @@ class BuiltInService {
});
}
const _builtInServices = [
BuiltInService(
const _builtInServiceCatalog = {
'tidal': BuiltInService(
id: 'tidal',
label: 'Tidal',
qualityOptions: [
@@ -43,7 +43,7 @@ const _builtInServices = [
),
],
),
BuiltInService(
'qobuz': BuiltInService(
id: 'qobuz',
label: 'Qobuz',
qualityOptions: [
@@ -64,7 +64,7 @@ const _builtInServices = [
),
],
),
];
};
class DownloadServicePicker extends ConsumerStatefulWidget {
final String? trackName;
@@ -118,58 +118,93 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
late String _selectedService;
List<BuiltInService> _availableBuiltInServices() {
final availableIds = builtInDownloadProviderIds.toSet();
final services = <BuiltInService>[];
for (final id in availableIds) {
final service = _builtInServiceCatalog[id];
if (service != null) {
services.add(service);
}
}
return services;
}
List<Extension> _downloadExtensions() {
final extensionState = ref.read(extensionProvider);
return extensionState.extensions
.where((ext) => ext.enabled && ext.hasDownloadProvider)
.toList(growable: false);
}
bool _serviceExists(
String serviceId,
List<BuiltInService> builtInServices,
List<Extension> downloadExtensions,
) {
if (serviceId.isEmpty) return false;
if (builtInServices.any((service) => service.id == serviceId)) return true;
return downloadExtensions.any((ext) => ext.id == serviceId);
}
@override
void initState() {
super.initState();
final builtInServices = _availableBuiltInServices();
final downloadExtensions = _downloadExtensions();
final recommended = widget.recommendedService;
if (recommended != null && recommended.isNotEmpty) {
if (recommended != null &&
_serviceExists(recommended, builtInServices, downloadExtensions)) {
_selectedService = recommended;
} else {
_selectedService = ref.read(settingsProvider).defaultService;
}
if (!_builtInServices.any((service) => service.id == _selectedService)) {
final extensionState = ref.read(extensionProvider);
final hasMatchingExtension = extensionState.extensions.any(
(ext) =>
ext.enabled &&
ext.hasDownloadProvider &&
ext.id == _selectedService,
);
if (!hasMatchingExtension) {
_selectedService = 'tidal';
}
if (!_serviceExists(
_selectedService,
builtInServices,
downloadExtensions,
)) {
_selectedService = builtInServices.isNotEmpty
? builtInServices.first.id
: downloadExtensions.isNotEmpty
? downloadExtensions.first.id
: '';
}
}
List<QualityOption> _getQualityOptions() {
final builtIn = _builtInServices
.where((s) => s.id == _selectedService)
List<QualityOption> _getQualityOptions(
List<BuiltInService> builtInServices,
List<Extension> downloadExtensions,
) {
final builtIn = builtInServices
.where((service) => service.id == _selectedService)
.firstOrNull;
if (builtIn != null) {
return builtIn.qualityOptions;
}
final extensionState = ref.read(extensionProvider);
final ext = extensionState.extensions
final ext = downloadExtensions
.where((e) => e.id == _selectedService)
.firstOrNull;
if (ext != null && ext.qualityOptions.isNotEmpty) {
return ext.qualityOptions;
}
return _builtInServices.firstWhere((s) => s.id == 'tidal').qualityOptions;
return const [];
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final extensionState = ref.watch(extensionProvider);
final downloadExtensions = extensionState.extensions
.where((ext) => ext.enabled && ext.hasDownloadProvider)
.toList();
final qualityOptions = _getQualityOptions();
ref.watch(extensionProvider);
final builtInServices = _availableBuiltInServices();
final downloadExtensions = _downloadExtensions();
final hasProviders =
builtInServices.isNotEmpty || downloadExtensions.isNotEmpty;
final qualityOptions = _getQualityOptions(
builtInServices,
downloadExtensions,
);
return SafeArea(
child: SingleChildScrollView(
@@ -213,68 +248,77 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final service in _builtInServices)
_ServiceChip(
label: service.isDisabled
? '${service.label} (${service.disabledReason})'
: widget.recommendedService == service.id
? '${service.label} (Recommended)'
: service.label,
isSelected: _selectedService == service.id,
isDisabled: service.isDisabled,
onTap: service.isDisabled
? null
: () => setState(() => _selectedService = service.id),
child: hasProviders
? Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final service in builtInServices)
_ServiceChip(
label: service.isDisabled
? '${service.label} (${service.disabledReason})'
: widget.recommendedService == service.id
? '${service.label} (Recommended)'
: service.label,
isSelected: _selectedService == service.id,
isDisabled: service.isDisabled,
onTap: service.isDisabled
? null
: () => setState(
() => _selectedService = service.id,
),
),
for (final ext in downloadExtensions)
_ServiceChip(
label: widget.recommendedService == ext.id
? '${ext.displayName} (Recommended)'
: ext.displayName,
isSelected: _selectedService == ext.id,
onTap: () =>
setState(() => _selectedService = ext.id),
iconPath: ext.iconPath,
),
],
)
: _NoDownloadProviderHint(
primaryText: context.l10n.extensionsNoDownloadProvider,
secondaryText: context.l10n.storeAddRepoDescription,
),
for (final ext in downloadExtensions)
_ServiceChip(
label: widget.recommendedService == ext.id
? '${ext.displayName} (Recommended)'
: ext.displayName,
isSelected: _selectedService == ext.id,
onTap: () => setState(() => _selectedService = ext.id),
iconPath: ext.iconPath,
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text(
context.l10n.downloadSelectQuality,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
if (_builtInServices.any((s) => s.id == _selectedService))
if (hasProviders) ...[
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text(
context.l10n.qualityNote,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
context.l10n.downloadSelectQuality,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
for (final quality in qualityOptions)
_QualityOption(
title: quality.label,
subtitle: quality.description ?? '',
icon: _getQualityIcon(quality.id),
onTap: () {
Navigator.pop(context);
widget.onSelect(quality.id, _selectedService);
},
),
if (builtInServices.any(
(service) => service.id == _selectedService,
))
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text(
context.l10n.qualityNote,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
for (final quality in qualityOptions)
_QualityOption(
title: _localizedQualityLabel(context, quality),
subtitle: _localizedQualityDescription(context, quality),
icon: _getQualityIcon(quality.id),
onTap: () {
Navigator.pop(context);
widget.onSelect(quality.id, _selectedService);
},
),
],
const SizedBox(height: 16),
],
@@ -303,6 +347,35 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
return Icons.music_note;
}
}
String _localizedQualityLabel(BuildContext context, QualityOption quality) {
switch (quality.id.toUpperCase()) {
case 'LOSSLESS':
return context.l10n.qualityFlacLossless;
case 'HI_RES':
return context.l10n.qualityHiResFlac;
case 'HI_RES_LOSSLESS':
return context.l10n.qualityHiResFlacMax;
default:
return quality.label;
}
}
String _localizedQualityDescription(
BuildContext context,
QualityOption quality,
) {
switch (quality.id.toUpperCase()) {
case 'LOSSLESS':
return context.l10n.qualityFlacLosslessSubtitle;
case 'HI_RES':
return context.l10n.qualityHiResFlacSubtitle;
case 'HI_RES_LOSSLESS':
return context.l10n.qualityHiResFlacMaxSubtitle;
default:
return quality.description ?? '';
}
}
}
class _QualityOption extends StatelessWidget {
@@ -421,6 +494,63 @@ class _ServiceChip extends StatelessWidget {
}
}
class _NoDownloadProviderHint extends StatelessWidget {
final String primaryText;
final String secondaryText;
const _NoDownloadProviderHint({
required this.primaryText,
required this.secondaryText,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.4),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.extension_outlined,
size: 18,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
primaryText,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Text(
secondaryText,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
);
}
}
class _TrackInfoHeader extends StatefulWidget {
final String trackName;
final String? artistName;