Compare commits

...

25 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
zarzet bd73eb292d chore: bump version to 4.1.1+118 2026-03-27 22:29:16 +07:00
zarzet 8ee2919934 feat: track byte-level download progress for extension downloads
Pass active download item ID through extension download pipeline so
fileDownload can report bytes received/total via ItemProgressWriter.
Add bytesTotal field to DownloadItem model and show X/Y MB progress
in queue tab when total size is known.
2026-03-27 21:58:01 +07:00
zarzet f29177216d refactor: enable strict analysis options and fix type safety across codebase
Enable strict-casts, strict-inference, and strict-raw-types in
analysis_options.yaml. Add custom_lint with riverpod_lint. Fix all
resulting type warnings with explicit type parameters and safer casts.

Also improves APK update checker to detect device ABIs for correct
variant selection and fixes Deezer artist name parsing edge case.
2026-03-27 19:28:42 +07:00
zarzet 18d3612674 fix(ui): skip popular section in artist skeleton for providers without top tracks 2026-03-27 13:27:07 +07:00
zarzet f7c0e417d7 refactor: unexport extension store types and methods (package-internal only) 2026-03-27 04:50:40 +07:00
zarzet 3fd13e9930 fix(ui): match GridSkeleton cover height with actual album cards 2026-03-27 04:39:29 +07:00
zarzet 0b20cb895e fix: conditionally show cover header in artist skeleton and add showCoverHeader param to ArtistScreenSkeleton 2026-03-27 04:35:22 +07:00
zarzet 8979210804 fix: null check crash in SpectrogramView when spectrum loaded from PNG cache 2026-03-27 04:24:19 +07:00
zarzet e9b24712c5 feat: cache spectrogram as PNG for instant loading on subsequent views 2026-03-27 04:21:11 +07:00
zarzet 3d6e5615fa Revert "docs: move badges below screenshots in README"
This reverts commit 198ed5ce6f.
2026-03-27 03:56:57 +07:00
zarzet fc7220b572 docs: update VirusTotal hash for v4.1.0 2026-03-27 03:54:31 +07:00
zarzet 198ed5ce6f docs: move badges below screenshots in README 2026-03-27 03:53:31 +07:00
zarzet b48462a945 fix: add artist_album_flat case to SAF relative output dir builder 2026-03-26 18:31:00 +07:00
82 changed files with 4950 additions and 1033 deletions
+1 -1
View File
@@ -17,7 +17,7 @@
<div align="center"> <div align="center">
[![GitHub Release](https://img.shields.io/github/v/release/zarzet/SpotiFLAC-Mobile?style=for-the-badge&logo=github)](https://github.com/zarzet/SpotiFLAC-Mobile/releases) [![GitHub Release](https://img.shields.io/github/v/release/zarzet/SpotiFLAC-Mobile?style=for-the-badge&logo=github)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/cc11355330c76f97548b8d26452b91746db9d9c1edbcfc4c18250133484d1487) [![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/31d1bf3c3b2015c13e83c4f909a7c6093a9423e3e702f0c582a3e0035c849424)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile) [![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
[![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac) [![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac)
+20
View File
@@ -9,6 +9,19 @@
# packages, and plugins designed to encourage good coding practices. # packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- build/**
- .dart_tool/**
- lib/**/*.g.dart
- lib/l10n/*.dart
language:
strict-casts: true
strict-inference: true
strict-raw-types: true
plugins:
- custom_lint
linter: linter:
# The lint rules applied to this project can be customized in the # The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml` # section below to disable rules from the `package:flutter_lints/flutter.yaml`
@@ -23,6 +36,13 @@ linter:
rules: rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule # avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
avoid_dynamic_calls: true
cancel_subscriptions: true
close_sinks: true
custom_lint:
rules:
- avoid_public_notifier_properties
# Additional information about this file can be found at # Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options # https://dart.dev/guides/language/analysis-options
@@ -104,7 +104,7 @@ class DownloadService : Service() {
updateNotification(progress, total) updateNotification(progress, total)
} }
} }
return START_STICKY return START_NOT_STICKY
} }
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
@@ -304,6 +304,7 @@ class MainActivity: FlutterFragmentActivity() {
".mp3" -> "audio/mpeg" ".mp3" -> "audio/mpeg"
".opus" -> "audio/ogg" ".opus" -> "audio/ogg"
".flac" -> "audio/flac" ".flac" -> "audio/flac"
".lrc" -> "application/octet-stream"
else -> "application/octet-stream" else -> "application/octet-stream"
} }
} }
@@ -1940,13 +1941,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
result.success(null) result.success(null)
} }
"parseSpotifyUrl" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.parseSpotifyURL(url)
}
result.success(response)
}
"checkAvailability" -> { "checkAvailability" -> {
val spotifyId = call.argument<String>("spotify_id") ?: "" val spotifyId = call.argument<String>("spotify_id") ?: ""
val isrc = call.argument<String>("isrc") ?: "" val isrc = call.argument<String>("isrc") ?: ""
@@ -2710,13 +2704,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
result.success(response) result.success(response)
} }
"getSpotifyMetadataWithFallback" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getSpotifyMetadataWithDeezerFallback(url)
}
result.success(response)
}
"checkAvailabilityFromDeezerID" -> { "checkAvailabilityFromDeezerID" -> {
val deezerTrackId = call.argument<String>("deezer_track_id") ?: "" val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
val response = withContext(Dispatchers.IO) { val response = withContext(Dispatchers.IO) {
+9 -7
View File
@@ -204,7 +204,7 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
} }
if deezerID != "" { if deezerID != "" {
trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", 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) GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err)
// Don't reject direct IDs from request payload — they're presumably correct. // 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 != "" { if err == nil && availability.Deezer && availability.DeezerURL != "" {
resolvedID := extractDeezerIDFromURL(availability.DeezerURL) resolvedID := extractDeezerIDFromURL(availability.DeezerURL)
if resolvedID != "" { 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) GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr)
// Fall through to ISRC search instead of using wrong track. // Fall through to ISRC search instead of using wrong track.
} else { } else {
@@ -240,7 +240,7 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
if err == nil && track != nil { if err == nil && track != nil {
resolvedID := songLinkExtractDeezerTrackID(track) resolvedID := songLinkExtractDeezerTrackID(track)
if resolvedID != "" { 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) GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr)
return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", 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") 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) ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
defer cancel() defer cancel()
trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID) 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. return nil // Can't verify — don't block the download.
} }
resolved := resolvedTrackInfo{ resolved := resolvedTrackInfo{
Title: trackResp.Track.Name, Title: trackResp.Track.Name,
ArtistName: trackResp.Track.Artists, ArtistName: trackResp.Track.Artists,
Duration: trackResp.Track.DurationMS / 1000, ISRC: trackResp.Track.ISRC,
Duration: trackResp.Track.DurationMS / 1000,
SkipNameVerification: skipNameVerification,
} }
if !trackMatchesRequest(req, resolved, "Deezer") { if !trackMatchesRequest(req, resolved, "Deezer") {
return fmt.Errorf("expected '%s - %s', got '%s - %s'", return fmt.Errorf("expected '%s - %s', got '%s - %s'",
+296 -156
View File
@@ -13,25 +13,6 @@ import (
"github.com/dop251/goja" "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) { func CheckAvailability(spotifyID, isrc string) (string, error) {
client := NewSongLinkClient() client := NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyID, isrc) availability, err := client.CheckTrackAvailability(spotifyID, isrc)
@@ -135,6 +116,270 @@ type DownloadResult struct {
DecryptionKey string 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( func preferredReleaseMetadata(
req DownloadRequest, req DownloadRequest,
album string, 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) 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) { func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
client := NewSongLinkClient() client := NewSongLinkClient()
availability, err := client.CheckAvailabilityFromDeezer(deezerTrackID) 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 // When search_online is true, searches Spotify/Deezer by track name + artist to fetch
// complete metadata from the internet before embedding. // complete metadata from the internet before embedding.
func ReEnrichFile(requestJSON string) (string, error) { func ReEnrichFile(requestJSON string) (string, error) {
var req struct { var req reEnrichRequest
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"`
}
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return "", fmt.Errorf("failed to parse request: %w", err) return "", fmt.Errorf("failed to parse request: %w", err)
@@ -1842,42 +2002,22 @@ func ReEnrichFile(requestJSON string) (string, error) {
deezerClient := GetDeezerClient() deezerClient := GetDeezerClient()
GoLog("[ReEnrich] Trying metadata providers in configured priority...\n") GoLog("[ReEnrich] Trying metadata providers in configured priority...\n")
manager := GetExtensionManager() 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) tracks, searchErr := manager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
if searchErr == nil && len(tracks) > 0 { if searchErr == nil && len(tracks) > 0 {
track := tracks[0] track := selectBestReEnrichTrack(req, tracks)
GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s)\n", track.ProviderID, track.Name, track.Artists, track.AlbumName) if track != nil {
if track.SpotifyID != "" { GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s, date: %s)\n",
req.SpotifyID = track.SpotifyID track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate)
} else if track.DeezerID != "" { applyReEnrichTrackMetadata(&req, *track)
req.SpotifyID = "deezer:" + track.DeezerID found = true
} else if track.QobuzID != "" {
req.SpotifyID = "qobuz:" + track.QobuzID
} else if track.TidalID != "" {
req.SpotifyID = "tidal:" + track.TidalID
} else {
req.SpotifyID = track.ID
} }
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 { } else if searchErr != nil {
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr) GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
} }
@@ -3111,17 +3251,17 @@ func GetPostProcessingProvidersJSON() (string, error) {
} }
func InitExtensionStoreJSON(cacheDir string) error { func InitExtensionStoreJSON(cacheDir string) error {
InitExtensionStore(cacheDir) initExtensionStore(cacheDir)
return nil return nil
} }
func SetStoreRegistryURLJSON(registryURL string) error { func SetStoreRegistryURLJSON(registryURL string) error {
store := GetExtensionStore() store := getExtensionStore()
if store == nil { if store == nil {
return fmt.Errorf("extension store not initialized") return fmt.Errorf("extension store not initialized")
} }
resolved, err := ResolveRegistryURL(registryURL) resolved, err := resolveRegistryURL(registryURL)
if err != nil { if err != nil {
return err return err
} }
@@ -3130,32 +3270,32 @@ func SetStoreRegistryURLJSON(registryURL string) error {
return err return err
} }
store.SetRegistryURL(resolved) store.setRegistryURL(resolved)
return nil return nil
} }
func ClearStoreRegistryURLJSON() error { func ClearStoreRegistryURLJSON() error {
store := GetExtensionStore() store := getExtensionStore()
if store == nil { if store == nil {
return fmt.Errorf("extension store not initialized") return fmt.Errorf("extension store not initialized")
} }
store.SetRegistryURL("") store.setRegistryURL("")
store.ClearCache() store.clearCache()
return nil return nil
} }
func GetStoreRegistryURLJSON() (string, error) { func GetStoreRegistryURLJSON() (string, error) {
store := GetExtensionStore() store := getExtensionStore()
if store == nil { if store == nil {
return "", fmt.Errorf("extension store not initialized") return "", fmt.Errorf("extension store not initialized")
} }
return store.GetRegistryURL(), nil return store.getRegistryURL(), nil
} }
func GetStoreExtensionsJSON(forceRefresh bool) (string, error) { func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
store := GetExtensionStore() store := getExtensionStore()
if store == nil { if store == nil {
return "", fmt.Errorf("extension store not initialized") return "", fmt.Errorf("extension store not initialized")
} }
@@ -3174,12 +3314,12 @@ func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
} }
func SearchStoreExtensionsJSON(query, category string) (string, error) { func SearchStoreExtensionsJSON(query, category string) (string, error) {
store := GetExtensionStore() store := getExtensionStore()
if store == nil { if store == nil {
return "", fmt.Errorf("extension store not initialized") return "", fmt.Errorf("extension store not initialized")
} }
extensions, err := store.SearchExtensions(query, category) extensions, err := store.searchExtensions(query, category)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -3193,12 +3333,12 @@ func SearchStoreExtensionsJSON(query, category string) (string, error) {
} }
func GetStoreCategoriesJSON() (string, error) { func GetStoreCategoriesJSON() (string, error) {
store := GetExtensionStore() store := getExtensionStore()
if store == nil { if store == nil {
return "", fmt.Errorf("extension store not initialized") return "", fmt.Errorf("extension store not initialized")
} }
categories := store.GetCategories() categories := store.getCategories()
jsonBytes, err := json.Marshal(categories) jsonBytes, err := json.Marshal(categories)
if err != nil { if err != nil {
return "", err return "", err
@@ -3217,7 +3357,7 @@ func buildStoreExtensionDestPath(destDir, extensionID string) (string, error) {
} }
func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) { func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
store := GetExtensionStore() store := getExtensionStore()
if store == nil { if store == nil {
return "", fmt.Errorf("extension store not initialized") return "", fmt.Errorf("extension store not initialized")
} }
@@ -3226,7 +3366,7 @@ func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
err = store.DownloadExtension(extensionID, destPath) err = store.downloadExtension(extensionID, destPath)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -3235,12 +3375,12 @@ func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
} }
func ClearStoreCacheJSON() error { func ClearStoreCacheJSON() error {
store := GetExtensionStore() store := getExtensionStore()
if store == nil { if store == nil {
return fmt.Errorf("extension store not initialized") return fmt.Errorf("extension store not initialized")
} }
store.ClearCache() store.clearCache()
return nil return nil
} }
+64
View File
@@ -113,3 +113,67 @@ func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) {
t.Fatalf("cover url = %q, want %q", resp.CoverURL, result.CoverURL) 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)
}
}
+7 -3
View File
@@ -510,7 +510,7 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
const ExtDownloadTimeout = DownloadTimeout const ExtDownloadTimeout = DownloadTimeout
func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, onProgress func(percent int)) (*ExtDownloadResult, error) { func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath, itemID string, onProgress func(percent int)) (*ExtDownloadResult, error) {
if !p.extension.Manifest.IsDownloadProvider() { if !p.extension.Manifest.IsDownloadProvider() {
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID) return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
} }
@@ -526,6 +526,10 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
}, nil }, nil
} }
defer p.extension.VMMu.Unlock() defer p.extension.VMMu.Unlock()
if p.extension.runtime != nil {
p.extension.runtime.setActiveDownloadItemID(itemID)
defer p.extension.runtime.clearActiveDownloadItemID()
}
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value { p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 { if len(call.Arguments) > 0 {
@@ -1128,7 +1132,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
StartItemProgress(req.ItemID) StartItemProgress(req.ItemID)
} }
result, err := provider.Download(trackID, req.Quality, outputPath, func(percent int) { result, err := provider.Download(trackID, req.Quality, outputPath, req.ItemID, func(percent int) {
if req.ItemID != "" { if req.ItemID != "" {
normalized := float64(percent) / 100.0 normalized := float64(percent) / 100.0
if normalized < 0 { if normalized < 0 {
@@ -1356,7 +1360,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
StartItemProgress(req.ItemID) StartItemProgress(req.ItemID)
} }
result, err := provider.Download(availability.TrackID, req.Quality, outputPath, func(percent int) { result, err := provider.Download(availability.TrackID, req.Quality, outputPath, req.ItemID, func(percent int) {
if req.ItemID != "" { if req.ItemID != "" {
normalized := float64(percent) / 100.0 normalized := float64(percent) / 100.0
if normalized < 0 { if normalized < 0 {
+21
View File
@@ -90,6 +90,9 @@ type ExtensionRuntime struct {
dataDir string dataDir string
vm *goja.Runtime vm *goja.Runtime
activeDownloadMu sync.RWMutex
activeDownloadItemID string
storageMu sync.RWMutex storageMu sync.RWMutex
storageCache map[string]interface{} storageCache map[string]interface{}
storageLoaded bool storageLoaded bool
@@ -139,6 +142,24 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
return runtime return runtime
} }
func (r *ExtensionRuntime) setActiveDownloadItemID(itemID string) {
r.activeDownloadMu.Lock()
defer r.activeDownloadMu.Unlock()
r.activeDownloadItemID = strings.TrimSpace(itemID)
}
func (r *ExtensionRuntime) clearActiveDownloadItemID() {
r.activeDownloadMu.Lock()
defer r.activeDownloadMu.Unlock()
r.activeDownloadItemID = ""
}
func (r *ExtensionRuntime) getActiveDownloadItemID() string {
r.activeDownloadMu.RLock()
defer r.activeDownloadMu.RUnlock()
return r.activeDownloadItemID
}
func newExtensionHTTPClient(ext *LoadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client { func newExtensionHTTPClient(ext *LoadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
// Extension sandbox enforces HTTPS-only domains. Do not apply global // Extension sandbox enforces HTTPS-only domains. Do not apply global
// allow_http scheme downgrade here, because some extension APIs (e.g. // allow_http scheme downgrade here, because some extension APIs (e.g.
+16 -1
View File
@@ -205,13 +205,22 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
defer out.Close() defer out.Close()
contentLength := resp.ContentLength contentLength := resp.ContentLength
activeItemID := r.getActiveDownloadItemID()
if activeItemID != "" && contentLength > 0 {
SetItemBytesTotal(activeItemID, contentLength)
}
var progressWriter interface{ Write([]byte) (int, error) } = out
if activeItemID != "" {
progressWriter = NewItemProgressWriter(out, activeItemID)
}
var written int64 var written int64
buf := make([]byte, 32*1024) buf := make([]byte, 32*1024)
for { for {
nr, er := resp.Body.Read(buf) nr, er := resp.Body.Read(buf)
if nr > 0 { if nr > 0 {
nw, ew := out.Write(buf[0:nr]) nw, ew := progressWriter.Write(buf[0:nr])
if nw < 0 || nr < nw { if nw < 0 || nr < nw {
nw = 0 nw = 0
if ew == nil { if ew == nil {
@@ -220,6 +229,12 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
} }
written += int64(nw) written += int64(nw)
if ew != nil { if ew != nil {
if ew == ErrDownloadCancelled {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "download cancelled",
})
}
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
"error": fmt.Sprintf("failed to write file: %v", ew), "error": fmt.Sprintf("failed to write file: %v", ew),
+48 -47
View File
@@ -21,7 +21,7 @@ const (
CategoryIntegration = "integration" CategoryIntegration = "integration"
) )
type StoreExtension struct { type storeExtension struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"` DisplayName string `json:"display_name,omitempty"`
@@ -41,7 +41,7 @@ type StoreExtension struct {
MinAppVersionAlt string `json:"minAppVersion,omitempty"` MinAppVersionAlt string `json:"minAppVersion,omitempty"`
} }
func (e *StoreExtension) getDisplayName() string { func (e *storeExtension) getDisplayName() string {
if e.DisplayName != "" { if e.DisplayName != "" {
return e.DisplayName return e.DisplayName
} }
@@ -51,34 +51,34 @@ func (e *StoreExtension) getDisplayName() string {
return e.Name return e.Name
} }
func (e *StoreExtension) getDownloadURL() string { func (e *storeExtension) getDownloadURL() string {
if e.DownloadURL != "" { if e.DownloadURL != "" {
return e.DownloadURL return e.DownloadURL
} }
return e.DownloadURLAlt return e.DownloadURLAlt
} }
func (e *StoreExtension) getIconURL() string { func (e *storeExtension) getIconURL() string {
if e.IconURL != "" { if e.IconURL != "" {
return e.IconURL return e.IconURL
} }
return e.IconURLAlt return e.IconURLAlt
} }
func (e *StoreExtension) getMinAppVersion() string { func (e *storeExtension) getMinAppVersion() string {
if e.MinAppVersion != "" { if e.MinAppVersion != "" {
return e.MinAppVersion return e.MinAppVersion
} }
return e.MinAppVersionAlt return e.MinAppVersionAlt
} }
type StoreRegistry struct { type storeRegistry struct {
Version int `json:"version"` Version int `json:"version"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
Extensions []StoreExtension `json:"extensions"` Extensions []storeExtension `json:"extensions"`
} }
type StoreExtensionResponse struct { type storeExtensionResponse struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
@@ -97,8 +97,8 @@ type StoreExtensionResponse struct {
HasUpdate bool `json:"has_update"` HasUpdate bool `json:"has_update"`
} }
func (e *StoreExtension) ToResponse() *StoreExtensionResponse { func (e *storeExtension) toResponse() storeExtensionResponse {
return &StoreExtensionResponse{ resp := storeExtensionResponse{
ID: e.ID, ID: e.ID,
Name: e.Name, Name: e.Name,
DisplayName: e.getDisplayName(), DisplayName: e.getDisplayName(),
@@ -108,25 +108,30 @@ func (e *StoreExtension) ToResponse() *StoreExtensionResponse {
DownloadURL: e.getDownloadURL(), DownloadURL: e.getDownloadURL(),
IconURL: e.getIconURL(), IconURL: e.getIconURL(),
Category: e.Category, Category: e.Category,
Tags: e.Tags,
Downloads: e.Downloads, Downloads: e.Downloads,
UpdatedAt: e.UpdatedAt, UpdatedAt: e.UpdatedAt,
MinAppVersion: e.getMinAppVersion(), MinAppVersion: e.getMinAppVersion(),
} }
if len(e.Tags) > 0 {
resp.Tags = append([]string(nil), e.Tags...)
}
return resp
} }
type ExtensionStore struct { type extensionStore struct {
registryURL string registryURL string
cacheDir string cacheDir string
cache *StoreRegistry cache *storeRegistry
cacheMu sync.RWMutex cacheMu sync.RWMutex
cacheTime time.Time cacheTime time.Time
cacheTTL time.Duration cacheTTL time.Duration
} }
var ( var (
extensionStore *ExtensionStore globalExtensionStore *extensionStore
extensionStoreMu sync.Mutex extensionStoreMu sync.Mutex
) )
const ( const (
@@ -134,24 +139,24 @@ const (
cacheFileName = "store_cache.json" cacheFileName = "store_cache.json"
) )
func InitExtensionStore(cacheDir string) *ExtensionStore { func initExtensionStore(cacheDir string) *extensionStore {
extensionStoreMu.Lock() extensionStoreMu.Lock()
defer extensionStoreMu.Unlock() defer extensionStoreMu.Unlock()
if extensionStore == nil { if globalExtensionStore == nil {
extensionStore = &ExtensionStore{ globalExtensionStore = &extensionStore{
registryURL: "", // No default - user must provide a registry URL registryURL: "", // No default - user must provide a registry URL
cacheDir: cacheDir, cacheDir: cacheDir,
cacheTTL: cacheTTL, cacheTTL: cacheTTL,
} }
extensionStore.loadDiskCache() globalExtensionStore.loadDiskCache()
} }
return extensionStore return globalExtensionStore
} }
// SetRegistryURL updates the registry URL and clears the in-memory cache // SetRegistryURL updates the registry URL and clears the in-memory cache
// so the next fetch will use the new URL. Disk cache is also cleared. // so the next fetch will use the new URL. Disk cache is also cleared.
func (s *ExtensionStore) SetRegistryURL(registryURL string) { func (s *extensionStore) setRegistryURL(registryURL string) {
s.cacheMu.Lock() s.cacheMu.Lock()
defer s.cacheMu.Unlock() defer s.cacheMu.Unlock()
@@ -173,19 +178,19 @@ func (s *ExtensionStore) SetRegistryURL(registryURL string) {
} }
// GetRegistryURL returns the currently configured registry URL. // GetRegistryURL returns the currently configured registry URL.
func (s *ExtensionStore) GetRegistryURL() string { func (s *extensionStore) getRegistryURL() string {
s.cacheMu.RLock() s.cacheMu.RLock()
defer s.cacheMu.RUnlock() defer s.cacheMu.RUnlock()
return s.registryURL return s.registryURL
} }
func GetExtensionStore() *ExtensionStore { func getExtensionStore() *extensionStore {
extensionStoreMu.Lock() extensionStoreMu.Lock()
defer extensionStoreMu.Unlock() defer extensionStoreMu.Unlock()
return extensionStore return globalExtensionStore
} }
func (s *ExtensionStore) loadDiskCache() { func (s *extensionStore) loadDiskCache() {
if s.cacheDir == "" { if s.cacheDir == "" {
return return
} }
@@ -197,7 +202,7 @@ func (s *ExtensionStore) loadDiskCache() {
} }
var cacheData struct { var cacheData struct {
Registry StoreRegistry `json:"registry"` Registry storeRegistry `json:"registry"`
CacheTime int64 `json:"cache_time"` CacheTime int64 `json:"cache_time"`
} }
@@ -210,13 +215,13 @@ func (s *ExtensionStore) loadDiskCache() {
LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions)) LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
} }
func (s *ExtensionStore) saveDiskCache() { func (s *extensionStore) saveDiskCache() {
if s.cacheDir == "" || s.cache == nil { if s.cacheDir == "" || s.cache == nil {
return return
} }
cacheData := struct { cacheData := struct {
Registry StoreRegistry `json:"registry"` Registry storeRegistry `json:"registry"`
CacheTime int64 `json:"cache_time"` CacheTime int64 `json:"cache_time"`
}{ }{
Registry: *s.cache, Registry: *s.cache,
@@ -232,7 +237,7 @@ func (s *ExtensionStore) saveDiskCache() {
os.WriteFile(cachePath, data, 0644) os.WriteFile(cachePath, data, 0644)
} }
func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) { func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error) {
s.cacheMu.Lock() s.cacheMu.Lock()
defer s.cacheMu.Unlock() defer s.cacheMu.Unlock()
@@ -275,7 +280,7 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
return nil, fmt.Errorf("failed to read registry: %w", err) return nil, fmt.Errorf("failed to read registry: %w", err)
} }
var registry StoreRegistry var registry storeRegistry
if err := json.Unmarshal(body, &registry); err != nil { if err := json.Unmarshal(body, &registry); err != nil {
return nil, fmt.Errorf("failed to parse registry: %w", err) return nil, fmt.Errorf("failed to parse registry: %w", err)
} }
@@ -288,8 +293,8 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
return &registry, nil return &registry, nil
} }
func (s *ExtensionStore) getExtensionsWithStatus(forceRefresh bool) ([]*StoreExtensionResponse, error) { func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExtensionResponse, error) {
registry, err := s.FetchRegistry(forceRefresh) registry, err := s.fetchRegistry(forceRefresh)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -305,10 +310,10 @@ func (s *ExtensionStore) getExtensionsWithStatus(forceRefresh bool) ([]*StoreExt
LogDebug("ExtensionStore", "Building store response for %d registry extensions (%d installed)", len(registry.Extensions), len(installed)) LogDebug("ExtensionStore", "Building store response for %d registry extensions (%d installed)", len(registry.Extensions), len(installed))
result := make([]*StoreExtensionResponse, 0, len(registry.Extensions)) result := make([]storeExtensionResponse, 0, len(registry.Extensions))
for i := range registry.Extensions { for i := range registry.Extensions {
ext := &registry.Extensions[i] ext := &registry.Extensions[i]
resp := ext.ToResponse() resp := ext.toResponse()
if installedVersion, ok := installed[ext.ID]; ok { if installedVersion, ok := installed[ext.ID]; ok {
resp.IsInstalled = true resp.IsInstalled = true
resp.InstalledVersion = installedVersion resp.InstalledVersion = installedVersion
@@ -322,17 +327,13 @@ func (s *ExtensionStore) getExtensionsWithStatus(forceRefresh bool) ([]*StoreExt
return result, nil return result, nil
} }
func (s *ExtensionStore) GetExtensionsWithStatus() ([]*StoreExtensionResponse, error) { func (s *extensionStore) downloadExtension(extensionID string, destPath string) error {
return s.getExtensionsWithStatus(false) registry, err := s.fetchRegistry(false)
}
func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
registry, err := s.FetchRegistry(false)
if err != nil { if err != nil {
return err return err
} }
var ext *StoreExtension var ext *storeExtension
for _, e := range registry.Extensions { for _, e := range registry.Extensions {
if e.ID == extensionID { if e.ID == extensionID {
ext = &e ext = &e
@@ -384,7 +385,7 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
// - https://github.com/owner/repo (with optional trailing path / .git) → resolved via // - https://github.com/owner/repo (with optional trailing path / .git) → resolved via
// the GitHub API to discover the default branch, then converted to the raw URL // the GitHub API to discover the default branch, then converted to the raw URL
// - Any other HTTPS URL → returned as-is (assumed to be a direct link) // - Any other HTTPS URL → returned as-is (assumed to be a direct link)
func ResolveRegistryURL(input string) (string, error) { func resolveRegistryURL(input string) (string, error) {
input = strings.TrimSpace(input) input = strings.TrimSpace(input)
if input == "" { if input == "" {
return "", fmt.Errorf("registry URL is empty") return "", fmt.Errorf("registry URL is empty")
@@ -465,7 +466,7 @@ func requireHTTPSURL(rawURL string, context string) error {
return nil return nil
} }
func (s *ExtensionStore) GetCategories() []string { func (s *extensionStore) getCategories() []string {
return []string{ return []string{
CategoryMetadata, CategoryMetadata,
CategoryDownload, CategoryDownload,
@@ -475,8 +476,8 @@ func (s *ExtensionStore) GetCategories() []string {
} }
} }
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]*StoreExtensionResponse, error) { func (s *extensionStore) searchExtensions(query string, category string) ([]storeExtensionResponse, error) {
extensions, err := s.GetExtensionsWithStatus() extensions, err := s.getExtensionsWithStatus(false)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -485,7 +486,7 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]*Sto
return extensions, nil return extensions, nil
} }
result := make([]*StoreExtensionResponse, 0, len(extensions)) result := make([]storeExtensionResponse, 0, len(extensions))
queryLower := toLower(query) queryLower := toLower(query)
for _, ext := range extensions { for _, ext := range extensions {
@@ -517,7 +518,7 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]*Sto
return result, nil return result, nil
} }
func (s *ExtensionStore) ClearCache() { func (s *extensionStore) clearCache() {
s.cacheMu.Lock() s.cacheMu.Lock()
defer s.cacheMu.Unlock() defer s.cacheMu.Unlock()
+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) 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 { if track == nil {
return false return false
} }
if req.ArtistName != "" && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { exactISRCMatch := req.ISRC != "" &&
GoLog("[%s] Artist mismatch from %s: expected '%s', got '%s'. Rejecting.\n", track.ISRC != "" &&
logPrefix, source, req.ArtistName, track.Performer.Name) strings.EqualFold(strings.TrimSpace(req.ISRC), strings.TrimSpace(track.ISRC))
return false
}
if req.TrackName != "" && !qobuzTitlesMatch(req.TrackName, track.Title) { if !exactISRCMatch && !skipNameVerification {
GoLog("[%s] Title mismatch from %s: expected '%s', got '%s'. Rejecting.\n", if req.ArtistName != "" && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
logPrefix, source, req.TrackName, track.Title) GoLog("[%s] Artist mismatch from %s: expected '%s', got '%s'. Rejecting.\n",
return false 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 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) GoLog("[%s] Failed to get track by request Qobuz ID %d: %v\n", logPrefix, trackID, err)
track = nil track = nil
} else if 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) GoLog("[%s] Successfully found track via request Qobuz ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
} else { } else {
track = nil track = nil
@@ -2142,7 +2148,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
if err != nil { if err != nil {
GoLog("[%s] Cache hit but GetTrackByID failed: %v\n", logPrefix, err) GoLog("[%s] Cache hit but GetTrackByID failed: %v\n", logPrefix, err)
track = nil 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 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) GoLog("[%s] Failed to get track by SongLink ID %d: %v\n", logPrefix, trackID, err)
track = nil track = nil
} else if 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) GoLog("[%s] Successfully found track via SongLink ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
if req.ISRC != "" { if req.ISRC != "" {
GetTrackIDCache().SetQobuz(req.ISRC, track.ID) GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
@@ -2179,7 +2185,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
if track == nil && req.ISRC != "" { if track == nil && req.ISRC != "" {
GoLog("[%s] Trying ISRC search: %s\n", logPrefix, req.ISRC) GoLog("[%s] Trying ISRC search: %s\n", logPrefix, req.ISRC)
track, err = qobuzSearchTrackByISRCWithDurationFunc(downloader, req.ISRC, expectedDurationSec) 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 track = nil
} }
} }
@@ -2188,7 +2194,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
if track == nil { if track == nil {
GoLog("[%s] Trying metadata search: '%s' by '%s'\n", logPrefix, req.TrackName, req.ArtistName) GoLog("[%s] Trying metadata search: '%s' by '%s'\n", logPrefix, req.TrackName, req.ArtistName)
track, err = qobuzSearchTrackByMetadataWithDurationFunc(downloader, req.TrackName, req.ArtistName, expectedDurationSec) 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 track = nil
} }
} }
@@ -2253,7 +2259,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
qobuzQuality = "6" qobuzQuality = "6"
case "HI_RES": case "HI_RES":
qobuzQuality = "7" qobuzQuality = "7"
case "HI_RES_LOSSLESS": case "HI_RES_LOSSLESS", "", "DEFAULT":
qobuzQuality = "27" qobuzQuality = "27"
} }
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality) 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) 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{ resolved := resolvedTrackInfo{
Title: strings.TrimSpace(track.Title), Title: strings.TrimSpace(track.Title),
ArtistName: tidalTrackArtistsDisplay(track), ArtistName: tidalTrackArtistsDisplay(track),
ISRC: strings.TrimSpace(track.ISRC),
Duration: track.Duration, Duration: track.Duration,
} }
if trackMatchesRequest(req, resolved, "Tidal search") { if trackMatchesRequest(req, resolved, "Tidal search") {
@@ -2035,6 +2036,7 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
expectedDurationSec := req.DurationMS / 1000 expectedDurationSec := req.DurationMS / 1000
var trackID int64 var trackID int64
var gotTidalID bool var gotTidalID bool
var resolvedViaSongLink bool
if req.TidalID != "" { if req.TidalID != "" {
GoLog("[%s] Using Tidal ID from request payload: %s\n", logPrefix, 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 trackID = parsedTrackID
GoLog("[%s] Got Tidal ID %d directly from SongLink\n", logPrefix, trackID) GoLog("[%s] Got Tidal ID %d directly from SongLink\n", logPrefix, trackID)
gotTidalID = true gotTidalID = true
resolvedViaSongLink = true
return return
} }
} }
@@ -2103,6 +2106,7 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
if idErr == nil && trackID > 0 { if idErr == nil && trackID > 0 {
GoLog("[%s] Got Tidal ID %d from URL parsing\n", logPrefix, trackID) GoLog("[%s] Got Tidal ID %d from URL parsing\n", logPrefix, trackID)
gotTidalID = true gotTidalID = true
resolvedViaSongLink = true
} }
} }
} }
@@ -2157,9 +2161,11 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
providerArtist = actualTrack.Artists[0].Name providerArtist = actualTrack.Artists[0].Name
} }
resolved := resolvedTrackInfo{ resolved := resolvedTrackInfo{
Title: actualTrack.Title, Title: actualTrack.Title,
ArtistName: providerArtist, ArtistName: providerArtist,
Duration: actualTrack.Duration, ISRC: strings.TrimSpace(actualTrack.ISRC),
Duration: actualTrack.Duration,
SkipNameVerification: resolvedViaSongLink,
} }
if !trackMatchesRequest(req, resolved, logPrefix) { if !trackMatchesRequest(req, resolved, logPrefix) {
// Invalidate the cached ID so future requests don't reuse it. // Invalidate the cached ID so future requests don't reuse it.
@@ -2206,7 +2212,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
} }
quality := req.Quality quality := req.Quality
if quality == "" { if quality == "" || quality == "DEFAULT" {
quality = "LOSSLESS" quality = "LOSSLESS"
} }
+38 -15
View File
@@ -7,6 +7,21 @@ import (
"golang.org/x/text/unicode/norm" "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 // normalizeLooseTitle collapses separators/punctuation so titles like
// "Doctor / Cops" and "Doctor _ Cops" can still match. // "Doctor / Cops" and "Doctor _ Cops" can still match.
func normalizeLooseTitle(title string) string { 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): case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Mc, r), unicode.Is(unicode.Me, r):
continue continue
case unicode.IsLetter(r), unicode.IsNumber(r): case unicode.IsLetter(r), unicode.IsNumber(r):
b.WriteRune(r) writeNormalizedArtistRune(&b, r)
case unicode.IsSpace(r): case unicode.IsSpace(r):
b.WriteByte(' ') b.WriteByte(' ')
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+': 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. // resolvedTrackInfo holds the metadata fetched from a provider for verification.
type resolvedTrackInfo struct { type resolvedTrackInfo struct {
Title string Title string
ArtistName string ArtistName string
Duration int ISRC string
Duration int
SkipNameVerification bool
} }
// trackMatchesRequest checks whether a resolved track from a provider matches // trackMatchesRequest checks whether a resolved track from a provider matches
// the original download request. Returns true if the track is a plausible match. // the original download request. Returns true if the track is a plausible match.
func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool { func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool {
if req.ArtistName != "" && resolved.ArtistName != "" && exactISRCMatch := req.ISRC != "" &&
!artistsMatch(req.ArtistName, resolved.ArtistName) { resolved.ISRC != "" &&
GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n", strings.EqualFold(strings.TrimSpace(req.ISRC), strings.TrimSpace(resolved.ISRC))
logPrefix, req.ArtistName, resolved.ArtistName)
return false
}
if req.TrackName != "" && resolved.Title != "" && if !exactISRCMatch && !resolved.SkipNameVerification {
!titlesMatch(req.TrackName, resolved.Title) { if req.ArtistName != "" && resolved.ArtistName != "" &&
GoLog("[%s] Verification failed: title mismatch — expected '%s', got '%s'\n", !artistsMatch(req.ArtistName, resolved.ArtistName) {
logPrefix, req.TrackName, resolved.Title) GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n",
return false 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 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) { func TestTitlesMatch_SeparatorVariants(t *testing.T) {
if !titlesMatch("Doctor / Cops", "Doctor _ Cops") { if !titlesMatch("Doctor / Cops", "Doctor _ Cops") {
t.Fatal("expected tidal titlesMatch to accept / vs _ variant") t.Fatal("expected tidal titlesMatch to accept / vs _ variant")
-14
View File
@@ -153,13 +153,6 @@ import Gobackend // Import Go framework
var error: NSError? var error: NSError?
switch call.method { 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": case "checkAvailability":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
let spotifyId = args["spotify_id"] as! String let spotifyId = args["spotify_id"] as! String
@@ -469,13 +462,6 @@ import Gobackend // Import Go framework
if let error = error { throw error } if let error = error { throw error }
return response 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": case "checkAvailabilityFromDeezerID":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
let deezerTrackId = args["deezer_track_id"] as! String 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 /// App version and info constants
/// Update version here only - all other files will reference this /// Update version here only - all other files will reference this
class AppInfo { class AppInfo {
static const String version = '4.1.0'; static const String version = '4.1.2';
static const String buildNumber = '117'; static const String buildNumber = '119';
static const String fullVersion = '$version+$buildNumber'; static const String fullVersion = '$version+$buildNumber';
/// Shows "Internal" in debug builds, actual version in release. /// 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 /// Bottom navigation - Extension store tab
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Store'** /// **'Repo'**
String get navStore; String get navStore;
/// Home screen title /// Home screen title
@@ -163,7 +163,7 @@ abstract class AppLocalizations {
/// Subtitle shown below search box /// Subtitle shown below search box
/// ///
/// In en, this message translates to: /// 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; String get homeSubtitle;
/// Info text about supported URL types /// Info text about supported URL types
@@ -427,13 +427,13 @@ abstract class AppLocalizations {
/// Show/hide store tab /// Show/hide store tab
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Extension Store'** /// **'Extension Repo'**
String get optionsExtensionStore; String get optionsExtensionStore;
/// Subtitle for extension store toggle /// Subtitle for extension store toggle
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Show Store tab in navigation'** /// **'Show Repo tab in navigation'**
String get optionsExtensionStoreSubtitle; String get optionsExtensionStoreSubtitle;
/// Auto update check toggle /// Auto update check toggle
@@ -565,7 +565,7 @@ abstract class AppLocalizations {
/// Store screen title /// Store screen title
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Extension Store'** /// **'Extension Repo'**
String get storeTitle; String get storeTitle;
/// Store search placeholder /// Store search placeholder
@@ -2365,7 +2365,7 @@ abstract class AppLocalizations {
/// Error heading when the store cannot be loaded /// Error heading when the store cannot be loaded
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Failed to load store'** /// **'Failed to load repository'**
String get storeLoadError; String get storeLoadError;
/// Message when store has no extensions /// Message when store has no extensions
@@ -3613,7 +3613,7 @@ abstract class AppLocalizations {
/// Tutorial extensions tip 1 /// Tutorial extensions tip 1
/// ///
/// In en, this message translates to: /// 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; String get tutorialExtensionsTip1;
/// Tutorial extensions tip 2 /// Tutorial extensions tip 2
@@ -5300,6 +5300,298 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Samples'** /// **'Samples'**
String get audioAnalysisSamples; 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 class _AppLocalizationsDelegate
+189 -1
View File
@@ -1281,7 +1281,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -3124,4 +3124,192 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; 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'; String get navSettings => 'Settings';
@override @override
String get navStore => 'Store'; String get navStore => 'Repo';
@override @override
String get homeTitle => 'Home'; String get homeTitle => 'Home';
@override @override
String get homeSubtitle => 'Paste a Spotify link or search by name'; String get homeSubtitle => 'Paste a supported URL or search by name';
@override @override
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs'; String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
@@ -170,10 +170,10 @@ class AppLocalizationsEn extends AppLocalizations {
'Parallel downloads may trigger rate limiting'; 'Parallel downloads may trigger rate limiting';
@override @override
String get optionsExtensionStore => 'Extension Store'; String get optionsExtensionStore => 'Extension Repo';
@override @override
String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation'; String get optionsExtensionStoreSubtitle => 'Show Repo tab in navigation';
@override @override
String get optionsCheckUpdates => 'Check for Updates'; String get optionsCheckUpdates => 'Check for Updates';
@@ -250,7 +250,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get extensionsUninstall => 'Uninstall'; String get extensionsUninstall => 'Uninstall';
@override @override
String get storeTitle => 'Extension Store'; String get storeTitle => 'Extension Repo';
@override @override
String get storeSearch => 'Search extensions...'; String get storeSearch => 'Search extensions...';
@@ -1261,7 +1261,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -1997,7 +1997,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get tutorialExtensionsTip1 => String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions'; 'Browse the Repo tab to discover useful extensions';
@override @override
String get tutorialExtensionsTip2 => String get tutorialExtensionsTip2 =>
@@ -3092,4 +3092,192 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; 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'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -1997,7 +1997,7 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get tutorialExtensionsTip1 => String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions'; 'Browse the Repo tab to discover useful extensions';
@override @override
String get tutorialExtensionsTip2 => String get tutorialExtensionsTip2 =>
@@ -3092,6 +3092,194 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; 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`). /// 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'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -3093,4 +3093,192 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; 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'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -3091,4 +3091,192 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; 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'; String get navSettings => 'Pengaturan';
@override @override
String get navStore => 'Toko'; String get navStore => 'Repo';
@override @override
String get homeTitle => 'Beranda'; String get homeTitle => 'Beranda';
@override @override
String get homeSubtitle => 'Tempel link Spotify atau cari berdasarkan nama'; String get homeSubtitle =>
'Tempel URL yang didukung atau cari berdasarkan nama';
@override @override
String get homeSupports => 'Mendukung: URL Track, Album, Playlist, Artis'; String get homeSupports => 'Mendukung: URL Track, Album, Playlist, Artis';
@@ -173,10 +174,10 @@ class AppLocalizationsId extends AppLocalizations {
'Unduhan paralel dapat memicu pembatasan rate'; 'Unduhan paralel dapat memicu pembatasan rate';
@override @override
String get optionsExtensionStore => 'Toko Ekstensi'; String get optionsExtensionStore => 'Repo Ekstensi';
@override @override
String get optionsExtensionStoreSubtitle => 'Tampilkan tab Toko di navigasi'; String get optionsExtensionStoreSubtitle => 'Tampilkan tab Repo di navigasi';
@override @override
String get optionsCheckUpdates => 'Periksa Pembaruan'; String get optionsCheckUpdates => 'Periksa Pembaruan';
@@ -252,7 +253,7 @@ class AppLocalizationsId extends AppLocalizations {
String get extensionsUninstall => 'Copot'; String get extensionsUninstall => 'Copot';
@override @override
String get storeTitle => 'Toko Ekstensi'; String get storeTitle => 'Repo Ekstensi';
@override @override
String get storeSearch => 'Cari ekstensi...'; String get storeSearch => 'Cari ekstensi...';
@@ -1267,7 +1268,7 @@ class AppLocalizationsId extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Gagal memuat repo';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -2006,7 +2007,7 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get tutorialExtensionsTip1 => String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions'; 'Buka tab Repo untuk menemukan ekstensi yang berguna';
@override @override
String get tutorialExtensionsTip2 => String get tutorialExtensionsTip2 =>
@@ -3101,4 +3102,192 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; 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'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -3078,4 +3078,192 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; 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'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -3071,4 +3071,192 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; 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'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -3091,4 +3091,192 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; 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'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -1997,7 +1997,7 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get tutorialExtensionsTip1 => String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions'; 'Browse the Repo tab to discover useful extensions';
@override @override
String get tutorialExtensionsTip2 => String get tutorialExtensionsTip2 =>
@@ -3092,6 +3092,194 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; 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`). /// 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'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -3151,4 +3151,192 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; 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'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -3097,4 +3097,192 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; 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'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -1997,7 +1997,7 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get tutorialExtensionsTip1 => String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions'; 'Browse the Repo tab to discover useful extensions';
@override @override
String get tutorialExtensionsTip2 => String get tutorialExtensionsTip2 =>
@@ -3092,6 +3092,194 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; 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`). /// The translations for Chinese, as used in China (`zh_CN`).
+319 -7
View File
@@ -17,7 +17,7 @@
"@navSettings": { "@navSettings": {
"description": "Bottom navigation - Settings tab" "description": "Bottom navigation - Settings tab"
}, },
"navStore": "Store", "navStore": "Repo",
"@navStore": { "@navStore": {
"description": "Bottom navigation - Extension store tab" "description": "Bottom navigation - Extension store tab"
}, },
@@ -25,7 +25,7 @@
"@homeTitle": { "@homeTitle": {
"description": "Home screen title" "description": "Home screen title"
}, },
"homeSubtitle": "Paste a Spotify link or search by name", "homeSubtitle": "Paste a supported URL or search by name",
"@homeSubtitle": { "@homeSubtitle": {
"description": "Subtitle shown below search box" "description": "Subtitle shown below search box"
}, },
@@ -211,11 +211,11 @@
"@optionsConcurrentWarning": { "@optionsConcurrentWarning": {
"description": "Warning about rate limits" "description": "Warning about rate limits"
}, },
"optionsExtensionStore": "Extension Store", "optionsExtensionStore": "Extension Repo",
"@optionsExtensionStore": { "@optionsExtensionStore": {
"description": "Show/hide store tab" "description": "Show/hide store tab"
}, },
"optionsExtensionStoreSubtitle": "Show Store tab in navigation", "optionsExtensionStoreSubtitle": "Show Repo tab in navigation",
"@optionsExtensionStoreSubtitle": { "@optionsExtensionStoreSubtitle": {
"description": "Subtitle for extension store toggle" "description": "Subtitle for extension store toggle"
}, },
@@ -318,7 +318,7 @@
"@extensionsUninstall": { "@extensionsUninstall": {
"description": "Uninstall extension button" "description": "Uninstall extension button"
}, },
"storeTitle": "Extension Store", "storeTitle": "Extension Repo",
"@storeTitle": { "@storeTitle": {
"description": "Store screen title" "description": "Store screen title"
}, },
@@ -1654,7 +1654,7 @@
"@storeNewRepoUrlLabel": { "@storeNewRepoUrlLabel": {
"description": "Label for the new repository URL field inside the dialog" "description": "Label for the new repository URL field inside the dialog"
}, },
"storeLoadError": "Failed to load store", "storeLoadError": "Failed to load repository",
"@storeLoadError": { "@storeLoadError": {
"description": "Error heading when the store cannot be loaded" "description": "Error heading when the store cannot be loaded"
}, },
@@ -2611,7 +2611,7 @@
"@tutorialExtensionsDesc": { "@tutorialExtensionsDesc": {
"description": "Tutorial extensions page description" "description": "Tutorial extensions page description"
}, },
"tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions", "tutorialExtensionsTip1": "Browse the Repo tab to discover useful extensions",
"@tutorialExtensionsTip1": { "@tutorialExtensionsTip1": {
"description": "Tutorial extensions tip 1" "description": "Tutorial extensions tip 1"
}, },
@@ -4063,5 +4063,317 @@
"audioAnalysisSamples": "Samples", "audioAnalysisSamples": "Samples",
"@audioAnalysisSamples": { "@audioAnalysisSamples": {
"description": "Total samples metric label" "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": { "@navSettings": {
"description": "Bottom navigation - Settings tab" "description": "Bottom navigation - Settings tab"
}, },
"navStore": "Toko", "navStore": "Repo",
"@navStore": { "@navStore": {
"description": "Bottom navigation - Extension store tab" "description": "Bottom navigation - Extension store tab"
}, },
@@ -25,7 +25,7 @@
"@homeTitle": { "@homeTitle": {
"description": "Home screen title" "description": "Home screen title"
}, },
"homeSubtitle": "Tempel link Spotify atau cari berdasarkan nama", "homeSubtitle": "Tempel URL yang didukung atau cari berdasarkan nama",
"@homeSubtitle": { "@homeSubtitle": {
"description": "Subtitle shown below search box" "description": "Subtitle shown below search box"
}, },
@@ -211,11 +211,11 @@
"@optionsConcurrentWarning": { "@optionsConcurrentWarning": {
"description": "Warning about rate limits" "description": "Warning about rate limits"
}, },
"optionsExtensionStore": "Toko Ekstensi", "optionsExtensionStore": "Repo Ekstensi",
"@optionsExtensionStore": { "@optionsExtensionStore": {
"description": "Show/hide store tab" "description": "Show/hide store tab"
}, },
"optionsExtensionStoreSubtitle": "Tampilkan tab Toko di navigasi", "optionsExtensionStoreSubtitle": "Tampilkan tab Repo di navigasi",
"@optionsExtensionStoreSubtitle": { "@optionsExtensionStoreSubtitle": {
"description": "Subtitle for extension store toggle" "description": "Subtitle for extension store toggle"
}, },
@@ -318,10 +318,14 @@
"@extensionsUninstall": { "@extensionsUninstall": {
"description": "Uninstall extension button" "description": "Uninstall extension button"
}, },
"storeTitle": "Toko Ekstensi", "storeTitle": "Repo Ekstensi",
"@storeTitle": { "@storeTitle": {
"description": "Store screen title" "description": "Store screen title"
}, },
"storeLoadError": "Gagal memuat repo",
"@storeLoadError": {
"description": "Error heading when the store cannot be loaded"
},
"storeSearch": "Cari ekstensi...", "storeSearch": "Cari ekstensi...",
"@storeSearch": { "@storeSearch": {
"description": "Store search placeholder" "description": "Store search placeholder"
@@ -2459,7 +2463,7 @@
"@tutorialExtensionsDesc": { "@tutorialExtensionsDesc": {
"description": "Tutorial extensions page description" "description": "Tutorial extensions page description"
}, },
"tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions", "tutorialExtensionsTip1": "Buka tab Repo untuk menemukan ekstensi yang berguna",
"@tutorialExtensionsTip1": { "@tutorialExtensionsTip1": {
"description": "Tutorial extensions tip 1" "description": "Tutorial extensions tip 1"
}, },
+7 -9
View File
@@ -12,13 +12,7 @@ enum DownloadStatus {
skipped, skipped,
} }
enum DownloadErrorType { enum DownloadErrorType { unknown, notFound, rateLimit, network, permission }
unknown,
notFound,
rateLimit,
network,
permission,
}
@JsonSerializable() @JsonSerializable()
class DownloadItem { class DownloadItem {
@@ -28,7 +22,8 @@ class DownloadItem {
final DownloadStatus status; final DownloadStatus status;
final double progress; final double progress;
final double speedMBps; final double speedMBps;
final int bytesReceived; // Bytes downloaded so far (for unknown size downloads) final int bytesReceived; // Bytes downloaded so far
final int bytesTotal; // Total bytes when the server provides content length
final String? filePath; final String? filePath;
final String? error; final String? error;
final DownloadErrorType? errorType; final DownloadErrorType? errorType;
@@ -44,6 +39,7 @@ class DownloadItem {
this.progress = 0.0, this.progress = 0.0,
this.speedMBps = 0.0, this.speedMBps = 0.0,
this.bytesReceived = 0, this.bytesReceived = 0,
this.bytesTotal = 0,
this.filePath, this.filePath,
this.error, this.error,
this.errorType, this.errorType,
@@ -60,6 +56,7 @@ class DownloadItem {
double? progress, double? progress,
double? speedMBps, double? speedMBps,
int? bytesReceived, int? bytesReceived,
int? bytesTotal,
String? filePath, String? filePath,
String? error, String? error,
DownloadErrorType? errorType, DownloadErrorType? errorType,
@@ -75,6 +72,7 @@ class DownloadItem {
progress: progress ?? this.progress, progress: progress ?? this.progress,
speedMBps: speedMBps ?? this.speedMBps, speedMBps: speedMBps ?? this.speedMBps,
bytesReceived: bytesReceived ?? this.bytesReceived, bytesReceived: bytesReceived ?? this.bytesReceived,
bytesTotal: bytesTotal ?? this.bytesTotal,
filePath: filePath ?? this.filePath, filePath: filePath ?? this.filePath,
error: error ?? this.error, error: error ?? this.error,
errorType: errorType ?? this.errorType, errorType: errorType ?? this.errorType,
@@ -86,7 +84,7 @@ class DownloadItem {
String get errorMessage { String get errorMessage {
if (error == null) return ''; if (error == null) return '';
switch (errorType) { switch (errorType) {
case DownloadErrorType.notFound: case DownloadErrorType.notFound:
return 'Song not found on any service'; return 'Song not found on any service';
+2
View File
@@ -16,6 +16,7 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
progress: (json['progress'] as num?)?.toDouble() ?? 0.0, progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0, speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0,
bytesReceived: (json['bytesReceived'] as num?)?.toInt() ?? 0, bytesReceived: (json['bytesReceived'] as num?)?.toInt() ?? 0,
bytesTotal: (json['bytesTotal'] as num?)?.toInt() ?? 0,
filePath: json['filePath'] as String?, filePath: json['filePath'] as String?,
error: json['error'] as String?, error: json['error'] as String?,
errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']), errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
@@ -33,6 +34,7 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
'progress': instance.progress, 'progress': instance.progress,
'speedMBps': instance.speedMBps, 'speedMBps': instance.speedMBps,
'bytesReceived': instance.bytesReceived, 'bytesReceived': instance.bytesReceived,
'bytesTotal': instance.bytesTotal,
'filePath': instance.filePath, 'filePath': instance.filePath,
'error': instance.error, 'error': instance.error,
'errorType': _$DownloadErrorTypeEnumMap[instance.errorType], 'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
+34 -19
View File
@@ -510,7 +510,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
} }
if ((c + 1) % _safRepairBatchSize == 0) { if ((c + 1) % _safRepairBatchSize == 0) {
await Future.delayed(const Duration(milliseconds: 16)); await Future<void>.delayed(const Duration(milliseconds: 16));
} }
} }
@@ -762,7 +762,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
_historyLog.d('Added new history entry: ${mergedItem.trackName}'); _historyLog.d('Added new history entry: ${mergedItem.trackName}');
} }
_db.upsert(mergedItem.toJson()).catchError((e) { _db.upsert(mergedItem.toJson()).catchError((Object e) {
_historyLog.e('Failed to save to database: $e'); _historyLog.e('Failed to save to database: $e');
}); });
} }
@@ -771,7 +771,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
state = state.copyWith( state = state.copyWith(
items: state.items.where((item) => item.id != id).toList(), items: state.items.where((item) => item.id != id).toList(),
); );
_db.deleteById(id).catchError((e) { _db.deleteById(id).catchError((Object e) {
_historyLog.e('Failed to delete from database: $e'); _historyLog.e('Failed to delete from database: $e');
}); });
} }
@@ -780,7 +780,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
state = state.copyWith( state = state.copyWith(
items: state.items.where((item) => item.spotifyId != spotifyId).toList(), items: state.items.where((item) => item.spotifyId != spotifyId).toList(),
); );
_db.deleteBySpotifyId(spotifyId).catchError((e) { _db.deleteBySpotifyId(spotifyId).catchError((Object e) {
_historyLog.e('Failed to delete from database: $e'); _historyLog.e('Failed to delete from database: $e');
}); });
_historyLog.d('Removed item with spotifyId: $spotifyId'); _historyLog.d('Removed item with spotifyId: $spotifyId');
@@ -1081,7 +1081,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
void clearHistory() { void clearHistory() {
state = DownloadHistoryState(); state = DownloadHistoryState();
_db.clearAll().catchError((e) { _db.clearAll().catchError((Object e) {
_historyLog.e('Failed to clear database: $e'); _historyLog.e('Failed to clear database: $e');
}); });
} }
@@ -1166,12 +1166,14 @@ class _ProgressUpdate {
final double progress; final double progress;
final double? speedMBps; final double? speedMBps;
final int? bytesReceived; final int? bytesReceived;
final int? bytesTotal;
const _ProgressUpdate({ const _ProgressUpdate({
required this.status, required this.status,
required this.progress, required this.progress,
this.speedMBps, this.speedMBps,
this.bytesReceived, this.bytesReceived,
this.bytesTotal,
}); });
} }
@@ -1557,7 +1559,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final isDownloading = itemProgress['is_downloading'] as bool? ?? false; final isDownloading = itemProgress['is_downloading'] as bool? ?? false;
final status = itemProgress['status'] as String? ?? 'downloading'; final status = itemProgress['status'] as String? ?? 'downloading';
if (status == 'finalizing' && bytesTotal > 0) { if (status == 'finalizing') {
progressUpdates[itemId] = const _ProgressUpdate( progressUpdates[itemId] = const _ProgressUpdate(
status: DownloadStatus.finalizing, status: DownloadStatus.finalizing,
progress: 1.0, progress: 1.0,
@@ -1587,6 +1589,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
progress: normalizedProgress, progress: normalizedProgress,
speedMBps: normalizedSpeed, speedMBps: normalizedSpeed,
bytesReceived: normalizedBytes, bytesReceived: normalizedBytes,
bytesTotal: bytesTotal,
); );
if (LogBuffer.loggingEnabled) { if (LogBuffer.loggingEnabled) {
@@ -1624,11 +1627,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
progress: update.progress, progress: update.progress,
speedMBps: update.speedMBps ?? current.speedMBps, speedMBps: update.speedMBps ?? current.speedMBps,
bytesReceived: update.bytesReceived ?? current.bytesReceived, bytesReceived: update.bytesReceived ?? current.bytesReceived,
bytesTotal: update.bytesTotal ?? current.bytesTotal,
); );
if (current.status != next.status || if (current.status != next.status ||
current.progress != next.progress || current.progress != next.progress ||
current.speedMBps != next.speedMBps || current.speedMBps != next.speedMBps ||
current.bytesReceived != next.bytesReceived) { current.bytesReceived != next.bytesReceived ||
current.bytesTotal != next.bytesTotal) {
if (!changed) { if (!changed) {
updatedItems = List<DownloadItem>.from(updatedItems); updatedItems = List<DownloadItem>.from(updatedItems);
changed = true; changed = true;
@@ -2080,6 +2085,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return _joinRelativePath(playlistPrefix, '$artistName/$albumName'); return _joinRelativePath(playlistPrefix, '$artistName/$albumName');
} }
if (albumFolderStructure == 'artist_album_flat') {
if (isSingle) {
return _joinRelativePath(playlistPrefix, artistName);
}
final albumName = _sanitizeFolderName(track.albumName);
return _joinRelativePath(playlistPrefix, '$artistName/$albumName');
}
if (isSingle) { if (isSingle) {
return _joinRelativePath(playlistPrefix, 'Singles'); return _joinRelativePath(playlistPrefix, 'Singles');
} }
@@ -2400,6 +2413,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
progress: 0, progress: 0,
speedMBps: 0, speedMBps: 0,
bytesReceived: 0, bytesReceived: 0,
bytesTotal: 0,
); );
}) })
.toList(growable: false); .toList(growable: false);
@@ -3594,7 +3608,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.d('Queue is paused, waiting for active downloads...'); _log.d('Queue is paused, waiting for active downloads...');
await Future.any([ await Future.any([
Future.wait(activeDownloads.values), Future.wait(activeDownloads.values),
Future.delayed(_queueSchedulingInterval), Future<void>.delayed(_queueSchedulingInterval),
]); ]);
continue; continue;
} }
@@ -3639,10 +3653,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (activeDownloads.isNotEmpty) { if (activeDownloads.isNotEmpty) {
await Future.any([ await Future.any([
Future.any(activeDownloads.values), Future.any(activeDownloads.values),
Future.delayed(_queueSchedulingInterval), Future<void>.delayed(_queueSchedulingInterval),
]); ]);
} else { } else {
await Future.delayed(_queueSchedulingInterval); await Future<void>.delayed(_queueSchedulingInterval);
} }
} }
@@ -3788,6 +3802,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
); );
var quality = item.qualityOverride ?? state.audioQuality; var quality = item.qualityOverride ?? state.audioQuality;
if (quality == 'DEFAULT') quality = state.audioQuality;
final isSafMode = _isSafMode(settings); final isSafMode = _isSafMode(settings);
final relativeOutputDir = isSafMode final relativeOutputDir = isSafMode
? await _buildRelativeOutputDir( ? await _buildRelativeOutputDir(
@@ -4344,7 +4359,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (!wasExisting && decryptionKey.isNotEmpty && filePath != null) { if (!wasExisting && decryptionKey.isNotEmpty && filePath != null) {
_log.i('Encrypted stream detected, decrypting via FFmpeg...'); _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)) { if (effectiveSafMode && isContentUri(filePath)) {
final currentFilePath = filePath; final currentFilePath = filePath;
@@ -4489,7 +4504,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
try { try {
updateItemStatus( updateItemStatus(
item.id, item.id,
DownloadStatus.downloading, DownloadStatus.finalizing,
progress: 0.95, progress: 0.95,
); );
@@ -4510,7 +4525,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.i('Embedding metadata to $format...'); _log.i('Embedding metadata to $format...');
updateItemStatus( updateItemStatus(
item.id, item.id,
DownloadStatus.downloading, DownloadStatus.finalizing,
progress: 0.99, progress: 0.99,
); );
@@ -4594,7 +4609,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} else { } else {
updateItemStatus( updateItemStatus(
item.id, item.id,
DownloadStatus.downloading, DownloadStatus.finalizing,
progress: 0.95, progress: 0.95,
); );
flacPath = await FFmpegService.convertM4aToFlac(tempPath); flacPath = await FFmpegService.convertM4aToFlac(tempPath);
@@ -4670,7 +4685,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
try { try {
updateItemStatus( updateItemStatus(
item.id, item.id,
DownloadStatus.downloading, DownloadStatus.finalizing,
progress: 0.95, progress: 0.95,
); );
@@ -4697,7 +4712,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.i('Embedding metadata to $format...'); _log.i('Embedding metadata to $format...');
updateItemStatus( updateItemStatus(
item.id, item.id,
DownloadStatus.downloading, DownloadStatus.finalizing,
progress: 0.99, progress: 0.99,
); );
@@ -4751,7 +4766,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} else { } else {
updateItemStatus( updateItemStatus(
item.id, item.id,
DownloadStatus.downloading, DownloadStatus.finalizing,
progress: 0.95, progress: 0.95,
); );
final flacPath = await FFmpegService.convertM4aToFlac( final flacPath = await FFmpegService.convertM4aToFlac(
@@ -4835,7 +4850,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
try { try {
updateItemStatus( updateItemStatus(
item.id, item.id,
DownloadStatus.downloading, DownloadStatus.finalizing,
progress: 0.99, progress: 0.99,
); );
@@ -4916,7 +4931,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
try { try {
updateItemStatus( updateItemStatus(
item.id, item.id,
DownloadStatus.downloading, DownloadStatus.finalizing,
progress: 0.99, progress: 0.99,
); );
@@ -118,7 +118,7 @@ class UserPlaylistCollection {
createdAt: createdAt, createdAt: createdAt,
updatedAt: updatedAt, updatedAt: updatedAt,
tracks: tracksRaw tracks: tracksRaw
.whereType<Map>() .whereType<Map<Object?, Object?>>()
.map( .map(
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)), (e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
) )
@@ -233,19 +233,19 @@ class LibraryCollectionsState {
return LibraryCollectionsState( return LibraryCollectionsState(
wishlist: wishlistRaw wishlist: wishlistRaw
.whereType<Map>() .whereType<Map<Object?, Object?>>()
.map( .map(
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)), (e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
) )
.toList(growable: false), .toList(growable: false),
loved: lovedRaw loved: lovedRaw
.whereType<Map>() .whereType<Map<Object?, Object?>>()
.map( .map(
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)), (e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
) )
.toList(growable: false), .toList(growable: false),
playlists: playlistsRaw playlists: playlistsRaw
.whereType<Map>() .whereType<Map<Object?, Object?>>()
.map( .map(
(e) => (e) =>
UserPlaylistCollection.fromJson(Map<String, dynamic>.from(e)), UserPlaylistCollection.fromJson(Map<String, dynamic>.from(e)),
+8 -4
View File
@@ -34,7 +34,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
final prefs = await _prefs; final prefs = await _prefs;
final json = prefs.getString(_settingsKey); final json = prefs.getString(_settingsKey);
if (json != null) { if (json != null) {
state = AppSettings.fromJson(jsonDecode(json)); state = AppSettings.fromJson(
Map<String, dynamic>.from(jsonDecode(json) as Map),
);
await _runMigrations(prefs); await _runMigrations(prefs);
await _normalizeIosDownloadDirectoryIfNeeded(); await _normalizeIosDownloadDirectoryIfNeeded();
@@ -52,7 +54,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
void _syncLyricsSettingsToBackend() { void _syncLyricsSettingsToBackend() {
if (!PlatformBridge.supportsCoreBackend) return; if (!PlatformBridge.supportsCoreBackend) return;
PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError((e) { PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError((
Object e,
) {
_log.w('Failed to sync lyrics providers to backend: $e'); _log.w('Failed to sync lyrics providers to backend: $e');
}); });
@@ -61,7 +65,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
'include_romanization_netease': state.lyricsIncludeRomanizationNetease, 'include_romanization_netease': state.lyricsIncludeRomanizationNetease,
'multi_person_word_by_word': state.lyricsMultiPersonWordByWord, 'multi_person_word_by_word': state.lyricsMultiPersonWordByWord,
'musixmatch_language': state.musixmatchLanguage, 'musixmatch_language': state.musixmatchLanguage,
}).catchError((e) { }).catchError((Object e) {
_log.w('Failed to sync lyrics fetch options to backend: $e'); _log.w('Failed to sync lyrics fetch options to backend: $e');
}); });
} }
@@ -73,7 +77,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
PlatformBridge.setNetworkCompatibilityOptions( PlatformBridge.setNetworkCompatibilityOptions(
allowHttp: compatibilityMode, allowHttp: compatibilityMode,
insecureTls: compatibilityMode, insecureTls: compatibilityMode,
).catchError((e) { ).catchError((Object e) {
_log.w('Failed to sync network compatibility options to backend: $e'); _log.w('Failed to sync network compatibility options to backend: $e');
}); });
} }
+12 -91
View File
@@ -234,7 +234,7 @@ class TrackNotifier extends Notifier<TrackState> {
} }
if (attempt < 3) { if (attempt < 3) {
await Future.delayed(const Duration(milliseconds: 500)); await Future<void>.delayed(const Duration(milliseconds: 500));
} }
} }
@@ -275,10 +275,12 @@ class TrackNotifier extends Notifier<TrackState> {
state = TrackState( state = TrackState(
tracks: tracks, tracks: tracks,
isLoading: false, isLoading: false,
albumId: result['album']?['id'] as String?, albumId:
(result['album'] as Map<String, dynamic>?)?['id'] as String?,
albumName: albumName:
result['name'] as String? ?? result['name'] as String? ??
result['album']?['name'] as String?, (result['album'] as Map<String, dynamic>?)?['name']
as String?,
playlistName: type == 'playlist' playlistName: type == 'playlist'
? result['name'] as String? ? result['name'] as String?
: null, : null,
@@ -536,90 +538,11 @@ class TrackNotifier extends Notifier<TrackState> {
return; return;
} }
final isSpotifyUrl = state = TrackState(
url.contains('open.spotify.com') || isLoading: false,
url.contains('spotify.link') || error: 'url_not_recognized',
url.startsWith('spotify:'); hasSearchText: state.hasSearchText,
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,
);
}
} catch (e) { } catch (e) {
if (!_isRequestValid(requestId)) return; if (!_isRequestValid(requestId)) return;
state = TrackState( state = TrackState(
@@ -825,8 +748,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: true, isLoading: true,
hasSearchText: state.hasSearchText, hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess, isShowingRecentAccess: state.isShowingRecentAccess,
selectedSearchFilter: selectedSearchFilter: state.selectedSearchFilter,
state.selectedSearchFilter,
); );
try { try {
@@ -921,8 +843,7 @@ class TrackNotifier extends Notifier<TrackState> {
final tracks = List<Track>.from(state.tracks); final tracks = List<Track>.from(state.tracks);
tracks[index] = updatedTrack; tracks[index] = updatedTrack;
state = state.copyWith(tracks: tracks); state = state.copyWith(tracks: tracks);
} catch (_) { } catch (_) {}
}
} }
void clear() { void clear() {
+86 -21
View File
@@ -174,42 +174,107 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
Future<void> _fetchTracks() async { Future<void> _fetchTracks() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
Map<String, dynamic> metadata;
if (widget.albumId.startsWith('deezer:')) { if (widget.albumId.startsWith('deezer:')) {
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', ''); final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
metadata = await PlatformBridge.getDeezerMetadata( final metadata = await PlatformBridge.getDeezerMetadata(
'album', 'album',
deezerAlbumId, 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:')) { } else if (widget.albumId.startsWith('qobuz:')) {
final qobuzAlbumId = widget.albumId.replaceFirst('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:')) { } else if (widget.albumId.startsWith('tidal:')) {
final tidalAlbumId = widget.albumId.replaceFirst('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 { } else {
final url = 'https://open.spotify.com/album/${widget.albumId}'; 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 trackList = result['tracks'] as List<dynamic>;
final tracks = trackList final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>)) .map((t) => _parseTrack(t as Map<String, dynamic>))
.toList(); .toList();
final albumInfo = metadata['album_info'] as Map<String, dynamic>?; final albumInfo = result['album'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId']) final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString(); ?.toString();
_AlbumCache.set(widget.albumId, tracks); _AlbumCache.set(widget.albumId, tracks);
if (mounted) { if (mounted) {
setState(() { setState(() {
_tracks = tracks; _tracks = tracks;
_artistId = artistId; _artistId = artistId;
_isLoading = false; _isLoading = false;
}); });
}
return;
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
+23 -24
View File
@@ -343,13 +343,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
headerImage = artistData['header_image'] as String?; headerImage = artistData['header_image'] as String?;
listeners = artistData['listeners'] as int?; listeners = artistData['listeners'] as int?;
} else { } else {
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback( throw StateError('Failed to load artist metadata from extension');
url,
);
final albumsList = metadata['albums'] as List<dynamic>;
albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
} }
} }
@@ -492,7 +486,19 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
hasDiscography: hasDiscography, hasDiscography: hasDiscography,
), ),
if (_isLoadingDiscography) if (_isLoadingDiscography)
const SliverToBoxAdapter(child: ArtistScreenSkeleton()), SliverToBoxAdapter(
child: ArtistScreenSkeleton(
showCoverHeader:
(_headerImageUrl ??
widget.headerImageUrl ??
widget.coverUrl) ==
null,
showPopularSection:
!widget.artistId.startsWith('deezer:') &&
!widget.artistId.startsWith('qobuz:') &&
!widget.artistId.startsWith('tidal:'),
),
),
if (_error != null) if (_error != null)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
@@ -793,7 +799,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
); );
final singleTracks = singles.fold<int>(0, (sum, a) => sum + a.totalTracks); final singleTracks = singles.fold<int>(0, (sum, a) => sum + a.totalTracks);
showModalBottomSheet( showModalBottomSheet<void>(
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh, backgroundColor: colorScheme.surfaceContainerHigh,
@@ -927,7 +933,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
return; return;
} }
showDialog( showDialog<void>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (ctx) => _FetchingProgressDialog( builder: (ctx) => _FetchingProgressDialog(
@@ -1093,15 +1099,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album)) .map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
.toList(); .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 []; return [];
} }
@@ -1109,6 +1106,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
Track _parseTrackFromDeezer(Map<String, dynamic> data, ArtistAlbum album) { Track _parseTrackFromDeezer(Map<String, dynamic> data, ArtistAlbum album) {
int durationMs = 0; int durationMs = 0;
final durationValue = data['duration']; final durationValue = data['duration'];
final artistData = data['artist'];
final artistName = artistData is Map<String, dynamic>
? (artistData['name'] as String? ?? widget.artistName)
: (artistData?.toString() ?? widget.artistName);
if (durationValue is int) { if (durationValue is int) {
durationMs = durationValue * 1000; // Deezer returns seconds durationMs = durationValue * 1000; // Deezer returns seconds
} else if (durationValue is double) { } else if (durationValue is double) {
@@ -1118,9 +1119,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
return Track( return Track(
id: 'deezer:${data['id']}', id: 'deezer:${data['id']}',
name: (data['title'] ?? data['name'] ?? '').toString(), name: (data['title'] ?? data['name'] ?? '').toString(),
artistName: artistName: artistName,
(data['artist']?['name'] ?? data['artist'] ?? widget.artistName)
.toString(),
albumName: album.name, albumName: album.name,
albumArtist: widget.artistName, albumArtist: widget.artistName,
artistId: widget.artistId, artistId: widget.artistId,
@@ -1926,7 +1925,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
if (album.providerId != null && album.providerId!.isNotEmpty) { if (album.providerId != null && album.providerId!.isNotEmpty) {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute<void>(
builder: (context) => ExtensionAlbumScreen( builder: (context) => ExtensionAlbumScreen(
extensionId: album.providerId!, extensionId: album.providerId!,
albumId: album.id, albumId: album.id,
@@ -1938,7 +1937,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
} else { } else {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute<void>(
builder: (context) => AlbumScreen( builder: (context) => AlbumScreen(
albumId: album.id, albumId: album.id,
albumName: album.name, albumName: album.name,
+20 -12
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/file_access.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/providers/download_queue_provider.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/playback_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart';
@@ -309,7 +310,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
if (!mounted) return; if (!mounted) return;
final result = await navigator.push( final result = await navigator.push(
slidePageRoute(page: TrackMetadataScreen(item: item)), slidePageRoute<bool>(page: TrackMetadataScreen(item: item)),
); );
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath( await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
item.filePath, item.filePath,
@@ -932,7 +933,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
? '320k' ? '320k'
: (selectedFormat == 'Opus' ? '128k' : '320k'); : (selectedFormat == 'Opus' ? '128k' : '320k');
showModalBottomSheet( showModalBottomSheet<void>(
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
@@ -1164,19 +1165,23 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final shouldEmbedLyrics = final shouldEmbedLyrics =
settings.embedLyrics && settings.lyricsMode != 'external'; 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++) { for (int i = 0; i < total; i++) {
if (!mounted) break; if (!mounted || cancelled) break;
final item = selected[i]; final item = selected[i];
ScaffoldMessenger.of(context).clearSnackBars(); BatchProgressDialog.update(current: i + 1, detail: item.trackName);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.selectionBatchConvertProgress(i + 1, total),
),
duration: const Duration(seconds: 30),
),
);
try { try {
final metadata = <String, String>{ final metadata = <String, String>{
@@ -1335,6 +1340,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
_exitSelectionMode(); _exitSelectionMode();
if (mounted) { if (mounted) {
if (!cancelled) {
BatchProgressDialog.dismiss(context);
}
ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
+35 -36
View File
@@ -556,7 +556,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
pending != query && pending != query &&
mounted && mounted &&
_urlController.text.trim() == pending) { _urlController.text.trim() == pending) {
await Future.delayed(const Duration(milliseconds: 100)); await Future<void>.delayed(const Duration(milliseconds: 100));
if (mounted && _urlController.text.trim() == pending) { if (mounted && _urlController.text.trim() == pending) {
_executeLiveSearch(pending); _executeLiveSearch(pending);
} }
@@ -681,7 +681,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
final extensionId = trackState.searchExtensionId; final extensionId = trackState.searchExtensionId;
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute<void>(
builder: (context) => AlbumScreen( builder: (context) => AlbumScreen(
albumId: trackState.albumId!, albumId: trackState.albumId!,
albumName: trackState.albumName!, albumName: trackState.albumName!,
@@ -708,7 +708,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute<void>(
builder: (context) => PlaylistScreen( builder: (context) => PlaylistScreen(
playlistName: trackState.playlistName!, playlistName: trackState.playlistName!,
coverUrl: trackState.coverUrl, coverUrl: trackState.coverUrl,
@@ -729,7 +729,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
final extensionId = trackState.searchExtensionId; final extensionId = trackState.searchExtensionId;
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute<void>(
builder: (context) => ArtistScreen( builder: (context) => ArtistScreen(
artistId: trackState.artistId!, artistId: trackState.artistId!,
artistName: trackState.artistName!, artistName: trackState.artistName!,
@@ -798,7 +798,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (progressDialogInitialized || !mounted) return; if (progressDialogInitialized || !mounted) return;
progressDialogInitialized = true; progressDialogInitialized = true;
progressDialogVisible = true; progressDialogVisible = true;
showDialog( showDialog<void>(
context: this.context, context: this.context,
useRootNavigator: false, useRootNavigator: false,
barrierDismissible: false, barrierDismissible: false,
@@ -1691,7 +1691,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
case 'album': case 'album':
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute<void>(
builder: (context) => ExtensionAlbumScreen( builder: (context) => ExtensionAlbumScreen(
extensionId: extensionId, extensionId: extensionId,
albumId: item.id, albumId: item.id,
@@ -1704,7 +1704,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
case 'playlist': case 'playlist':
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute<void>(
builder: (context) => ExtensionPlaylistScreen( builder: (context) => ExtensionPlaylistScreen(
extensionId: extensionId, extensionId: extensionId,
playlistId: item.id, playlistId: item.id,
@@ -1717,7 +1717,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
case 'artist': case 'artist':
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute<void>(
builder: (context) => ExtensionArtistScreen( builder: (context) => ExtensionArtistScreen(
extensionId: extensionId, extensionId: extensionId,
artistId: item.id, artistId: item.id,
@@ -1738,7 +1738,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
void _showTrackBottomSheet(ExploreItem item) { void _showTrackBottomSheet(ExploreItem item) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet( showModalBottomSheet<void>(
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
@@ -1884,7 +1884,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (item.albumId != null && item.albumId!.isNotEmpty) { if (item.albumId != null && item.albumId!.isNotEmpty) {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute<void>(
builder: (context) => ExtensionAlbumScreen( builder: (context) => ExtensionAlbumScreen(
extensionId: item.providerId ?? 'spotify-web', extensionId: item.providerId ?? 'spotify-web',
albumId: item.albumId!, albumId: item.albumId!,
@@ -2148,7 +2148,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
item.providerId != 'qobuz') { item.providerId != 'qobuz') {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute<void>(
builder: (context) => ExtensionArtistScreen( builder: (context) => ExtensionArtistScreen(
extensionId: item.providerId!, extensionId: item.providerId!,
artistId: item.id, artistId: item.id,
@@ -2160,7 +2160,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
} else { } else {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute<void>(
builder: (context) => ArtistScreen( builder: (context) => ArtistScreen(
artistId: item.id, artistId: item.id,
artistName: item.name, artistName: item.name,
@@ -2174,7 +2174,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (item.providerId == 'download') { if (item.providerId == 'download') {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute<void>(
builder: (context) => DownloadedAlbumScreen( builder: (context) => DownloadedAlbumScreen(
albumName: item.name, albumName: item.name,
artistName: item.subtitle ?? '', artistName: item.subtitle ?? '',
@@ -2190,7 +2190,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
item.providerId != 'qobuz') { item.providerId != 'qobuz') {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute<void>(
builder: (context) => ExtensionAlbumScreen( builder: (context) => ExtensionAlbumScreen(
extensionId: item.providerId!, extensionId: item.providerId!,
albumId: item.id, albumId: item.id,
@@ -2202,7 +2202,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
} else { } else {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute<void>(
builder: (context) => AlbumScreen( builder: (context) => AlbumScreen(
albumId: item.id, albumId: item.id,
albumName: item.name, albumName: item.name,
@@ -2240,7 +2240,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
item.providerId != 'qobuz') { item.providerId != 'qobuz') {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute<void>(
builder: (context) => ExtensionPlaylistScreen( builder: (context) => ExtensionPlaylistScreen(
extensionId: item.providerId!, extensionId: item.providerId!,
playlistId: item.id, playlistId: item.id,
@@ -2252,7 +2252,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
} else { } else {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute<void>(
builder: (context) => PlaylistScreen( builder: (context) => PlaylistScreen(
playlistName: item.name, playlistName: item.name,
coverUrl: item.imageUrl, coverUrl: item.imageUrl,
@@ -2275,7 +2275,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
); );
if (!mounted) return; if (!mounted) return;
final result = await navigator.push( final result = await navigator.push(
slidePageRoute(page: TrackMetadataScreen(item: item)), slidePageRoute<bool>(page: TrackMetadataScreen(item: item)),
); );
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath( await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
item.filePath, item.filePath,
@@ -2313,7 +2313,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
error.contains('429') || error.contains('429') ||
error.toLowerCase().contains('rate limit') || error.toLowerCase().contains('rate limit') ||
error.toLowerCase().contains('too many requests'); error.toLowerCase().contains('too many requests');
final isUrlNotRecognized = error == 'url_not_recognized'; final isUrlNotRecognized = error == 'url_not_recognized';
if (isRateLimit) { if (isRateLimit) {
@@ -2910,7 +2909,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute<void>(
builder: (context) => ArtistScreen( builder: (context) => ArtistScreen(
artistId: artistId, artistId: artistId,
artistName: artistName, artistName: artistName,
@@ -2936,7 +2935,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
// Keep the full ID with prefix (e.g., "deezer:123") for AlbumScreen to detect source // Keep the full ID with prefix (e.g., "deezer:123") for AlbumScreen to detect source
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute<void>(
builder: (context) => AlbumScreen( builder: (context) => AlbumScreen(
albumId: album.id, albumId: album.id,
albumName: album.name, albumName: album.name,
@@ -2963,7 +2962,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
// Keep the full ID with prefix (e.g., "deezer:123") for PlaylistScreen to detect source // Keep the full ID with prefix (e.g., "deezer:123") for PlaylistScreen to detect source
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute<void>(
builder: (context) => PlaylistScreen( builder: (context) => PlaylistScreen(
playlistName: playlist.name, playlistName: playlist.name,
coverUrl: playlist.imageUrl, coverUrl: playlist.imageUrl,
@@ -2999,7 +2998,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute<void>(
builder: (context) => ExtensionAlbumScreen( builder: (context) => ExtensionAlbumScreen(
extensionId: extensionId, extensionId: extensionId,
albumId: albumItem.id, albumId: albumItem.id,
@@ -3035,7 +3034,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute<void>(
builder: (context) => ExtensionPlaylistScreen( builder: (context) => ExtensionPlaylistScreen(
extensionId: extensionId, extensionId: extensionId,
playlistId: playlistItem.id, playlistId: playlistItem.id,
@@ -3070,7 +3069,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute<void>(
builder: (context) => ExtensionArtistScreen( builder: (context) => ExtensionArtistScreen(
extensionId: extensionId, extensionId: extensionId,
artistId: artistItem.id, artistId: artistItem.id,
@@ -3087,7 +3086,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
final extState = ref.read(extensionProvider); final extState = ref.read(extensionProvider);
if (!extState.isInitialized) { if (!extState.isInitialized) {
return 'Paste Spotify URL or search...'; return 'Paste supported URL or search...';
} }
if (searchProvider != null && searchProvider.isNotEmpty) { if (searchProvider != null && searchProvider.isNotEmpty) {
@@ -3108,7 +3107,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
return 'Search with ${ext.displayName}...'; return 'Search with ${ext.displayName}...';
} }
} }
return 'Paste Spotify URL or search...'; return 'Paste supported URL or search...';
} }
Widget _buildSearchFilterBar( Widget _buildSearchFilterBar(
@@ -3125,7 +3124,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
Padding( Padding(
padding: const EdgeInsets.only(right: 8), padding: const EdgeInsets.only(right: 8),
child: FilterChip( child: FilterChip(
label: const Text('All'), label: Text(context.l10n.historyFilterAll),
selected: selectedFilter == null, selected: selectedFilter == null,
onSelected: (_) { onSelected: (_) {
ref.read(trackProvider.notifier).setSearchFilter(null); ref.read(trackProvider.notifier).setSearchFilter(null);
@@ -4213,7 +4212,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
if (result == null) { if (result == null) {
setState(() { setState(() {
_error = 'Failed to load album'; _error = context.l10n.errorLoadAlbum;
_isLoading = false; _isLoading = false;
}); });
return; return;
@@ -4222,7 +4221,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
final trackList = result['tracks'] as List<dynamic>?; final trackList = result['tracks'] as List<dynamic>?;
if (trackList == null) { if (trackList == null) {
setState(() { setState(() {
_error = 'No tracks found'; _error = context.l10n.errorNoTracksFound;
_isLoading = false; _isLoading = false;
}); });
return; return;
@@ -4244,7 +4243,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_error = 'Error: $e'; _error = context.l10n.snackbarError(e.toString());
_isLoading = false; _isLoading = false;
}); });
} }
@@ -4377,7 +4376,7 @@ class _ExtensionPlaylistScreenState
if (result == null) { if (result == null) {
setState(() { setState(() {
_error = 'Failed to load playlist'; _error = context.l10n.errorLoadPlaylist;
_isLoading = false; _isLoading = false;
}); });
return; return;
@@ -4386,7 +4385,7 @@ class _ExtensionPlaylistScreenState
final trackList = result['tracks'] as List<dynamic>?; final trackList = result['tracks'] as List<dynamic>?;
if (trackList == null) { if (trackList == null) {
setState(() { setState(() {
_error = 'No tracks found'; _error = context.l10n.errorNoTracksFound;
_isLoading = false; _isLoading = false;
}); });
return; return;
@@ -4403,7 +4402,7 @@ class _ExtensionPlaylistScreenState
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_error = 'Error: $e'; _error = context.l10n.snackbarError(e.toString());
_isLoading = false; _isLoading = false;
}); });
} }
@@ -4529,7 +4528,7 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
if (result == null) { if (result == null) {
setState(() { setState(() {
_error = 'Failed to load artist'; _error = context.l10n.errorLoadArtist;
_isLoading = false; _isLoading = false;
}); });
return; return;
@@ -4563,7 +4562,7 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_error = 'Error: $e'; _error = context.l10n.snackbarError(e.toString());
_isLoading = false; _isLoading = false;
}); });
} }
+2 -2
View File
@@ -119,7 +119,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
), ),
onTap: () { onTap: () {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute<void>(
builder: (_) => LibraryTracksFolderScreen( builder: (_) => LibraryTracksFolderScreen(
mode: LibraryTracksFolderMode.playlist, mode: LibraryTracksFolderMode.playlist,
playlistId: playlist.id, playlistId: playlist.id,
@@ -149,7 +149,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
) { ) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet( showModalBottomSheet<void>(
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh, backgroundColor: colorScheme.surfaceContainerHigh,
+28 -18
View File
@@ -9,6 +9,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/library_collections_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/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
@@ -847,7 +848,7 @@ class _LibraryTracksFolderScreenState
void _confirmDownloadAll(List<Track> tracks) { void _confirmDownloadAll(List<Track> tracks) {
if (tracks.isEmpty) return; if (tracks.isEmpty) return;
showDialog( showDialog<void>(
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
final colorScheme = Theme.of(dialogContext).colorScheme; final colorScheme = Theme.of(dialogContext).colorScheme;
@@ -980,7 +981,7 @@ class _LibraryTracksFolderScreenState
void _showCoverOptionsSheet(BuildContext context, bool hasCustomCover) { void _showCoverOptionsSheet(BuildContext context, bool hasCustomCover) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet( showModalBottomSheet<void>(
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh, backgroundColor: colorScheme.surfaceContainerHigh,
@@ -1236,15 +1237,24 @@ class _CollectionTrackTile extends ConsumerWidget {
), ),
trailing: isSelectionMode trailing: isSelectionMode
? null ? null
: IconButton( : historyItem != null || localItem != null
tooltip: MaterialLocalizations.of(context).showMenuTooltip, ? IconButton(
icon: Icon( tooltip: context.l10n.tooltipPlay,
Icons.more_vert, onPressed: () {
color: colorScheme.onSurfaceVariant, ref
size: 20, .read(playbackProvider.notifier)
), .playTrackList([track]);
onPressed: () => _showTrackOptionsSheet(context, ref), },
), icon: Icon(
Icons.play_arrow,
color: colorScheme.primary,
),
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer
.withValues(alpha: 0.3),
),
)
: null,
onTap: isSelectionMode onTap: isSelectionMode
? onTap ? onTap
: () { : () {
@@ -1338,7 +1348,7 @@ class _CollectionTrackTile extends ConsumerWidget {
final showAddToPlaylist = final showAddToPlaylist =
mode != LibraryTracksFolderMode.wishlist || isDownloaded; mode != LibraryTracksFolderMode.wishlist || isDownloaded;
showModalBottomSheet( showModalBottomSheet<void>(
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh, backgroundColor: colorScheme.surfaceContainerHigh,
@@ -1523,9 +1533,9 @@ class _CollectionTrackTile extends ConsumerWidget {
); );
if (historyItem != null) { if (historyItem != null) {
await Navigator.of( await Navigator.of(context).push(
context, slidePageRoute<void>(page: TrackMetadataScreen(item: historyItem)),
).push(slidePageRoute(page: TrackMetadataScreen(item: historyItem))); );
return; return;
} }
@@ -1540,9 +1550,9 @@ class _CollectionTrackTile extends ConsumerWidget {
localItem ??= localState.findByTrackAndArtist(track.name, track.artistName); localItem ??= localState.findByTrackAndArtist(track.name, track.artistName);
if (localItem != null) { if (localItem != null) {
await Navigator.of( await Navigator.of(context).push(
context, slidePageRoute<void>(page: TrackMetadataScreen(localItem: localItem)),
).push(slidePageRoute(page: TrackMetadataScreen(localItem: localItem))); );
return; return;
} }
+56 -30
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/library_database.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/services/local_track_redownload_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/services/platform_bridge.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart';
@@ -957,16 +958,22 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
var skippedCount = 0; var skippedCount = 0;
final total = selected.length; final total = selected.length;
for (var i = 0; i < total; i++) { var cancelled = false;
if (!mounted) break; BatchProgressDialog.show(
context: context,
title: context.l10n.queueFlacAction,
total: total,
icon: Icons.queue_music,
onCancel: () {
cancelled = true;
BatchProgressDialog.dismiss(context);
},
);
ScaffoldMessenger.of(context).clearSnackBars(); for (var i = 0; i < total; i++) {
ScaffoldMessenger.of(context).showSnackBar( if (!mounted || cancelled) break;
SnackBar(
content: Text(context.l10n.queueFlacFindingProgress(i + 1, total)), BatchProgressDialog.update(current: i + 1, detail: selected[i].trackName);
duration: const Duration(seconds: 30),
),
);
try { try {
final resolution = await LocalTrackRedownloadService.resolveBestMatch( final resolution = await LocalTrackRedownloadService.resolveBestMatch(
@@ -987,7 +994,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return; return;
} }
ScaffoldMessenger.of(context).clearSnackBars(); if (!cancelled) {
BatchProgressDialog.dismiss(context);
}
if (matchedTracks.isEmpty) { if (matchedTracks.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -1063,18 +1072,25 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
var successCount = 0; var successCount = 0;
final total = selected.length; 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++) { for (var i = 0; i < total; i++) {
if (!mounted) break; if (!mounted || cancelled) break;
final item = selected[i]; final item = selected[i];
ScaffoldMessenger.of(context).clearSnackBars(); BatchProgressDialog.update(
ScaffoldMessenger.of(context).showSnackBar( current: i + 1,
SnackBar( detail: '${item.trackName} - ${item.artistName}',
content: Text(
'${context.l10n.trackReEnrichProgress} (${i + 1}/$total)',
),
duration: const Duration(seconds: 30),
),
); );
try { try {
@@ -1114,6 +1130,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return; return;
} }
if (!cancelled) {
BatchProgressDialog.dismiss(context);
}
ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).clearSnackBars();
final failedCount = total - successCount; final failedCount = total - successCount;
final summary = failedCount <= 0 final summary = failedCount <= 0
@@ -1180,7 +1199,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
? '320k' ? '320k'
: (selectedFormat == 'Opus' ? '128k' : '320k'); : (selectedFormat == 'Opus' ? '128k' : '320k');
showModalBottomSheet( showModalBottomSheet<void>(
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
@@ -1422,19 +1441,23 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final shouldEmbedLyrics = final shouldEmbedLyrics =
settings.embedLyrics && settings.lyricsMode != 'external'; 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++) { for (int i = 0; i < total; i++) {
if (!mounted) break; if (!mounted || cancelled) break;
final item = selected[i]; final item = selected[i];
ScaffoldMessenger.of(context).clearSnackBars(); BatchProgressDialog.update(current: i + 1, detail: item.trackName);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.selectionBatchConvertProgress(i + 1, total),
),
duration: const Duration(seconds: 30),
),
);
try { try {
final metadata = <String, String>{ final metadata = <String, String>{
@@ -1621,6 +1644,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
_exitSelectionMode(); _exitSelectionMode();
if (mounted) { if (mounted) {
if (!cancelled) {
BatchProgressDialog.dismiss(context);
}
ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
+40 -32
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/store_provider.dart';
import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/screens/home_tab.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/queue_tab.dart';
import 'package:spotiflac_android/screens/settings/settings_tab.dart'; import 'package:spotiflac_android/screens/settings/settings_tab.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/shell_navigation_service.dart'; import 'package:spotiflac_android/services/shell_navigation_service.dart';
import 'package:spotiflac_android/services/share_intent_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/services/update_checker.dart';
import 'package:spotiflac_android/widgets/update_dialog.dart'; import 'package:spotiflac_android/widgets/update_dialog.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart';
@@ -44,8 +45,14 @@ class _MainShellState extends ConsumerState<MainShell>
ShellNavigationService.homeTabNavigatorKey; ShellNavigationService.homeTabNavigatorKey;
final GlobalKey<NavigatorState> _libraryTabNavigatorKey = final GlobalKey<NavigatorState> _libraryTabNavigatorKey =
ShellNavigationService.libraryTabNavigatorKey; ShellNavigationService.libraryTabNavigatorKey;
final GlobalKey<NavigatorState> _storeTabNavigatorKey = final GlobalKey<NavigatorState> _repoTabNavigatorKey =
ShellNavigationService.storeTabNavigatorKey; ShellNavigationService.repoTabNavigatorKey;
@override
void didChangeDependencies() {
super.didChangeDependencies();
NotificationService().updateStrings(context.l10n);
}
@override @override
void initState() { void initState() {
@@ -58,7 +65,7 @@ class _MainShellState extends ConsumerState<MainShell>
); );
ShellNavigationService.syncState( ShellNavigationService.syncState(
currentTabIndex: _currentIndex, currentTabIndex: _currentIndex,
showStoreTab: false, showRepoTab: false,
); );
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_checkForUpdates(); _checkForUpdates();
@@ -79,7 +86,7 @@ class _MainShellState extends ConsumerState<MainShell>
_log.d('Received shared URL from stream: $url'); _log.d('Received shared URL from stream: $url');
_handleSharedUrl(url); _handleSharedUrl(url);
}, },
onError: (error) { onError: (Object error) {
_log.e('Share stream error: $error'); _log.e('Share stream error: $error');
}, },
cancelOnError: false, cancelOnError: false,
@@ -92,7 +99,7 @@ class _MainShellState extends ConsumerState<MainShell>
if (!extState.isInitialized) { if (!extState.isInitialized) {
_log.d('Waiting for extensions to initialize before handling URL...'); _log.d('Waiting for extensions to initialize before handling URL...');
for (int i = 0; i < 50; i++) { for (int i = 0; i < 50; i++) {
await Future.delayed(const Duration(milliseconds: 100)); await Future<void>.delayed(const Duration(milliseconds: 100));
if (!mounted) return; if (!mounted) return;
if (ref.read(extensionProvider).isInitialized) { if (ref.read(extensionProvider).isInitialized) {
_log.d('Extensions initialized, proceeding with URL handling'); _log.d('Extensions initialized, proceeding with URL handling');
@@ -177,7 +184,7 @@ class _MainShellState extends ConsumerState<MainShell>
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
showDialog( showDialog<void>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
@@ -268,7 +275,7 @@ class _MainShellState extends ConsumerState<MainShell>
); );
ShellNavigationService.syncState( ShellNavigationService.syncState(
currentTabIndex: _currentIndex, currentTabIndex: _currentIndex,
showStoreTab: showStore, showRepoTab: showStore,
); );
FocusManager.instance.primaryFocus?.unfocus(); FocusManager.instance.primaryFocus?.unfocus();
// Jump directly when skipping intermediate tabs to avoid // Jump directly when skipping intermediate tabs to avoid
@@ -295,17 +302,17 @@ class _MainShellState extends ConsumerState<MainShell>
); );
ShellNavigationService.syncState( ShellNavigationService.syncState(
currentTabIndex: _currentIndex, currentTabIndex: _currentIndex,
showStoreTab: showStore, showRepoTab: showStore,
); );
FocusManager.instance.primaryFocus?.unfocus(); FocusManager.instance.primaryFocus?.unfocus();
} }
} }
void _handleBackPress() { Future<void> _handleBackPress() async {
final rootNavigator = Navigator.of(context, rootNavigator: true); final rootNavigator = Navigator.of(context, rootNavigator: true);
if (rootNavigator.canPop()) { final handledByRootNavigator = await rootNavigator.maybePop();
_log.i('Back: step 1 - root navigator pop'); if (handledByRootNavigator) {
rootNavigator.pop(); _log.i('Back: step 1 - root navigator handled back');
_lastBackPress = null; _lastBackPress = null;
return; return;
} }
@@ -314,9 +321,10 @@ class _MainShellState extends ConsumerState<MainShell>
settingsProvider.select((s) => s.showExtensionStore), settingsProvider.select((s) => s.showExtensionStore),
); );
final currentNavigator = _navigatorForTab(_currentIndex, showStore); final currentNavigator = _navigatorForTab(_currentIndex, showStore);
if (currentNavigator != null && currentNavigator.canPop()) { final handledByCurrentNavigator =
_log.i('Back: step 2 - tab navigator pop (tab=$_currentIndex)'); await currentNavigator?.maybePop() ?? false;
currentNavigator.pop(); if (handledByCurrentNavigator) {
_log.i('Back: step 2 - tab navigator handled back (tab=$_currentIndex)');
_lastBackPress = null; _lastBackPress = null;
return; return;
} }
@@ -413,7 +421,7 @@ class _MainShellState extends ConsumerState<MainShell>
NavigatorState? _navigatorForTab(int index, bool showStore) { NavigatorState? _navigatorForTab(int index, bool showStore) {
if (index == 0) return _homeTabNavigatorKey.currentState; if (index == 0) return _homeTabNavigatorKey.currentState;
if (index == 1) return _libraryTabNavigatorKey.currentState; if (index == 1) return _libraryTabNavigatorKey.currentState;
if (showStore && index == 2) return _storeTabNavigatorKey.currentState; if (showStore && index == 2) return _repoTabNavigatorKey.currentState;
return null; return null;
} }
@@ -427,9 +435,9 @@ class _MainShellState extends ConsumerState<MainShell>
); );
ShellNavigationService.syncState( ShellNavigationService.syncState(
currentTabIndex: _currentIndex, currentTabIndex: _currentIndex,
showStoreTab: showStore, showRepoTab: showStore,
); );
final storeUpdatesCount = ref.watch( final repoUpdatesCount = ref.watch(
storeProvider.select((s) => s.updatesAvailableCount), storeProvider.select((s) => s.updatesAvailableCount),
); );
@@ -446,9 +454,9 @@ class _MainShellState extends ConsumerState<MainShell>
), ),
if (showStore) if (showStore)
_TabNavigator( _TabNavigator(
key: const ValueKey('tab-store'), key: const ValueKey('tab-repo'),
navigatorKey: _storeTabNavigatorKey, navigatorKey: _repoTabNavigatorKey,
child: const StoreTab(), child: const RepoTab(),
), ),
const SettingsTab(), const SettingsTab(),
]; ];
@@ -484,20 +492,20 @@ class _MainShellState extends ConsumerState<MainShell>
if (showStore) if (showStore)
NavigationDestination( NavigationDestination(
icon: AnimatedBadge( icon: AnimatedBadge(
count: storeUpdatesCount, count: repoUpdatesCount,
child: Badge( child: Badge(
isLabelVisible: storeUpdatesCount > 0, isLabelVisible: repoUpdatesCount > 0,
label: Text('$storeUpdatesCount'), label: Text('$repoUpdatesCount'),
child: const Icon(Icons.store_outlined), child: const Icon(Icons.extension_outlined),
), ),
), ),
selectedIcon: SwingIcon( selectedIcon: BouncingIcon(
child: AnimatedBadge( child: AnimatedBadge(
count: storeUpdatesCount, count: repoUpdatesCount,
child: Badge( child: Badge(
isLabelVisible: storeUpdatesCount > 0, isLabelVisible: repoUpdatesCount > 0,
label: Text('$storeUpdatesCount'), label: Text('$repoUpdatesCount'),
child: const Icon(Icons.store), child: const Icon(Icons.extension),
), ),
), ),
), ),
@@ -522,7 +530,7 @@ class _MainShellState extends ConsumerState<MainShell>
return BackButtonListener( return BackButtonListener(
onBackButtonPressed: () async { onBackButtonPressed: () async {
_handleBackPress(); await _handleBackPress();
return true; return true;
}, },
child: Scaffold( child: Scaffold(
+1 -1
View File
@@ -578,7 +578,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
void _confirmDownloadAll(BuildContext context) { void _confirmDownloadAll(BuildContext context) {
if (_tracks.isEmpty) return; if (_tracks.isEmpty) return;
showDialog( showDialog<void>(
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
final colorScheme = Theme.of(dialogContext).colorScheme; final colorScheme = Theme.of(dialogContext).colorScheme;
+159 -65
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/services/downloaded_embedded_cover_resolver.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/screens/downloaded_album_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/library_tracks_folder_screen.dart';
import 'package:spotiflac_android/screens/local_album_screen.dart'; import 'package:spotiflac_android/screens/local_album_screen.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart';
@@ -656,8 +657,8 @@ final _queueFilteredAlbumsProvider =
}); });
Map<String, List<String>> _filterHistoryInIsolate(Map<String, Object> payload) { Map<String, List<String>> _filterHistoryInIsolate(Map<String, Object> payload) {
final entries = (payload['entries'] as List).cast<List>(); final entries = (payload['entries'] as List).cast<List<Object?>>();
final albumCounts = (payload['albumCounts'] as Map).cast<String, int>(); final albumCounts = Map<String, int>.from(payload['albumCounts'] as Map);
final query = (payload['query'] as String?) ?? ''; final query = (payload['query'] as String?) ?? '';
final hasQuery = query.isNotEmpty; final hasQuery = query.isNotEmpty;
@@ -1187,6 +1188,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
} }
void _onFilterPageChanged(int index) { void _onFilterPageChanged(int index) {
HapticFeedback.selectionClick();
final filterMode = _filterModes[index]; final filterMode = _filterModes[index];
ref.read(settingsProvider.notifier).setHistoryFilterMode(filterMode); ref.read(settingsProvider.notifier).setHistoryFilterMode(filterMode);
} }
@@ -1219,16 +1221,21 @@ class _QueueTabState extends ConsumerState<QueueTab> {
} }
void _toggleSelection(String itemId) { void _toggleSelection(String itemId) {
var shouldHideOverlay = false;
setState(() { setState(() {
if (_selectedIds.contains(itemId)) { if (_selectedIds.contains(itemId)) {
_selectedIds.remove(itemId); _selectedIds.remove(itemId);
if (_selectedIds.isEmpty) { if (_selectedIds.isEmpty) {
_isSelectionMode = false; _isSelectionMode = false;
shouldHideOverlay = true;
} }
} else { } else {
_selectedIds.add(itemId); _selectedIds.add(itemId);
} }
}); });
if (shouldHideOverlay) {
_hideSelectionOverlay();
}
} }
void _selectAll(List<UnifiedLibraryItem> items) { void _selectAll(List<UnifiedLibraryItem> items) {
@@ -1268,13 +1275,15 @@ class _QueueTabState extends ConsumerState<QueueTab> {
left: 0, left: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
child: Material( child: _AnimatedOverlayBottomBar(
color: Colors.transparent, child: Material(
child: _buildSelectionBottomBar( color: Colors.transparent,
context, child: _buildSelectionBottomBar(
colorScheme, context,
_selectionOverlayItems, colorScheme,
_selectionOverlayBottomPadding, _selectionOverlayItems,
_selectionOverlayBottomPadding,
),
), ),
), ),
); );
@@ -1314,13 +1323,15 @@ class _QueueTabState extends ConsumerState<QueueTab> {
left: 0, left: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
child: Material( child: _AnimatedOverlayBottomBar(
color: Colors.transparent, child: Material(
child: _buildPlaylistSelectionBottomBar( color: Colors.transparent,
context, child: _buildPlaylistSelectionBottomBar(
colorScheme, context,
_playlistSelectionOverlayItems, colorScheme,
_playlistSelectionOverlayBottomPadding, _playlistSelectionOverlayItems,
_playlistSelectionOverlayBottomPadding,
),
), ),
), ),
); );
@@ -1349,16 +1360,21 @@ class _QueueTabState extends ConsumerState<QueueTab> {
} }
void _togglePlaylistSelection(String playlistId) { void _togglePlaylistSelection(String playlistId) {
var shouldHideOverlay = false;
setState(() { setState(() {
if (_selectedPlaylistIds.contains(playlistId)) { if (_selectedPlaylistIds.contains(playlistId)) {
_selectedPlaylistIds.remove(playlistId); _selectedPlaylistIds.remove(playlistId);
if (_selectedPlaylistIds.isEmpty) { if (_selectedPlaylistIds.isEmpty) {
_isPlaylistSelectionMode = false; _isPlaylistSelectionMode = false;
shouldHideOverlay = true;
} }
} else { } else {
_selectedPlaylistIds.add(playlistId); _selectedPlaylistIds.add(playlistId);
} }
}); });
if (shouldHideOverlay) {
_hidePlaylistSelectionOverlay();
}
} }
void _selectAllPlaylists(List<UserPlaylistCollection> playlists) { void _selectAllPlaylists(List<UserPlaylistCollection> playlists) {
@@ -1968,7 +1984,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
String? tempFormat = _filterFormat; String? tempFormat = _filterFormat;
String tempSortMode = _sortMode; String tempSortMode = _sortMode;
showModalBottomSheet( showModalBottomSheet<void>(
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
isScrollControlled: true, isScrollControlled: true,
@@ -2159,13 +2175,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
), ),
), ),
FilterChip( FilterChip(
label: const Text('A-Z'), label: Text(context.l10n.sortAlphaAsc),
selected: tempSortMode == 'a-z', selected: tempSortMode == 'a-z',
onSelected: (_) => onSelected: (_) =>
setSheetState(() => tempSortMode = 'a-z'), setSheetState(() => tempSortMode = 'a-z'),
), ),
FilterChip( FilterChip(
label: const Text('Z-A'), label: Text(context.l10n.sortAlphaDesc),
selected: tempSortMode == 'z-a', selected: tempSortMode == 'z-a',
onSelected: (_) => onSelected: (_) =>
setSheetState(() => tempSortMode = 'z-a'), setSheetState(() => tempSortMode = 'z-a'),
@@ -2280,7 +2296,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final beforeModTime = await _readFileModTimeMillis(historyItem.filePath); final beforeModTime = await _readFileModTimeMillis(historyItem.filePath);
if (!mounted) return; if (!mounted) return;
final result = await navigator.push( final result = await navigator.push(
slidePageRoute(page: TrackMetadataScreen(item: historyItem)), slidePageRoute<bool>(page: TrackMetadataScreen(item: historyItem)),
); );
_searchFocusNode.unfocus(); _searchFocusNode.unfocus();
if (result == true) { if (result == true) {
@@ -2306,7 +2322,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final beforeModTime = await _readFileModTimeMillis(item.filePath); final beforeModTime = await _readFileModTimeMillis(item.filePath);
if (!mounted) return; if (!mounted) return;
final result = await navigator.push( final result = await navigator.push(
slidePageRoute(page: TrackMetadataScreen(item: item)), slidePageRoute<bool>(page: TrackMetadataScreen(item: item)),
); );
_searchFocusNode.unfocus(); _searchFocusNode.unfocus();
if (result == true) { if (result == true) {
@@ -2327,7 +2343,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
_searchFocusNode.unfocus(); _searchFocusNode.unfocus();
Navigator.push( Navigator.push(
context, context,
slidePageRoute(page: TrackMetadataScreen(localItem: item)), slidePageRoute<void>(page: TrackMetadataScreen(localItem: item)),
).then((_) => _searchFocusNode.unfocus()); ).then((_) => _searchFocusNode.unfocus());
} }
@@ -4448,15 +4464,24 @@ class _QueueTabState extends ConsumerState<QueueTab> {
var skippedCount = 0; var skippedCount = 0;
final total = selectedLocalItems.length; final total = selectedLocalItems.length;
for (var i = 0; i < total; i++) { var cancelled = false;
if (!mounted) break; BatchProgressDialog.show(
context: context,
title: context.l10n.queueFlacAction,
total: total,
icon: Icons.queue_music,
onCancel: () {
cancelled = true;
BatchProgressDialog.dismiss(context);
},
);
ScaffoldMessenger.of(context).clearSnackBars(); for (var i = 0; i < total; i++) {
ScaffoldMessenger.of(context).showSnackBar( if (!mounted || cancelled) break;
SnackBar(
content: Text(context.l10n.queueFlacFindingProgress(i + 1, total)), BatchProgressDialog.update(
duration: const Duration(seconds: 30), current: i + 1,
), detail: selectedLocalItems[i].trackName,
); );
try { try {
@@ -4478,7 +4503,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return; return;
} }
ScaffoldMessenger.of(context).clearSnackBars(); if (!cancelled) {
BatchProgressDialog.dismiss(context);
}
if (matchedTracks.isEmpty) { if (matchedTracks.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -4552,18 +4579,25 @@ class _QueueTabState extends ConsumerState<QueueTab> {
var successCount = 0; var successCount = 0;
final total = selectedLocalItems.length; 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++) { for (var i = 0; i < total; i++) {
if (!mounted) break; if (!mounted || cancelled) break;
final item = selectedLocalItems[i]; final item = selectedLocalItems[i];
ScaffoldMessenger.of(context).clearSnackBars(); BatchProgressDialog.update(
ScaffoldMessenger.of(context).showSnackBar( current: i + 1,
SnackBar( detail: '${item.trackName} - ${item.artistName}',
content: Text(
'${context.l10n.trackReEnrichProgress} (${i + 1}/$total)',
),
duration: const Duration(seconds: 30),
),
); );
try { try {
@@ -4603,6 +4637,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return; return;
} }
if (!cancelled) {
BatchProgressDialog.dismiss(context);
}
ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).clearSnackBars();
final failedCount = total - successCount; final failedCount = total - successCount;
final summary = failedCount <= 0 final summary = failedCount <= 0
@@ -4711,7 +4748,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
_hideSelectionOverlay(); _hideSelectionOverlay();
_hidePlaylistSelectionOverlay(); _hidePlaylistSelectionOverlay();
await showModalBottomSheet( await showModalBottomSheet<void>(
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
@@ -4963,19 +5000,23 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final shouldEmbedLyrics = final shouldEmbedLyrics =
settings.embedLyrics && settings.lyricsMode != 'external'; 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++) { for (int i = 0; i < total; i++) {
if (!mounted) break; if (!mounted || cancelled) break;
final item = selectedItems[i]; final item = selectedItems[i];
ScaffoldMessenger.of(context).clearSnackBars(); BatchProgressDialog.update(current: i + 1, detail: item.trackName);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.selectionBatchConvertProgress(i + 1, total),
),
duration: const Duration(seconds: 30),
),
);
try { try {
final metadata = <String, String>{ final metadata = <String, String>{
@@ -5229,6 +5270,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
_exitSelectionMode(); _exitSelectionMode();
if (mounted) { if (mounted) {
if (!cancelled) {
BatchProgressDialog.dismiss(context);
}
ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@@ -5446,18 +5490,18 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return await showDialog<bool>( return await showDialog<bool>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('Cancel download?'), title: Text(context.l10n.cancelDownloadTitle),
content: Text( content: Text(
'This will cancel the active download for "${item.track.name}".', context.l10n.cancelDownloadContent(item.track.name),
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(ctx).pop(false), onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Keep'), child: Text(context.l10n.cancelDownloadKeep),
), ),
TextButton( TextButton(
onPressed: () => Navigator.of(ctx).pop(true), onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Cancel'), child: Text(context.l10n.dialogCancel),
), ),
], ],
), ),
@@ -5537,17 +5581,17 @@ class _QueueTabState extends ConsumerState<QueueTab> {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
// When progress is 0 (unknown size, e.g. YouTube tunnel mode), item.bytesTotal > 0
// show bytes downloaded instead of percentage ? '${(item.progress * 100).toStringAsFixed(0)}%'
item.progress > 0
? (item.speedMBps > 0
? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s'
: '${(item.progress * 100).toStringAsFixed(0)}%')
: (item.bytesReceived > 0 : (item.bytesReceived > 0
? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB${item.speedMBps.toStringAsFixed(1)} MB/s' ? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB${item.speedMBps > 0 ? '${item.speedMBps.toStringAsFixed(1)} MB/s' : ''}'
: (item.speedMBps > 0 : (item.progress > 0
? 'Downloading • ${item.speedMBps.toStringAsFixed(1)} MB/s' ? (item.speedMBps > 0
: 'Starting...')), ? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s'
: '${(item.progress * 100).toStringAsFixed(0)}%')
: (item.speedMBps > 0
? 'Downloading • ${item.speedMBps.toStringAsFixed(1)} MB/s'
: 'Starting...'))),
style: Theme.of(context).textTheme.labelSmall style: Theme.of(context).textTheme.labelSmall
?.copyWith( ?.copyWith(
color: colorScheme.primary, color: colorScheme.primary,
@@ -6453,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/screens/store/extension_details_screen.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart';
class StoreTab extends ConsumerStatefulWidget { class RepoTab extends ConsumerStatefulWidget {
const StoreTab({super.key}); const RepoTab({super.key});
@override @override
ConsumerState<StoreTab> createState() => _StoreTabState(); ConsumerState<RepoTab> createState() => _RepoTabState();
} }
class _StoreTabState extends ConsumerState<StoreTab> { class _RepoTabState extends ConsumerState<RepoTab> {
final _searchController = TextEditingController(); final _searchController = TextEditingController();
final _repoUrlController = TextEditingController(); final _repoUrlController = TextEditingController();
bool _isInitialized = false; bool _isInitialized = false;
@@ -323,7 +323,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(
Icons.store_outlined, Icons.extension_outlined,
size: 72, size: 72,
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
@@ -416,7 +416,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
void _showChangeRepoDialog(String currentUrl) { void _showChangeRepoDialog(String currentUrl) {
final changeUrlController = TextEditingController(text: currentUrl); final changeUrlController = TextEditingController(text: currentUrl);
showDialog( showDialog<void>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: Text(context.l10n.storeRepoDialogTitle), title: Text(context.l10n.storeRepoDialogTitle),
@@ -583,7 +583,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
void _showExtensionDetails(StoreExtension ext) { void _showExtensionDetails(StoreExtension ext) {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute<void>(
builder: (context) => ExtensionDetailsScreen(extension: ext), builder: (context) => ExtensionDetailsScreen(extension: ext),
), ),
); );
@@ -770,7 +770,7 @@ class _LanguageSelector extends StatelessWidget {
void _showLanguagePicker(BuildContext context) { void _showLanguagePicker(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet( showModalBottomSheet<void>(
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
@@ -510,7 +510,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
), ),
onTap: () => Navigator.push( onTap: () => Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute<void>(
builder: (_) => const LyricsProviderPriorityPage(), builder: (_) => const LyricsProviderPriorityPage(),
), ),
), ),
@@ -853,7 +853,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
WidgetRef ref, WidgetRef ref,
String current, String current,
) { ) {
showModalBottomSheet( showModalBottomSheet<void>(
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
builder: (context) => SafeArea( builder: (context) => SafeArea(
@@ -1002,7 +1002,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
); );
} }
showModalBottomSheet( showModalBottomSheet<void>(
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
isScrollControlled: true, isScrollControlled: true,
@@ -1220,7 +1220,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
final isSafMode = final isSafMode =
settings.storageMode == 'saf' && settings.downloadTreeUri.isNotEmpty; settings.storageMode == 'saf' && settings.downloadTreeUri.isNotEmpty;
showModalBottomSheet( showModalBottomSheet<void>(
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh, backgroundColor: colorScheme.surfaceContainerHigh,
@@ -1298,7 +1298,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
void _showIOSDirectoryOptions(BuildContext context, WidgetRef ref) { void _showIOSDirectoryOptions(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet( showModalBottomSheet<void>(
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh, backgroundColor: colorScheme.surfaceContainerHigh,
@@ -1359,7 +1359,9 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
if (ctx.mounted) { if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar( ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar( SnackBar(
content: Text('Failed to open folder picker: $e'), content: Text(
ctx.l10n.snackbarFolderPickerFailed(e.toString()),
),
backgroundColor: Theme.of(ctx).colorScheme.error, backgroundColor: Theme.of(ctx).colorScheme.error,
duration: const Duration(seconds: 4), duration: const Duration(seconds: 4),
), ),
@@ -1493,7 +1495,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
String current, String current,
) { ) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet( showModalBottomSheet<void>(
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh, backgroundColor: colorScheme.surfaceContainerHigh,
@@ -1598,7 +1600,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
String current, String current,
) { ) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet( showModalBottomSheet<void>(
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh, backgroundColor: colorScheme.surfaceContainerHigh,
@@ -1685,7 +1687,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final controller = TextEditingController(text: currentLanguage); final controller = TextEditingController(text: currentLanguage);
showModalBottomSheet( showModalBottomSheet<void>(
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh, backgroundColor: colorScheme.surfaceContainerHigh,
@@ -1771,7 +1773,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
String current, String current,
) { ) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet( showModalBottomSheet<void>(
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh, backgroundColor: colorScheme.surfaceContainerHigh,
@@ -1843,7 +1845,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
) { ) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final normalizedCurrent = current.trim().toUpperCase(); final normalizedCurrent = current.trim().toUpperCase();
showModalBottomSheet( showModalBottomSheet<void>(
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh, backgroundColor: colorScheme.surfaceContainerHigh,
@@ -1911,7 +1913,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
String current, String current,
) { ) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet( showModalBottomSheet<void>(
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh, backgroundColor: colorScheme.surfaceContainerHigh,
@@ -832,9 +832,9 @@ class _SettingItemState extends State<_SettingItem> {
} }
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(context.l10n.snackbarError(e.toString()))),
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarError(e.toString())))); );
} }
} finally { } finally {
if (mounted) { if (mounted) {
@@ -849,7 +849,7 @@ class _SettingItemState extends State<_SettingItem> {
); );
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
showDialog( showDialog<void>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: Text(widget.setting.label), title: Text(widget.setting.label),
+21 -16
View File
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/explore_provider.dart'; import 'package:spotiflac_android/providers/explore_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
@@ -212,7 +213,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
showDivider: index < extState.extensions.length - 1, showDivider: index < extState.extensions.length - 1,
onTap: () => Navigator.push( onTap: () => Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute<void>(
builder: (_) => builder: (_) =>
ExtensionDetailPage(extensionId: ext.id), ExtensionDetailPage(extensionId: ext.id),
), ),
@@ -469,7 +470,9 @@ class _DownloadPriorityItem extends ConsumerWidget {
onTap: hasDownloadExtensions onTap: hasDownloadExtensions
? () => Navigator.push( ? () => Navigator.push(
context, context,
MaterialPageRoute(builder: (_) => const ProviderPriorityPage()), MaterialPageRoute<void>(
builder: (_) => const ProviderPriorityPage(),
),
) )
: null, : null,
child: Padding( child: Padding(
@@ -534,7 +537,7 @@ class _MetadataPriorityItem extends ConsumerWidget {
onTap: hasMetadataExtensions onTap: hasMetadataExtensions
? () => Navigator.push( ? () => Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute<void>(
builder: (_) => const MetadataProviderPriorityPage(), builder: (_) => const MetadataProviderPriorityPage(),
), ),
) )
@@ -678,12 +681,12 @@ class _SearchProviderSelector extends ConsumerWidget {
void _showSearchProviderPicker( void _showSearchProviderPicker(
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
dynamic settings, AppSettings settings,
List<Extension> searchProviders, List<Extension> searchProviders,
) { ) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet( showModalBottomSheet<void>(
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh, backgroundColor: colorScheme.surfaceContainerHigh,
@@ -732,7 +735,7 @@ class _SearchProviderSelector extends ConsumerWidget {
(entry) => ListTile( (entry) => ListTile(
leading: Icon(Icons.search, color: colorScheme.tertiary), leading: Icon(Icons.search, color: colorScheme.tertiary),
title: Text(entry.value), title: Text(entry.value),
subtitle: Text('Search with ${entry.value}'), subtitle: Text(ctx.l10n.extensionsSearchWith(entry.value)),
trailing: settings.searchProvider == entry.key trailing: settings.searchProvider == entry.key
? Icon(Icons.check_circle, color: colorScheme.primary) ? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline), : Icon(Icons.circle_outlined, color: colorScheme.outline),
@@ -788,7 +791,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
final hasAnyProvider = homeFeedProviders.isNotEmpty; final hasAnyProvider = homeFeedProviders.isNotEmpty;
String currentProviderName = 'Auto'; String currentProviderName = context.l10n.extensionsHomeFeedAuto;
if (settings.homeFeedProvider != null && if (settings.homeFeedProvider != null &&
settings.homeFeedProvider!.isNotEmpty) { settings.homeFeedProvider!.isNotEmpty) {
final ext = homeFeedProviders final ext = homeFeedProviders
@@ -825,7 +828,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Home Feed Provider', context.l10n.extensionsHomeFeedProvider,
style: Theme.of(context).textTheme.bodyLarge?.copyWith( style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: !hasAnyProvider ? colorScheme.outline : null, color: !hasAnyProvider ? colorScheme.outline : null,
), ),
@@ -833,7 +836,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
!hasAnyProvider !hasAnyProvider
? 'No extensions with home feed' ? context.l10n.extensionsNoHomeFeedExtensions
: currentProviderName, : currentProviderName,
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
@@ -859,12 +862,12 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
void _showHomeFeedProviderPicker( void _showHomeFeedProviderPicker(
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
dynamic settings, AppSettings settings,
List<Extension> homeFeedProviders, List<Extension> homeFeedProviders,
) { ) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet( showModalBottomSheet<void>(
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh, backgroundColor: colorScheme.surfaceContainerHigh,
@@ -880,7 +883,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
Padding( Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text( child: Text(
'Home Feed Provider', ctx.l10n.extensionsHomeFeedProvider,
style: Theme.of( style: Theme.of(
context, context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
@@ -889,7 +892,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
Padding( Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text( child: Text(
'Choose which extension provides the home feed on the main screen', ctx.l10n.extensionsHomeFeedDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
@@ -897,8 +900,8 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
), ),
ListTile( ListTile(
leading: Icon(Icons.auto_awesome, color: colorScheme.primary), leading: Icon(Icons.auto_awesome, color: colorScheme.primary),
title: const Text('Auto'), title: Text(ctx.l10n.extensionsHomeFeedAuto),
subtitle: const Text('Automatically select the best available'), subtitle: Text(ctx.l10n.extensionsHomeFeedAutoSubtitle),
trailing: trailing:
(settings.homeFeedProvider == null || (settings.homeFeedProvider == null ||
settings.homeFeedProvider!.isEmpty) settings.homeFeedProvider!.isEmpty)
@@ -914,7 +917,9 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
(ext) => ListTile( (ext) => ListTile(
leading: Icon(Icons.extension, color: colorScheme.secondary), leading: Icon(Icons.extension, color: colorScheme.secondary),
title: Text(ext.displayName), title: Text(ext.displayName),
subtitle: Text('Use ${ext.displayName} home feed'), subtitle: Text(
ctx.l10n.extensionsHomeFeedUse(ext.displayName),
),
trailing: settings.homeFeedProvider == ext.id trailing: settings.homeFeedProvider == ext.id
? Icon(Icons.check_circle, color: colorScheme.primary) ? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline), : Icon(Icons.circle_outlined, color: colorScheme.outline),
@@ -255,7 +255,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
void _showAutoScanPicker(BuildContext context, String current) { void _showAutoScanPicker(BuildContext context, String current) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet( showModalBottomSheet<void>(
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh, backgroundColor: colorScheme.surfaceContainerHigh,
+1 -1
View File
@@ -92,7 +92,7 @@ class _LogScreenState extends State<LogScreen> {
} }
void _clearLogs() { void _clearLogs() {
showDialog( showDialog<void>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: Text(context.l10n.logClearLogsTitle), title: Text(context.l10n.logClearLogsTitle),
@@ -158,7 +158,7 @@ class OptionsSettingsPage extends ConsumerWidget {
child: SettingsGroup( child: SettingsGroup(
children: [ children: [
SettingsSwitchItem( SettingsSwitchItem(
icon: Icons.store, icon: Icons.extension,
title: context.l10n.optionsExtensionStore, title: context.l10n.optionsExtensionStore,
subtitle: context.l10n.optionsExtensionStoreSubtitle, subtitle: context.l10n.optionsExtensionStoreSubtitle,
value: settings.showExtensionStore, value: settings.showExtensionStore,
@@ -241,7 +241,7 @@ class OptionsSettingsPage extends ConsumerWidget {
WidgetRef ref, WidgetRef ref,
ColorScheme colorScheme, ColorScheme colorScheme,
) { ) {
showDialog( showDialog<void>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: Text(context.l10n.dialogClearHistoryTitle), title: Text(context.l10n.dialogClearHistoryTitle),
@@ -273,7 +273,7 @@ class OptionsSettingsPage extends ConsumerWidget {
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
) async { ) async {
showDialog( showDialog<void>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
@@ -307,9 +307,9 @@ class OptionsSettingsPage extends ConsumerWidget {
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
Navigator.pop(context); // Close loading dialog Navigator.pop(context); // Close loading dialog
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(context.l10n.snackbarError(e.toString()))),
).showSnackBar(SnackBar(content: Text('Error: $e'))); );
} }
} }
} }
+1 -1
View File
@@ -151,6 +151,6 @@ class SettingsTab extends ConsumerWidget {
void _navigateTo(BuildContext context, Widget page) { void _navigateTo(BuildContext context, Widget page) {
FocusManager.instance.primaryFocus?.unfocus(); FocusManager.instance.primaryFocus?.unfocus();
Navigator.of(context).push(slidePageRoute(page: page)); Navigator.of(context).push(slidePageRoute<void>(page: page));
} }
} }
+9 -7
View File
@@ -124,7 +124,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
final shouldOpen = await _showAndroid11StorageDialog(); final shouldOpen = await _showAndroid11StorageDialog();
if (shouldOpen == true) { if (shouldOpen == true) {
await Permission.manageExternalStorage.request(); await Permission.manageExternalStorage.request();
await Future.delayed(const Duration(milliseconds: 500)); await Future<void>.delayed(const Duration(milliseconds: 500));
manageStatus = await Permission.manageExternalStorage.status; manageStatus = await Permission.manageExternalStorage.status;
} }
} }
@@ -203,7 +203,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
} }
Future<void> _showPermissionDeniedDialog(String permissionType) async { Future<void> _showPermissionDeniedDialog(String permissionType) async {
await showDialog( await showDialog<void>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: Text(context.l10n.setupPermissionRequired(permissionType)), title: Text(context.l10n.setupPermissionRequired(permissionType)),
@@ -286,7 +286,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Future<void> _showIOSDirectoryOptions() async { Future<void> _showIOSDirectoryOptions() async {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
await showModalBottomSheet( await showModalBottomSheet<void>(
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh, backgroundColor: colorScheme.surfaceContainerHigh,
@@ -339,7 +339,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Failed to open folder picker: $e'), content: Text(
context.l10n.snackbarFolderPickerFailed(e.toString()),
),
backgroundColor: Theme.of(context).colorScheme.error, backgroundColor: Theme.of(context).colorScheme.error,
duration: const Duration(seconds: 4), duration: const Duration(seconds: 4),
), ),
@@ -430,9 +432,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
if (mounted) context.go('/tutorial'); if (mounted) context.go('/tutorial');
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(context.l10n.snackbarError(e.toString()))),
).showSnackBar(SnackBar(content: Text('Error: $e'))); );
} }
} finally { } finally {
setState(() => _isLoading = false); setState(() => _isLoading = false);
+14 -15
View File
@@ -2135,7 +2135,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
treeUri: treeUri, treeUri: treeUri,
relativeDir: relativeDir, relativeDir: relativeDir,
fileName: '$baseName.lrc', fileName: '$baseName.lrc',
mimeType: 'text/plain', mimeType: 'application/octet-stream',
srcPath: tempOutput, srcPath: tempOutput,
); );
try { try {
@@ -2533,7 +2533,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
WidgetRef ref, WidgetRef ref,
ColorScheme colorScheme, ColorScheme colorScheme,
) { ) {
showModalBottomSheet( showModalBottomSheet<void>(
context: screenContext, context: screenContext,
useRootNavigator: true, useRootNavigator: true,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
@@ -2824,7 +2824,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool isLosslessTarget = bool isLosslessTarget =
selectedFormat == 'ALAC' || selectedFormat == 'FLAC'; selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
showModalBottomSheet( showModalBottomSheet<void>(
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
@@ -3023,7 +3023,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (!mounted) return; if (!mounted) return;
showModalBottomSheet( showModalBottomSheet<void>(
context: this.context, context: this.context,
useRootNavigator: true, useRootNavigator: true,
isScrollControlled: true, isScrollControlled: true,
@@ -3186,7 +3186,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
required String date, required String date,
required List<CueSplitTrackInfo> tracks, required List<CueSplitTrackInfo> tracks,
}) { }) {
showDialog( showDialog<void>(
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
return AlertDialog( return AlertDialog(
@@ -3442,7 +3442,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final isLossless = final isLossless =
targetFormat.toUpperCase() == 'ALAC' || targetFormat.toUpperCase() == 'ALAC' ||
targetFormat.toUpperCase() == 'FLAC'; targetFormat.toUpperCase() == 'FLAC';
showDialog( showDialog<void>(
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
return AlertDialog( return AlertDialog(
@@ -3792,7 +3792,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
WidgetRef ref, WidgetRef ref,
ColorScheme colorScheme, ColorScheme colorScheme,
) { ) {
showDialog( showDialog<void>(
context: screenContext, context: screenContext,
useRootNavigator: true, useRootNavigator: true,
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => AlertDialog(
@@ -5025,9 +5025,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
if (ffmpegResult == null) { if (ffmpegResult == null) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(content: Text(context.l10n.metadataSaveFailedFfmpeg)),
content: Text('Failed to save metadata via FFmpeg'),
),
); );
} }
setState(() => _saving = false); setState(() => _saving = false);
@@ -5038,9 +5036,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri); final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri);
if (!ok && mounted) { if (!ok && mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(content: Text(context.l10n.metadataSaveFailedStorage)),
content: Text('Failed to write metadata back to storage'),
),
); );
setState(() => _saving = false); setState(() => _saving = false);
return; return;
@@ -5094,7 +5090,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
'Edit Metadata', context.l10n.trackEditMetadata,
style: Theme.of(context).textTheme.titleLarge?.copyWith( style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -5107,7 +5103,10 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
else else
FilledButton(onPressed: _save, child: const Text('Save')), FilledButton(
onPressed: _save,
child: Text(context.l10n.dialogSave),
),
], ],
), ),
), ),
+3 -3
View File
@@ -185,7 +185,7 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
title: l10n.tutorialExtensionsTitle, title: l10n.tutorialExtensionsTitle,
description: l10n.tutorialExtensionsDesc, description: l10n.tutorialExtensionsDesc,
content: _buildFeatureList(context, [ content: _buildFeatureList(context, [
(Icons.storefront_rounded, l10n.tutorialExtensionsTip1), (Icons.extension_rounded, l10n.tutorialExtensionsTip1),
( (
Icons.add_circle_outline_rounded, Icons.add_circle_outline_rounded,
l10n.tutorialExtensionsTip2, l10n.tutorialExtensionsTip2,
@@ -527,7 +527,7 @@ class _InteractiveDownloadExampleState
for (int i = 0; i <= 100; i += 5) { for (int i = 0; i <= 100; i += 5) {
if (!mounted) return; if (!mounted) return;
await Future.delayed(const Duration(milliseconds: 50)); await Future<void>.delayed(const Duration(milliseconds: 50));
setState(() => _progress = i / 100); setState(() => _progress = i / 100);
} }
@@ -536,7 +536,7 @@ class _InteractiveDownloadExampleState
_isCompleted = true; _isCompleted = true;
}); });
await Future.delayed(const Duration(seconds: 2)); await Future<void>.delayed(const Duration(seconds: 2));
if (mounted) { if (mounted) {
setState(() { setState(() {
_isCompleted = false; _isCompleted = false;
+2 -2
View File
@@ -119,7 +119,7 @@ class AppStateDatabase {
final db = await database; final db = await database;
await db.transaction((txn) async { await db.transaction((txn) async {
final batch = txn.batch(); final batch = txn.batch();
for (final entry in decoded.whereType<Map>()) { for (final entry in decoded.whereType<Map<Object?, Object?>>()) {
final map = Map<String, dynamic>.from(entry); final map = Map<String, dynamic>.from(entry);
final id = map['id'] as String?; final id = map['id'] as String?;
if (id == null || id.isEmpty) continue; if (id == null || id.isEmpty) continue;
@@ -179,7 +179,7 @@ class AppStateDatabase {
final decoded = jsonDecode(rawRecent); final decoded = jsonDecode(rawRecent);
if (decoded is List) { if (decoded is List) {
final batch = txn.batch(); final batch = txn.batch();
for (final entry in decoded.whereType<Map>()) { for (final entry in decoded.whereType<Map<Object?, Object?>>()) {
final map = Map<String, dynamic>.from(entry); final map = Map<String, dynamic>.from(entry);
final type = map['type'] as String?; final type = map['type'] as String?;
final id = map['id'] as String?; final id = map['id'] as String?;
+1 -1
View File
@@ -124,7 +124,7 @@ class CsvImportService {
); );
if (i < tracks.length - 1) { if (i < tracks.length - 1) {
await Future.delayed(const Duration(milliseconds: 100)); await Future<void>.delayed(const Duration(milliseconds: 100));
} }
continue; continue;
} }
+4 -5
View File
@@ -1446,10 +1446,10 @@ class FFmpegService {
} }
cmdBuffer.write('-map 0:a '); 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) { 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('-c:a alac ');
cmdBuffer.write('-map_metadata -1 '); cmdBuffer.write('-map_metadata -1 ');
@@ -1836,8 +1836,7 @@ class FFmpegService {
continue; continue;
} }
if (coverPath != null && coverPath.isNotEmpty && outputExt == 'flac') { if (coverPath != null && coverPath.isNotEmpty && outputExt == 'flac') {}
}
outputPaths.add(outputPath); outputPaths.add(outputPath);
_log.i('CUE split: track ${track.number} -> $outputFileName'); _log.i('CUE split: track ${track.number} -> $outputFileName');
+2 -2
View File
@@ -224,7 +224,7 @@ class HistoryDatabase {
} }
try { try {
final List<dynamic> jsonList = jsonDecode(jsonStr); final jsonList = List<dynamic>.from(jsonDecode(jsonStr) as List);
_log.i( _log.i(
'Migrating ${jsonList.length} items from SharedPreferences to SQLite', 'Migrating ${jsonList.length} items from SharedPreferences to SQLite',
); );
@@ -233,7 +233,7 @@ class HistoryDatabase {
final batch = db.batch(); final batch = db.batch();
for (final json in jsonList) { for (final json in jsonList) {
final map = json as Map<String, dynamic>; final map = Map<String, dynamic>.from(json as Map);
batch.insert( batch.insert(
'history', 'history',
_jsonToDbRow(map), _jsonToDbRow(map),
@@ -155,11 +155,11 @@ class LibraryCollectionsDatabase {
final db = await database; final db = await database;
await db.transaction((txn) async { await db.transaction((txn) async {
for (final entry in wishlistRaw.whereType<Map>()) { for (final entry in wishlistRaw.whereType<Map<Object?, Object?>>()) {
final map = Map<String, dynamic>.from(entry); final map = Map<String, dynamic>.from(entry);
final trackKey = map['key'] as String?; final trackKey = map['key'] as String?;
final track = map['track']; final track = map['track'];
if (trackKey == null || track is! Map) continue; if (trackKey == null || track is! Map<Object?, Object?>) continue;
final addedAt = (map['addedAt'] as String?) ?? nowIso; final addedAt = (map['addedAt'] as String?) ?? nowIso;
await txn.insert(_tableWishlist, { await txn.insert(_tableWishlist, {
'track_key': trackKey, 'track_key': trackKey,
@@ -168,11 +168,11 @@ class LibraryCollectionsDatabase {
}, conflictAlgorithm: ConflictAlgorithm.replace); }, conflictAlgorithm: ConflictAlgorithm.replace);
} }
for (final entry in lovedRaw.whereType<Map>()) { for (final entry in lovedRaw.whereType<Map<Object?, Object?>>()) {
final map = Map<String, dynamic>.from(entry); final map = Map<String, dynamic>.from(entry);
final trackKey = map['key'] as String?; final trackKey = map['key'] as String?;
final track = map['track']; final track = map['track'];
if (trackKey == null || track is! Map) continue; if (trackKey == null || track is! Map<Object?, Object?>) continue;
final addedAt = (map['addedAt'] as String?) ?? nowIso; final addedAt = (map['addedAt'] as String?) ?? nowIso;
await txn.insert(_tableLoved, { await txn.insert(_tableLoved, {
'track_key': trackKey, 'track_key': trackKey,
@@ -181,7 +181,8 @@ class LibraryCollectionsDatabase {
}, conflictAlgorithm: ConflictAlgorithm.replace); }, conflictAlgorithm: ConflictAlgorithm.replace);
} }
for (final playlistEntry in playlistsRaw.whereType<Map>()) { for (final playlistEntry
in playlistsRaw.whereType<Map<Object?, Object?>>()) {
final playlist = Map<String, dynamic>.from(playlistEntry); final playlist = Map<String, dynamic>.from(playlistEntry);
final playlistId = playlist['id'] as String?; final playlistId = playlist['id'] as String?;
if (playlistId == null || playlistId.isEmpty) continue; if (playlistId == null || playlistId.isEmpty) continue;
@@ -197,11 +198,12 @@ class LibraryCollectionsDatabase {
}, conflictAlgorithm: ConflictAlgorithm.replace); }, conflictAlgorithm: ConflictAlgorithm.replace);
final tracksRaw = (playlist['tracks'] as List?) ?? const []; final tracksRaw = (playlist['tracks'] as List?) ?? const [];
for (final trackEntry in tracksRaw.whereType<Map>()) { for (final trackEntry
in tracksRaw.whereType<Map<Object?, Object?>>()) {
final trackMap = Map<String, dynamic>.from(trackEntry); final trackMap = Map<String, dynamic>.from(trackEntry);
final trackKey = trackMap['key'] as String?; final trackKey = trackMap['key'] as String?;
final track = trackMap['track']; final track = trackMap['track'];
if (trackKey == null || track is! Map) continue; if (trackKey == null || track is! Map<Object?, Object?>) continue;
final addedAt = (trackMap['addedAt'] as String?) ?? nowIso; final addedAt = (trackMap['addedAt'] as String?) ?? nowIso;
await txn.insert(_tablePlaylistTracks, { await txn.insert(_tablePlaylistTracks, {
'playlist_id': playlistId, 'playlist_id': playlistId,
+61 -26
View File
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
class NotificationService { class NotificationService {
static final NotificationService _instance = NotificationService._internal(); static final NotificationService _instance = NotificationService._internal();
@@ -13,6 +14,13 @@ class NotificationService {
FlutterLocalNotificationsPlugin(); FlutterLocalNotificationsPlugin();
bool _isInitialized = false; bool _isInitialized = false;
bool _notificationPermissionRequested = 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 downloadProgressId = 1;
static const int updateDownloadId = 2; static const int updateDownloadId = 2;
@@ -165,7 +173,8 @@ class NotificationService {
await _showSafely( await _showSafely(
id: downloadProgressId, id: downloadProgressId,
title: 'Downloading $trackName', title:
_l10n?.notifDownloadingTrack(trackName) ?? 'Downloading $trackName',
body: '$artistName$percentage%', body: '$artistName$percentage%',
details: details, details: details,
); );
@@ -208,8 +217,9 @@ class NotificationService {
await _showSafely( await _showSafely(
id: downloadProgressId, id: downloadProgressId,
title: 'Finalizing $trackName', title: _l10n?.notifFinalizingTrack(trackName) ?? 'Finalizing $trackName',
body: '$artistName • Embedding metadata...', body:
'$artistName${_l10n?.notifEmbeddingMetadata ?? 'Embedding metadata...'}',
details: details, details: details,
); );
} }
@@ -226,12 +236,14 @@ class NotificationService {
String title; String title;
if (alreadyInLibrary) { if (alreadyInLibrary) {
title = completedCount != null && totalCount != null title = completedCount != null && totalCount != null
? 'Already in Library ($completedCount/$totalCount)' ? (_l10n?.notifAlreadyInLibraryCount(completedCount, totalCount) ??
: 'Already in Library'; 'Already in Library ($completedCount/$totalCount)')
: (_l10n?.notifAlreadyInLibrary ?? 'Already in Library');
} else { } else {
title = completedCount != null && totalCount != null title = completedCount != null && totalCount != null
? 'Download Complete ($completedCount/$totalCount)' ? (_l10n?.notifDownloadCompleteCount(completedCount, totalCount) ??
: 'Download Complete'; 'Download Complete ($completedCount/$totalCount)')
: (_l10n?.notifDownloadComplete ?? 'Download Complete');
} }
const androidDetails = AndroidNotificationDetails( const androidDetails = AndroidNotificationDetails(
@@ -271,8 +283,9 @@ class NotificationService {
if (!_isInitialized) await initialize(); if (!_isInitialized) await initialize();
final title = failedCount > 0 final title = failedCount > 0
? 'Downloads Finished ($completedCount done, $failedCount failed)' ? (_l10n?.notifDownloadsFinished(completedCount, failedCount) ??
: 'All Downloads Complete'; 'Downloads Finished ($completedCount done, $failedCount failed)')
: (_l10n?.notifAllDownloadsComplete ?? 'All Downloads Complete');
const androidDetails = AndroidNotificationDetails( const androidDetails = AndroidNotificationDetails(
channelId, channelId,
@@ -299,7 +312,9 @@ class NotificationService {
await _showSafely( await _showSafely(
id: downloadProgressId, id: downloadProgressId,
title: title, title: title,
body: '$completedCount tracks downloaded successfully', body:
_l10n?.notifTracksDownloadedSuccess(completedCount) ??
'$completedCount tracks downloaded successfully',
details: details, details: details,
); );
} }
@@ -319,8 +334,14 @@ class NotificationService {
final clampedProgress = progress.clamp(0.0, 100.0); final clampedProgress = progress.clamp(0.0, 100.0);
final percentage = clampedProgress.round(); final percentage = clampedProgress.round();
final progressBody = totalFiles > 0 final progressBody = totalFiles > 0
? '$scannedFiles/$totalFiles files • $percentage%' ? (_l10n?.notifLibraryScanProgressWithTotal(
: '$scannedFiles files scanned • $percentage%'; scannedFiles,
totalFiles,
percentage,
) ??
'$scannedFiles/$totalFiles files • $percentage%')
: (_l10n?.notifLibraryScanProgressNoTotal(scannedFiles, percentage) ??
'$scannedFiles files scanned • $percentage%');
final body = (currentFile != null && currentFile.isNotEmpty) final body = (currentFile != null && currentFile.isNotEmpty)
? '$progressBody\n$currentFile' ? '$progressBody\n$currentFile'
: progressBody; : progressBody;
@@ -355,7 +376,7 @@ class NotificationService {
await _showSafely( await _showSafely(
id: libraryScanId, id: libraryScanId,
title: 'Scanning local library', title: _l10n?.notifScanningLibrary ?? 'Scanning local library',
body: body, body: body,
details: details, details: details,
); );
@@ -370,10 +391,15 @@ class NotificationService {
final extras = <String>[]; final extras = <String>[];
if (excludedDownloadedCount > 0) { if (excludedDownloadedCount > 0) {
extras.add('$excludedDownloadedCount excluded'); extras.add(
_l10n?.notifLibraryScanExcluded(excludedDownloadedCount) ??
'$excludedDownloadedCount excluded',
);
} }
if (errorCount > 0) { if (errorCount > 0) {
extras.add('$errorCount errors'); extras.add(
_l10n?.notifLibraryScanErrors(errorCount) ?? '$errorCount errors',
);
} }
final suffix = extras.isEmpty ? '' : ' (${extras.join(', ')})'; final suffix = extras.isEmpty ? '' : ' (${extras.join(', ')})';
@@ -401,8 +427,9 @@ class NotificationService {
await _showSafely( await _showSafely(
id: libraryScanId, id: libraryScanId,
title: 'Library scan complete', title: _l10n?.notifLibraryScanComplete ?? 'Library scan complete',
body: '$totalTracks tracks indexed$suffix', body:
'${_l10n?.notifLibraryScanCompleteBody(totalTracks) ?? '$totalTracks tracks indexed'}$suffix',
details: details, details: details,
); );
} }
@@ -434,7 +461,7 @@ class NotificationService {
await _showSafely( await _showSafely(
id: libraryScanId, id: libraryScanId,
title: 'Library scan failed', title: _l10n?.notifLibraryScanFailed ?? 'Library scan failed',
body: message, body: message,
details: details, details: details,
); );
@@ -467,8 +494,8 @@ class NotificationService {
await _showSafely( await _showSafely(
id: libraryScanId, id: libraryScanId,
title: 'Library scan cancelled', title: _l10n?.notifLibraryScanCancelled ?? 'Library scan cancelled',
body: 'Scan stopped before completion.', body: _l10n?.notifLibraryScanStopped ?? 'Scan stopped before completion.',
details: details, details: details,
); );
} }
@@ -518,8 +545,12 @@ class NotificationService {
await _showSafely( await _showSafely(
id: updateDownloadId, id: updateDownloadId,
title: 'Downloading SpotiFLAC v$version', title:
body: '$receivedMB / $totalMB MB • $percentage%', _l10n?.notifDownloadingUpdate(version) ??
'Downloading SpotiFLAC v$version',
body:
_l10n?.notifUpdateProgress(receivedMB, totalMB, percentage) ??
'$receivedMB / $totalMB MB • $percentage%',
details: details, details: details,
); );
} }
@@ -551,8 +582,10 @@ class NotificationService {
await _showSafely( await _showSafely(
id: updateDownloadId, id: updateDownloadId,
title: 'Update Ready', title: _l10n?.notifUpdateReady ?? 'Update Ready',
body: 'SpotiFLAC v$version downloaded. Tap to install.', body:
_l10n?.notifUpdateReadyBody(version) ??
'SpotiFLAC v$version downloaded. Tap to install.',
details: details, details: details,
); );
} }
@@ -583,8 +616,10 @@ class NotificationService {
await _showSafely( await _showSafely(
id: updateDownloadId, id: updateDownloadId,
title: 'Update Failed', title: _l10n?.notifUpdateFailed ?? 'Update Failed',
body: 'Could not download update. Try again later.', body:
_l10n?.notifUpdateFailedBody ??
'Could not download update. Try again later.',
details: details, details: details,
); );
} }
+2 -18
View File
@@ -20,12 +20,6 @@ class PlatformBridge {
static bool get supportsExtensionSystem => static bool get supportsExtensionSystem =>
Platform.isAndroid || Platform.isIOS; 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( static Future<Map<String, dynamic>> checkAvailability(
String spotifyId, String spotifyId,
String isrc, String isrc,
@@ -67,8 +61,8 @@ class PlatformBridge {
if (response['success'] == true) { if (response['success'] == true) {
final service = response['service'] ?? payload.service; final service = response['service'] ?? payload.service;
final filePath = response['file_path'] ?? ''; final filePath = response['file_path'] ?? '';
final bitDepth = response['actual_bit_depth']; final bitDepth = response['actual_bit_depth'] as num?;
final sampleRate = response['actual_sample_rate']; final sampleRate = response['actual_sample_rate'] as num?;
final qualityStr = bitDepth != null && sampleRate != null final qualityStr = bitDepth != null && sampleRate != null
? ' ($bitDepth-bit/${(sampleRate / 1000).toStringAsFixed(1)}kHz)' ? ' ($bitDepth-bit/${(sampleRate / 1000).toStringAsFixed(1)}kHz)'
: ''; : '';
@@ -654,16 +648,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; 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 { static Future<List<Map<String, dynamic>>> getGoLogs() async {
final result = await _channel.invokeMethod('getLogs'); final result = await _channel.invokeMethod('getLogs');
final logs = jsonDecode(result as String) as List<dynamic>; final logs = jsonDecode(result as String) as List<dynamic>;
+1 -1
View File
@@ -65,7 +65,7 @@ class ShareIntentService {
_mediaSubscription = ReceiveSharingIntent.instance.getMediaStream().listen( _mediaSubscription = ReceiveSharingIntent.instance.getMediaStream().listen(
_handleSharedMedia, _handleSharedMedia,
onError: (err) => _log.e('Error: $err'), onError: (Object err) => _log.e('Error: $err'),
); );
final initialMedia = await ReceiveSharingIntent.instance.getInitialMedia(); final initialMedia = await ReceiveSharingIntent.instance.getInitialMedia();
+6 -6
View File
@@ -5,25 +5,25 @@ class ShellNavigationService {
GlobalKey<NavigatorState>(); GlobalKey<NavigatorState>();
static final GlobalKey<NavigatorState> libraryTabNavigatorKey = static final GlobalKey<NavigatorState> libraryTabNavigatorKey =
GlobalKey<NavigatorState>(); GlobalKey<NavigatorState>();
static final GlobalKey<NavigatorState> storeTabNavigatorKey = static final GlobalKey<NavigatorState> repoTabNavigatorKey =
GlobalKey<NavigatorState>(); GlobalKey<NavigatorState>();
static int _currentTabIndex = 0; static int _currentTabIndex = 0;
static bool _showStoreTab = false; static bool _showRepoTab = false;
static void syncState({ static void syncState({
required int currentTabIndex, required int currentTabIndex,
required bool showStoreTab, required bool showRepoTab,
}) { }) {
_currentTabIndex = currentTabIndex; _currentTabIndex = currentTabIndex;
_showStoreTab = showStoreTab; _showRepoTab = showRepoTab;
} }
static NavigatorState? activeTabNavigator() { static NavigatorState? activeTabNavigator() {
if (_currentTabIndex == 0) return homeTabNavigatorKey.currentState; if (_currentTabIndex == 0) return homeTabNavigatorKey.currentState;
if (_currentTabIndex == 1) return libraryTabNavigatorKey.currentState; if (_currentTabIndex == 1) return libraryTabNavigatorKey.currentState;
if (_showStoreTab && _currentTabIndex == 2) { if (_showRepoTab && _currentTabIndex == 2) {
return storeTabNavigatorKey.currentState; return repoTabNavigatorKey.currentState;
} }
return null; return null;
} }
+146 -24
View File
@@ -1,11 +1,26 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('UpdateChecker'); final _log = AppLogger('UpdateChecker');
enum _ApkVariant { arm64, arm32, universal }
class _ApkAsset {
final String name;
final String url;
final _ApkVariant variant;
const _ApkAsset({
required this.name,
required this.url,
required this.variant,
});
}
class UpdateInfo { class UpdateInfo {
final String version; final String version;
final String changelog; final String changelog;
@@ -94,32 +109,15 @@ class UpdateChecker {
DateTime.tryParse(releaseData['published_at'] as String? ?? '') ?? DateTime.tryParse(releaseData['published_at'] as String? ?? '') ??
DateTime.now(); DateTime.now();
String? arm64Url; final assets = _collectApkAssets(
String? universalUrl; releaseData['assets'] as List<dynamic>? ?? const [],
);
final assets = releaseData['assets'] as List<dynamic>? ?? []; final selectedAsset = await _selectApkForCurrentDevice(assets);
for (final asset in assets) { final apkUrl = selectedAsset?.url;
final name = (asset['name'] as String? ?? '').toLowerCase();
if (name.endsWith('.apk')) {
final downloadUrl = asset['browser_download_url'] as String?;
final uri = downloadUrl != null ? Uri.tryParse(downloadUrl) : null;
if (uri == null || uri.scheme != 'https') {
_log.w('Skipping non-HTTPS APK URL: $downloadUrl');
continue;
}
if (name.contains('arm64') || name.contains('v8a')) {
arm64Url = downloadUrl;
} else if (name.contains('universal')) {
universalUrl = downloadUrl;
}
}
}
// Only arm64 is supported; fall back to universal if available
final apkUrl = arm64Url ?? universalUrl;
_log.i( _log.i(
'Update available: $latestVersion (prerelease: $isPrerelease), APK URL: $apkUrl', 'Update available: $latestVersion (prerelease: $isPrerelease), '
'APK asset: ${selectedAsset?.name ?? 'none'}, APK URL: $apkUrl',
); );
return UpdateInfo( return UpdateInfo(
@@ -169,4 +167,128 @@ class UpdateChecker {
} }
static String get currentVersion => AppInfo.version; static String get currentVersion => AppInfo.version;
static List<_ApkAsset> _collectApkAssets(List<dynamic> assets) {
final apkAssets = <_ApkAsset>[];
for (final asset in assets.whereType<Map<Object?, Object?>>()) {
final assetMap = Map<String, dynamic>.from(asset);
final name = (assetMap['name'] as String? ?? '').trim();
final normalizedName = name.toLowerCase();
if (!normalizedName.endsWith('.apk')) {
continue;
}
final downloadUrl = assetMap['browser_download_url'] as String?;
final uri = downloadUrl != null ? Uri.tryParse(downloadUrl) : null;
if (uri == null || uri.scheme != 'https') {
_log.w('Skipping non-HTTPS APK URL: $downloadUrl');
continue;
}
final variant = _apkVariantFromName(normalizedName);
if (variant == null) {
_log.w('Skipping APK with unknown variant: $name');
continue;
}
apkAssets.add(
_ApkAsset(name: name, url: uri.toString(), variant: variant),
);
}
return apkAssets;
}
static _ApkVariant? _apkVariantFromName(String name) {
if (name.contains('universal')) {
return _ApkVariant.universal;
}
if (name.contains('arm64') || name.contains('arm64-v8a')) {
return _ApkVariant.arm64;
}
if (name.contains('arm32') ||
name.contains('armeabi') ||
name.contains('armv7') ||
name.contains('v7a')) {
return _ApkVariant.arm32;
}
return null;
}
static Future<_ApkAsset?> _selectApkForCurrentDevice(
List<_ApkAsset> assets,
) async {
if (assets.isEmpty) {
return null;
}
_ApkAsset? arm64Asset;
_ApkAsset? arm32Asset;
_ApkAsset? universalAsset;
for (final asset in assets) {
switch (asset.variant) {
case _ApkVariant.arm64:
arm64Asset ??= asset;
break;
case _ApkVariant.arm32:
arm32Asset ??= asset;
break;
case _ApkVariant.universal:
universalAsset ??= asset;
break;
}
}
final supportedAbis = await _getSupportedAndroidAbis();
final hasArm64 = supportedAbis.any(_isArm64Abi);
final hasArm32 = supportedAbis.any(_isArm32Abi);
if (hasArm64) {
return arm64Asset ?? universalAsset ?? arm32Asset;
}
if (hasArm32) {
return arm32Asset ?? universalAsset;
}
if (universalAsset != null) {
_log.w(
'Could not match APK asset to supported ABIs ${supportedAbis.join(', ')}; '
'falling back to universal APK.',
);
return universalAsset;
}
_log.w(
'Could not match APK asset to supported ABIs ${supportedAbis.join(', ')}; '
'no universal APK available.',
);
return null;
}
static Future<List<String>> _getSupportedAndroidAbis() async {
if (!Platform.isAndroid) {
return const [];
}
try {
final androidInfo = await DeviceInfoPlugin().androidInfo;
final supportedAbis = androidInfo.supportedAbis
.map((abi) => abi.toLowerCase())
.where((abi) => abi.isNotEmpty)
.toSet()
.toList();
_log.i('Detected supported Android ABIs: ${supportedAbis.join(', ')}');
return supportedAbis;
} catch (e) {
_log.w('Failed to detect supported Android ABIs: $e');
return const [];
}
}
static bool _isArm64Abi(String abi) =>
abi.contains('arm64') || abi.contains('aarch64');
static bool _isArm32Abi(String abi) =>
abi.contains('armeabi') || abi.contains('armv7') || abi.contains('arm');
} }
+3 -3
View File
@@ -252,7 +252,7 @@ void _pushViaPreferredNavigator(BuildContext context, WidgetBuilder builder) {
identical(currentNavigator, rootNavigator) && activeTabNavigator != null; identical(currentNavigator, rootNavigator) && activeTabNavigator != null;
if (!shouldRouteToTabNavigator) { if (!shouldRouteToTabNavigator) {
currentNavigator.push(MaterialPageRoute(builder: builder)); currentNavigator.push(MaterialPageRoute<void>(builder: builder));
return; return;
} }
@@ -264,12 +264,12 @@ void _pushViaPreferredNavigator(BuildContext context, WidgetBuilder builder) {
currentNavigator.pop(); currentNavigator.pop();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!activeTabNavigator.mounted) return; if (!activeTabNavigator.mounted) return;
activeTabNavigator.push(MaterialPageRoute(builder: builder)); activeTabNavigator.push(MaterialPageRoute<void>(builder: builder));
}); });
return; return;
} }
activeTabNavigator.push(MaterialPageRoute(builder: builder)); activeTabNavigator.push(MaterialPageRoute<void>(builder: builder));
} }
void _showLoadingSnackBar(BuildContext context, String message) { void _showLoadingSnackBar(BuildContext context, String message) {
+6 -5
View File
@@ -179,15 +179,16 @@ class LogBuffer extends ChangeNotifier {
final nextIndex = result['next_index'] as int? ?? _lastGoLogIndex; final nextIndex = result['next_index'] as int? ?? _lastGoLogIndex;
final keepNonErrorLogs = _loggingEnabled; final keepNonErrorLogs = _loggingEnabled;
for (final log in logs) { for (final log in logs.whereType<Map<Object?, Object?>>()) {
final level = log['level'] as String? ?? 'INFO'; final logMap = Map<String, dynamic>.from(log);
final level = logMap['level'] as String? ?? 'INFO';
if (!keepNonErrorLogs && level != 'ERROR' && level != 'FATAL') { if (!keepNonErrorLogs && level != 'ERROR' && level != 'FATAL') {
continue; continue;
} }
final timestamp = log['timestamp'] as String? ?? ''; final timestamp = logMap['timestamp'] as String? ?? '';
final tag = log['tag'] as String? ?? 'Go'; final tag = logMap['tag'] as String? ?? 'Go';
final message = log['message'] as String? ?? ''; final message = logMap['message'] as String? ?? '';
DateTime parsedTime = DateTime.now(); DateTime parsedTime = DateTime.now();
if (timestamp.isNotEmpty) { if (timestamp.isNotEmpty) {
+69 -54
View File
@@ -405,16 +405,19 @@ class GridSkeleton extends StatelessWidget {
crossAxisCount: crossAxisCount, crossAxisCount: crossAxisCount,
mainAxisSpacing: 12, mainAxisSpacing: 12,
crossAxisSpacing: 12, crossAxisSpacing: 12,
childAspectRatio: 0.78, childAspectRatio: 0.75,
), ),
itemCount: itemCount, itemCount: itemCount,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const AspectRatio( const Expanded(
aspectRatio: 1, child: SkeletonBox(
child: SkeletonBox(width: double.infinity, height: 0), width: double.infinity,
height: double.infinity,
borderRadius: 12,
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
SkeletonBox( SkeletonBox(
@@ -443,11 +446,15 @@ class GridSkeleton extends StatelessWidget {
class ArtistScreenSkeleton extends StatelessWidget { class ArtistScreenSkeleton extends StatelessWidget {
final int popularCount; final int popularCount;
final int albumCount; final int albumCount;
final bool showCoverHeader;
final bool showPopularSection;
const ArtistScreenSkeleton({ const ArtistScreenSkeleton({
super.key, super.key,
this.popularCount = 5, this.popularCount = 5,
this.albumCount = 5, this.albumCount = 5,
this.showCoverHeader = true,
this.showPopularSection = true,
}); });
@override @override
@@ -459,11 +466,13 @@ class ArtistScreenSkeleton extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SkeletonBox( if (showCoverHeader) ...[
width: screenWidth, SkeletonBox(
height: screenWidth * 0.75, width: screenWidth,
borderRadius: 0, height: screenWidth * 0.75,
), borderRadius: 0,
),
],
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: SkeletonBox(width: 180, height: 24, borderRadius: 4), child: SkeletonBox(width: 180, height: 24, borderRadius: 4),
@@ -472,55 +481,61 @@ class ArtistScreenSkeleton extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
child: SkeletonBox(width: 120, height: 14, borderRadius: 4), child: SkeletonBox(width: 120, height: 14, borderRadius: 4),
), ),
Padding( if (showPopularSection) ...[
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), Padding(
child: SkeletonBox(width: 90, height: 20, borderRadius: 4), padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
), child: SkeletonBox(width: 90, height: 20, borderRadius: 4),
...List.generate(popularCount, (index) { ),
return Padding( ...List.generate(popularCount, (index) {
padding: const EdgeInsets.symmetric( return Padding(
horizontal: 16, padding: const EdgeInsets.symmetric(
vertical: 8, horizontal: 16,
), vertical: 8,
child: Row( ),
children: [ child: Row(
SizedBox( children: [
width: 24, SizedBox(
child: Center( width: 24,
child: SkeletonBox( child: Center(
width: 12, child: SkeletonBox(
height: 14, width: 12,
borderRadius: 4,
),
),
),
const SizedBox(width: 12),
const SkeletonBox(width: 48, height: 48, borderRadius: 4),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SkeletonBox(
width: 110 + (index % 4) * 30,
height: 14, height: 14,
borderRadius: 4, borderRadius: 4,
), ),
const SizedBox(height: 6), ),
SkeletonBox(
width: 70 + (index % 3) * 15,
height: 11,
borderRadius: 4,
),
],
), ),
), const SizedBox(width: 12),
const SkeletonBox(width: 20, height: 20, borderRadius: 10), const SkeletonBox(width: 48, height: 48, borderRadius: 4),
], const SizedBox(width: 12),
), Expanded(
); child: Column(
}), crossAxisAlignment: CrossAxisAlignment.start,
const SizedBox(height: 16), children: [
SkeletonBox(
width: 110 + (index % 4) * 30,
height: 14,
borderRadius: 4,
),
const SizedBox(height: 6),
SkeletonBox(
width: 70 + (index % 3) * 15,
height: 11,
borderRadius: 4,
),
],
),
),
const SkeletonBox(
width: 20,
height: 20,
borderRadius: 10,
),
],
),
);
}),
const SizedBox(height: 16),
],
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: SkeletonBox(width: 80, height: 20, borderRadius: 4), child: SkeletonBox(width: 80, height: 20, borderRadius: 4),
+63 -15
View File
@@ -150,14 +150,12 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
_data = cached; _data = cached;
_checkingCache = false; _checkingCache = false;
}); });
if (cached.spectrum != null && cached.spectrum!.sliceCount > 0) { final image = await _loadSpectrogramFromCache(widget.filePath);
final image = await _renderSpectrogramToImage(cached.spectrum!); if (image != null && mounted) {
if (mounted) { setState(() {
setState(() { _spectrogramImage?.dispose();
_spectrogramImage?.dispose(); _spectrogramImage = image;
_spectrogramImage = image; });
});
}
} }
return; return;
} }
@@ -177,17 +175,25 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
try { try {
final cached = await _loadFromCache(widget.filePath); final cached = await _loadFromCache(widget.filePath);
AudioAnalysisData data; AudioAnalysisData data;
bool fromCache = false;
if (cached != null) { if (cached != null) {
data = cached; data = cached;
fromCache = true;
} else { } else {
data = await _runAnalysis(widget.filePath); data = await _runAnalysis(widget.filePath);
_saveToCache(widget.filePath, data); _saveToCache(widget.filePath, data);
} }
ui.Image? image; ui.Image? image;
if (data.spectrum != null && data.spectrum!.sliceCount > 0) { if (fromCache) {
image = await _loadSpectrogramFromCache(widget.filePath);
}
if (image == null &&
data.spectrum != null &&
data.spectrum!.sliceCount > 0) {
image = await _renderSpectrogramToImage(data.spectrum!); image = await _renderSpectrogramToImage(data.spectrum!);
_saveSpectrogramToCache(widget.filePath, image);
} }
if (mounted) { if (mounted) {
@@ -233,7 +239,9 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
final file = File('${dir.path}/$key.json'); final file = File('${dir.path}/$key.json');
if (!await file.exists()) return null; if (!await file.exists()) return null;
final json = jsonDecode(await file.readAsString()); final json = Map<String, dynamic>.from(
jsonDecode(await file.readAsString()) as Map,
);
final cachedSize = json['fileSize'] as int; final cachedSize = json['fileSize'] as int;
if (!filePath.startsWith('content://')) { if (!filePath.startsWith('content://')) {
@@ -259,6 +267,37 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
} catch (_) {} } catch (_) {}
} }
static Future<void> _saveSpectrogramToCache(
String filePath,
ui.Image image,
) async {
try {
final dir = await _cacheDir();
final key = _cacheKey(filePath);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
if (byteData != null) {
final file = File('${dir.path}/$key.png');
await file.writeAsBytes(byteData.buffer.asUint8List());
}
} catch (_) {}
}
static Future<ui.Image?> _loadSpectrogramFromCache(String filePath) async {
try {
final dir = await _cacheDir();
final key = _cacheKey(filePath);
final file = File('${dir.path}/$key.png');
if (!await file.exists()) return null;
final bytes = await file.readAsBytes();
final completer = Completer<ui.Image>();
ui.decodeImageFromList(bytes, completer.complete);
return completer.future;
} catch (_) {
return null;
}
}
Future<AudioAnalysisData> _runAnalysis(String filePath) async { Future<AudioAnalysisData> _runAnalysis(String filePath) async {
await FFmpegKitConfig.setLogLevel(Level.avLogError); await FFmpegKitConfig.setLogLevel(Level.avLogError);
@@ -556,7 +595,11 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
_AudioInfoCard(data: data), _AudioInfoCard(data: data),
if (_spectrogramImage != null) ...[ if (_spectrogramImage != null) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
_SpectrogramView(image: _spectrogramImage!, spectrum: data.spectrum!), _SpectrogramView(
image: _spectrogramImage!,
sampleRate: data.sampleRate,
maxFreq: data.spectrum?.maxFreq ?? data.sampleRate / 2,
),
], ],
], ],
); );
@@ -929,9 +972,14 @@ class _MetricChip extends StatelessWidget {
class _SpectrogramView extends StatelessWidget { class _SpectrogramView extends StatelessWidget {
final ui.Image image; final ui.Image image;
final SpectrogramData spectrum; final int sampleRate;
final double maxFreq;
const _SpectrogramView({required this.image, required this.spectrum}); const _SpectrogramView({
required this.image,
required this.sampleRate,
required this.maxFreq,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -955,12 +1003,12 @@ class _SpectrogramView extends StatelessWidget {
child: Row( child: Row(
children: [ children: [
Text( Text(
'${context.l10n.audioAnalysisSampleRate}: ${spectrum.sampleRate} Hz', '${context.l10n.audioAnalysisSampleRate}: $sampleRate Hz',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11), style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
), ),
const Spacer(), const Spacer(),
Text( Text(
'${context.l10n.audioAnalysisNyquist}: ${(spectrum.maxFreq / 1000).toStringAsFixed(1)} kHz', '${context.l10n.audioAnalysisNyquist}: ${(maxFreq / 1000).toStringAsFixed(1)} kHz',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11), style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
), ),
], ],
+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),
),
],
);
}
}
+4 -8
View File
@@ -110,7 +110,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
}) { }) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet( showModalBottomSheet<void>(
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh, backgroundColor: colorScheme.surfaceContainerHigh,
@@ -161,13 +161,9 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
return ext.qualityOptions; return ext.qualityOptions;
} }
return [ // Extensions without quality options use Tidal's options as default
const QualityOption( // since the download will fall back to built-in providers anyway.
id: 'DEFAULT', return _builtInServices.firstWhere((s) => s.id == 'tidal').qualityOptions;
label: 'Default Quality',
description: 'Best available',
),
];
} }
@override @override
@@ -19,7 +19,7 @@ class TrackCollectionQuickActions extends ConsumerWidget {
Track track, Track track,
) { ) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet( showModalBottomSheet<void>(
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
isScrollControlled: true, isScrollControlled: true,
+66 -2
View File
@@ -9,14 +9,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "91.0.0" version: "91.0.0"
analysis_server_plugin:
dependency: transitive
description:
name: analysis_server_plugin
sha256: "26844e7f977087567135d62532b67d5639fe206c5194c3f410ba75e1a04a2747"
url: "https://pub.dev"
source: hosted
version: "0.3.3"
analyzer: analyzer:
dependency: transitive dependency: transitive
description: description:
name: analyzer name: analyzer
sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 sha256: a40a0cee526a7e1f387c6847bd8a5ccbf510a75952ef8a28338e989558072cb0
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.4.1" version: "8.4.0"
analyzer_buffer: analyzer_buffer:
dependency: transitive dependency: transitive
description: description:
@@ -25,6 +33,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.11" version: "0.1.11"
analyzer_plugin:
dependency: transitive
description:
name: analyzer_plugin
sha256: "08cfefa90b4f4dd3b447bda831cecf644029f9f8e22820f6ee310213ebe2dd53"
url: "https://pub.dev"
source: hosted
version: "0.13.10"
archive: archive:
dependency: transitive dependency: transitive
description: description:
@@ -145,6 +161,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.4" version: "2.0.4"
ci:
dependency: transitive
description:
name: ci
sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13"
url: "https://pub.dev"
source: hosted
version: "0.1.0"
cli_config: cli_config:
dependency: transitive dependency: transitive
description: description:
@@ -241,6 +265,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" version: "3.0.7"
custom_lint:
dependency: "direct dev"
description:
name: custom_lint
sha256: "751ee9440920f808266c3ec2553420dea56d3c7837dd2d62af76b11be3fcece5"
url: "https://pub.dev"
source: hosted
version: "0.8.1"
custom_lint_core:
dependency: transitive
description:
name: custom_lint_core
sha256: "85b339346154d5646952d44d682965dfe9e12cae5febd706f0db3aa5010d6423"
url: "https://pub.dev"
source: hosted
version: "0.8.1"
custom_lint_visitor:
dependency: transitive
description:
name: custom_lint_visitor
sha256: "91f2a81e9f0abb4b9f3bb529f78b6227ce6050300d1ae5b1e2c69c66c7a566d8"
url: "https://pub.dev"
source: hosted
version: "1.0.0+8.4.0"
dart_style: dart_style:
dependency: transitive dependency: transitive
description: description:
@@ -925,6 +973,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.0+1" version: "4.0.0+1"
riverpod_lint:
dependency: "direct dev"
description:
name: riverpod_lint
sha256: "4d2eb0d19bbe7e3323bd0ce4553b2e6170d161a13914bfdd85a3612329edcb43"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
rxdart: rxdart:
dependency: transitive dependency: transitive
description: description:
@@ -1402,6 +1458,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.3" version: "3.1.3"
yaml_edit:
dependency: transitive
description:
name: yaml_edit
sha256: "07c9e63ba42519745182b88ca12264a7ba2484d8239958778dfe4d44fe760488"
url: "https://pub.dev"
source: hosted
version: "2.2.4"
sdks: sdks:
dart: ">=3.10.0 <4.0.0" dart: ">=3.10.0 <4.0.0"
flutter: ">=3.38.1" flutter: ">=3.38.1"
+4 -2
View File
@@ -1,7 +1,7 @@
name: spotiflac_android name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer
publish_to: "none" publish_to: "none"
version: 4.1.0+117 version: 4.1.2+119
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0
@@ -13,7 +13,7 @@ dependencies:
# Localization # Localization
flutter_localizations: flutter_localizations:
sdk: flutter sdk: flutter
intl: any intl: ^0.20.2
# State Management # State Management
flutter_riverpod: ^3.1.0 flutter_riverpod: ^3.1.0
@@ -68,7 +68,9 @@ dev_dependencies:
sdk: flutter sdk: flutter
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
build_runner: ^2.10.4 build_runner: ^2.10.4
custom_lint: ^0.8.1
riverpod_generator: ^4.0.0 riverpod_generator: ^4.0.0
riverpod_lint: ^3.1.0
json_serializable: ^6.11.2 json_serializable: ^6.11.2
flutter_launcher_icons: ^0.14.3 flutter_launcher_icons: ^0.14.3