Compare commits

...

12 Commits

Author SHA1 Message Date
zarzet fb90c73f42 fix: use Tidal quality options as fallback instead of DEFAULT for extensions 2026-03-29 18:57:13 +07:00
zarzet c6cf65f075 fix: normalize DEFAULT quality to prevent Tidal/Qobuz API failures 2026-03-29 18:49:57 +07:00
zarzet 25de009ebc feat: replace batch operation snackbars with progress dialog
Add reusable BatchProgressDialog widget with circular/linear progress
indicators, cancel support, and track detail display. Uses ValueNotifier
pattern to communicate progress from caller to dialog across navigator
routes.
2026-03-29 18:04:38 +07:00
zarzet 8918d74bb5 refactor: extract and improve ReEnrich track selection with scoring-based matching 2026-03-29 17:45:51 +07:00
zarzet f9de8d45d9 fix: add attached_pic disposition to ALAC cover art embedding 2026-03-29 17:41:43 +07:00
zarzet 48eef0853d i18n: extract hardcoded strings into l10n keys
Move hardcoded UI strings across multiple screens and the notification
service into ARB-backed l10n keys so they can be translated via Crowdin.
Adds 62 new keys covering sort labels, dialog copy, metadata error
snackbars, folder-picker errors, home-tab error states, extensions home
feed selector, and all notification titles/bodies. NotificationService
now caches an AppLocalizations instance (injected from MainShell via
didChangeDependencies) and falls back to English literals when no locale
is available.
2026-03-29 17:02:12 +07:00
zarzet fc70a912bf refactor: route spotify URLs through extensions 2026-03-29 16:35:16 +07:00
zarzet cd3e5b4b28 chore: bump version to 4.1.2+119 2026-03-29 15:40:24 +07:00
zarzet 482ca82eb4 feat: improve track matching 2026-03-29 15:34:44 +07:00
zarzet 6d87ae5484 feat: add haptic feedback when swiping library tabs 2026-03-29 01:56:22 +07:00
zarzet bd3e2b999b feat: add play button to playlist/library track tiles
Show a play IconButton (matching local album style) next to the
more-options button when a track has a local file available.
Uses PlaybackController.playTrackList to resolve and open the file.
2026-03-29 01:54:27 +07:00
zarzet 186196e12b fix: use START_NOT_STICKY for DownloadService to prevent auto-restart
Prevents Android from automatically recreating the download service
after it is killed, avoiding duplicate or orphaned download processes.
2026-03-29 01:37:24 +07:00
53 changed files with 4268 additions and 714 deletions
@@ -104,7 +104,7 @@ class DownloadService : Service() {
updateNotification(progress, total)
}
}
return START_STICKY
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
@@ -1941,13 +1941,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
"parseSpotifyUrl" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.parseSpotifyURL(url)
}
result.success(response)
}
"checkAvailability" -> {
val spotifyId = call.argument<String>("spotify_id") ?: ""
val isrc = call.argument<String>("isrc") ?: ""
@@ -2711,13 +2704,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"getSpotifyMetadataWithFallback" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getSpotifyMetadataWithDeezerFallback(url)
}
result.success(response)
}
"checkAvailabilityFromDeezerID" -> {
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
val response = withContext(Dispatchers.IO) {
+9 -7
View File
@@ -204,7 +204,7 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
}
if deezerID != "" {
trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID)
if err := verifyDeezerTrack(req, deezerID); err != nil {
if err := verifyDeezerTrack(req, deezerID, false); err != nil {
GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err)
// Don't reject direct IDs from request payload — they're presumably correct.
}
@@ -219,7 +219,7 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
if err == nil && availability.Deezer && availability.DeezerURL != "" {
resolvedID := extractDeezerIDFromURL(availability.DeezerURL)
if resolvedID != "" {
if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil {
if verifyErr := verifyDeezerTrack(req, resolvedID, true); verifyErr != nil {
GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr)
// Fall through to ISRC search instead of using wrong track.
} else {
@@ -240,7 +240,7 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
if err == nil && track != nil {
resolvedID := songLinkExtractDeezerTrackID(track)
if resolvedID != "" {
if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil {
if verifyErr := verifyDeezerTrack(req, resolvedID, false); verifyErr != nil {
GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr)
return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr)
}
@@ -252,7 +252,7 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
return "", fmt.Errorf("could not resolve Deezer track URL")
}
func verifyDeezerTrack(req DownloadRequest, deezerID string) error {
func verifyDeezerTrack(req DownloadRequest, deezerID string, skipNameVerification bool) error {
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
defer cancel()
trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID)
@@ -260,9 +260,11 @@ func verifyDeezerTrack(req DownloadRequest, deezerID string) error {
return nil // Can't verify — don't block the download.
}
resolved := resolvedTrackInfo{
Title: trackResp.Track.Name,
ArtistName: trackResp.Track.Artists,
Duration: trackResp.Track.DurationMS / 1000,
Title: trackResp.Track.Name,
ArtistName: trackResp.Track.Artists,
ISRC: trackResp.Track.ISRC,
Duration: trackResp.Track.DurationMS / 1000,
SkipNameVerification: skipNameVerification,
}
if !trackMatchesRequest(req, resolved, "Deezer") {
return fmt.Errorf("expected '%s - %s', got '%s - %s'",
+278 -138
View File
@@ -13,25 +13,6 @@ import (
"github.com/dop251/goja"
)
func ParseSpotifyURL(url string) (string, error) {
parsed, err := parseSpotifyURI(url)
if err != nil {
return "", err
}
result := map[string]string{
"type": parsed.Type,
"id": parsed.ID,
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func CheckAvailability(spotifyID, isrc string) (string, error) {
client := NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
@@ -135,6 +116,270 @@ type DownloadResult struct {
DecryptionKey string
}
type reEnrichRequest struct {
FilePath string `json:"file_path"`
CoverURL string `json:"cover_url"`
MaxQuality bool `json:"max_quality"`
EmbedLyrics bool `json:"embed_lyrics"`
SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist"`
TrackNumber int `json:"track_number"`
DiscNumber int `json:"disc_number"`
ReleaseDate string `json:"release_date"`
ISRC string `json:"isrc"`
Genre string `json:"genre"`
Label string `json:"label"`
Copyright string `json:"copyright"`
DurationMs int64 `json:"duration_ms"`
SearchOnline bool `json:"search_online"`
}
func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
if req == nil {
return
}
if track.SpotifyID != "" {
req.SpotifyID = track.SpotifyID
} else if track.DeezerID != "" {
req.SpotifyID = "deezer:" + track.DeezerID
} else if track.QobuzID != "" {
req.SpotifyID = "qobuz:" + track.QobuzID
} else if track.TidalID != "" {
req.SpotifyID = "tidal:" + track.TidalID
} else if track.ID != "" {
req.SpotifyID = track.ID
}
if track.AlbumName != "" {
req.AlbumName = track.AlbumName
}
if track.AlbumArtist != "" {
req.AlbumArtist = track.AlbumArtist
}
if track.TrackNumber > 0 {
req.TrackNumber = track.TrackNumber
}
if track.DiscNumber > 0 {
req.DiscNumber = track.DiscNumber
}
if track.ReleaseDate != "" {
req.ReleaseDate = track.ReleaseDate
}
if track.ISRC != "" {
req.ISRC = track.ISRC
}
if coverURL := track.ResolvedCoverURL(); coverURL != "" {
req.CoverURL = coverURL
}
if track.DurationMS > 0 {
req.DurationMs = int64(track.DurationMS)
}
if track.Genre != "" {
req.Genre = track.Genre
}
if track.Label != "" {
req.Label = track.Label
}
if track.Copyright != "" {
req.Copyright = track.Copyright
}
}
func reEnrichDownloadRequest(req reEnrichRequest) DownloadRequest {
return DownloadRequest{
TrackName: req.TrackName,
ArtistName: req.ArtistName,
AlbumName: req.AlbumName,
ReleaseDate: req.ReleaseDate,
ISRC: req.ISRC,
DurationMS: int(req.DurationMs),
}
}
func selectBestReEnrichTrack(req reEnrichRequest, tracks []ExtTrackMetadata) *ExtTrackMetadata {
if len(tracks) == 0 {
return nil
}
downloadReq := reEnrichDownloadRequest(req)
currentISRC := strings.TrimSpace(req.ISRC)
currentAlbum := strings.TrimSpace(req.AlbumName)
var best *ExtTrackMetadata
bestScore := -1 << 30
for i := range tracks {
track := &tracks[i]
score := 0
resolved := resolvedTrackInfo{
Title: track.Name,
ArtistName: track.Artists,
ISRC: track.ISRC,
Duration: track.DurationMS / 1000,
}
if trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
score += 2000
}
if currentISRC != "" && strings.EqualFold(currentISRC, strings.TrimSpace(track.ISRC)) {
score += 10000
}
if req.TrackName != "" && track.Name != "" && titlesMatch(req.TrackName, track.Name) {
score += 400
}
if req.ArtistName != "" && track.Artists != "" && artistsMatch(req.ArtistName, track.Artists) {
score += 320
}
if currentAlbum != "" && track.AlbumName != "" {
switch {
case titlesMatch(currentAlbum, track.AlbumName):
score += 120
case strings.Contains(strings.ToLower(track.AlbumName), strings.ToLower(currentAlbum)),
strings.Contains(strings.ToLower(currentAlbum), strings.ToLower(track.AlbumName)):
score += 50
}
}
if req.DurationMs > 0 && track.DurationMS > 0 {
diff := int(req.DurationMs/1000) - (track.DurationMS / 1000)
if diff < 0 {
diff = -diff
}
if diff <= 10 {
score += 80
}
}
if track.ReleaseDate != "" {
score += 70
}
if track.TrackNumber > 0 {
score += 20
}
if track.DiscNumber > 0 {
score += 10
}
if track.ISRC != "" {
score += 40
}
if best == nil || score > bestScore {
best = track
bestScore = score
}
}
return best
}
func extTrackFromTrackMetadata(track *TrackMetadata, providerID string) *ExtTrackMetadata {
if track == nil {
return nil
}
deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:"))
return &ExtTrackMetadata{
ID: track.SpotifyID,
Name: track.Name,
Artists: track.Artists,
AlbumName: track.AlbumName,
AlbumArtist: track.AlbumArtist,
DurationMS: track.DurationMS,
CoverURL: track.Images,
Images: track.Images,
ReleaseDate: track.ReleaseDate,
TrackNumber: track.TrackNumber,
DiscNumber: track.DiscNumber,
ISRC: track.ISRC,
ProviderID: providerID,
DeezerID: deezerID,
SpotifyID: track.SpotifyID,
}
}
func normalizeReEnrichSpotifyTrackID(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
if extracted := extractSpotifyIDFromURL(trimmed); extracted != "" {
return extracted
}
if len(trimmed) == 22 && !strings.Contains(trimmed, ":") && !strings.Contains(trimmed, "/") {
return trimmed
}
return ""
}
func resolveReEnrichTrackFromIdentifiers(req reEnrichRequest) (*ExtTrackMetadata, error) {
deezerClient := GetDeezerClient()
downloadReq := reEnrichDownloadRequest(req)
if isrc := strings.TrimSpace(req.ISRC); isrc != "" {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
track, err := deezerClient.SearchByISRC(ctx, isrc)
cancel()
if err == nil && track != nil {
resolved := resolvedTrackInfo{
Title: track.Name,
ArtistName: track.Artists,
ISRC: track.ISRC,
Duration: track.DurationMS / 1000,
}
if trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
return extTrackFromTrackMetadata(track, "deezer"), nil
}
}
}
sourceTrackID := strings.TrimSpace(req.SpotifyID)
if sourceTrackID == "" {
return nil, nil
}
deezerID := strings.TrimSpace(strings.TrimPrefix(sourceTrackID, "deezer:"))
if deezerID == sourceTrackID {
deezerID = extractDeezerIDFromURL(sourceTrackID)
}
if deezerID == "" {
spotifyID := normalizeReEnrichSpotifyTrackID(sourceTrackID)
if spotifyID != "" {
resolvedDeezerID, err := NewSongLinkClient().GetDeezerIDFromSpotify(spotifyID)
if err == nil {
deezerID = strings.TrimSpace(resolvedDeezerID)
}
}
}
if deezerID == "" {
return nil, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
if err != nil || trackResp == nil {
return nil, err
}
track := &trackResp.Track
resolved := resolvedTrackInfo{
Title: track.Name,
ArtistName: track.Artists,
ISRC: track.ISRC,
Duration: track.DurationMS / 1000,
}
if !trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
return nil, nil
}
return extTrackFromTrackMetadata(track, "deezer"), nil
}
func preferredReleaseMetadata(
req DownloadRequest,
album string,
@@ -1526,72 +1771,6 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
return "", fmt.Errorf("Spotify to Deezer conversion only supported for tracks and albums. Please search by name for %s", resourceType)
}
func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
spotFetchData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
if apiErr == nil {
GoLog("[Fallback] Spotify metadata fetched via SpotFetch API\n")
jsonBytes, err := json.Marshal(spotFetchData)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
GoLog("[Fallback] SpotFetch API fallback failed: %v\n", apiErr)
parsed, parseErr := parseSpotifyURI(spotifyURL)
if parseErr != nil {
return "", fmt.Errorf("SpotFetch fallback failed (%v) and URL parsing failed: %w", apiErr, parseErr)
}
GoLog("[Fallback] Trying Deezer conversion fallback for %s...\n", parsed.Type)
if parsed.Type == "track" || parsed.Type == "album" {
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
}
if parsed.Type == "artist" {
return "", fmt.Errorf("SpotFetch fallback failed (%v). Artist pages now require SpotFetch or a metadata extension such as spotify-web", apiErr)
}
return "", fmt.Errorf("SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", apiErr)
}
func shouldTrySpotFetchFallback(err error) bool {
if err == nil {
return false
}
if errors.Is(err, ErrNoSpotifyCredentials) {
return true
}
errStr := strings.ToLower(err.Error())
indicators := []string{
"429",
"rate",
"limit",
"403",
"forbidden",
"401",
"unauthorized",
"timeout",
"connection",
"spotify error",
"access token",
"client token",
"eof",
}
for _, indicator := range indicators {
if strings.Contains(errStr, indicator) {
return true
}
}
return false
}
func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
client := NewSongLinkClient()
availability, err := client.CheckAvailabilityFromDeezer(deezerTrackID)
@@ -1801,26 +1980,7 @@ func GetLyricsFetchOptionsJSON() (string, error) {
// When search_online is true, searches Spotify/Deezer by track name + artist to fetch
// complete metadata from the internet before embedding.
func ReEnrichFile(requestJSON string) (string, error) {
var req struct {
FilePath string `json:"file_path"`
CoverURL string `json:"cover_url"`
MaxQuality bool `json:"max_quality"`
EmbedLyrics bool `json:"embed_lyrics"`
SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist"`
TrackNumber int `json:"track_number"`
DiscNumber int `json:"disc_number"`
ReleaseDate string `json:"release_date"`
ISRC string `json:"isrc"`
Genre string `json:"genre"`
Label string `json:"label"`
Copyright string `json:"copyright"`
DurationMs int64 `json:"duration_ms"`
SearchOnline bool `json:"search_online"`
}
var req reEnrichRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return "", fmt.Errorf("failed to parse request: %w", err)
@@ -1842,42 +2002,22 @@ func ReEnrichFile(requestJSON string) (string, error) {
deezerClient := GetDeezerClient()
GoLog("[ReEnrich] Trying metadata providers in configured priority...\n")
manager := GetExtensionManager()
if identifierTrack, err := resolveReEnrichTrackFromIdentifiers(req); err == nil && identifierTrack != nil {
GoLog("[ReEnrich] Identifier-first metadata match (%s): %s - %s (album: %s, date: %s)\n",
identifierTrack.ProviderID, identifierTrack.Name, identifierTrack.Artists, identifierTrack.AlbumName, identifierTrack.ReleaseDate)
applyReEnrichTrackMetadata(&req, *identifierTrack)
found = true
}
tracks, searchErr := manager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
if searchErr == nil && len(tracks) > 0 {
track := tracks[0]
GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s)\n", track.ProviderID, track.Name, track.Artists, track.AlbumName)
if track.SpotifyID != "" {
req.SpotifyID = track.SpotifyID
} else if track.DeezerID != "" {
req.SpotifyID = "deezer:" + track.DeezerID
} else if track.QobuzID != "" {
req.SpotifyID = "qobuz:" + track.QobuzID
} else if track.TidalID != "" {
req.SpotifyID = "tidal:" + track.TidalID
} else {
req.SpotifyID = track.ID
track := selectBestReEnrichTrack(req, tracks)
if track != nil {
GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s, date: %s)\n",
track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate)
applyReEnrichTrackMetadata(&req, *track)
found = true
}
req.AlbumName = track.AlbumName
req.AlbumArtist = track.AlbumArtist
req.TrackNumber = track.TrackNumber
req.DiscNumber = track.DiscNumber
req.ReleaseDate = track.ReleaseDate
req.ISRC = track.ISRC
coverURL := track.ResolvedCoverURL()
if coverURL != "" {
req.CoverURL = coverURL
}
req.DurationMs = int64(track.DurationMS)
if track.Genre != "" {
req.Genre = track.Genre
}
if track.Label != "" {
req.Label = track.Label
}
if track.Copyright != "" {
req.Copyright = track.Copyright
}
found = true
} else if searchErr != nil {
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
}
+64
View File
@@ -113,3 +113,67 @@ func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) {
t.Fatalf("cover url = %q, want %q", resp.CoverURL, result.CoverURL)
}
}
func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMissing(t *testing.T) {
req := reEnrichRequest{
SpotifyID: "spotify-track-id",
AlbumName: "Original Album",
ReleaseDate: "2024-01-01",
ISRC: "REQ123",
}
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
AlbumName: "Resolved Album",
ReleaseDate: "",
ISRC: "",
})
if req.ReleaseDate != "2024-01-01" {
t.Fatalf("release date = %q, want existing value preserved", req.ReleaseDate)
}
if req.AlbumName != "Resolved Album" {
t.Fatalf("album = %q, want updated album", req.AlbumName)
}
if req.ISRC != "REQ123" {
t.Fatalf("isrc = %q, want existing value preserved", req.ISRC)
}
}
func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
req := reEnrichRequest{
TrackName: "Song Title",
ArtistName: "Artist Name",
AlbumName: "Album Name",
ReleaseDate: "",
DurationMs: 180000,
}
tracks := []ExtTrackMetadata{
{
ID: "first",
Name: "Song Title",
Artists: "Artist Name",
AlbumName: "Album Name",
DurationMS: 180000,
ReleaseDate: "",
ProviderID: "spotify",
},
{
ID: "second",
Name: "Song Title",
Artists: "Artist Name",
AlbumName: "Album Name",
DurationMS: 180000,
ReleaseDate: "2024-03-09",
ProviderID: "deezer",
},
}
best := selectBestReEnrichTrack(req, tracks)
if best == nil {
t.Fatal("expected a selected track")
}
if best.ID != "second" {
t.Fatalf("selected track = %q, want candidate with release date", best.ID)
}
}
+22 -16
View File
@@ -1597,21 +1597,27 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName)
}
func qobuzTrackMatchesRequest(req DownloadRequest, track *QobuzTrack, logPrefix, source string) bool {
func qobuzTrackMatchesRequest(req DownloadRequest, track *QobuzTrack, logPrefix, source string, skipNameVerification bool) bool {
if track == nil {
return false
}
if req.ArtistName != "" && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
GoLog("[%s] Artist mismatch from %s: expected '%s', got '%s'. Rejecting.\n",
logPrefix, source, req.ArtistName, track.Performer.Name)
return false
}
exactISRCMatch := req.ISRC != "" &&
track.ISRC != "" &&
strings.EqualFold(strings.TrimSpace(req.ISRC), strings.TrimSpace(track.ISRC))
if req.TrackName != "" && !qobuzTitlesMatch(req.TrackName, track.Title) {
GoLog("[%s] Title mismatch from %s: expected '%s', got '%s'. Rejecting.\n",
logPrefix, source, req.TrackName, track.Title)
return false
if !exactISRCMatch && !skipNameVerification {
if req.ArtistName != "" && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
GoLog("[%s] Artist mismatch from %s: expected '%s', got '%s'. Rejecting.\n",
logPrefix, source, req.ArtistName, track.Performer.Name)
return false
}
if req.TrackName != "" && !qobuzTitlesMatch(req.TrackName, track.Title) {
GoLog("[%s] Title mismatch from %s: expected '%s', got '%s'. Rejecting.\n",
logPrefix, source, req.TrackName, track.Title)
return false
}
}
expectedDurationSec := req.DurationMS / 1000
@@ -2125,7 +2131,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
GoLog("[%s] Failed to get track by request Qobuz ID %d: %v\n", logPrefix, trackID, err)
track = nil
} else if track != nil {
if qobuzTrackMatchesRequest(req, track, logPrefix, "request Qobuz ID") {
if qobuzTrackMatchesRequest(req, track, logPrefix, "request Qobuz ID", false) {
GoLog("[%s] Successfully found track via request Qobuz ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
} else {
track = nil
@@ -2142,7 +2148,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
if err != nil {
GoLog("[%s] Cache hit but GetTrackByID failed: %v\n", logPrefix, err)
track = nil
} else if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "cached Qobuz ID") {
} else if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "cached Qobuz ID", false) {
track = nil
}
}
@@ -2162,7 +2168,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
GoLog("[%s] Failed to get track by SongLink ID %d: %v\n", logPrefix, trackID, err)
track = nil
} else if track != nil {
if qobuzTrackMatchesRequest(req, track, logPrefix, "SongLink Qobuz ID") {
if qobuzTrackMatchesRequest(req, track, logPrefix, "SongLink Qobuz ID", true) {
GoLog("[%s] Successfully found track via SongLink ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
if req.ISRC != "" {
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
@@ -2179,7 +2185,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
if track == nil && req.ISRC != "" {
GoLog("[%s] Trying ISRC search: %s\n", logPrefix, req.ISRC)
track, err = qobuzSearchTrackByISRCWithDurationFunc(downloader, req.ISRC, expectedDurationSec)
if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "ISRC search") {
if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "ISRC search", false) {
track = nil
}
}
@@ -2188,7 +2194,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
if track == nil {
GoLog("[%s] Trying metadata search: '%s' by '%s'\n", logPrefix, req.TrackName, req.ArtistName)
track, err = qobuzSearchTrackByMetadataWithDurationFunc(downloader, req.TrackName, req.ArtistName, expectedDurationSec)
if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "metadata search") {
if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "metadata search", false) {
track = nil
}
}
@@ -2253,7 +2259,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
qobuzQuality = "6"
case "HI_RES":
qobuzQuality = "7"
case "HI_RES_LOSSLESS":
case "HI_RES_LOSSLESS", "", "DEFAULT":
qobuzQuality = "27"
}
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
+17
View File
@@ -436,3 +436,20 @@ func TestResolveQobuzTrackForRequestUsesPrefixedQobuzIDWithoutSongLink(t *testin
t.Fatalf("unexpected resolved track: %+v", track)
}
}
func TestQobuzTrackMatchesRequest_SongLinkBypassesArtistAndTitle(t *testing.T) {
req := DownloadRequest{
TrackName: "Ringišpil",
ArtistName: "Djordje Balasevic",
}
track := &QobuzTrack{
Title: "Different Title",
Duration: 0,
}
track.Performer.Name = "Different Artist"
if !qobuzTrackMatchesRequest(req, track, "Qobuz", "SongLink Qobuz ID", true) {
t.Fatal("expected SongLink Qobuz source to bypass artist/title verification")
}
}
-80
View File
@@ -1,80 +0,0 @@
package gobackend
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const DefaultSpotFetchAPIBaseURL = "https://sp.afkarxyz.qzz.io/api"
// GetSpotifyDataWithAPI fetches Spotify metadata through SpotFetch-compatible API.
// This is used as a fallback when direct Spotify API access is blocked/limited.
func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL, apiBaseURL string) (interface{}, error) {
parsed, err := parseSpotifyURI(spotifyURL)
if err != nil {
return nil, fmt.Errorf("invalid Spotify URL: %w", err)
}
base := strings.TrimSpace(apiBaseURL)
if base == "" {
base = DefaultSpotFetchAPIBaseURL
}
endpoint := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(base, "/"), parsed.Type, parsed.ID)
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to create SpotFetch API request: %w", err)
}
req.Header.Set("User-Agent", getRandomUserAgent())
req.Header.Set("Accept", "application/json")
client := NewHTTPClientWithTimeout(30 * time.Second)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("SpotFetch API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("SpotFetch API error: HTTP %d", resp.StatusCode)
}
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read SpotFetch API response: %w", err)
}
switch parsed.Type {
case "track":
var trackResp TrackResponse
if err := json.Unmarshal(bodyBytes, &trackResp); err != nil {
return nil, fmt.Errorf("failed to decode track response: %w", err)
}
return trackResp, nil
case "album":
var albumResp AlbumResponsePayload
if err := json.Unmarshal(bodyBytes, &albumResp); err != nil {
return nil, fmt.Errorf("failed to decode album response: %w", err)
}
return &albumResp, nil
case "playlist":
var playlistResp PlaylistResponsePayload
if err := json.Unmarshal(bodyBytes, &playlistResp); err != nil {
return nil, fmt.Errorf("failed to decode playlist response: %w", err)
}
return playlistResp, nil
case "artist":
var artistResp ArtistResponsePayload
if err := json.Unmarshal(bodyBytes, &artistResp); err != nil {
return nil, fmt.Errorf("failed to decode artist response: %w", err)
}
return &artistResp, nil
default:
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
}
}
+10 -4
View File
@@ -829,6 +829,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, a
resolved := resolvedTrackInfo{
Title: strings.TrimSpace(track.Title),
ArtistName: tidalTrackArtistsDisplay(track),
ISRC: strings.TrimSpace(track.ISRC),
Duration: track.Duration,
}
if trackMatchesRequest(req, resolved, "Tidal search") {
@@ -2035,6 +2036,7 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
expectedDurationSec := req.DurationMS / 1000
var trackID int64
var gotTidalID bool
var resolvedViaSongLink bool
if req.TidalID != "" {
GoLog("[%s] Using Tidal ID from request payload: %s\n", logPrefix, req.TidalID)
@@ -2094,6 +2096,7 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
trackID = parsedTrackID
GoLog("[%s] Got Tidal ID %d directly from SongLink\n", logPrefix, trackID)
gotTidalID = true
resolvedViaSongLink = true
return
}
}
@@ -2103,6 +2106,7 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
if idErr == nil && trackID > 0 {
GoLog("[%s] Got Tidal ID %d from URL parsing\n", logPrefix, trackID)
gotTidalID = true
resolvedViaSongLink = true
}
}
}
@@ -2157,9 +2161,11 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
providerArtist = actualTrack.Artists[0].Name
}
resolved := resolvedTrackInfo{
Title: actualTrack.Title,
ArtistName: providerArtist,
Duration: actualTrack.Duration,
Title: actualTrack.Title,
ArtistName: providerArtist,
ISRC: strings.TrimSpace(actualTrack.ISRC),
Duration: actualTrack.Duration,
SkipNameVerification: resolvedViaSongLink,
}
if !trackMatchesRequest(req, resolved, logPrefix) {
// Invalidate the cached ID so future requests don't reuse it.
@@ -2206,7 +2212,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
}
quality := req.Quality
if quality == "" {
if quality == "" || quality == "DEFAULT" {
quality = "LOSSLESS"
}
+38 -15
View File
@@ -7,6 +7,21 @@ import (
"golang.org/x/text/unicode/norm"
)
func writeNormalizedArtistRune(b *strings.Builder, r rune) {
switch r {
case 'đ':
b.WriteString("dj")
case 'ß':
b.WriteString("ss")
case 'æ':
b.WriteString("ae")
case 'œ':
b.WriteString("oe")
default:
b.WriteRune(r)
}
}
// normalizeLooseTitle collapses separators/punctuation so titles like
// "Doctor / Cops" and "Doctor _ Cops" can still match.
func normalizeLooseTitle(title string) string {
@@ -51,7 +66,7 @@ func normalizeLooseArtistName(name string) string {
case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Mc, r), unicode.Is(unicode.Me, r):
continue
case unicode.IsLetter(r), unicode.IsNumber(r):
b.WriteRune(r)
writeNormalizedArtistRune(&b, r)
case unicode.IsSpace(r):
b.WriteByte(' ')
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
@@ -101,26 +116,34 @@ func normalizeSymbolOnlyTitle(title string) string {
// resolvedTrackInfo holds the metadata fetched from a provider for verification.
type resolvedTrackInfo struct {
Title string
ArtistName string
Duration int
Title string
ArtistName string
ISRC string
Duration int
SkipNameVerification bool
}
// trackMatchesRequest checks whether a resolved track from a provider matches
// the original download request. Returns true if the track is a plausible match.
func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool {
if req.ArtistName != "" && resolved.ArtistName != "" &&
!artistsMatch(req.ArtistName, resolved.ArtistName) {
GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n",
logPrefix, req.ArtistName, resolved.ArtistName)
return false
}
exactISRCMatch := req.ISRC != "" &&
resolved.ISRC != "" &&
strings.EqualFold(strings.TrimSpace(req.ISRC), strings.TrimSpace(resolved.ISRC))
if req.TrackName != "" && resolved.Title != "" &&
!titlesMatch(req.TrackName, resolved.Title) {
GoLog("[%s] Verification failed: title mismatch — expected '%s', got '%s'\n",
logPrefix, req.TrackName, resolved.Title)
return false
if !exactISRCMatch && !resolved.SkipNameVerification {
if req.ArtistName != "" && resolved.ArtistName != "" &&
!artistsMatch(req.ArtistName, resolved.ArtistName) {
GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n",
logPrefix, req.ArtistName, resolved.ArtistName)
return false
}
if req.TrackName != "" && resolved.Title != "" &&
!titlesMatch(req.TrackName, resolved.Title) {
GoLog("[%s] Verification failed: title mismatch — expected '%s', got '%s'\n",
logPrefix, req.TrackName, resolved.Title)
return false
}
}
expectedDurationSec := req.DurationMS / 1000
+34
View File
@@ -21,6 +21,40 @@ func TestNormalizeLooseTitle_EmojiAndSymbols(t *testing.T) {
}
}
func TestTrackMatchesRequest_SongLinkBypassesArtistAndTitle(t *testing.T) {
req := DownloadRequest{
TrackName: "Ringišpil",
ArtistName: "Djordje Balasevic",
}
resolved := resolvedTrackInfo{
Title: "Completely Different Title",
ArtistName: "Totally Different Artist",
SkipNameVerification: true,
}
if !trackMatchesRequest(req, resolved, "test") {
t.Fatal("expected SongLink-resolved track to bypass artist/title verification")
}
}
func TestTrackMatchesRequest_SongLinkStillChecksDuration(t *testing.T) {
req := DownloadRequest{
TrackName: "Ringišpil",
ArtistName: "Djordje Balasevic",
DurationMS: 180000,
}
resolved := resolvedTrackInfo{
Title: "Completely Different Title",
ArtistName: "Totally Different Artist",
Duration: 240,
SkipNameVerification: true,
}
if trackMatchesRequest(req, resolved, "test") {
t.Fatal("expected SongLink-resolved track with large duration mismatch to be rejected")
}
}
func TestTitlesMatch_SeparatorVariants(t *testing.T) {
if !titlesMatch("Doctor / Cops", "Doctor _ Cops") {
t.Fatal("expected tidal titlesMatch to accept / vs _ variant")
-14
View File
@@ -153,13 +153,6 @@ import Gobackend // Import Go framework
var error: NSError?
switch call.method {
case "parseSpotifyUrl":
let args = call.arguments as! [String: Any]
let url = args["url"] as! String
let response = GobackendParseSpotifyURL(url, &error)
if let error = error { throw error }
return response
case "checkAvailability":
let args = call.arguments as! [String: Any]
let spotifyId = args["spotify_id"] as! String
@@ -469,13 +462,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "getSpotifyMetadataWithFallback":
let args = call.arguments as! [String: Any]
let url = args["url"] as! String
let response = GobackendGetSpotifyMetadataWithDeezerFallback(url, &error)
if let error = error { throw error }
return response
case "checkAvailabilityFromDeezerID":
let args = call.arguments as! [String: Any]
let deezerTrackId = args["deezer_track_id"] as! String
+2 -2
View File
@@ -3,8 +3,8 @@ import 'package:flutter/foundation.dart';
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '4.1.1';
static const String buildNumber = '118';
static const String version = '4.1.2';
static const String buildNumber = '119';
static const String fullVersion = '$version+$buildNumber';
/// Shows "Internal" in debug builds, actual version in release.
+299 -7
View File
@@ -151,7 +151,7 @@ abstract class AppLocalizations {
/// Bottom navigation - Extension store tab
///
/// In en, this message translates to:
/// **'Store'**
/// **'Repo'**
String get navStore;
/// Home screen title
@@ -163,7 +163,7 @@ abstract class AppLocalizations {
/// Subtitle shown below search box
///
/// In en, this message translates to:
/// **'Paste a Spotify link or search by name'**
/// **'Paste a supported URL or search by name'**
String get homeSubtitle;
/// Info text about supported URL types
@@ -427,13 +427,13 @@ abstract class AppLocalizations {
/// Show/hide store tab
///
/// In en, this message translates to:
/// **'Extension Store'**
/// **'Extension Repo'**
String get optionsExtensionStore;
/// Subtitle for extension store toggle
///
/// In en, this message translates to:
/// **'Show Store tab in navigation'**
/// **'Show Repo tab in navigation'**
String get optionsExtensionStoreSubtitle;
/// Auto update check toggle
@@ -565,7 +565,7 @@ abstract class AppLocalizations {
/// Store screen title
///
/// In en, this message translates to:
/// **'Extension Store'**
/// **'Extension Repo'**
String get storeTitle;
/// Store search placeholder
@@ -2365,7 +2365,7 @@ abstract class AppLocalizations {
/// Error heading when the store cannot be loaded
///
/// In en, this message translates to:
/// **'Failed to load store'**
/// **'Failed to load repository'**
String get storeLoadError;
/// Message when store has no extensions
@@ -3613,7 +3613,7 @@ abstract class AppLocalizations {
/// Tutorial extensions tip 1
///
/// In en, this message translates to:
/// **'Browse the Store tab to discover useful extensions'**
/// **'Browse the Repo tab to discover useful extensions'**
String get tutorialExtensionsTip1;
/// Tutorial extensions tip 2
@@ -5300,6 +5300,298 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Samples'**
String get audioAnalysisSamples;
/// Extensions page - subtitle for built-in search provider option
///
/// In en, this message translates to:
/// **'Search with {providerName}'**
String extensionsSearchWith(String providerName);
/// Extensions page - label for home feed provider selector
///
/// In en, this message translates to:
/// **'Home Feed Provider'**
String get extensionsHomeFeedProvider;
/// Extensions page - description for home feed provider picker
///
/// In en, this message translates to:
/// **'Choose which extension provides the home feed on the main screen'**
String get extensionsHomeFeedDescription;
/// Extensions page - home feed provider option: auto
///
/// In en, this message translates to:
/// **'Auto'**
String get extensionsHomeFeedAuto;
/// Extensions page - subtitle for auto home feed option
///
/// In en, this message translates to:
/// **'Automatically select the best available'**
String get extensionsHomeFeedAutoSubtitle;
/// Extensions page - subtitle for a specific extension home feed option
///
/// In en, this message translates to:
/// **'Use {extensionName} home feed'**
String extensionsHomeFeedUse(String extensionName);
/// Extensions page - shown when no installed extension has home feed
///
/// In en, this message translates to:
/// **'No extensions with home feed'**
String get extensionsNoHomeFeedExtensions;
/// Sort option - alphabetical ascending
///
/// In en, this message translates to:
/// **'A-Z'**
String get sortAlphaAsc;
/// Sort option - alphabetical descending
///
/// In en, this message translates to:
/// **'Z-A'**
String get sortAlphaDesc;
/// Dialog title when confirming cancellation of an active download
///
/// In en, this message translates to:
/// **'Cancel download?'**
String get cancelDownloadTitle;
/// Dialog body when confirming cancellation of an active download
///
/// In en, this message translates to:
/// **'This will cancel the active download for \"{trackName}\".'**
String cancelDownloadContent(String trackName);
/// Dialog button - keep the active download (do not cancel)
///
/// In en, this message translates to:
/// **'Keep'**
String get cancelDownloadKeep;
/// Snackbar error when FFmpeg fails to write metadata
///
/// In en, this message translates to:
/// **'Failed to save metadata via FFmpeg'**
String get metadataSaveFailedFfmpeg;
/// Snackbar error when writing metadata file back to storage fails
///
/// In en, this message translates to:
/// **'Failed to write metadata back to storage'**
String get metadataSaveFailedStorage;
/// Snackbar shown when folder picker fails to open
///
/// In en, this message translates to:
/// **'Failed to open folder picker: {error}'**
String snackbarFolderPickerFailed(String error);
/// Error state shown when album fails to load
///
/// In en, this message translates to:
/// **'Failed to load album'**
String get errorLoadAlbum;
/// Error state shown when playlist fails to load
///
/// In en, this message translates to:
/// **'Failed to load playlist'**
String get errorLoadPlaylist;
/// Error state shown when artist fails to load
///
/// In en, this message translates to:
/// **'Failed to load artist'**
String get errorLoadArtist;
/// Android notification channel name for download progress
///
/// In en, this message translates to:
/// **'Download Progress'**
String get notifChannelDownloadName;
/// Android notification channel description for download progress
///
/// In en, this message translates to:
/// **'Shows download progress for tracks'**
String get notifChannelDownloadDesc;
/// Android notification channel name for library scan
///
/// In en, this message translates to:
/// **'Library Scan'**
String get notifChannelLibraryScanName;
/// Android notification channel description for library scan
///
/// In en, this message translates to:
/// **'Shows local library scan progress'**
String get notifChannelLibraryScanDesc;
/// Notification title while downloading a track
///
/// In en, this message translates to:
/// **'Downloading {trackName}'**
String notifDownloadingTrack(String trackName);
/// Notification title while finalizing (embedding metadata) a track
///
/// In en, this message translates to:
/// **'Finalizing {trackName}'**
String notifFinalizingTrack(String trackName);
/// Notification body while embedding metadata into a downloaded track
///
/// In en, this message translates to:
/// **'Embedding metadata...'**
String get notifEmbeddingMetadata;
/// Notification title when track is already in library, with count
///
/// In en, this message translates to:
/// **'Already in Library ({completed}/{total})'**
String notifAlreadyInLibraryCount(int completed, int total);
/// Notification title when track is already in library
///
/// In en, this message translates to:
/// **'Already in Library'**
String get notifAlreadyInLibrary;
/// Notification title when download is complete, with count
///
/// In en, this message translates to:
/// **'Download Complete ({completed}/{total})'**
String notifDownloadCompleteCount(int completed, int total);
/// Notification title when a single download is complete
///
/// In en, this message translates to:
/// **'Download Complete'**
String get notifDownloadComplete;
/// Notification title when queue finishes with some failures
///
/// In en, this message translates to:
/// **'Downloads Finished ({completed} done, {failed} failed)'**
String notifDownloadsFinished(int completed, int failed);
/// Notification title when all downloads finish successfully
///
/// In en, this message translates to:
/// **'All Downloads Complete'**
String get notifAllDownloadsComplete;
/// Notification body for queue complete - how many tracks were downloaded
///
/// In en, this message translates to:
/// **'{count} tracks downloaded successfully'**
String notifTracksDownloadedSuccess(int count);
/// Notification title while scanning local library
///
/// In en, this message translates to:
/// **'Scanning local library'**
String get notifScanningLibrary;
/// Notification body for library scan progress when total is known
///
/// In en, this message translates to:
/// **'{scanned}/{total} files • {percentage}%'**
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
);
/// Notification body for library scan progress when total is unknown
///
/// In en, this message translates to:
/// **'{scanned} files scanned • {percentage}%'**
String notifLibraryScanProgressNoTotal(int scanned, int percentage);
/// Notification title when library scan finishes
///
/// In en, this message translates to:
/// **'Library scan complete'**
String get notifLibraryScanComplete;
/// Notification body for library scan complete - number of indexed tracks
///
/// In en, this message translates to:
/// **'{count} tracks indexed'**
String notifLibraryScanCompleteBody(int count);
/// Library scan complete suffix - excluded track count
///
/// In en, this message translates to:
/// **'{count} excluded'**
String notifLibraryScanExcluded(int count);
/// Library scan complete suffix - error count
///
/// In en, this message translates to:
/// **'{count} errors'**
String notifLibraryScanErrors(int count);
/// Notification title when library scan fails
///
/// In en, this message translates to:
/// **'Library scan failed'**
String get notifLibraryScanFailed;
/// Notification title when library scan is cancelled by the user
///
/// In en, this message translates to:
/// **'Library scan cancelled'**
String get notifLibraryScanCancelled;
/// Notification body when library scan is cancelled
///
/// In en, this message translates to:
/// **'Scan stopped before completion.'**
String get notifLibraryScanStopped;
/// Notification title while downloading an app update
///
/// In en, this message translates to:
/// **'Downloading SpotiFLAC v{version}'**
String notifDownloadingUpdate(String version);
/// Notification body showing update download progress
///
/// In en, this message translates to:
/// **'{received} / {total} MB • {percentage}%'**
String notifUpdateProgress(String received, String total, int percentage);
/// Notification title when app update download is complete
///
/// In en, this message translates to:
/// **'Update Ready'**
String get notifUpdateReady;
/// Notification body when app update is ready to install
///
/// In en, this message translates to:
/// **'SpotiFLAC v{version} downloaded. Tap to install.'**
String notifUpdateReadyBody(String version);
/// Notification title when app update download fails
///
/// In en, this message translates to:
/// **'Update Failed'**
String get notifUpdateFailed;
/// Notification body when app update download fails
///
/// In en, this message translates to:
/// **'Could not download update. Try again later.'**
String get notifUpdateFailedBody;
}
class _AppLocalizationsDelegate
+189 -1
View File
@@ -1281,7 +1281,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
String get storeLoadError => 'Failed to load repository';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@@ -3124,4 +3124,192 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
}
+195 -7
View File
@@ -21,13 +21,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get navSettings => 'Settings';
@override
String get navStore => 'Store';
String get navStore => 'Repo';
@override
String get homeTitle => 'Home';
@override
String get homeSubtitle => 'Paste a Spotify link or search by name';
String get homeSubtitle => 'Paste a supported URL or search by name';
@override
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
@@ -170,10 +170,10 @@ class AppLocalizationsEn extends AppLocalizations {
'Parallel downloads may trigger rate limiting';
@override
String get optionsExtensionStore => 'Extension Store';
String get optionsExtensionStore => 'Extension Repo';
@override
String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation';
String get optionsExtensionStoreSubtitle => 'Show Repo tab in navigation';
@override
String get optionsCheckUpdates => 'Check for Updates';
@@ -250,7 +250,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get extensionsUninstall => 'Uninstall';
@override
String get storeTitle => 'Extension Store';
String get storeTitle => 'Extension Repo';
@override
String get storeSearch => 'Search extensions...';
@@ -1261,7 +1261,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
String get storeLoadError => 'Failed to load repository';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@@ -1997,7 +1997,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
'Browse the Repo tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
@@ -3092,4 +3092,192 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
}
+190 -2
View File
@@ -1261,7 +1261,7 @@ class AppLocalizationsEs extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
String get storeLoadError => 'Failed to load repository';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@@ -1997,7 +1997,7 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
'Browse the Repo tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
@@ -3092,6 +3092,194 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
}
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
+189 -1
View File
@@ -1263,7 +1263,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
String get storeLoadError => 'Failed to load repository';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@@ -3093,4 +3093,192 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
}
+189 -1
View File
@@ -1261,7 +1261,7 @@ class AppLocalizationsHi extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
String get storeLoadError => 'Failed to load repository';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@@ -3091,4 +3091,192 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
}
+196 -7
View File
@@ -21,13 +21,14 @@ class AppLocalizationsId extends AppLocalizations {
String get navSettings => 'Pengaturan';
@override
String get navStore => 'Toko';
String get navStore => 'Repo';
@override
String get homeTitle => 'Beranda';
@override
String get homeSubtitle => 'Tempel link Spotify atau cari berdasarkan nama';
String get homeSubtitle =>
'Tempel URL yang didukung atau cari berdasarkan nama';
@override
String get homeSupports => 'Mendukung: URL Track, Album, Playlist, Artis';
@@ -173,10 +174,10 @@ class AppLocalizationsId extends AppLocalizations {
'Unduhan paralel dapat memicu pembatasan rate';
@override
String get optionsExtensionStore => 'Toko Ekstensi';
String get optionsExtensionStore => 'Repo Ekstensi';
@override
String get optionsExtensionStoreSubtitle => 'Tampilkan tab Toko di navigasi';
String get optionsExtensionStoreSubtitle => 'Tampilkan tab Repo di navigasi';
@override
String get optionsCheckUpdates => 'Periksa Pembaruan';
@@ -252,7 +253,7 @@ class AppLocalizationsId extends AppLocalizations {
String get extensionsUninstall => 'Copot';
@override
String get storeTitle => 'Toko Ekstensi';
String get storeTitle => 'Repo Ekstensi';
@override
String get storeSearch => 'Cari ekstensi...';
@@ -1267,7 +1268,7 @@ class AppLocalizationsId extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
String get storeLoadError => 'Gagal memuat repo';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@@ -2006,7 +2007,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
'Buka tab Repo untuk menemukan ekstensi yang berguna';
@override
String get tutorialExtensionsTip2 =>
@@ -3101,4 +3102,192 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
}
+189 -1
View File
@@ -1255,7 +1255,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
String get storeLoadError => 'Failed to load repository';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@@ -3078,4 +3078,192 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
}
+189 -1
View File
@@ -1241,7 +1241,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
String get storeLoadError => 'Failed to load repository';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@@ -3071,4 +3071,192 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
}
+189 -1
View File
@@ -1261,7 +1261,7 @@ class AppLocalizationsNl extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
String get storeLoadError => 'Failed to load repository';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@@ -3091,4 +3091,192 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
}
+190 -2
View File
@@ -1261,7 +1261,7 @@ class AppLocalizationsPt extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
String get storeLoadError => 'Failed to load repository';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@@ -1997,7 +1997,7 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
'Browse the Repo tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
@@ -3092,6 +3092,194 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
}
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
+189 -1
View File
@@ -1282,7 +1282,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
String get storeLoadError => 'Failed to load repository';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@@ -3151,4 +3151,192 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
}
+189 -1
View File
@@ -1267,7 +1267,7 @@ class AppLocalizationsTr extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
String get storeLoadError => 'Failed to load repository';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@@ -3097,4 +3097,192 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
}
+190 -2
View File
@@ -1261,7 +1261,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
String get storeLoadError => 'Failed to load repository';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@@ -1997,7 +1997,7 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
'Browse the Repo tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
@@ -3092,6 +3092,194 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
}
/// The translations for Chinese, as used in China (`zh_CN`).
+319 -7
View File
@@ -17,7 +17,7 @@
"@navSettings": {
"description": "Bottom navigation - Settings tab"
},
"navStore": "Store",
"navStore": "Repo",
"@navStore": {
"description": "Bottom navigation - Extension store tab"
},
@@ -25,7 +25,7 @@
"@homeTitle": {
"description": "Home screen title"
},
"homeSubtitle": "Paste a Spotify link or search by name",
"homeSubtitle": "Paste a supported URL or search by name",
"@homeSubtitle": {
"description": "Subtitle shown below search box"
},
@@ -211,11 +211,11 @@
"@optionsConcurrentWarning": {
"description": "Warning about rate limits"
},
"optionsExtensionStore": "Extension Store",
"optionsExtensionStore": "Extension Repo",
"@optionsExtensionStore": {
"description": "Show/hide store tab"
},
"optionsExtensionStoreSubtitle": "Show Store tab in navigation",
"optionsExtensionStoreSubtitle": "Show Repo tab in navigation",
"@optionsExtensionStoreSubtitle": {
"description": "Subtitle for extension store toggle"
},
@@ -318,7 +318,7 @@
"@extensionsUninstall": {
"description": "Uninstall extension button"
},
"storeTitle": "Extension Store",
"storeTitle": "Extension Repo",
"@storeTitle": {
"description": "Store screen title"
},
@@ -1654,7 +1654,7 @@
"@storeNewRepoUrlLabel": {
"description": "Label for the new repository URL field inside the dialog"
},
"storeLoadError": "Failed to load store",
"storeLoadError": "Failed to load repository",
"@storeLoadError": {
"description": "Error heading when the store cannot be loaded"
},
@@ -2611,7 +2611,7 @@
"@tutorialExtensionsDesc": {
"description": "Tutorial extensions page description"
},
"tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions",
"tutorialExtensionsTip1": "Browse the Repo tab to discover useful extensions",
"@tutorialExtensionsTip1": {
"description": "Tutorial extensions tip 1"
},
@@ -4063,5 +4063,317 @@
"audioAnalysisSamples": "Samples",
"@audioAnalysisSamples": {
"description": "Total samples metric label"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
"placeholders": {
"providerName": {
"type": "String"
}
}
},
"extensionsHomeFeedProvider": "Home Feed Provider",
"@extensionsHomeFeedProvider": {
"description": "Extensions page - label for home feed provider selector"
},
"extensionsHomeFeedDescription": "Choose which extension provides the home feed on the main screen",
"@extensionsHomeFeedDescription": {
"description": "Extensions page - description for home feed provider picker"
},
"extensionsHomeFeedAuto": "Auto",
"@extensionsHomeFeedAuto": {
"description": "Extensions page - home feed provider option: auto"
},
"extensionsHomeFeedAutoSubtitle": "Automatically select the best available",
"@extensionsHomeFeedAutoSubtitle": {
"description": "Extensions page - subtitle for auto home feed option"
},
"extensionsHomeFeedUse": "Use {extensionName} home feed",
"@extensionsHomeFeedUse": {
"description": "Extensions page - subtitle for a specific extension home feed option",
"placeholders": {
"extensionName": {
"type": "String"
}
}
},
"extensionsNoHomeFeedExtensions": "No extensions with home feed",
"@extensionsNoHomeFeedExtensions": {
"description": "Extensions page - shown when no installed extension has home feed"
},
"sortAlphaAsc": "A-Z",
"@sortAlphaAsc": {
"description": "Sort option - alphabetical ascending"
},
"sortAlphaDesc": "Z-A",
"@sortAlphaDesc": {
"description": "Sort option - alphabetical descending"
},
"cancelDownloadTitle": "Cancel download?",
"@cancelDownloadTitle": {
"description": "Dialog title when confirming cancellation of an active download"
},
"cancelDownloadContent": "This will cancel the active download for \"{trackName}\".",
"@cancelDownloadContent": {
"description": "Dialog body when confirming cancellation of an active download",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"cancelDownloadKeep": "Keep",
"@cancelDownloadKeep": {
"description": "Dialog button - keep the active download (do not cancel)"
},
"metadataSaveFailedFfmpeg": "Failed to save metadata via FFmpeg",
"@metadataSaveFailedFfmpeg": {
"description": "Snackbar error when FFmpeg fails to write metadata"
},
"metadataSaveFailedStorage": "Failed to write metadata back to storage",
"@metadataSaveFailedStorage": {
"description": "Snackbar error when writing metadata file back to storage fails"
},
"snackbarFolderPickerFailed": "Failed to open folder picker: {error}",
"@snackbarFolderPickerFailed": {
"description": "Snackbar shown when folder picker fails to open",
"placeholders": {
"error": {
"type": "String"
}
}
},
"errorLoadAlbum": "Failed to load album",
"@errorLoadAlbum": {
"description": "Error state shown when album fails to load"
},
"errorLoadPlaylist": "Failed to load playlist",
"@errorLoadPlaylist": {
"description": "Error state shown when playlist fails to load"
},
"errorLoadArtist": "Failed to load artist",
"@errorLoadArtist": {
"description": "Error state shown when artist fails to load"
},
"notifChannelDownloadName": "Download Progress",
"@notifChannelDownloadName": {
"description": "Android notification channel name for download progress"
},
"notifChannelDownloadDesc": "Shows download progress for tracks",
"@notifChannelDownloadDesc": {
"description": "Android notification channel description for download progress"
},
"notifChannelLibraryScanName": "Library Scan",
"@notifChannelLibraryScanName": {
"description": "Android notification channel name for library scan"
},
"notifChannelLibraryScanDesc": "Shows local library scan progress",
"@notifChannelLibraryScanDesc": {
"description": "Android notification channel description for library scan"
},
"notifDownloadingTrack": "Downloading {trackName}",
"@notifDownloadingTrack": {
"description": "Notification title while downloading a track",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"notifFinalizingTrack": "Finalizing {trackName}",
"@notifFinalizingTrack": {
"description": "Notification title while finalizing (embedding metadata) a track",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"notifEmbeddingMetadata": "Embedding metadata...",
"@notifEmbeddingMetadata": {
"description": "Notification body while embedding metadata into a downloaded track"
},
"notifAlreadyInLibraryCount": "Already in Library ({completed}/{total})",
"@notifAlreadyInLibraryCount": {
"description": "Notification title when track is already in library, with count",
"placeholders": {
"completed": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"notifAlreadyInLibrary": "Already in Library",
"@notifAlreadyInLibrary": {
"description": "Notification title when track is already in library"
},
"notifDownloadCompleteCount": "Download Complete ({completed}/{total})",
"@notifDownloadCompleteCount": {
"description": "Notification title when download is complete, with count",
"placeholders": {
"completed": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"notifDownloadComplete": "Download Complete",
"@notifDownloadComplete": {
"description": "Notification title when a single download is complete"
},
"notifDownloadsFinished": "Downloads Finished ({completed} done, {failed} failed)",
"@notifDownloadsFinished": {
"description": "Notification title when queue finishes with some failures",
"placeholders": {
"completed": {
"type": "int"
},
"failed": {
"type": "int"
}
}
},
"notifAllDownloadsComplete": "All Downloads Complete",
"@notifAllDownloadsComplete": {
"description": "Notification title when all downloads finish successfully"
},
"notifTracksDownloadedSuccess": "{count} tracks downloaded successfully",
"@notifTracksDownloadedSuccess": {
"description": "Notification body for queue complete - how many tracks were downloaded",
"placeholders": {
"count": {
"type": "int"
}
}
},
"notifScanningLibrary": "Scanning local library",
"@notifScanningLibrary": {
"description": "Notification title while scanning local library"
},
"notifLibraryScanProgressWithTotal": "{scanned}/{total} files • {percentage}%",
"@notifLibraryScanProgressWithTotal": {
"description": "Notification body for library scan progress when total is known",
"placeholders": {
"scanned": {
"type": "int"
},
"total": {
"type": "int"
},
"percentage": {
"type": "int"
}
}
},
"notifLibraryScanProgressNoTotal": "{scanned} files scanned • {percentage}%",
"@notifLibraryScanProgressNoTotal": {
"description": "Notification body for library scan progress when total is unknown",
"placeholders": {
"scanned": {
"type": "int"
},
"percentage": {
"type": "int"
}
}
},
"notifLibraryScanComplete": "Library scan complete",
"@notifLibraryScanComplete": {
"description": "Notification title when library scan finishes"
},
"notifLibraryScanCompleteBody": "{count} tracks indexed",
"@notifLibraryScanCompleteBody": {
"description": "Notification body for library scan complete - number of indexed tracks",
"placeholders": {
"count": {
"type": "int"
}
}
},
"notifLibraryScanExcluded": "{count} excluded",
"@notifLibraryScanExcluded": {
"description": "Library scan complete suffix - excluded track count",
"placeholders": {
"count": {
"type": "int"
}
}
},
"notifLibraryScanErrors": "{count} errors",
"@notifLibraryScanErrors": {
"description": "Library scan complete suffix - error count",
"placeholders": {
"count": {
"type": "int"
}
}
},
"notifLibraryScanFailed": "Library scan failed",
"@notifLibraryScanFailed": {
"description": "Notification title when library scan fails"
},
"notifLibraryScanCancelled": "Library scan cancelled",
"@notifLibraryScanCancelled": {
"description": "Notification title when library scan is cancelled by the user"
},
"notifLibraryScanStopped": "Scan stopped before completion.",
"@notifLibraryScanStopped": {
"description": "Notification body when library scan is cancelled"
},
"notifDownloadingUpdate": "Downloading SpotiFLAC v{version}",
"@notifDownloadingUpdate": {
"description": "Notification title while downloading an app update",
"placeholders": {
"version": {
"type": "String"
}
}
},
"notifUpdateProgress": "{received} / {total} MB • {percentage}%",
"@notifUpdateProgress": {
"description": "Notification body showing update download progress",
"placeholders": {
"received": {
"type": "String"
},
"total": {
"type": "String"
},
"percentage": {
"type": "int"
}
}
},
"notifUpdateReady": "Update Ready",
"@notifUpdateReady": {
"description": "Notification title when app update download is complete"
},
"notifUpdateReadyBody": "SpotiFLAC v{version} downloaded. Tap to install.",
"@notifUpdateReadyBody": {
"description": "Notification body when app update is ready to install",
"placeholders": {
"version": {
"type": "String"
}
}
},
"notifUpdateFailed": "Update Failed",
"@notifUpdateFailed": {
"description": "Notification title when app update download fails"
},
"notifUpdateFailedBody": "Could not download update. Try again later.",
"@notifUpdateFailedBody": {
"description": "Notification body when app update download fails"
}
}
+10 -6
View File
@@ -17,7 +17,7 @@
"@navSettings": {
"description": "Bottom navigation - Settings tab"
},
"navStore": "Toko",
"navStore": "Repo",
"@navStore": {
"description": "Bottom navigation - Extension store tab"
},
@@ -25,7 +25,7 @@
"@homeTitle": {
"description": "Home screen title"
},
"homeSubtitle": "Tempel link Spotify atau cari berdasarkan nama",
"homeSubtitle": "Tempel URL yang didukung atau cari berdasarkan nama",
"@homeSubtitle": {
"description": "Subtitle shown below search box"
},
@@ -211,11 +211,11 @@
"@optionsConcurrentWarning": {
"description": "Warning about rate limits"
},
"optionsExtensionStore": "Toko Ekstensi",
"optionsExtensionStore": "Repo Ekstensi",
"@optionsExtensionStore": {
"description": "Show/hide store tab"
},
"optionsExtensionStoreSubtitle": "Tampilkan tab Toko di navigasi",
"optionsExtensionStoreSubtitle": "Tampilkan tab Repo di navigasi",
"@optionsExtensionStoreSubtitle": {
"description": "Subtitle for extension store toggle"
},
@@ -318,10 +318,14 @@
"@extensionsUninstall": {
"description": "Uninstall extension button"
},
"storeTitle": "Toko Ekstensi",
"storeTitle": "Repo Ekstensi",
"@storeTitle": {
"description": "Store screen title"
},
"storeLoadError": "Gagal memuat repo",
"@storeLoadError": {
"description": "Error heading when the store cannot be loaded"
},
"storeSearch": "Cari ekstensi...",
"@storeSearch": {
"description": "Store search placeholder"
@@ -2459,7 +2463,7 @@
"@tutorialExtensionsDesc": {
"description": "Tutorial extensions page description"
},
"tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions",
"tutorialExtensionsTip1": "Buka tab Repo untuk menemukan ekstensi yang berguna",
"@tutorialExtensionsTip1": {
"description": "Tutorial extensions tip 1"
},
+11 -10
View File
@@ -1559,7 +1559,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final isDownloading = itemProgress['is_downloading'] as bool? ?? false;
final status = itemProgress['status'] as String? ?? 'downloading';
if (status == 'finalizing' && bytesTotal > 0) {
if (status == 'finalizing') {
progressUpdates[itemId] = const _ProgressUpdate(
status: DownloadStatus.finalizing,
progress: 1.0,
@@ -3802,6 +3802,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
var quality = item.qualityOverride ?? state.audioQuality;
if (quality == 'DEFAULT') quality = state.audioQuality;
final isSafMode = _isSafMode(settings);
final relativeOutputDir = isSafMode
? await _buildRelativeOutputDir(
@@ -4358,7 +4359,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (!wasExisting && decryptionKey.isNotEmpty && filePath != null) {
_log.i('Encrypted stream detected, decrypting via FFmpeg...');
updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.9);
updateItemStatus(item.id, DownloadStatus.finalizing, progress: 0.9);
if (effectiveSafMode && isContentUri(filePath)) {
final currentFilePath = filePath;
@@ -4503,7 +4504,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
try {
updateItemStatus(
item.id,
DownloadStatus.downloading,
DownloadStatus.finalizing,
progress: 0.95,
);
@@ -4524,7 +4525,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.i('Embedding metadata to $format...');
updateItemStatus(
item.id,
DownloadStatus.downloading,
DownloadStatus.finalizing,
progress: 0.99,
);
@@ -4608,7 +4609,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} else {
updateItemStatus(
item.id,
DownloadStatus.downloading,
DownloadStatus.finalizing,
progress: 0.95,
);
flacPath = await FFmpegService.convertM4aToFlac(tempPath);
@@ -4684,7 +4685,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
try {
updateItemStatus(
item.id,
DownloadStatus.downloading,
DownloadStatus.finalizing,
progress: 0.95,
);
@@ -4711,7 +4712,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.i('Embedding metadata to $format...');
updateItemStatus(
item.id,
DownloadStatus.downloading,
DownloadStatus.finalizing,
progress: 0.99,
);
@@ -4765,7 +4766,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} else {
updateItemStatus(
item.id,
DownloadStatus.downloading,
DownloadStatus.finalizing,
progress: 0.95,
);
final flacPath = await FFmpegService.convertM4aToFlac(
@@ -4849,7 +4850,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
try {
updateItemStatus(
item.id,
DownloadStatus.downloading,
DownloadStatus.finalizing,
progress: 0.99,
);
@@ -4930,7 +4931,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
try {
updateItemStatus(
item.id,
DownloadStatus.downloading,
DownloadStatus.finalizing,
progress: 0.99,
);
+5 -84
View File
@@ -538,90 +538,11 @@ class TrackNotifier extends Notifier<TrackState> {
return;
}
final isSpotifyUrl =
url.contains('open.spotify.com') ||
url.contains('spotify.link') ||
url.startsWith('spotify:');
if (!isSpotifyUrl) {
state = TrackState(
isLoading: false,
error: 'url_not_recognized',
hasSearchText: state.hasSearchText,
);
return;
}
final parsed = await PlatformBridge.parseSpotifyUrl(url);
if (!_isRequestValid(requestId)) return;
final type = parsed['type'] as String;
Map<String, dynamic> metadata;
try {
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
} catch (e) {
rethrow;
}
if (!_isRequestValid(requestId)) return;
if (type == 'track') {
final trackData = metadata['track'] as Map<String, dynamic>;
final track = _parseTrack(trackData);
state = TrackState(
tracks: [track],
isLoading: false,
coverUrl: track.coverUrl,
);
} else if (type == 'album') {
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
state = TrackState(
tracks: tracks,
isLoading: false,
albumId: parsed['id'] as String?,
albumName: albumInfo['name'] as String?,
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
);
_preWarmCacheForTracks(tracks);
} else if (type == 'playlist') {
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
final playlistName =
(playlistInfo['name'] ?? owner?['name']) as String?;
final coverUrl = normalizeRemoteHttpUrl(
(playlistInfo['images'] ?? owner?['images'])?.toString(),
);
state = TrackState(
tracks: tracks,
isLoading: false,
playlistName: playlistName,
coverUrl: coverUrl,
);
_preWarmCacheForTracks(tracks);
} else if (type == 'artist') {
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
final albumsList = metadata['albums'] as List<dynamic>;
final albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
state = TrackState(
tracks: [],
isLoading: false,
artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?,
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
artistAlbums: albums,
);
}
state = TrackState(
isLoading: false,
error: 'url_not_recognized',
hasSearchText: state.hasSearchText,
);
} catch (e) {
if (!_isRequestValid(requestId)) return;
state = TrackState(
+86 -21
View File
@@ -174,42 +174,107 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
Future<void> _fetchTracks() async {
setState(() => _isLoading = true);
try {
Map<String, dynamic> metadata;
if (widget.albumId.startsWith('deezer:')) {
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
metadata = await PlatformBridge.getDeezerMetadata(
final metadata = await PlatformBridge.getDeezerMetadata(
'album',
deezerAlbumId,
);
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString();
_AlbumCache.set(widget.albumId, tracks);
if (mounted) {
setState(() {
_tracks = tracks;
_artistId = artistId;
_isLoading = false;
});
}
return;
} else if (widget.albumId.startsWith('qobuz:')) {
final qobuzAlbumId = widget.albumId.replaceFirst('qobuz:', '');
metadata = await PlatformBridge.getQobuzMetadata('album', qobuzAlbumId);
final metadata = await PlatformBridge.getQobuzMetadata(
'album',
qobuzAlbumId,
);
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString();
_AlbumCache.set(widget.albumId, tracks);
if (mounted) {
setState(() {
_tracks = tracks;
_artistId = artistId;
_isLoading = false;
});
}
return;
} else if (widget.albumId.startsWith('tidal:')) {
final tidalAlbumId = widget.albumId.replaceFirst('tidal:', '');
metadata = await PlatformBridge.getTidalMetadata('album', tidalAlbumId);
final metadata = await PlatformBridge.getTidalMetadata(
'album',
tidalAlbumId,
);
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString();
_AlbumCache.set(widget.albumId, tracks);
if (mounted) {
setState(() {
_tracks = tracks;
_artistId = artistId;
_isLoading = false;
});
}
return;
} else {
final url = 'https://open.spotify.com/album/${widget.albumId}';
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
}
final result = await PlatformBridge.handleURLWithExtension(url);
if (result == null || result['tracks'] == null) {
throw StateError('Failed to load album metadata from extension');
}
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final trackList = result['tracks'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString();
final albumInfo = result['album'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString();
_AlbumCache.set(widget.albumId, tracks);
_AlbumCache.set(widget.albumId, tracks);
if (mounted) {
setState(() {
_tracks = tracks;
_artistId = artistId;
_isLoading = false;
});
if (mounted) {
setState(() {
_tracks = tracks;
_artistId = artistId;
_isLoading = false;
});
}
return;
}
} catch (e) {
if (mounted) {
+1 -16
View File
@@ -343,13 +343,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
headerImage = artistData['header_image'] as String?;
listeners = artistData['listeners'] as int?;
} else {
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(
url,
);
final albumsList = metadata['albums'] as List<dynamic>;
albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
throw StateError('Failed to load artist metadata from extension');
}
}
@@ -1105,15 +1099,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
.toList();
}
// Fallback to direct Spotify metadata
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
if (metadata['tracks'] != null) {
final tracksList = metadata['tracks'] as List<dynamic>;
return tracksList
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
.toList();
}
}
return [];
}
+18 -10
View File
@@ -13,6 +13,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
@@ -1164,19 +1165,23 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final shouldEmbedLyrics =
settings.embedLyrics && settings.lyricsMode != 'external';
var cancelled = false;
BatchProgressDialog.show(
context: context,
title: context.l10n.trackConvertConverting,
total: total,
icon: Icons.transform,
onCancel: () {
cancelled = true;
BatchProgressDialog.dismiss(context);
},
);
for (int i = 0; i < total; i++) {
if (!mounted) break;
if (!mounted || cancelled) break;
final item = selected[i];
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.selectionBatchConvertProgress(i + 1, total),
),
duration: const Duration(seconds: 30),
),
);
BatchProgressDialog.update(current: i + 1, detail: item.trackName);
try {
final metadata = <String, String>{
@@ -1335,6 +1340,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
_exitSelectionMode();
if (mounted) {
if (!cancelled) {
BatchProgressDialog.dismiss(context);
}
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
+11 -12
View File
@@ -2313,7 +2313,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
error.contains('429') ||
error.toLowerCase().contains('rate limit') ||
error.toLowerCase().contains('too many requests');
final isUrlNotRecognized = error == 'url_not_recognized';
if (isRateLimit) {
@@ -3087,7 +3086,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
final extState = ref.read(extensionProvider);
if (!extState.isInitialized) {
return 'Paste Spotify URL or search...';
return 'Paste supported URL or search...';
}
if (searchProvider != null && searchProvider.isNotEmpty) {
@@ -3108,7 +3107,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
return 'Search with ${ext.displayName}...';
}
}
return 'Paste Spotify URL or search...';
return 'Paste supported URL or search...';
}
Widget _buildSearchFilterBar(
@@ -3125,7 +3124,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
label: const Text('All'),
label: Text(context.l10n.historyFilterAll),
selected: selectedFilter == null,
onSelected: (_) {
ref.read(trackProvider.notifier).setSearchFilter(null);
@@ -4213,7 +4212,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
if (result == null) {
setState(() {
_error = 'Failed to load album';
_error = context.l10n.errorLoadAlbum;
_isLoading = false;
});
return;
@@ -4222,7 +4221,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
final trackList = result['tracks'] as List<dynamic>?;
if (trackList == null) {
setState(() {
_error = 'No tracks found';
_error = context.l10n.errorNoTracksFound;
_isLoading = false;
});
return;
@@ -4244,7 +4243,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
} catch (e) {
if (!mounted) return;
setState(() {
_error = 'Error: $e';
_error = context.l10n.snackbarError(e.toString());
_isLoading = false;
});
}
@@ -4377,7 +4376,7 @@ class _ExtensionPlaylistScreenState
if (result == null) {
setState(() {
_error = 'Failed to load playlist';
_error = context.l10n.errorLoadPlaylist;
_isLoading = false;
});
return;
@@ -4386,7 +4385,7 @@ class _ExtensionPlaylistScreenState
final trackList = result['tracks'] as List<dynamic>?;
if (trackList == null) {
setState(() {
_error = 'No tracks found';
_error = context.l10n.errorNoTracksFound;
_isLoading = false;
});
return;
@@ -4403,7 +4402,7 @@ class _ExtensionPlaylistScreenState
} catch (e) {
if (!mounted) return;
setState(() {
_error = 'Error: $e';
_error = context.l10n.snackbarError(e.toString());
_isLoading = false;
});
}
@@ -4529,7 +4528,7 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
if (result == null) {
setState(() {
_error = 'Failed to load artist';
_error = context.l10n.errorLoadArtist;
_isLoading = false;
});
return;
@@ -4563,7 +4562,7 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
} catch (e) {
if (!mounted) return;
setState(() {
_error = 'Error: $e';
_error = context.l10n.snackbarError(e.toString());
_isLoading = false;
});
}
+19 -9
View File
@@ -9,6 +9,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
@@ -1236,15 +1237,24 @@ class _CollectionTrackTile extends ConsumerWidget {
),
trailing: isSelectionMode
? null
: IconButton(
tooltip: MaterialLocalizations.of(context).showMenuTooltip,
icon: Icon(
Icons.more_vert,
color: colorScheme.onSurfaceVariant,
size: 20,
),
onPressed: () => _showTrackOptionsSheet(context, ref),
),
: historyItem != null || localItem != null
? IconButton(
tooltip: context.l10n.tooltipPlay,
onPressed: () {
ref
.read(playbackProvider.notifier)
.playTrackList([track]);
},
icon: Icon(
Icons.play_arrow,
color: colorScheme.primary,
),
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer
.withValues(alpha: 0.3),
),
)
: null,
onTap: isSelectionMode
? onTap
: () {
+55 -29
View File
@@ -13,6 +13,7 @@ import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
@@ -957,16 +958,22 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
var skippedCount = 0;
final total = selected.length;
for (var i = 0; i < total; i++) {
if (!mounted) break;
var cancelled = false;
BatchProgressDialog.show(
context: context,
title: context.l10n.queueFlacAction,
total: total,
icon: Icons.queue_music,
onCancel: () {
cancelled = true;
BatchProgressDialog.dismiss(context);
},
);
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.queueFlacFindingProgress(i + 1, total)),
duration: const Duration(seconds: 30),
),
);
for (var i = 0; i < total; i++) {
if (!mounted || cancelled) break;
BatchProgressDialog.update(current: i + 1, detail: selected[i].trackName);
try {
final resolution = await LocalTrackRedownloadService.resolveBestMatch(
@@ -987,7 +994,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return;
}
ScaffoldMessenger.of(context).clearSnackBars();
if (!cancelled) {
BatchProgressDialog.dismiss(context);
}
if (matchedTracks.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -1063,18 +1072,25 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
var successCount = 0;
final total = selected.length;
var cancelled = false;
BatchProgressDialog.show(
context: context,
title: context.l10n.trackReEnrichProgress,
total: total,
icon: Icons.auto_fix_high,
onCancel: () {
cancelled = true;
BatchProgressDialog.dismiss(context);
},
);
for (var i = 0; i < total; i++) {
if (!mounted) break;
if (!mounted || cancelled) break;
final item = selected[i];
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${context.l10n.trackReEnrichProgress} (${i + 1}/$total)',
),
duration: const Duration(seconds: 30),
),
BatchProgressDialog.update(
current: i + 1,
detail: '${item.trackName} - ${item.artistName}',
);
try {
@@ -1114,6 +1130,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return;
}
if (!cancelled) {
BatchProgressDialog.dismiss(context);
}
ScaffoldMessenger.of(context).clearSnackBars();
final failedCount = total - successCount;
final summary = failedCount <= 0
@@ -1422,19 +1441,23 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final shouldEmbedLyrics =
settings.embedLyrics && settings.lyricsMode != 'external';
var cancelled = false;
BatchProgressDialog.show(
context: context,
title: context.l10n.trackConvertConverting,
total: total,
icon: Icons.transform,
onCancel: () {
cancelled = true;
BatchProgressDialog.dismiss(context);
},
);
for (int i = 0; i < total; i++) {
if (!mounted) break;
if (!mounted || cancelled) break;
final item = selected[i];
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.selectionBatchConvertProgress(i + 1, total),
),
duration: const Duration(seconds: 30),
),
);
BatchProgressDialog.update(current: i + 1, detail: item.trackName);
try {
final metadata = <String, String>{
@@ -1621,6 +1644,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
_exitSelectionMode();
if (mounted) {
if (!cancelled) {
BatchProgressDialog.dismiss(context);
}
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
+37 -29
View File
@@ -12,12 +12,13 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/screens/home_tab.dart';
import 'package:spotiflac_android/screens/store_tab.dart';
import 'package:spotiflac_android/screens/repo_tab.dart';
import 'package:spotiflac_android/screens/queue_tab.dart';
import 'package:spotiflac_android/screens/settings/settings_tab.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/shell_navigation_service.dart';
import 'package:spotiflac_android/services/share_intent_service.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/services/update_checker.dart';
import 'package:spotiflac_android/widgets/update_dialog.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
@@ -44,8 +45,14 @@ class _MainShellState extends ConsumerState<MainShell>
ShellNavigationService.homeTabNavigatorKey;
final GlobalKey<NavigatorState> _libraryTabNavigatorKey =
ShellNavigationService.libraryTabNavigatorKey;
final GlobalKey<NavigatorState> _storeTabNavigatorKey =
ShellNavigationService.storeTabNavigatorKey;
final GlobalKey<NavigatorState> _repoTabNavigatorKey =
ShellNavigationService.repoTabNavigatorKey;
@override
void didChangeDependencies() {
super.didChangeDependencies();
NotificationService().updateStrings(context.l10n);
}
@override
void initState() {
@@ -58,7 +65,7 @@ class _MainShellState extends ConsumerState<MainShell>
);
ShellNavigationService.syncState(
currentTabIndex: _currentIndex,
showStoreTab: false,
showRepoTab: false,
);
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkForUpdates();
@@ -268,7 +275,7 @@ class _MainShellState extends ConsumerState<MainShell>
);
ShellNavigationService.syncState(
currentTabIndex: _currentIndex,
showStoreTab: showStore,
showRepoTab: showStore,
);
FocusManager.instance.primaryFocus?.unfocus();
// Jump directly when skipping intermediate tabs to avoid
@@ -295,17 +302,17 @@ class _MainShellState extends ConsumerState<MainShell>
);
ShellNavigationService.syncState(
currentTabIndex: _currentIndex,
showStoreTab: showStore,
showRepoTab: showStore,
);
FocusManager.instance.primaryFocus?.unfocus();
}
}
void _handleBackPress() {
Future<void> _handleBackPress() async {
final rootNavigator = Navigator.of(context, rootNavigator: true);
if (rootNavigator.canPop()) {
_log.i('Back: step 1 - root navigator pop');
rootNavigator.pop();
final handledByRootNavigator = await rootNavigator.maybePop();
if (handledByRootNavigator) {
_log.i('Back: step 1 - root navigator handled back');
_lastBackPress = null;
return;
}
@@ -314,9 +321,10 @@ class _MainShellState extends ConsumerState<MainShell>
settingsProvider.select((s) => s.showExtensionStore),
);
final currentNavigator = _navigatorForTab(_currentIndex, showStore);
if (currentNavigator != null && currentNavigator.canPop()) {
_log.i('Back: step 2 - tab navigator pop (tab=$_currentIndex)');
currentNavigator.pop();
final handledByCurrentNavigator =
await currentNavigator?.maybePop() ?? false;
if (handledByCurrentNavigator) {
_log.i('Back: step 2 - tab navigator handled back (tab=$_currentIndex)');
_lastBackPress = null;
return;
}
@@ -413,7 +421,7 @@ class _MainShellState extends ConsumerState<MainShell>
NavigatorState? _navigatorForTab(int index, bool showStore) {
if (index == 0) return _homeTabNavigatorKey.currentState;
if (index == 1) return _libraryTabNavigatorKey.currentState;
if (showStore && index == 2) return _storeTabNavigatorKey.currentState;
if (showStore && index == 2) return _repoTabNavigatorKey.currentState;
return null;
}
@@ -427,9 +435,9 @@ class _MainShellState extends ConsumerState<MainShell>
);
ShellNavigationService.syncState(
currentTabIndex: _currentIndex,
showStoreTab: showStore,
showRepoTab: showStore,
);
final storeUpdatesCount = ref.watch(
final repoUpdatesCount = ref.watch(
storeProvider.select((s) => s.updatesAvailableCount),
);
@@ -446,9 +454,9 @@ class _MainShellState extends ConsumerState<MainShell>
),
if (showStore)
_TabNavigator(
key: const ValueKey('tab-store'),
navigatorKey: _storeTabNavigatorKey,
child: const StoreTab(),
key: const ValueKey('tab-repo'),
navigatorKey: _repoTabNavigatorKey,
child: const RepoTab(),
),
const SettingsTab(),
];
@@ -484,20 +492,20 @@ class _MainShellState extends ConsumerState<MainShell>
if (showStore)
NavigationDestination(
icon: AnimatedBadge(
count: storeUpdatesCount,
count: repoUpdatesCount,
child: Badge(
isLabelVisible: storeUpdatesCount > 0,
label: Text('$storeUpdatesCount'),
child: const Icon(Icons.store_outlined),
isLabelVisible: repoUpdatesCount > 0,
label: Text('$repoUpdatesCount'),
child: const Icon(Icons.extension_outlined),
),
),
selectedIcon: SwingIcon(
selectedIcon: BouncingIcon(
child: AnimatedBadge(
count: storeUpdatesCount,
count: repoUpdatesCount,
child: Badge(
isLabelVisible: storeUpdatesCount > 0,
label: Text('$storeUpdatesCount'),
child: const Icon(Icons.store),
isLabelVisible: repoUpdatesCount > 0,
label: Text('$repoUpdatesCount'),
child: const Icon(Icons.extension),
),
),
),
@@ -522,7 +530,7 @@ class _MainShellState extends ConsumerState<MainShell>
return BackButtonListener(
onBackButtonPressed: () async {
_handleBackPress();
await _handleBackPress();
return true;
},
child: Scaffold(
+144 -62
View File
@@ -28,6 +28,7 @@ import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart';
import 'package:spotiflac_android/screens/local_album_screen.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
@@ -1187,6 +1188,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
void _onFilterPageChanged(int index) {
HapticFeedback.selectionClick();
final filterMode = _filterModes[index];
ref.read(settingsProvider.notifier).setHistoryFilterMode(filterMode);
}
@@ -1219,16 +1221,21 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
void _toggleSelection(String itemId) {
var shouldHideOverlay = false;
setState(() {
if (_selectedIds.contains(itemId)) {
_selectedIds.remove(itemId);
if (_selectedIds.isEmpty) {
_isSelectionMode = false;
shouldHideOverlay = true;
}
} else {
_selectedIds.add(itemId);
}
});
if (shouldHideOverlay) {
_hideSelectionOverlay();
}
}
void _selectAll(List<UnifiedLibraryItem> items) {
@@ -1268,13 +1275,15 @@ class _QueueTabState extends ConsumerState<QueueTab> {
left: 0,
right: 0,
bottom: 0,
child: Material(
color: Colors.transparent,
child: _buildSelectionBottomBar(
context,
colorScheme,
_selectionOverlayItems,
_selectionOverlayBottomPadding,
child: _AnimatedOverlayBottomBar(
child: Material(
color: Colors.transparent,
child: _buildSelectionBottomBar(
context,
colorScheme,
_selectionOverlayItems,
_selectionOverlayBottomPadding,
),
),
),
);
@@ -1314,13 +1323,15 @@ class _QueueTabState extends ConsumerState<QueueTab> {
left: 0,
right: 0,
bottom: 0,
child: Material(
color: Colors.transparent,
child: _buildPlaylistSelectionBottomBar(
context,
colorScheme,
_playlistSelectionOverlayItems,
_playlistSelectionOverlayBottomPadding,
child: _AnimatedOverlayBottomBar(
child: Material(
color: Colors.transparent,
child: _buildPlaylistSelectionBottomBar(
context,
colorScheme,
_playlistSelectionOverlayItems,
_playlistSelectionOverlayBottomPadding,
),
),
),
);
@@ -1349,16 +1360,21 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
void _togglePlaylistSelection(String playlistId) {
var shouldHideOverlay = false;
setState(() {
if (_selectedPlaylistIds.contains(playlistId)) {
_selectedPlaylistIds.remove(playlistId);
if (_selectedPlaylistIds.isEmpty) {
_isPlaylistSelectionMode = false;
shouldHideOverlay = true;
}
} else {
_selectedPlaylistIds.add(playlistId);
}
});
if (shouldHideOverlay) {
_hidePlaylistSelectionOverlay();
}
}
void _selectAllPlaylists(List<UserPlaylistCollection> playlists) {
@@ -2159,13 +2175,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
FilterChip(
label: const Text('A-Z'),
label: Text(context.l10n.sortAlphaAsc),
selected: tempSortMode == 'a-z',
onSelected: (_) =>
setSheetState(() => tempSortMode = 'a-z'),
),
FilterChip(
label: const Text('Z-A'),
label: Text(context.l10n.sortAlphaDesc),
selected: tempSortMode == 'z-a',
onSelected: (_) =>
setSheetState(() => tempSortMode = 'z-a'),
@@ -4448,15 +4464,24 @@ class _QueueTabState extends ConsumerState<QueueTab> {
var skippedCount = 0;
final total = selectedLocalItems.length;
for (var i = 0; i < total; i++) {
if (!mounted) break;
var cancelled = false;
BatchProgressDialog.show(
context: context,
title: context.l10n.queueFlacAction,
total: total,
icon: Icons.queue_music,
onCancel: () {
cancelled = true;
BatchProgressDialog.dismiss(context);
},
);
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.queueFlacFindingProgress(i + 1, total)),
duration: const Duration(seconds: 30),
),
for (var i = 0; i < total; i++) {
if (!mounted || cancelled) break;
BatchProgressDialog.update(
current: i + 1,
detail: selectedLocalItems[i].trackName,
);
try {
@@ -4478,7 +4503,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return;
}
ScaffoldMessenger.of(context).clearSnackBars();
if (!cancelled) {
BatchProgressDialog.dismiss(context);
}
if (matchedTracks.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -4552,18 +4579,25 @@ class _QueueTabState extends ConsumerState<QueueTab> {
var successCount = 0;
final total = selectedLocalItems.length;
var cancelled = false;
BatchProgressDialog.show(
context: context,
title: context.l10n.trackReEnrichProgress,
total: total,
icon: Icons.auto_fix_high,
onCancel: () {
cancelled = true;
BatchProgressDialog.dismiss(context);
},
);
for (var i = 0; i < total; i++) {
if (!mounted) break;
if (!mounted || cancelled) break;
final item = selectedLocalItems[i];
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${context.l10n.trackReEnrichProgress} (${i + 1}/$total)',
),
duration: const Duration(seconds: 30),
),
BatchProgressDialog.update(
current: i + 1,
detail: '${item.trackName} - ${item.artistName}',
);
try {
@@ -4603,6 +4637,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return;
}
if (!cancelled) {
BatchProgressDialog.dismiss(context);
}
ScaffoldMessenger.of(context).clearSnackBars();
final failedCount = total - successCount;
final summary = failedCount <= 0
@@ -4963,19 +5000,23 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final shouldEmbedLyrics =
settings.embedLyrics && settings.lyricsMode != 'external';
var cancelled = false;
BatchProgressDialog.show(
context: context,
title: context.l10n.trackConvertConverting,
total: total,
icon: Icons.transform,
onCancel: () {
cancelled = true;
BatchProgressDialog.dismiss(context);
},
);
for (int i = 0; i < total; i++) {
if (!mounted) break;
if (!mounted || cancelled) break;
final item = selectedItems[i];
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.selectionBatchConvertProgress(i + 1, total),
),
duration: const Duration(seconds: 30),
),
);
BatchProgressDialog.update(current: i + 1, detail: item.trackName);
try {
final metadata = <String, String>{
@@ -5229,6 +5270,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
_exitSelectionMode();
if (mounted) {
if (!cancelled) {
BatchProgressDialog.dismiss(context);
}
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -5446,18 +5490,18 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Cancel download?'),
title: Text(context.l10n.cancelDownloadTitle),
content: Text(
'This will cancel the active download for "${item.track.name}".',
context.l10n.cancelDownloadContent(item.track.name),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Keep'),
child: Text(context.l10n.cancelDownloadKeep),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
],
),
@@ -5537,20 +5581,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
const SizedBox(width: 8),
Text(
item.bytesTotal > 0 && item.bytesReceived > 0
? (() {
final receivedMB =
item.bytesReceived / (1024 * 1024);
final totalMB =
item.bytesTotal / (1024 * 1024);
final progressLabel = item.progress > 0
? '${(item.progress * 100).toStringAsFixed(0)}% • '
: '';
final speedLabel = item.speedMBps > 0
? '${item.speedMBps.toStringAsFixed(1)} MB/s'
: '';
return '$progressLabel${receivedMB.toStringAsFixed(1)} / ${totalMB.toStringAsFixed(1)} MB$speedLabel';
})()
item.bytesTotal > 0
? '${(item.progress * 100).toStringAsFixed(0)}%'
: (item.bytesReceived > 0
? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB${item.speedMBps > 0 ? '${item.speedMBps.toStringAsFixed(1)} MB/s' : ''}'
: (item.progress > 0
@@ -6465,3 +6497,53 @@ class _SelectionActionButton extends StatelessWidget {
);
}
}
class _AnimatedOverlayBottomBar extends StatefulWidget {
final Widget child;
const _AnimatedOverlayBottomBar({required this.child});
@override
State<_AnimatedOverlayBottomBar> createState() =>
_AnimatedOverlayBottomBarState();
}
class _AnimatedOverlayBottomBarState extends State<_AnimatedOverlayBottomBar>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<Offset> _slideAnimation;
late final Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 240),
);
final curve = CurvedAnimation(
parent: _controller,
curve: Curves.easeOutCubic,
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 0.08),
end: Offset.zero,
).animate(curve);
_fadeAnimation = Tween<double>(begin: 0, end: 1).animate(curve);
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _fadeAnimation,
child: SlideTransition(position: _slideAnimation, child: widget.child),
);
}
}
@@ -8,14 +8,14 @@ import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/screens/store/extension_details_screen.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
class StoreTab extends ConsumerStatefulWidget {
const StoreTab({super.key});
class RepoTab extends ConsumerStatefulWidget {
const RepoTab({super.key});
@override
ConsumerState<StoreTab> createState() => _StoreTabState();
ConsumerState<RepoTab> createState() => _RepoTabState();
}
class _StoreTabState extends ConsumerState<StoreTab> {
class _RepoTabState extends ConsumerState<RepoTab> {
final _searchController = TextEditingController();
final _repoUrlController = TextEditingController();
bool _isInitialized = false;
@@ -323,7 +323,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.store_outlined,
Icons.extension_outlined,
size: 72,
color: colorScheme.onSurfaceVariant,
),
@@ -1359,7 +1359,9 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
content: Text('Failed to open folder picker: $e'),
content: Text(
ctx.l10n.snackbarFolderPickerFailed(e.toString()),
),
backgroundColor: Theme.of(ctx).colorScheme.error,
duration: const Duration(seconds: 4),
),
+11 -9
View File
@@ -735,7 +735,7 @@ class _SearchProviderSelector extends ConsumerWidget {
(entry) => ListTile(
leading: Icon(Icons.search, color: colorScheme.tertiary),
title: Text(entry.value),
subtitle: Text('Search with ${entry.value}'),
subtitle: Text(ctx.l10n.extensionsSearchWith(entry.value)),
trailing: settings.searchProvider == entry.key
? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline),
@@ -791,7 +791,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
final hasAnyProvider = homeFeedProviders.isNotEmpty;
String currentProviderName = 'Auto';
String currentProviderName = context.l10n.extensionsHomeFeedAuto;
if (settings.homeFeedProvider != null &&
settings.homeFeedProvider!.isNotEmpty) {
final ext = homeFeedProviders
@@ -828,7 +828,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Home Feed Provider',
context.l10n.extensionsHomeFeedProvider,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: !hasAnyProvider ? colorScheme.outline : null,
),
@@ -836,7 +836,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
const SizedBox(height: 2),
Text(
!hasAnyProvider
? 'No extensions with home feed'
? context.l10n.extensionsNoHomeFeedExtensions
: currentProviderName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
@@ -883,7 +883,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
'Home Feed Provider',
ctx.l10n.extensionsHomeFeedProvider,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
@@ -892,7 +892,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'Choose which extension provides the home feed on the main screen',
ctx.l10n.extensionsHomeFeedDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -900,8 +900,8 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
),
ListTile(
leading: Icon(Icons.auto_awesome, color: colorScheme.primary),
title: const Text('Auto'),
subtitle: const Text('Automatically select the best available'),
title: Text(ctx.l10n.extensionsHomeFeedAuto),
subtitle: Text(ctx.l10n.extensionsHomeFeedAutoSubtitle),
trailing:
(settings.homeFeedProvider == null ||
settings.homeFeedProvider!.isEmpty)
@@ -917,7 +917,9 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
(ext) => ListTile(
leading: Icon(Icons.extension, color: colorScheme.secondary),
title: Text(ext.displayName),
subtitle: Text('Use ${ext.displayName} home feed'),
subtitle: Text(
ctx.l10n.extensionsHomeFeedUse(ext.displayName),
),
trailing: settings.homeFeedProvider == ext.id
? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline),
@@ -158,7 +158,7 @@ class OptionsSettingsPage extends ConsumerWidget {
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.store,
icon: Icons.extension,
title: context.l10n.optionsExtensionStore,
subtitle: context.l10n.optionsExtensionStoreSubtitle,
value: settings.showExtensionStore,
@@ -307,9 +307,9 @@ class OptionsSettingsPage extends ConsumerWidget {
} catch (e) {
if (context.mounted) {
Navigator.pop(context); // Close loading dialog
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $e')));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarError(e.toString()))),
);
}
}
}
+6 -4
View File
@@ -339,7 +339,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to open folder picker: $e'),
content: Text(
context.l10n.snackbarFolderPickerFailed(e.toString()),
),
backgroundColor: Theme.of(context).colorScheme.error,
duration: const Duration(seconds: 4),
),
@@ -430,9 +432,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
if (mounted) context.go('/tutorial');
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $e')));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarError(e.toString()))),
);
}
} finally {
setState(() => _isLoading = false);
+7 -8
View File
@@ -5025,9 +5025,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
if (ffmpegResult == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Failed to save metadata via FFmpeg'),
),
SnackBar(content: Text(context.l10n.metadataSaveFailedFfmpeg)),
);
}
setState(() => _saving = false);
@@ -5038,9 +5036,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri);
if (!ok && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Failed to write metadata back to storage'),
),
SnackBar(content: Text(context.l10n.metadataSaveFailedStorage)),
);
setState(() => _saving = false);
return;
@@ -5094,7 +5090,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
children: [
Expanded(
child: Text(
'Edit Metadata',
context.l10n.trackEditMetadata,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
@@ -5107,7 +5103,10 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
child: CircularProgressIndicator(strokeWidth: 2),
)
else
FilledButton(onPressed: _save, child: const Text('Save')),
FilledButton(
onPressed: _save,
child: Text(context.l10n.dialogSave),
),
],
),
),
+1 -1
View File
@@ -185,7 +185,7 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
title: l10n.tutorialExtensionsTitle,
description: l10n.tutorialExtensionsDesc,
content: _buildFeatureList(context, [
(Icons.storefront_rounded, l10n.tutorialExtensionsTip1),
(Icons.extension_rounded, l10n.tutorialExtensionsTip1),
(
Icons.add_circle_outline_rounded,
l10n.tutorialExtensionsTip2,
+4 -5
View File
@@ -1446,10 +1446,10 @@ class FFmpegService {
}
cmdBuffer.write('-map 0:a ');
// M4A/MP4 containers store cover art in the 'covr' atom automatically.
// '-disposition attached_pic' is only for Matroska/WebM and must NOT be used here.
if (hasCover) {
cmdBuffer.write('-map 1:v -c:v copy ');
cmdBuffer.write('-map 1:v -c:v copy -disposition:v:0 attached_pic ');
cmdBuffer.write('-metadata:s:v title="Album cover" ');
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
}
cmdBuffer.write('-c:a alac ');
cmdBuffer.write('-map_metadata -1 ');
@@ -1836,8 +1836,7 @@ class FFmpegService {
continue;
}
if (coverPath != null && coverPath.isNotEmpty && outputExt == 'flac') {
}
if (coverPath != null && coverPath.isNotEmpty && outputExt == 'flac') {}
outputPaths.add(outputPath);
_log.i('CUE split: track ${track.number} -> $outputFileName');
+61 -26
View File
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
class NotificationService {
static final NotificationService _instance = NotificationService._internal();
@@ -13,6 +14,13 @@ class NotificationService {
FlutterLocalNotificationsPlugin();
bool _isInitialized = false;
bool _notificationPermissionRequested = false;
AppLocalizations? _l10n;
/// Call this from the widget tree (e.g. didChangeDependencies) whenever the
/// app locale changes so that notification strings stay in sync.
void updateStrings(AppLocalizations l10n) {
_l10n = l10n;
}
static const int downloadProgressId = 1;
static const int updateDownloadId = 2;
@@ -165,7 +173,8 @@ class NotificationService {
await _showSafely(
id: downloadProgressId,
title: 'Downloading $trackName',
title:
_l10n?.notifDownloadingTrack(trackName) ?? 'Downloading $trackName',
body: '$artistName$percentage%',
details: details,
);
@@ -208,8 +217,9 @@ class NotificationService {
await _showSafely(
id: downloadProgressId,
title: 'Finalizing $trackName',
body: '$artistName • Embedding metadata...',
title: _l10n?.notifFinalizingTrack(trackName) ?? 'Finalizing $trackName',
body:
'$artistName${_l10n?.notifEmbeddingMetadata ?? 'Embedding metadata...'}',
details: details,
);
}
@@ -226,12 +236,14 @@ class NotificationService {
String title;
if (alreadyInLibrary) {
title = completedCount != null && totalCount != null
? 'Already in Library ($completedCount/$totalCount)'
: 'Already in Library';
? (_l10n?.notifAlreadyInLibraryCount(completedCount, totalCount) ??
'Already in Library ($completedCount/$totalCount)')
: (_l10n?.notifAlreadyInLibrary ?? 'Already in Library');
} else {
title = completedCount != null && totalCount != null
? 'Download Complete ($completedCount/$totalCount)'
: 'Download Complete';
? (_l10n?.notifDownloadCompleteCount(completedCount, totalCount) ??
'Download Complete ($completedCount/$totalCount)')
: (_l10n?.notifDownloadComplete ?? 'Download Complete');
}
const androidDetails = AndroidNotificationDetails(
@@ -271,8 +283,9 @@ class NotificationService {
if (!_isInitialized) await initialize();
final title = failedCount > 0
? 'Downloads Finished ($completedCount done, $failedCount failed)'
: 'All Downloads Complete';
? (_l10n?.notifDownloadsFinished(completedCount, failedCount) ??
'Downloads Finished ($completedCount done, $failedCount failed)')
: (_l10n?.notifAllDownloadsComplete ?? 'All Downloads Complete');
const androidDetails = AndroidNotificationDetails(
channelId,
@@ -299,7 +312,9 @@ class NotificationService {
await _showSafely(
id: downloadProgressId,
title: title,
body: '$completedCount tracks downloaded successfully',
body:
_l10n?.notifTracksDownloadedSuccess(completedCount) ??
'$completedCount tracks downloaded successfully',
details: details,
);
}
@@ -319,8 +334,14 @@ class NotificationService {
final clampedProgress = progress.clamp(0.0, 100.0);
final percentage = clampedProgress.round();
final progressBody = totalFiles > 0
? '$scannedFiles/$totalFiles files • $percentage%'
: '$scannedFiles files scanned • $percentage%';
? (_l10n?.notifLibraryScanProgressWithTotal(
scannedFiles,
totalFiles,
percentage,
) ??
'$scannedFiles/$totalFiles files • $percentage%')
: (_l10n?.notifLibraryScanProgressNoTotal(scannedFiles, percentage) ??
'$scannedFiles files scanned • $percentage%');
final body = (currentFile != null && currentFile.isNotEmpty)
? '$progressBody\n$currentFile'
: progressBody;
@@ -355,7 +376,7 @@ class NotificationService {
await _showSafely(
id: libraryScanId,
title: 'Scanning local library',
title: _l10n?.notifScanningLibrary ?? 'Scanning local library',
body: body,
details: details,
);
@@ -370,10 +391,15 @@ class NotificationService {
final extras = <String>[];
if (excludedDownloadedCount > 0) {
extras.add('$excludedDownloadedCount excluded');
extras.add(
_l10n?.notifLibraryScanExcluded(excludedDownloadedCount) ??
'$excludedDownloadedCount excluded',
);
}
if (errorCount > 0) {
extras.add('$errorCount errors');
extras.add(
_l10n?.notifLibraryScanErrors(errorCount) ?? '$errorCount errors',
);
}
final suffix = extras.isEmpty ? '' : ' (${extras.join(', ')})';
@@ -401,8 +427,9 @@ class NotificationService {
await _showSafely(
id: libraryScanId,
title: 'Library scan complete',
body: '$totalTracks tracks indexed$suffix',
title: _l10n?.notifLibraryScanComplete ?? 'Library scan complete',
body:
'${_l10n?.notifLibraryScanCompleteBody(totalTracks) ?? '$totalTracks tracks indexed'}$suffix',
details: details,
);
}
@@ -434,7 +461,7 @@ class NotificationService {
await _showSafely(
id: libraryScanId,
title: 'Library scan failed',
title: _l10n?.notifLibraryScanFailed ?? 'Library scan failed',
body: message,
details: details,
);
@@ -467,8 +494,8 @@ class NotificationService {
await _showSafely(
id: libraryScanId,
title: 'Library scan cancelled',
body: 'Scan stopped before completion.',
title: _l10n?.notifLibraryScanCancelled ?? 'Library scan cancelled',
body: _l10n?.notifLibraryScanStopped ?? 'Scan stopped before completion.',
details: details,
);
}
@@ -518,8 +545,12 @@ class NotificationService {
await _showSafely(
id: updateDownloadId,
title: 'Downloading SpotiFLAC v$version',
body: '$receivedMB / $totalMB MB • $percentage%',
title:
_l10n?.notifDownloadingUpdate(version) ??
'Downloading SpotiFLAC v$version',
body:
_l10n?.notifUpdateProgress(receivedMB, totalMB, percentage) ??
'$receivedMB / $totalMB MB • $percentage%',
details: details,
);
}
@@ -551,8 +582,10 @@ class NotificationService {
await _showSafely(
id: updateDownloadId,
title: 'Update Ready',
body: 'SpotiFLAC v$version downloaded. Tap to install.',
title: _l10n?.notifUpdateReady ?? 'Update Ready',
body:
_l10n?.notifUpdateReadyBody(version) ??
'SpotiFLAC v$version downloaded. Tap to install.',
details: details,
);
}
@@ -583,8 +616,10 @@ class NotificationService {
await _showSafely(
id: updateDownloadId,
title: 'Update Failed',
body: 'Could not download update. Try again later.',
title: _l10n?.notifUpdateFailed ?? 'Update Failed',
body:
_l10n?.notifUpdateFailedBody ??
'Could not download update. Try again later.',
details: details,
);
}
-16
View File
@@ -20,12 +20,6 @@ class PlatformBridge {
static bool get supportsExtensionSystem =>
Platform.isAndroid || Platform.isIOS;
static Future<Map<String, dynamic>> parseSpotifyUrl(String url) async {
_log.d('parseSpotifyUrl: $url');
final result = await _channel.invokeMethod('parseSpotifyUrl', {'url': url});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> checkAvailability(
String spotifyId,
String isrc,
@@ -654,16 +648,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> getSpotifyMetadataWithFallback(
String url,
) async {
final result = await _channel.invokeMethod(
'getSpotifyMetadataWithFallback',
{'url': url},
);
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<List<Map<String, dynamic>>> getGoLogs() async {
final result = await _channel.invokeMethod('getLogs');
final logs = jsonDecode(result as String) as List<dynamic>;
+6 -6
View File
@@ -5,25 +5,25 @@ class ShellNavigationService {
GlobalKey<NavigatorState>();
static final GlobalKey<NavigatorState> libraryTabNavigatorKey =
GlobalKey<NavigatorState>();
static final GlobalKey<NavigatorState> storeTabNavigatorKey =
static final GlobalKey<NavigatorState> repoTabNavigatorKey =
GlobalKey<NavigatorState>();
static int _currentTabIndex = 0;
static bool _showStoreTab = false;
static bool _showRepoTab = false;
static void syncState({
required int currentTabIndex,
required bool showStoreTab,
required bool showRepoTab,
}) {
_currentTabIndex = currentTabIndex;
_showStoreTab = showStoreTab;
_showRepoTab = showRepoTab;
}
static NavigatorState? activeTabNavigator() {
if (_currentTabIndex == 0) return homeTabNavigatorKey.currentState;
if (_currentTabIndex == 1) return libraryTabNavigatorKey.currentState;
if (_showStoreTab && _currentTabIndex == 2) {
return storeTabNavigatorKey.currentState;
if (_showRepoTab && _currentTabIndex == 2) {
return repoTabNavigatorKey.currentState;
}
return null;
}
+193
View File
@@ -0,0 +1,193 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
/// Progress state communicated from caller to dialog via [ValueNotifier].
class _BatchProgress {
final int current;
final String? detail;
const _BatchProgress({this.current = 0, this.detail});
}
/// A reusable progress dialog for batch operations like conversion and
/// re-enrich. Follows the same visual style as [_FetchingProgressDialog] in
/// artist_screen.dart.
///
/// Uses a static [ValueNotifier] so callers do not need the dialog's
/// [BuildContext] to push updates unlike `findAncestorStateOfType` which
/// fails because the dialog lives in a separate navigator route.
///
/// Usage:
/// ```dart
/// var cancelled = false;
/// BatchProgressDialog.show(
/// context: context,
/// title: 'Converting...',
/// total: items.length,
/// icon: Icons.transform,
/// onCancel: () {
/// cancelled = true;
/// BatchProgressDialog.dismiss(context);
/// },
/// );
///
/// for (int i = 0; i < items.length; i++) {
/// if (cancelled) break;
/// BatchProgressDialog.update(current: i + 1, detail: items[i].name);
/// await doWork(items[i]);
/// }
///
/// BatchProgressDialog.dismiss(context);
/// ```
class BatchProgressDialog extends StatefulWidget {
final String title;
final int total;
final IconData icon;
final VoidCallback onCancel;
final ValueNotifier<_BatchProgress> _progressNotifier;
// ignore: prefer_const_constructors_in_immutables
BatchProgressDialog._({
required this.title,
required this.total,
required this.icon,
required this.onCancel,
required ValueNotifier<_BatchProgress> progressNotifier,
}) : _progressNotifier = progressNotifier;
// Static bookkeeping
static ValueNotifier<_BatchProgress>? _activeNotifier;
/// Show the dialog. Call [update] to push progress, [dismiss] to close.
static void show({
required BuildContext context,
required String title,
required int total,
required VoidCallback onCancel,
IconData icon = Icons.transform,
}) {
_activeNotifier = ValueNotifier(const _BatchProgress());
final notifier = _activeNotifier!;
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => BatchProgressDialog._(
title: title,
total: total,
icon: icon,
onCancel: onCancel,
progressNotifier: notifier,
),
);
}
/// Update the progress of the currently visible dialog.
/// No [BuildContext] needed communicates via [ValueNotifier].
static void update({required int current, String? detail}) {
_activeNotifier?.value = _BatchProgress(current: current, detail: detail);
}
/// Dismiss the dialog and clean up.
static void dismiss(BuildContext context) {
_activeNotifier = null;
Navigator.of(context, rootNavigator: true).pop();
}
@override
State<BatchProgressDialog> createState() => _BatchProgressDialogState();
}
class _BatchProgressDialogState extends State<BatchProgressDialog> {
@override
void initState() {
super.initState();
widget._progressNotifier.addListener(_onChanged);
}
@override
void dispose() {
widget._progressNotifier.removeListener(_onChanged);
super.dispose();
}
void _onChanged() {
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final current = widget._progressNotifier.value.current;
final detail = widget._progressNotifier.value.detail;
final progress = widget.total > 0 ? current / widget.total : 0.0;
return AlertDialog(
backgroundColor: colorScheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
SizedBox(
width: 64,
height: 64,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
value: progress > 0 ? progress : null,
strokeWidth: 4,
backgroundColor: colorScheme.surfaceContainerHighest,
),
Icon(widget.icon, color: colorScheme.primary, size: 24),
],
),
),
const SizedBox(height: 20),
Text(
widget.title,
style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'$current / ${widget.total}',
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
if (detail != null && detail.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
detail,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
],
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: progress > 0 ? progress : null,
backgroundColor: colorScheme.surfaceContainerHighest,
minHeight: 6,
),
),
],
),
actions: [
TextButton(
onPressed: widget.onCancel,
child: Text(context.l10n.dialogCancel),
),
],
);
}
}
+3 -7
View File
@@ -161,13 +161,9 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
return ext.qualityOptions;
}
return [
const QualityOption(
id: 'DEFAULT',
label: 'Default Quality',
description: 'Best available',
),
];
// Extensions without quality options use Tidal's options as default
// since the download will fall back to built-in providers anyway.
return _builtInServices.firstWhere((s) => s.id == 'tidal').qualityOptions;
}
@override
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer
publish_to: "none"
version: 4.1.1+118
version: 4.1.2+119
environment:
sdk: ^3.10.0