feat: improve extension metadata UI

This commit is contained in:
zarzet
2026-04-29 18:33:44 +07:00
parent cd2c2a9854
commit 611abdc6ae
29 changed files with 843 additions and 61 deletions
+40 -36
View File
@@ -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,
}
}
+3
View File
@@ -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 {
+1 -1
View File
@@ -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 {
+265 -7
View File
@@ -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)
+1 -1
View File
@@ -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")
+18
View File
@@ -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:
+10
View File
@@ -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';
+10
View File
@@ -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';
+10
View File
@@ -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';
+10
View File
@@ -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';
+10
View File
@@ -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';
+10
View File
@@ -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';
+10
View File
@@ -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 => 'キャンセル';
+10
View File
@@ -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 => '취소';
+10
View File
@@ -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';
+10
View File
@@ -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';
+10
View File
@@ -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 => 'Отмена';
+10
View File
@@ -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';
+10
View File
@@ -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';
+12
View File
@@ -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"
+9
View File
@@ -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()
+4
View File
@@ -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) =>
+8
View File
@@ -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(),
);
}
+8
View File
@@ -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(
+10
View File
@@ -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(),
);
}
+8
View File
@@ -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(
+24 -13
View File
@@ -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,
),
],
),
],
),
+168 -3
View File
@@ -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,
+134
View File
@@ -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;
}