mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-13 20:42:10 +02:00
feat: improve extension metadata UI
This commit is contained in:
+40
-36
@@ -2047,25 +2047,27 @@ func normalizeExtensionTrackMetadataMap(
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"id": track.ID,
|
||||
"name": track.Name,
|
||||
"artists": track.Artists,
|
||||
"album_name": track.AlbumName,
|
||||
"album_artist": track.AlbumArtist,
|
||||
"duration_ms": track.DurationMS,
|
||||
"images": coverURL,
|
||||
"cover_url": coverURL,
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": trackNum,
|
||||
"total_tracks": track.TotalTracks,
|
||||
"disc_number": track.DiscNumber,
|
||||
"total_discs": track.TotalDiscs,
|
||||
"isrc": track.ISRC,
|
||||
"provider_id": track.ProviderID,
|
||||
"item_type": track.ItemType,
|
||||
"album_type": track.AlbumType,
|
||||
"spotify_id": track.SpotifyID,
|
||||
"composer": track.Composer,
|
||||
"id": track.ID,
|
||||
"name": track.Name,
|
||||
"artists": track.Artists,
|
||||
"album_name": track.AlbumName,
|
||||
"album_artist": track.AlbumArtist,
|
||||
"duration_ms": track.DurationMS,
|
||||
"images": coverURL,
|
||||
"cover_url": coverURL,
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": trackNum,
|
||||
"total_tracks": track.TotalTracks,
|
||||
"disc_number": track.DiscNumber,
|
||||
"total_discs": track.TotalDiscs,
|
||||
"isrc": track.ISRC,
|
||||
"provider_id": track.ProviderID,
|
||||
"item_type": track.ItemType,
|
||||
"album_type": track.AlbumType,
|
||||
"spotify_id": track.SpotifyID,
|
||||
"composer": track.Composer,
|
||||
"audio_quality": track.AudioQuality,
|
||||
"audio_modes": track.AudioModes,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3434,23 +3436,25 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string
|
||||
result := make([]map[string]interface{}, len(tracks))
|
||||
for i, track := range tracks {
|
||||
result[i] = map[string]interface{}{
|
||||
"id": track.ID,
|
||||
"name": track.Name,
|
||||
"artists": track.Artists,
|
||||
"album_name": track.AlbumName,
|
||||
"album_artist": track.AlbumArtist,
|
||||
"duration_ms": track.DurationMS,
|
||||
"images": track.ResolvedCoverURL(),
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": track.TrackNumber,
|
||||
"total_tracks": track.TotalTracks,
|
||||
"disc_number": track.DiscNumber,
|
||||
"total_discs": track.TotalDiscs,
|
||||
"isrc": track.ISRC,
|
||||
"provider_id": track.ProviderID,
|
||||
"item_type": track.ItemType,
|
||||
"album_type": track.AlbumType,
|
||||
"composer": track.Composer,
|
||||
"id": track.ID,
|
||||
"name": track.Name,
|
||||
"artists": track.Artists,
|
||||
"album_name": track.AlbumName,
|
||||
"album_artist": track.AlbumArtist,
|
||||
"duration_ms": track.DurationMS,
|
||||
"images": track.ResolvedCoverURL(),
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": track.TrackNumber,
|
||||
"total_tracks": track.TotalTracks,
|
||||
"disc_number": track.DiscNumber,
|
||||
"total_discs": track.TotalDiscs,
|
||||
"isrc": track.ISRC,
|
||||
"provider_id": track.ProviderID,
|
||||
"item_type": track.ItemType,
|
||||
"album_type": track.AlbumType,
|
||||
"composer": track.Composer,
|
||||
"audio_quality": track.AudioQuality,
|
||||
"audio_modes": track.AudioModes,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,9 @@ type ExtTrackMetadata struct {
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
|
||||
AudioQuality string `json:"audio_quality,omitempty"`
|
||||
AudioModes string `json:"audio_modes,omitempty"`
|
||||
}
|
||||
|
||||
func (t *ExtTrackMetadata) ResolvedCoverURL() string {
|
||||
|
||||
@@ -461,7 +461,7 @@ func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
@@ -134,6 +135,8 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
|
||||
var onProgress goja.Callable
|
||||
var headers map[string]string
|
||||
var chunkedDownload bool
|
||||
var chunkSize int64
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
optionsObj := call.Arguments[2].Export()
|
||||
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||
@@ -148,9 +151,30 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
onProgress = callable
|
||||
}
|
||||
}
|
||||
if chunked, ok := opts["chunked"]; ok {
|
||||
switch v := chunked.(type) {
|
||||
case bool:
|
||||
chunkedDownload = v
|
||||
case int64:
|
||||
if v > 0 {
|
||||
chunkedDownload = true
|
||||
chunkSize = v
|
||||
}
|
||||
case float64:
|
||||
if v > 0 {
|
||||
chunkedDownload = true
|
||||
chunkSize = int64(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default chunk size: 1MB (YouTube CDN max without poToken)
|
||||
if chunkedDownload && chunkSize <= 0 {
|
||||
chunkSize = 1024 * 1024
|
||||
}
|
||||
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -159,6 +183,20 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
client := r.downloadClient
|
||||
if client == nil {
|
||||
client = r.httpClient
|
||||
}
|
||||
|
||||
ua := appUserAgent()
|
||||
if h, ok := headers["User-Agent"]; ok && h != "" {
|
||||
ua = h
|
||||
}
|
||||
|
||||
if chunkedDownload {
|
||||
return r.fileDownloadChunked(client, urlStr, fullPath, headers, ua, chunkSize, onProgress)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -172,12 +210,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
}
|
||||
|
||||
client := r.downloadClient
|
||||
if client == nil {
|
||||
client = r.httpClient
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
@@ -189,7 +222,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("HTTP error: %d", resp.StatusCode),
|
||||
@@ -277,6 +310,231 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// fileDownloadChunked downloads a URL using sequential Range requests.
|
||||
// This is needed for servers (like YouTube's googlevideo CDN) that reject
|
||||
// non-ranged or large-range requests with 403 and require small chunk downloads.
|
||||
func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, fullPath string, headers map[string]string, ua string, chunkSize int64, onProgress goja.Callable) goja.Value {
|
||||
// First, get the total content length with a small probe request
|
||||
probeReq, err := http.NewRequest("GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("chunked: probe request error: %v", err),
|
||||
})
|
||||
}
|
||||
probeReq = r.bindDownloadCancelContext(probeReq)
|
||||
probeReq.Header.Set("User-Agent", ua)
|
||||
for k, v := range headers {
|
||||
if k != "Range" { // Don't copy any existing Range header
|
||||
probeReq.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
probeReq.Header.Set("Range", "bytes=0-1")
|
||||
|
||||
probeResp, err := client.Do(probeReq)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("chunked: probe error: %v", err),
|
||||
})
|
||||
}
|
||||
io.Copy(io.Discard, probeResp.Body)
|
||||
probeResp.Body.Close()
|
||||
|
||||
if probeResp.StatusCode != 206 && probeResp.StatusCode != 200 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("chunked: probe HTTP %d", probeResp.StatusCode),
|
||||
})
|
||||
}
|
||||
|
||||
// Parse Content-Range to get total size: "bytes 0-1/TOTAL"
|
||||
var totalSize int64
|
||||
contentRange := probeResp.Header.Get("Content-Range")
|
||||
if contentRange != "" {
|
||||
// Format: "bytes 0-1/12345"
|
||||
if idx := strings.LastIndex(contentRange, "/"); idx >= 0 {
|
||||
sizeStr := contentRange[idx+1:]
|
||||
if sizeStr != "*" {
|
||||
fmt.Sscanf(sizeStr, "%d", &totalSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if totalSize <= 0 {
|
||||
// Fallback: try Content-Length from a HEAD-like approach
|
||||
// If we can't determine size, download with unknown size
|
||||
GoLog("[Extension:%s] Chunked download: unknown total size, will download until server says done\n", r.extensionID)
|
||||
} else {
|
||||
GoLog("[Extension:%s] Chunked download: total size %d bytes, chunk size %d\n", r.extensionID, totalSize, chunkSize)
|
||||
}
|
||||
|
||||
out, err := os.Create(fullPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to create file: %v", err),
|
||||
})
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
activeItemID := r.getActiveDownloadItemID()
|
||||
if activeItemID != "" {
|
||||
SetItemDownloading(activeItemID)
|
||||
}
|
||||
|
||||
shouldTrackItemBytes := activeItemID != "" && onProgress == nil
|
||||
if shouldTrackItemBytes && totalSize > 0 {
|
||||
SetItemBytesTotal(activeItemID, totalSize)
|
||||
}
|
||||
|
||||
var progressWriter interface{ Write([]byte) (int, error) } = out
|
||||
if shouldTrackItemBytes {
|
||||
progressWriter = NewItemProgressWriter(out, activeItemID)
|
||||
}
|
||||
|
||||
var totalWritten int64
|
||||
buf := make([]byte, 32*1024)
|
||||
maxRetries := 3
|
||||
|
||||
for offset := int64(0); totalSize <= 0 || offset < totalSize; {
|
||||
end := offset + chunkSize - 1
|
||||
if totalSize > 0 && end >= totalSize {
|
||||
end = totalSize - 1
|
||||
}
|
||||
|
||||
var chunkResp *http.Response
|
||||
var chunkErr error
|
||||
|
||||
for retry := 0; retry < maxRetries; retry++ {
|
||||
chunkReq, err := http.NewRequest("GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("chunked: request error at offset %d: %v", offset, err),
|
||||
})
|
||||
}
|
||||
chunkReq = r.bindDownloadCancelContext(chunkReq)
|
||||
chunkReq.Header.Set("User-Agent", ua)
|
||||
for k, v := range headers {
|
||||
if k != "Range" {
|
||||
chunkReq.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
chunkReq.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", offset, end))
|
||||
|
||||
chunkResp, chunkErr = client.Do(chunkReq)
|
||||
if chunkErr != nil {
|
||||
if retry < maxRetries-1 {
|
||||
time.Sleep(time.Duration(retry+1) * time.Second)
|
||||
continue
|
||||
}
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("chunked: error at offset %d after %d retries: %v", offset, maxRetries, chunkErr),
|
||||
})
|
||||
}
|
||||
|
||||
if chunkResp.StatusCode == 206 || chunkResp.StatusCode == 200 {
|
||||
break // Success
|
||||
}
|
||||
|
||||
// Non-success status
|
||||
io.Copy(io.Discard, chunkResp.Body)
|
||||
chunkResp.Body.Close()
|
||||
|
||||
if chunkResp.StatusCode == 403 || chunkResp.StatusCode == 429 {
|
||||
if retry < maxRetries-1 {
|
||||
time.Sleep(time.Duration(retry+1) * 2 * time.Second)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("chunked: HTTP %d at offset %d", chunkResp.StatusCode, offset),
|
||||
})
|
||||
}
|
||||
|
||||
// Read chunk body and write to file
|
||||
chunkWritten := int64(0)
|
||||
for {
|
||||
nr, er := chunkResp.Body.Read(buf)
|
||||
if nr > 0 {
|
||||
nw, ew := progressWriter.Write(buf[0:nr])
|
||||
if nw < 0 || nr < nw {
|
||||
nw = 0
|
||||
if ew == nil {
|
||||
ew = fmt.Errorf("invalid write result")
|
||||
}
|
||||
}
|
||||
chunkWritten += int64(nw)
|
||||
totalWritten += int64(nw)
|
||||
if ew != nil {
|
||||
chunkResp.Body.Close()
|
||||
if ew == ErrDownloadCancelled {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "download cancelled",
|
||||
})
|
||||
}
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to write file: %v", ew),
|
||||
})
|
||||
}
|
||||
if nr != nw {
|
||||
chunkResp.Body.Close()
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "short write",
|
||||
})
|
||||
}
|
||||
|
||||
if onProgress != nil && totalSize > 0 {
|
||||
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(totalWritten), r.vm.ToValue(totalSize))
|
||||
}
|
||||
}
|
||||
if er != nil {
|
||||
if er != io.EOF {
|
||||
chunkResp.Body.Close()
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to read chunk at offset %d: %v", offset, er),
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
chunkResp.Body.Close()
|
||||
|
||||
offset += chunkWritten
|
||||
|
||||
// If server returned 200 (full content) instead of 206, we're done
|
||||
if chunkResp.StatusCode == 200 {
|
||||
break
|
||||
}
|
||||
|
||||
// If we got less data than expected and we know total size, check if done
|
||||
if totalSize > 0 && offset >= totalSize {
|
||||
break
|
||||
}
|
||||
|
||||
// Unknown size: if we got less than chunk size, assume done
|
||||
if totalSize <= 0 && chunkWritten < chunkSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Extension:%s] Chunked download complete: %d bytes to %s\n", r.extensionID, totalWritten, fullPath)
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"path": fullPath,
|
||||
"size": totalWritten,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
|
||||
@@ -75,7 +75,7 @@ func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
}
|
||||
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -1120,6 +1120,24 @@ abstract class AppLocalizations {
|
||||
/// **'Please enable \"Allow access to manage all files\" in the next screen.'**
|
||||
String get setupAllowAccessToManageFiles;
|
||||
|
||||
/// Title for the language selection step in setup
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose Language'**
|
||||
String get setupLanguageTitle;
|
||||
|
||||
/// Description for the language selection step in setup
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select your preferred language for the app. You can change this later in Settings.'**
|
||||
String get setupLanguageDescription;
|
||||
|
||||
/// Option to use the system language
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'System Default'**
|
||||
String get setupLanguageSystemDefault;
|
||||
|
||||
/// Dialog button - cancel action
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -570,6 +570,16 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get setupAllowAccessToManageFiles =>
|
||||
'Bitte aktiviere \"Zugriff auf alle Dateien erlauben\" auf dem nächsten Bildschirm.';
|
||||
|
||||
@override
|
||||
String get setupLanguageTitle => 'Choose Language';
|
||||
|
||||
@override
|
||||
String get setupLanguageDescription =>
|
||||
'Select your preferred language for the app. You can change this later in Settings.';
|
||||
|
||||
@override
|
||||
String get setupLanguageSystemDefault => 'System Default';
|
||||
|
||||
@override
|
||||
String get dialogCancel => 'Abbrechen';
|
||||
|
||||
|
||||
@@ -559,6 +559,16 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get setupAllowAccessToManageFiles =>
|
||||
'Please enable \"Allow access to manage all files\" in the next screen.';
|
||||
|
||||
@override
|
||||
String get setupLanguageTitle => 'Choose Language';
|
||||
|
||||
@override
|
||||
String get setupLanguageDescription =>
|
||||
'Select your preferred language for the app. You can change this later in Settings.';
|
||||
|
||||
@override
|
||||
String get setupLanguageSystemDefault => 'System Default';
|
||||
|
||||
@override
|
||||
String get dialogCancel => 'Cancel';
|
||||
|
||||
|
||||
@@ -559,6 +559,16 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get setupAllowAccessToManageFiles =>
|
||||
'Please enable \"Allow access to manage all files\" in the next screen.';
|
||||
|
||||
@override
|
||||
String get setupLanguageTitle => 'Choose Language';
|
||||
|
||||
@override
|
||||
String get setupLanguageDescription =>
|
||||
'Select your preferred language for the app. You can change this later in Settings.';
|
||||
|
||||
@override
|
||||
String get setupLanguageSystemDefault => 'System Default';
|
||||
|
||||
@override
|
||||
String get dialogCancel => 'Cancel';
|
||||
|
||||
|
||||
@@ -561,6 +561,16 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get setupAllowAccessToManageFiles =>
|
||||
'Please enable \"Allow access to manage all files\" in the next screen.';
|
||||
|
||||
@override
|
||||
String get setupLanguageTitle => 'Choose Language';
|
||||
|
||||
@override
|
||||
String get setupLanguageDescription =>
|
||||
'Select your preferred language for the app. You can change this later in Settings.';
|
||||
|
||||
@override
|
||||
String get setupLanguageSystemDefault => 'System Default';
|
||||
|
||||
@override
|
||||
String get dialogCancel => 'Cancel';
|
||||
|
||||
|
||||
@@ -559,6 +559,16 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get setupAllowAccessToManageFiles =>
|
||||
'Please enable \"Allow access to manage all files\" in the next screen.';
|
||||
|
||||
@override
|
||||
String get setupLanguageTitle => 'Choose Language';
|
||||
|
||||
@override
|
||||
String get setupLanguageDescription =>
|
||||
'Select your preferred language for the app. You can change this later in Settings.';
|
||||
|
||||
@override
|
||||
String get setupLanguageSystemDefault => 'System Default';
|
||||
|
||||
@override
|
||||
String get dialogCancel => 'Cancel';
|
||||
|
||||
|
||||
@@ -562,6 +562,16 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get setupAllowAccessToManageFiles =>
|
||||
'Harap aktifkan \"Izinkan akses untuk mengelola semua file\" di layar berikutnya.';
|
||||
|
||||
@override
|
||||
String get setupLanguageTitle => 'Choose Language';
|
||||
|
||||
@override
|
||||
String get setupLanguageDescription =>
|
||||
'Select your preferred language for the app. You can change this later in Settings.';
|
||||
|
||||
@override
|
||||
String get setupLanguageSystemDefault => 'System Default';
|
||||
|
||||
@override
|
||||
String get dialogCancel => 'Batal';
|
||||
|
||||
|
||||
@@ -554,6 +554,16 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get setupAllowAccessToManageFiles =>
|
||||
'Please enable \"Allow access to manage all files\" in the next screen.';
|
||||
|
||||
@override
|
||||
String get setupLanguageTitle => 'Choose Language';
|
||||
|
||||
@override
|
||||
String get setupLanguageDescription =>
|
||||
'Select your preferred language for the app. You can change this later in Settings.';
|
||||
|
||||
@override
|
||||
String get setupLanguageSystemDefault => 'System Default';
|
||||
|
||||
@override
|
||||
String get dialogCancel => 'キャンセル';
|
||||
|
||||
|
||||
@@ -544,6 +544,16 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get setupAllowAccessToManageFiles =>
|
||||
'다음 화면에서 \"모든 파일 관리 권한 허용\"을 활성화해 주세요.';
|
||||
|
||||
@override
|
||||
String get setupLanguageTitle => 'Choose Language';
|
||||
|
||||
@override
|
||||
String get setupLanguageDescription =>
|
||||
'Select your preferred language for the app. You can change this later in Settings.';
|
||||
|
||||
@override
|
||||
String get setupLanguageSystemDefault => 'System Default';
|
||||
|
||||
@override
|
||||
String get dialogCancel => '취소';
|
||||
|
||||
|
||||
@@ -559,6 +559,16 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get setupAllowAccessToManageFiles =>
|
||||
'Please enable \"Allow access to manage all files\" in the next screen.';
|
||||
|
||||
@override
|
||||
String get setupLanguageTitle => 'Choose Language';
|
||||
|
||||
@override
|
||||
String get setupLanguageDescription =>
|
||||
'Select your preferred language for the app. You can change this later in Settings.';
|
||||
|
||||
@override
|
||||
String get setupLanguageSystemDefault => 'System Default';
|
||||
|
||||
@override
|
||||
String get dialogCancel => 'Cancel';
|
||||
|
||||
|
||||
@@ -559,6 +559,16 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String get setupAllowAccessToManageFiles =>
|
||||
'Please enable \"Allow access to manage all files\" in the next screen.';
|
||||
|
||||
@override
|
||||
String get setupLanguageTitle => 'Choose Language';
|
||||
|
||||
@override
|
||||
String get setupLanguageDescription =>
|
||||
'Select your preferred language for the app. You can change this later in Settings.';
|
||||
|
||||
@override
|
||||
String get setupLanguageSystemDefault => 'System Default';
|
||||
|
||||
@override
|
||||
String get dialogCancel => 'Cancel';
|
||||
|
||||
|
||||
@@ -568,6 +568,16 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get setupAllowAccessToManageFiles =>
|
||||
'Пожалуйста, включите \"Разрешить доступ для управления всеми файлами\" на следующем экране.';
|
||||
|
||||
@override
|
||||
String get setupLanguageTitle => 'Choose Language';
|
||||
|
||||
@override
|
||||
String get setupLanguageDescription =>
|
||||
'Select your preferred language for the app. You can change this later in Settings.';
|
||||
|
||||
@override
|
||||
String get setupLanguageSystemDefault => 'System Default';
|
||||
|
||||
@override
|
||||
String get dialogCancel => 'Отмена';
|
||||
|
||||
|
||||
@@ -570,6 +570,16 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
String get setupAllowAccessToManageFiles =>
|
||||
'Lütfen sonraki ekranda \"Tüm dosyaları yönetme erişimine izin ver\" seçeneğini açın.';
|
||||
|
||||
@override
|
||||
String get setupLanguageTitle => 'Choose Language';
|
||||
|
||||
@override
|
||||
String get setupLanguageDescription =>
|
||||
'Select your preferred language for the app. You can change this later in Settings.';
|
||||
|
||||
@override
|
||||
String get setupLanguageSystemDefault => 'System Default';
|
||||
|
||||
@override
|
||||
String get dialogCancel => 'İptal';
|
||||
|
||||
|
||||
@@ -559,6 +559,16 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get setupAllowAccessToManageFiles =>
|
||||
'Please enable \"Allow access to manage all files\" in the next screen.';
|
||||
|
||||
@override
|
||||
String get setupLanguageTitle => 'Choose Language';
|
||||
|
||||
@override
|
||||
String get setupLanguageDescription =>
|
||||
'Select your preferred language for the app. You can change this later in Settings.';
|
||||
|
||||
@override
|
||||
String get setupLanguageSystemDefault => 'System Default';
|
||||
|
||||
@override
|
||||
String get dialogCancel => 'Cancel';
|
||||
|
||||
|
||||
@@ -707,6 +707,18 @@
|
||||
"@setupAllowAccessToManageFiles": {
|
||||
"description": "Instruction for file access permission"
|
||||
},
|
||||
"setupLanguageTitle": "Choose Language",
|
||||
"@setupLanguageTitle": {
|
||||
"description": "Title for the language selection step in setup"
|
||||
},
|
||||
"setupLanguageDescription": "Select your preferred language for the app. You can change this later in Settings.",
|
||||
"@setupLanguageDescription": {
|
||||
"description": "Description for the language selection step in setup"
|
||||
},
|
||||
"setupLanguageSystemDefault": "System Default",
|
||||
"@setupLanguageSystemDefault": {
|
||||
"description": "Option to use the system language"
|
||||
},
|
||||
"dialogCancel": "Cancel",
|
||||
"@dialogCancel": {
|
||||
"description": "Dialog button - cancel action"
|
||||
|
||||
@@ -25,6 +25,8 @@ class Track {
|
||||
final int? totalTracks;
|
||||
final String? composer;
|
||||
final String? itemType;
|
||||
final String? audioQuality;
|
||||
final String? audioModes;
|
||||
|
||||
const Track({
|
||||
required this.id,
|
||||
@@ -48,6 +50,8 @@ class Track {
|
||||
this.totalTracks,
|
||||
this.composer,
|
||||
this.itemType,
|
||||
this.audioQuality,
|
||||
this.audioModes,
|
||||
});
|
||||
|
||||
bool get isSingle {
|
||||
@@ -72,6 +76,11 @@ class Track {
|
||||
Map<String, dynamic> toJson() => _$TrackToJson(this);
|
||||
|
||||
bool get isFromExtension => source != null && source!.isNotEmpty;
|
||||
|
||||
bool get isDolbyAtmos =>
|
||||
audioModes != null && audioModes!.contains('DOLBY_ATMOS');
|
||||
|
||||
bool get hasAudioQuality => audioQuality != null && audioQuality!.isNotEmpty;
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
|
||||
@@ -32,6 +32,8 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
||||
totalTracks: (json['totalTracks'] as num?)?.toInt(),
|
||||
composer: json['composer'] as String?,
|
||||
itemType: json['itemType'] as String?,
|
||||
audioQuality: json['audioQuality'] as String?,
|
||||
audioModes: json['audioModes'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||
@@ -56,6 +58,8 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||
'totalTracks': instance.totalTracks,
|
||||
'composer': instance.composer,
|
||||
'itemType': instance.itemType,
|
||||
'audioQuality': instance.audioQuality,
|
||||
'audioModes': instance.audioModes,
|
||||
};
|
||||
|
||||
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
|
||||
|
||||
@@ -851,6 +851,10 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
albumType: track.albumType,
|
||||
totalTracks: track.totalTracks,
|
||||
source: track.source,
|
||||
composer: track.composer,
|
||||
itemType: track.itemType,
|
||||
audioQuality: track.audioQuality,
|
||||
audioModes: track.audioModes,
|
||||
availability: ServiceAvailability(
|
||||
tidal: availability['tidal'] as bool? ?? false,
|
||||
qobuz: availability['qobuz'] as bool? ?? false,
|
||||
@@ -932,6 +936,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
albumType: normalizeOptionalString(data['album_type']?.toString()),
|
||||
totalTracks: data['total_tracks'] as int?,
|
||||
composer: data['composer']?.toString(),
|
||||
audioQuality: data['audio_quality']?.toString(),
|
||||
audioModes: data['audio_modes']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -969,6 +975,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
albumType: normalizeOptionalString(data['album_type']?.toString()),
|
||||
composer: data['composer']?.toString(),
|
||||
itemType: itemType,
|
||||
audioQuality: data['audio_quality']?.toString(),
|
||||
audioModes: data['audio_modes']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
|
||||
|
||||
class _AlbumCache {
|
||||
static final Map<String, _CacheEntry> _cache = {};
|
||||
@@ -320,6 +321,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
totalTracksFallback ??
|
||||
_albumTotalTracks,
|
||||
composer: data['composer']?.toString(),
|
||||
audioQuality: data['audio_quality']?.toString(),
|
||||
audioModes: data['audio_modes']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1012,6 +1015,11 @@ class _AlbumTrackItem extends ConsumerWidget {
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
...buildQualityBadges(
|
||||
audioQuality: track.audioQuality,
|
||||
audioModes: track.audioModes,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
if (isInLocalLibrary || isInHistory) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
|
||||
@@ -32,6 +32,7 @@ import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
import 'package:spotiflac_android/utils/provider_ui_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
|
||||
|
||||
class HomeTab extends ConsumerStatefulWidget {
|
||||
const HomeTab({super.key});
|
||||
@@ -4074,6 +4075,11 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
...buildQualityBadges(
|
||||
audioQuality: track.audioQuality,
|
||||
audioModes: track.audioModes,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
if (isInLocalLibrary) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
@@ -4854,6 +4860,8 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
||||
_albumTotalTracks,
|
||||
composer: data['composer']?.toString(),
|
||||
source: widget.extensionId,
|
||||
audioQuality: data['audio_quality']?.toString(),
|
||||
audioModes: data['audio_modes']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5011,6 +5019,8 @@ class _ExtensionPlaylistScreenState
|
||||
totalTracks: data['total_tracks'] as int?,
|
||||
composer: data['composer']?.toString(),
|
||||
source: widget.extensionId,
|
||||
audioQuality: data['audio_quality']?.toString(),
|
||||
audioModes: data['audio_modes']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
|
||||
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
|
||||
|
||||
class PlaylistScreen extends ConsumerStatefulWidget {
|
||||
final String playlistName;
|
||||
@@ -208,6 +209,8 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
totalTracks: data['total_tracks'] as int?,
|
||||
composer: data['composer']?.toString(),
|
||||
audioQuality: data['audio_quality']?.toString(),
|
||||
audioModes: data['audio_modes']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -876,6 +879,11 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
...buildQualityBadges(
|
||||
audioQuality: track.audioQuality,
|
||||
audioModes: track.audioModes,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
if (isInLocalLibrary || isInHistory) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
|
||||
|
||||
class SearchScreen extends ConsumerStatefulWidget {
|
||||
final String query;
|
||||
@@ -61,9 +62,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addToQueue(track, service);
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
|
||||
);
|
||||
@@ -169,6 +168,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
),
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||
);
|
||||
|
||||
return ListTile(
|
||||
leading: coverWidget,
|
||||
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
@@ -183,16 +183,27 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
ClickableAlbumName(
|
||||
albumName: track.albumName,
|
||||
albumId: track.albumId,
|
||||
artistName: track.artistName,
|
||||
coverUrl: track.coverUrl,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: ClickableAlbumName(
|
||||
albumName: track.albumName,
|
||||
albumId: track.albumId,
|
||||
artistName: track.artistName,
|
||||
coverUrl: track.coverUrl,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
...buildQualityBadges(
|
||||
audioQuality: track.audioQuality,
|
||||
audioModes: track.audioModes,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/l10n/supported_locales.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
@@ -25,6 +26,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
final PageController _pageController = PageController();
|
||||
int _currentStep = 0;
|
||||
|
||||
String _selectedLocale = 'system';
|
||||
bool _storagePermissionGranted = false;
|
||||
bool _notificationPermissionGranted = false;
|
||||
String? _selectedDirectory;
|
||||
@@ -32,7 +34,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
bool _isLoading = false;
|
||||
int _androidSdkVersion = 0;
|
||||
|
||||
int get _totalSteps => _androidSdkVersion >= 33 ? 4 : 3;
|
||||
int get _totalSteps => _androidSdkVersion >= 33 ? 5 : 4;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -483,9 +485,10 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
}
|
||||
|
||||
bool _isStepCompleted(int step) {
|
||||
if (step == 0) return true;
|
||||
if (step == 0) return true; // Welcome
|
||||
if (step == 1) return true; // Language (always valid)
|
||||
|
||||
final logicStep = step - 1;
|
||||
final logicStep = step - 2;
|
||||
|
||||
if (_androidSdkVersion >= 33) {
|
||||
switch (logicStep) {
|
||||
@@ -573,6 +576,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: [
|
||||
_buildWelcomeStep(colorScheme),
|
||||
_buildLanguageStep(colorScheme),
|
||||
_buildStorageStep(colorScheme),
|
||||
if (_androidSdkVersion >= 33)
|
||||
_buildNotificationStep(colorScheme),
|
||||
@@ -671,6 +675,167 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Language data (native names, always readable regardless of current locale) ---
|
||||
static const _allLanguages = [
|
||||
('system', 'System Default', Icons.phone_android),
|
||||
('en', 'English', Icons.language),
|
||||
('id', 'Bahasa Indonesia', Icons.language),
|
||||
('de', 'Deutsch', Icons.language),
|
||||
('es', 'Español', Icons.language),
|
||||
('es_ES', 'Español (España)', Icons.language),
|
||||
('fr', 'Français', Icons.language),
|
||||
('hi', 'हिन्दी', Icons.language),
|
||||
('ja', '日本語', Icons.language),
|
||||
('ko', '한국어', Icons.language),
|
||||
('nl', 'Nederlands', Icons.language),
|
||||
('pt', 'Português', Icons.language),
|
||||
('pt_PT', 'Português (Brasil)', Icons.language),
|
||||
('ru', 'Русский', Icons.language),
|
||||
('tr', 'Türkçe', Icons.language),
|
||||
('zh', '简体中文', Icons.language),
|
||||
('zh_CN', '简体中文 (中国)', Icons.language),
|
||||
('zh_TW', '繁體中文', Icons.language),
|
||||
];
|
||||
|
||||
List<(String, String, IconData)> get _filteredLanguages {
|
||||
return _allLanguages.where((lang) {
|
||||
if (lang.$1 == 'system') return true;
|
||||
return filteredLocaleCodes.contains(lang.$1);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
void _onLanguageSelected(String locale) {
|
||||
setState(() => _selectedLocale = locale);
|
||||
ref.read(settingsProvider.notifier).setLocale(locale);
|
||||
}
|
||||
|
||||
Widget _buildLanguageStep(ColorScheme colorScheme) {
|
||||
final languages = _filteredLanguages;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
|
||||
// Match _StepLayout sizing exactly
|
||||
final iconPadding = (shortestSide * 0.06).clamp(16.0, 24.0);
|
||||
final iconSize = (shortestSide * 0.12).clamp(32.0, 48.0);
|
||||
final titleGap = (shortestSide * 0.06).clamp(16.0, 32.0);
|
||||
final descriptionGap = (shortestSide * 0.04).clamp(8.0, 16.0);
|
||||
final actionGap = (shortestSide * 0.09).clamp(20.0, 48.0);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Header: identical to _StepLayout (same padding, spacing, styles)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.all(iconPadding),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.translate,
|
||||
size: iconSize,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: titleGap),
|
||||
Text(
|
||||
context.l10n.setupLanguageTitle,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: descriptionGap),
|
||||
Text(
|
||||
context.l10n.setupLanguageDescription,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: actionGap),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Language list (scrollable action area)
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 80),
|
||||
itemCount: languages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final lang = languages[index];
|
||||
final code = lang.$1;
|
||||
final name = lang.$2;
|
||||
final isSelected = _selectedLocale == code;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Material(
|
||||
color: isSelected
|
||||
? colorScheme.primaryContainer
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () => _onLanguageSelected(code),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
lang.$3,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
size: 22,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
code == 'system'
|
||||
? context.l10n.setupLanguageSystemDefault
|
||||
: name,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isSelected)
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
size: 22,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStorageStep(ColorScheme colorScheme) {
|
||||
return _StepLayout(
|
||||
title: context.l10n.setupStorageRequired,
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AudioQualityBadge extends StatelessWidget {
|
||||
final String label;
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
const AudioQualityBadge({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.colorScheme,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DolbyAtmosBadge extends StatelessWidget {
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
const DolbyAtmosBadge({super.key, required this.colorScheme});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CustomPaint(
|
||||
size: const Size(14, 10),
|
||||
painter: DolbyLogoPainter(color: colorScheme.onTertiaryContainer),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'Atmos',
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DolbyLogoPainter extends CustomPainter {
|
||||
final Color color;
|
||||
|
||||
DolbyLogoPainter({required this.color});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final h = size.height;
|
||||
final w = size.width;
|
||||
final cy = h / 2;
|
||||
|
||||
final leftPath = Path()
|
||||
..moveTo(w * 0.08, 0)
|
||||
..lineTo(w * 0.08, h)
|
||||
..lineTo(w * 0.20, h)
|
||||
..arcToPoint(
|
||||
Offset(w * 0.20, 0),
|
||||
radius: Radius.elliptical(w * 0.25, cy),
|
||||
clockwise: false,
|
||||
)
|
||||
..close();
|
||||
canvas.drawPath(leftPath, paint);
|
||||
|
||||
final rightPath = Path()
|
||||
..moveTo(w * 0.92, 0)
|
||||
..lineTo(w * 0.92, h)
|
||||
..lineTo(w * 0.80, h)
|
||||
..arcToPoint(
|
||||
Offset(w * 0.80, 0),
|
||||
radius: Radius.elliptical(w * 0.25, cy),
|
||||
clockwise: true,
|
||||
)
|
||||
..close();
|
||||
canvas.drawPath(rightPath, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(DolbyLogoPainter oldDelegate) =>
|
||||
color != oldDelegate.color;
|
||||
}
|
||||
|
||||
/// Convenience builder: returns a list of quality badge widgets for a track.
|
||||
/// Pass the result into a Row using spread operator.
|
||||
List<Widget> buildQualityBadges({
|
||||
required String? audioQuality,
|
||||
required String? audioModes,
|
||||
required ColorScheme colorScheme,
|
||||
}) {
|
||||
final badges = <Widget>[];
|
||||
if (audioQuality != null && audioQuality.isNotEmpty) {
|
||||
badges.add(const SizedBox(width: 6));
|
||||
badges.add(
|
||||
AudioQualityBadge(label: audioQuality, colorScheme: colorScheme),
|
||||
);
|
||||
}
|
||||
if (audioModes != null && audioModes.contains('DOLBY_ATMOS')) {
|
||||
badges.add(const SizedBox(width: 4));
|
||||
badges.add(DolbyAtmosBadge(colorScheme: colorScheme));
|
||||
}
|
||||
return badges;
|
||||
}
|
||||
Reference in New Issue
Block a user