mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-04 11:48:00 +02:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb90c73f42 | |||
| c6cf65f075 | |||
| 25de009ebc | |||
| 8918d74bb5 | |||
| f9de8d45d9 | |||
| 48eef0853d | |||
| fc70a912bf | |||
| cd3e5b4b28 | |||
| 482ca82eb4 | |||
| 6d87ae5484 | |||
| bd3e2b999b | |||
| 186196e12b | |||
| bd73eb292d | |||
| 8ee2919934 | |||
| f29177216d | |||
| 18d3612674 | |||
| f7c0e417d7 | |||
| 3fd13e9930 | |||
| 0b20cb895e | |||
| 8979210804 | |||
| e9b24712c5 | |||
| 3d6e5615fa | |||
| fc7220b572 | |||
| 198ed5ce6f | |||
| b48462a945 |
@@ -17,7 +17,7 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
[](https://www.virustotal.com/gui/file/cc11355330c76f97548b8d26452b91746db9d9c1edbcfc4c18250133484d1487)
|
[](https://www.virustotal.com/gui/file/31d1bf3c3b2015c13e83c4f909a7c6093a9423e3e702f0c582a3e0035c849424)
|
||||||
[](https://crowdin.com/project/spotiflac-mobile)
|
[](https://crowdin.com/project/spotiflac-mobile)
|
||||||
|
|
||||||
[](https://t.me/spotiflac)
|
[](https://t.me/spotiflac)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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, ®istry); err != nil {
|
if err := json.Unmarshal(body, ®istry); 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 ®istry, nil
|
return ®istry, 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 := ®istry.Extensions[i]
|
ext := ®istry.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
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`).
|
||||||
|
|||||||
@@ -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.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`).
|
||||||
|
|||||||
@@ -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.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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')));
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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?;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user