Compare commits

...

13 Commits

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

Also improves APK update checker to detect device ABIs for correct
variant selection and fixes Deezer artist name parsing edge case.
2026-03-27 19:28:42 +07:00
zarzet 18d3612674 fix(ui): skip popular section in artist skeleton for providers without top tracks 2026-03-27 13:27:07 +07:00
zarzet f7c0e417d7 refactor: unexport extension store types and methods (package-internal only) 2026-03-27 04:50:40 +07:00
zarzet 3fd13e9930 fix(ui): match GridSkeleton cover height with actual album cards 2026-03-27 04:39:29 +07:00
zarzet 0b20cb895e fix: conditionally show cover header in artist skeleton and add showCoverHeader param to ArtistScreenSkeleton 2026-03-27 04:35:22 +07:00
zarzet 8979210804 fix: null check crash in SpectrogramView when spectrum loaded from PNG cache 2026-03-27 04:24:19 +07:00
zarzet e9b24712c5 feat: cache spectrogram as PNG for instant loading on subsequent views 2026-03-27 04:21:11 +07:00
zarzet 3d6e5615fa Revert "docs: move badges below screenshots in README"
This reverts commit 198ed5ce6f.
2026-03-27 03:56:57 +07:00
zarzet fc7220b572 docs: update VirusTotal hash for v4.1.0 2026-03-27 03:54:31 +07:00
zarzet 198ed5ce6f docs: move badges below screenshots in README 2026-03-27 03:53:31 +07:00
zarzet b48462a945 fix: add artist_album_flat case to SAF relative output dir builder 2026-03-26 18:31:00 +07:00
51 changed files with 699 additions and 336 deletions
+1 -1
View File
@@ -17,7 +17,7 @@
<div align="center">
[![GitHub Release](https://img.shields.io/github/v/release/zarzet/SpotiFLAC-Mobile?style=for-the-badge&logo=github)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/cc11355330c76f97548b8d26452b91746db9d9c1edbcfc4c18250133484d1487)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/31d1bf3c3b2015c13e83c4f909a7c6093a9423e3e702f0c582a3e0035c849424)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
[![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac)
+20
View File
@@ -9,6 +9,19 @@
# packages, and plugins designed to encourage good coding practices.
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:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
@@ -23,6 +36,13 @@ linter:
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` 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
# https://dart.dev/guides/language/analysis-options
@@ -304,6 +304,7 @@ class MainActivity: FlutterFragmentActivity() {
".mp3" -> "audio/mpeg"
".opus" -> "audio/ogg"
".flac" -> "audio/flac"
".lrc" -> "application/octet-stream"
else -> "application/octet-stream"
}
}
+18 -18
View File
@@ -3111,17 +3111,17 @@ func GetPostProcessingProvidersJSON() (string, error) {
}
func InitExtensionStoreJSON(cacheDir string) error {
InitExtensionStore(cacheDir)
initExtensionStore(cacheDir)
return nil
}
func SetStoreRegistryURLJSON(registryURL string) error {
store := GetExtensionStore()
store := getExtensionStore()
if store == nil {
return fmt.Errorf("extension store not initialized")
}
resolved, err := ResolveRegistryURL(registryURL)
resolved, err := resolveRegistryURL(registryURL)
if err != nil {
return err
}
@@ -3130,32 +3130,32 @@ func SetStoreRegistryURLJSON(registryURL string) error {
return err
}
store.SetRegistryURL(resolved)
store.setRegistryURL(resolved)
return nil
}
func ClearStoreRegistryURLJSON() error {
store := GetExtensionStore()
store := getExtensionStore()
if store == nil {
return fmt.Errorf("extension store not initialized")
}
store.SetRegistryURL("")
store.ClearCache()
store.setRegistryURL("")
store.clearCache()
return nil
}
func GetStoreRegistryURLJSON() (string, error) {
store := GetExtensionStore()
store := getExtensionStore()
if store == nil {
return "", fmt.Errorf("extension store not initialized")
}
return store.GetRegistryURL(), nil
return store.getRegistryURL(), nil
}
func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
store := GetExtensionStore()
store := getExtensionStore()
if store == nil {
return "", fmt.Errorf("extension store not initialized")
}
@@ -3174,12 +3174,12 @@ func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
}
func SearchStoreExtensionsJSON(query, category string) (string, error) {
store := GetExtensionStore()
store := getExtensionStore()
if store == nil {
return "", fmt.Errorf("extension store not initialized")
}
extensions, err := store.SearchExtensions(query, category)
extensions, err := store.searchExtensions(query, category)
if err != nil {
return "", err
}
@@ -3193,12 +3193,12 @@ func SearchStoreExtensionsJSON(query, category string) (string, error) {
}
func GetStoreCategoriesJSON() (string, error) {
store := GetExtensionStore()
store := getExtensionStore()
if store == nil {
return "", fmt.Errorf("extension store not initialized")
}
categories := store.GetCategories()
categories := store.getCategories()
jsonBytes, err := json.Marshal(categories)
if err != nil {
return "", err
@@ -3217,7 +3217,7 @@ func buildStoreExtensionDestPath(destDir, extensionID string) (string, error) {
}
func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
store := GetExtensionStore()
store := getExtensionStore()
if store == nil {
return "", fmt.Errorf("extension store not initialized")
}
@@ -3226,7 +3226,7 @@ func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
if err != nil {
return "", err
}
err = store.DownloadExtension(extensionID, destPath)
err = store.downloadExtension(extensionID, destPath)
if err != nil {
return "", err
}
@@ -3235,12 +3235,12 @@ func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
}
func ClearStoreCacheJSON() error {
store := GetExtensionStore()
store := getExtensionStore()
if store == nil {
return fmt.Errorf("extension store not initialized")
}
store.ClearCache()
store.clearCache()
return nil
}
+7 -3
View File
@@ -510,7 +510,7 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
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() {
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
}
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 {
if len(call.Arguments) > 0 {
@@ -1128,7 +1132,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
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 != "" {
normalized := float64(percent) / 100.0
if normalized < 0 {
@@ -1356,7 +1360,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
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 != "" {
normalized := float64(percent) / 100.0
if normalized < 0 {
+21
View File
@@ -90,6 +90,9 @@ type ExtensionRuntime struct {
dataDir string
vm *goja.Runtime
activeDownloadMu sync.RWMutex
activeDownloadItemID string
storageMu sync.RWMutex
storageCache map[string]interface{}
storageLoaded bool
@@ -139,6 +142,24 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
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 {
// Extension sandbox enforces HTTPS-only domains. Do not apply global
// allow_http scheme downgrade here, because some extension APIs (e.g.
+16 -1
View File
@@ -205,13 +205,22 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
defer out.Close()
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
buf := make([]byte, 32*1024)
for {
nr, er := resp.Body.Read(buf)
if nr > 0 {
nw, ew := out.Write(buf[0:nr])
nw, ew := progressWriter.Write(buf[0:nr])
if nw < 0 || nr < nw {
nw = 0
if ew == nil {
@@ -220,6 +229,12 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}
written += int64(nw)
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{}{
"success": false,
"error": fmt.Sprintf("failed to write file: %v", ew),
+48 -47
View File
@@ -21,7 +21,7 @@ const (
CategoryIntegration = "integration"
)
type StoreExtension struct {
type storeExtension struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"`
@@ -41,7 +41,7 @@ type StoreExtension struct {
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
}
func (e *StoreExtension) getDisplayName() string {
func (e *storeExtension) getDisplayName() string {
if e.DisplayName != "" {
return e.DisplayName
}
@@ -51,34 +51,34 @@ func (e *StoreExtension) getDisplayName() string {
return e.Name
}
func (e *StoreExtension) getDownloadURL() string {
func (e *storeExtension) getDownloadURL() string {
if e.DownloadURL != "" {
return e.DownloadURL
}
return e.DownloadURLAlt
}
func (e *StoreExtension) getIconURL() string {
func (e *storeExtension) getIconURL() string {
if e.IconURL != "" {
return e.IconURL
}
return e.IconURLAlt
}
func (e *StoreExtension) getMinAppVersion() string {
func (e *storeExtension) getMinAppVersion() string {
if e.MinAppVersion != "" {
return e.MinAppVersion
}
return e.MinAppVersionAlt
}
type StoreRegistry struct {
type storeRegistry struct {
Version int `json:"version"`
UpdatedAt string `json:"updated_at"`
Extensions []StoreExtension `json:"extensions"`
Extensions []storeExtension `json:"extensions"`
}
type StoreExtensionResponse struct {
type storeExtensionResponse struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
@@ -97,8 +97,8 @@ type StoreExtensionResponse struct {
HasUpdate bool `json:"has_update"`
}
func (e *StoreExtension) ToResponse() *StoreExtensionResponse {
return &StoreExtensionResponse{
func (e *storeExtension) toResponse() storeExtensionResponse {
resp := storeExtensionResponse{
ID: e.ID,
Name: e.Name,
DisplayName: e.getDisplayName(),
@@ -108,25 +108,30 @@ func (e *StoreExtension) ToResponse() *StoreExtensionResponse {
DownloadURL: e.getDownloadURL(),
IconURL: e.getIconURL(),
Category: e.Category,
Tags: e.Tags,
Downloads: e.Downloads,
UpdatedAt: e.UpdatedAt,
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
cacheDir string
cache *StoreRegistry
cache *storeRegistry
cacheMu sync.RWMutex
cacheTime time.Time
cacheTTL time.Duration
}
var (
extensionStore *ExtensionStore
extensionStoreMu sync.Mutex
globalExtensionStore *extensionStore
extensionStoreMu sync.Mutex
)
const (
@@ -134,24 +139,24 @@ const (
cacheFileName = "store_cache.json"
)
func InitExtensionStore(cacheDir string) *ExtensionStore {
func initExtensionStore(cacheDir string) *extensionStore {
extensionStoreMu.Lock()
defer extensionStoreMu.Unlock()
if extensionStore == nil {
extensionStore = &ExtensionStore{
if globalExtensionStore == nil {
globalExtensionStore = &extensionStore{
registryURL: "", // No default - user must provide a registry URL
cacheDir: cacheDir,
cacheTTL: cacheTTL,
}
extensionStore.loadDiskCache()
globalExtensionStore.loadDiskCache()
}
return extensionStore
return globalExtensionStore
}
// 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.
func (s *ExtensionStore) SetRegistryURL(registryURL string) {
func (s *extensionStore) setRegistryURL(registryURL string) {
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
@@ -173,19 +178,19 @@ func (s *ExtensionStore) SetRegistryURL(registryURL string) {
}
// GetRegistryURL returns the currently configured registry URL.
func (s *ExtensionStore) GetRegistryURL() string {
func (s *extensionStore) getRegistryURL() string {
s.cacheMu.RLock()
defer s.cacheMu.RUnlock()
return s.registryURL
}
func GetExtensionStore() *ExtensionStore {
func getExtensionStore() *extensionStore {
extensionStoreMu.Lock()
defer extensionStoreMu.Unlock()
return extensionStore
return globalExtensionStore
}
func (s *ExtensionStore) loadDiskCache() {
func (s *extensionStore) loadDiskCache() {
if s.cacheDir == "" {
return
}
@@ -197,7 +202,7 @@ func (s *ExtensionStore) loadDiskCache() {
}
var cacheData struct {
Registry StoreRegistry `json:"registry"`
Registry storeRegistry `json:"registry"`
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))
}
func (s *ExtensionStore) saveDiskCache() {
func (s *extensionStore) saveDiskCache() {
if s.cacheDir == "" || s.cache == nil {
return
}
cacheData := struct {
Registry StoreRegistry `json:"registry"`
Registry storeRegistry `json:"registry"`
CacheTime int64 `json:"cache_time"`
}{
Registry: *s.cache,
@@ -232,7 +237,7 @@ func (s *ExtensionStore) saveDiskCache() {
os.WriteFile(cachePath, data, 0644)
}
func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) {
func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error) {
s.cacheMu.Lock()
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)
}
var registry StoreRegistry
var registry storeRegistry
if err := json.Unmarshal(body, &registry); err != nil {
return nil, fmt.Errorf("failed to parse registry: %w", err)
}
@@ -288,8 +293,8 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
return &registry, nil
}
func (s *ExtensionStore) getExtensionsWithStatus(forceRefresh bool) ([]*StoreExtensionResponse, error) {
registry, err := s.FetchRegistry(forceRefresh)
func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExtensionResponse, error) {
registry, err := s.fetchRegistry(forceRefresh)
if err != nil {
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))
result := make([]*StoreExtensionResponse, 0, len(registry.Extensions))
result := make([]storeExtensionResponse, 0, len(registry.Extensions))
for i := range registry.Extensions {
ext := &registry.Extensions[i]
resp := ext.ToResponse()
resp := ext.toResponse()
if installedVersion, ok := installed[ext.ID]; ok {
resp.IsInstalled = true
resp.InstalledVersion = installedVersion
@@ -322,17 +327,13 @@ func (s *ExtensionStore) getExtensionsWithStatus(forceRefresh bool) ([]*StoreExt
return result, nil
}
func (s *ExtensionStore) GetExtensionsWithStatus() ([]*StoreExtensionResponse, error) {
return s.getExtensionsWithStatus(false)
}
func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
registry, err := s.FetchRegistry(false)
func (s *extensionStore) downloadExtension(extensionID string, destPath string) error {
registry, err := s.fetchRegistry(false)
if err != nil {
return err
}
var ext *StoreExtension
var ext *storeExtension
for _, e := range registry.Extensions {
if e.ID == extensionID {
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
// 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)
func ResolveRegistryURL(input string) (string, error) {
func resolveRegistryURL(input string) (string, error) {
input = strings.TrimSpace(input)
if input == "" {
return "", fmt.Errorf("registry URL is empty")
@@ -465,7 +466,7 @@ func requireHTTPSURL(rawURL string, context string) error {
return nil
}
func (s *ExtensionStore) GetCategories() []string {
func (s *extensionStore) getCategories() []string {
return []string{
CategoryMetadata,
CategoryDownload,
@@ -475,8 +476,8 @@ func (s *ExtensionStore) GetCategories() []string {
}
}
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]*StoreExtensionResponse, error) {
extensions, err := s.GetExtensionsWithStatus()
func (s *extensionStore) searchExtensions(query string, category string) ([]storeExtensionResponse, error) {
extensions, err := s.getExtensionsWithStatus(false)
if err != nil {
return nil, err
}
@@ -485,7 +486,7 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]*Sto
return extensions, nil
}
result := make([]*StoreExtensionResponse, 0, len(extensions))
result := make([]storeExtensionResponse, 0, len(extensions))
queryLower := toLower(query)
for _, ext := range extensions {
@@ -517,7 +518,7 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]*Sto
return result, nil
}
func (s *ExtensionStore) ClearCache() {
func (s *extensionStore) clearCache() {
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
+2 -2
View File
@@ -3,8 +3,8 @@ import 'package:flutter/foundation.dart';
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '4.1.0';
static const String buildNumber = '117';
static const String version = '4.1.1';
static const String buildNumber = '118';
static const String fullVersion = '$version+$buildNumber';
/// Shows "Internal" in debug builds, actual version in release.
+7 -9
View File
@@ -12,13 +12,7 @@ enum DownloadStatus {
skipped,
}
enum DownloadErrorType {
unknown,
notFound,
rateLimit,
network,
permission,
}
enum DownloadErrorType { unknown, notFound, rateLimit, network, permission }
@JsonSerializable()
class DownloadItem {
@@ -28,7 +22,8 @@ class DownloadItem {
final DownloadStatus status;
final double progress;
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? error;
final DownloadErrorType? errorType;
@@ -44,6 +39,7 @@ class DownloadItem {
this.progress = 0.0,
this.speedMBps = 0.0,
this.bytesReceived = 0,
this.bytesTotal = 0,
this.filePath,
this.error,
this.errorType,
@@ -60,6 +56,7 @@ class DownloadItem {
double? progress,
double? speedMBps,
int? bytesReceived,
int? bytesTotal,
String? filePath,
String? error,
DownloadErrorType? errorType,
@@ -75,6 +72,7 @@ class DownloadItem {
progress: progress ?? this.progress,
speedMBps: speedMBps ?? this.speedMBps,
bytesReceived: bytesReceived ?? this.bytesReceived,
bytesTotal: bytesTotal ?? this.bytesTotal,
filePath: filePath ?? this.filePath,
error: error ?? this.error,
errorType: errorType ?? this.errorType,
@@ -86,7 +84,7 @@ class DownloadItem {
String get errorMessage {
if (error == null) return '';
switch (errorType) {
case DownloadErrorType.notFound:
return 'Song not found on any service';
+2
View File
@@ -16,6 +16,7 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0,
bytesReceived: (json['bytesReceived'] as num?)?.toInt() ?? 0,
bytesTotal: (json['bytesTotal'] as num?)?.toInt() ?? 0,
filePath: json['filePath'] as String?,
error: json['error'] as String?,
errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
@@ -33,6 +34,7 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
'progress': instance.progress,
'speedMBps': instance.speedMBps,
'bytesReceived': instance.bytesReceived,
'bytesTotal': instance.bytesTotal,
'filePath': instance.filePath,
'error': instance.error,
'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
+23 -9
View File
@@ -510,7 +510,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
}
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}');
}
_db.upsert(mergedItem.toJson()).catchError((e) {
_db.upsert(mergedItem.toJson()).catchError((Object e) {
_historyLog.e('Failed to save to database: $e');
});
}
@@ -771,7 +771,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
state = state.copyWith(
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');
});
}
@@ -780,7 +780,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
state = state.copyWith(
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.d('Removed item with spotifyId: $spotifyId');
@@ -1081,7 +1081,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
void clearHistory() {
state = DownloadHistoryState();
_db.clearAll().catchError((e) {
_db.clearAll().catchError((Object e) {
_historyLog.e('Failed to clear database: $e');
});
}
@@ -1166,12 +1166,14 @@ class _ProgressUpdate {
final double progress;
final double? speedMBps;
final int? bytesReceived;
final int? bytesTotal;
const _ProgressUpdate({
required this.status,
required this.progress,
this.speedMBps,
this.bytesReceived,
this.bytesTotal,
});
}
@@ -1587,6 +1589,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
progress: normalizedProgress,
speedMBps: normalizedSpeed,
bytesReceived: normalizedBytes,
bytesTotal: bytesTotal,
);
if (LogBuffer.loggingEnabled) {
@@ -1624,11 +1627,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
progress: update.progress,
speedMBps: update.speedMBps ?? current.speedMBps,
bytesReceived: update.bytesReceived ?? current.bytesReceived,
bytesTotal: update.bytesTotal ?? current.bytesTotal,
);
if (current.status != next.status ||
current.progress != next.progress ||
current.speedMBps != next.speedMBps ||
current.bytesReceived != next.bytesReceived) {
current.bytesReceived != next.bytesReceived ||
current.bytesTotal != next.bytesTotal) {
if (!changed) {
updatedItems = List<DownloadItem>.from(updatedItems);
changed = true;
@@ -2080,6 +2085,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
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) {
return _joinRelativePath(playlistPrefix, 'Singles');
}
@@ -2400,6 +2413,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
progress: 0,
speedMBps: 0,
bytesReceived: 0,
bytesTotal: 0,
);
})
.toList(growable: false);
@@ -3594,7 +3608,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.d('Queue is paused, waiting for active downloads...');
await Future.any([
Future.wait(activeDownloads.values),
Future.delayed(_queueSchedulingInterval),
Future<void>.delayed(_queueSchedulingInterval),
]);
continue;
}
@@ -3639,10 +3653,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (activeDownloads.isNotEmpty) {
await Future.any([
Future.any(activeDownloads.values),
Future.delayed(_queueSchedulingInterval),
Future<void>.delayed(_queueSchedulingInterval),
]);
} else {
await Future.delayed(_queueSchedulingInterval);
await Future<void>.delayed(_queueSchedulingInterval);
}
}
@@ -118,7 +118,7 @@ class UserPlaylistCollection {
createdAt: createdAt,
updatedAt: updatedAt,
tracks: tracksRaw
.whereType<Map>()
.whereType<Map<Object?, Object?>>()
.map(
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
)
@@ -233,19 +233,19 @@ class LibraryCollectionsState {
return LibraryCollectionsState(
wishlist: wishlistRaw
.whereType<Map>()
.whereType<Map<Object?, Object?>>()
.map(
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
)
.toList(growable: false),
loved: lovedRaw
.whereType<Map>()
.whereType<Map<Object?, Object?>>()
.map(
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
)
.toList(growable: false),
playlists: playlistsRaw
.whereType<Map>()
.whereType<Map<Object?, Object?>>()
.map(
(e) =>
UserPlaylistCollection.fromJson(Map<String, dynamic>.from(e)),
+8 -4
View File
@@ -34,7 +34,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
final prefs = await _prefs;
final json = prefs.getString(_settingsKey);
if (json != null) {
state = AppSettings.fromJson(jsonDecode(json));
state = AppSettings.fromJson(
Map<String, dynamic>.from(jsonDecode(json) as Map),
);
await _runMigrations(prefs);
await _normalizeIosDownloadDirectoryIfNeeded();
@@ -52,7 +54,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
void _syncLyricsSettingsToBackend() {
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');
});
@@ -61,7 +65,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
'include_romanization_netease': state.lyricsIncludeRomanizationNetease,
'multi_person_word_by_word': state.lyricsMultiPersonWordByWord,
'musixmatch_language': state.musixmatchLanguage,
}).catchError((e) {
}).catchError((Object e) {
_log.w('Failed to sync lyrics fetch options to backend: $e');
});
}
@@ -73,7 +77,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
PlatformBridge.setNetworkCompatibilityOptions(
allowHttp: compatibilityMode,
insecureTls: compatibilityMode,
).catchError((e) {
).catchError((Object e) {
_log.w('Failed to sync network compatibility options to backend: $e');
});
}
+7 -7
View File
@@ -234,7 +234,7 @@ class TrackNotifier extends Notifier<TrackState> {
}
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(
tracks: tracks,
isLoading: false,
albumId: result['album']?['id'] as String?,
albumId:
(result['album'] as Map<String, dynamic>?)?['id'] as String?,
albumName:
result['name'] as String? ??
result['album']?['name'] as String?,
(result['album'] as Map<String, dynamic>?)?['name']
as String?,
playlistName: type == 'playlist'
? result['name'] as String?
: null,
@@ -825,8 +827,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: true,
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
selectedSearchFilter:
state.selectedSearchFilter,
selectedSearchFilter: state.selectedSearchFilter,
);
try {
@@ -921,8 +922,7 @@ class TrackNotifier extends Notifier<TrackState> {
final tracks = List<Track>.from(state.tracks);
tracks[index] = updatedTrack;
state = state.copyWith(tracks: tracks);
} catch (_) {
}
} catch (_) {}
}
void clear() {
+22 -8
View File
@@ -492,7 +492,19 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
hasDiscography: hasDiscography,
),
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)
SliverToBoxAdapter(
child: Padding(
@@ -793,7 +805,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
);
final singleTracks = singles.fold<int>(0, (sum, a) => sum + a.totalTracks);
showModalBottomSheet(
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -927,7 +939,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
return;
}
showDialog(
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => _FetchingProgressDialog(
@@ -1109,6 +1121,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
Track _parseTrackFromDeezer(Map<String, dynamic> data, ArtistAlbum album) {
int durationMs = 0;
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) {
durationMs = durationValue * 1000; // Deezer returns seconds
} else if (durationValue is double) {
@@ -1118,9 +1134,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
return Track(
id: 'deezer:${data['id']}',
name: (data['title'] ?? data['name'] ?? '').toString(),
artistName:
(data['artist']?['name'] ?? data['artist'] ?? widget.artistName)
.toString(),
artistName: artistName,
albumName: album.name,
albumArtist: widget.artistName,
artistId: widget.artistId,
@@ -1926,7 +1940,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
if (album.providerId != null && album.providerId!.isNotEmpty) {
Navigator.push(
context,
MaterialPageRoute(
MaterialPageRoute<void>(
builder: (context) => ExtensionAlbumScreen(
extensionId: album.providerId!,
albumId: album.id,
@@ -1938,7 +1952,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
} else {
Navigator.push(
context,
MaterialPageRoute(
MaterialPageRoute<void>(
builder: (context) => AlbumScreen(
albumId: album.id,
albumName: album.name,
+2 -2
View File
@@ -309,7 +309,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
if (!mounted) return;
final result = await navigator.push(
slidePageRoute(page: TrackMetadataScreen(item: item)),
slidePageRoute<bool>(page: TrackMetadataScreen(item: item)),
);
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
item.filePath,
@@ -932,7 +932,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
? '320k'
: (selectedFormat == 'Opus' ? '128k' : '320k');
showModalBottomSheet(
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
shape: const RoundedRectangleBorder(
+24 -24
View File
@@ -556,7 +556,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
pending != query &&
mounted &&
_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) {
_executeLiveSearch(pending);
}
@@ -681,7 +681,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
final extensionId = trackState.searchExtensionId;
Navigator.push(
context,
MaterialPageRoute(
MaterialPageRoute<void>(
builder: (context) => AlbumScreen(
albumId: trackState.albumId!,
albumName: trackState.albumName!,
@@ -708,7 +708,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
Navigator.push(
context,
MaterialPageRoute(
MaterialPageRoute<void>(
builder: (context) => PlaylistScreen(
playlistName: trackState.playlistName!,
coverUrl: trackState.coverUrl,
@@ -729,7 +729,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
final extensionId = trackState.searchExtensionId;
Navigator.push(
context,
MaterialPageRoute(
MaterialPageRoute<void>(
builder: (context) => ArtistScreen(
artistId: trackState.artistId!,
artistName: trackState.artistName!,
@@ -798,7 +798,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (progressDialogInitialized || !mounted) return;
progressDialogInitialized = true;
progressDialogVisible = true;
showDialog(
showDialog<void>(
context: this.context,
useRootNavigator: false,
barrierDismissible: false,
@@ -1691,7 +1691,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
case 'album':
Navigator.push(
context,
MaterialPageRoute(
MaterialPageRoute<void>(
builder: (context) => ExtensionAlbumScreen(
extensionId: extensionId,
albumId: item.id,
@@ -1704,7 +1704,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
case 'playlist':
Navigator.push(
context,
MaterialPageRoute(
MaterialPageRoute<void>(
builder: (context) => ExtensionPlaylistScreen(
extensionId: extensionId,
playlistId: item.id,
@@ -1717,7 +1717,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
case 'artist':
Navigator.push(
context,
MaterialPageRoute(
MaterialPageRoute<void>(
builder: (context) => ExtensionArtistScreen(
extensionId: extensionId,
artistId: item.id,
@@ -1738,7 +1738,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
void _showTrackBottomSheet(ExploreItem item) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surface,
@@ -1884,7 +1884,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (item.albumId != null && item.albumId!.isNotEmpty) {
Navigator.push(
context,
MaterialPageRoute(
MaterialPageRoute<void>(
builder: (context) => ExtensionAlbumScreen(
extensionId: item.providerId ?? 'spotify-web',
albumId: item.albumId!,
@@ -2148,7 +2148,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
item.providerId != 'qobuz') {
Navigator.push(
context,
MaterialPageRoute(
MaterialPageRoute<void>(
builder: (context) => ExtensionArtistScreen(
extensionId: item.providerId!,
artistId: item.id,
@@ -2160,7 +2160,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
} else {
Navigator.push(
context,
MaterialPageRoute(
MaterialPageRoute<void>(
builder: (context) => ArtistScreen(
artistId: item.id,
artistName: item.name,
@@ -2174,7 +2174,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (item.providerId == 'download') {
Navigator.push(
context,
MaterialPageRoute(
MaterialPageRoute<void>(
builder: (context) => DownloadedAlbumScreen(
albumName: item.name,
artistName: item.subtitle ?? '',
@@ -2190,7 +2190,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
item.providerId != 'qobuz') {
Navigator.push(
context,
MaterialPageRoute(
MaterialPageRoute<void>(
builder: (context) => ExtensionAlbumScreen(
extensionId: item.providerId!,
albumId: item.id,
@@ -2202,7 +2202,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
} else {
Navigator.push(
context,
MaterialPageRoute(
MaterialPageRoute<void>(
builder: (context) => AlbumScreen(
albumId: item.id,
albumName: item.name,
@@ -2240,7 +2240,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
item.providerId != 'qobuz') {
Navigator.push(
context,
MaterialPageRoute(
MaterialPageRoute<void>(
builder: (context) => ExtensionPlaylistScreen(
extensionId: item.providerId!,
playlistId: item.id,
@@ -2252,7 +2252,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
} else {
Navigator.push(
context,
MaterialPageRoute(
MaterialPageRoute<void>(
builder: (context) => PlaylistScreen(
playlistName: item.name,
coverUrl: item.imageUrl,
@@ -2275,7 +2275,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
if (!mounted) return;
final result = await navigator.push(
slidePageRoute(page: TrackMetadataScreen(item: item)),
slidePageRoute<bool>(page: TrackMetadataScreen(item: item)),
);
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
item.filePath,
@@ -2910,7 +2910,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
Navigator.push(
context,
MaterialPageRoute(
MaterialPageRoute<void>(
builder: (context) => ArtistScreen(
artistId: artistId,
artistName: artistName,
@@ -2936,7 +2936,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
// Keep the full ID with prefix (e.g., "deezer:123") for AlbumScreen to detect source
Navigator.push(
context,
MaterialPageRoute(
MaterialPageRoute<void>(
builder: (context) => AlbumScreen(
albumId: album.id,
albumName: album.name,
@@ -2963,7 +2963,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
// Keep the full ID with prefix (e.g., "deezer:123") for PlaylistScreen to detect source
Navigator.push(
context,
MaterialPageRoute(
MaterialPageRoute<void>(
builder: (context) => PlaylistScreen(
playlistName: playlist.name,
coverUrl: playlist.imageUrl,
@@ -2999,7 +2999,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
Navigator.push(
context,
MaterialPageRoute(
MaterialPageRoute<void>(
builder: (context) => ExtensionAlbumScreen(
extensionId: extensionId,
albumId: albumItem.id,
@@ -3035,7 +3035,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
Navigator.push(
context,
MaterialPageRoute(
MaterialPageRoute<void>(
builder: (context) => ExtensionPlaylistScreen(
extensionId: extensionId,
playlistId: playlistItem.id,
@@ -3070,7 +3070,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
Navigator.push(
context,
MaterialPageRoute(
MaterialPageRoute<void>(
builder: (context) => ExtensionArtistScreen(
extensionId: extensionId,
artistId: artistItem.id,
+2 -2
View File
@@ -119,7 +119,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
MaterialPageRoute<void>(
builder: (_) => LibraryTracksFolderScreen(
mode: LibraryTracksFolderMode.playlist,
playlistId: playlist.id,
@@ -149,7 +149,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -847,7 +847,7 @@ class _LibraryTracksFolderScreenState
void _confirmDownloadAll(List<Track> tracks) {
if (tracks.isEmpty) return;
showDialog(
showDialog<void>(
context: context,
builder: (dialogContext) {
final colorScheme = Theme.of(dialogContext).colorScheme;
@@ -980,7 +980,7 @@ class _LibraryTracksFolderScreenState
void _showCoverOptionsSheet(BuildContext context, bool hasCustomCover) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -1338,7 +1338,7 @@ class _CollectionTrackTile extends ConsumerWidget {
final showAddToPlaylist =
mode != LibraryTracksFolderMode.wishlist || isDownloaded;
showModalBottomSheet(
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -1523,9 +1523,9 @@ class _CollectionTrackTile extends ConsumerWidget {
);
if (historyItem != null) {
await Navigator.of(
context,
).push(slidePageRoute(page: TrackMetadataScreen(item: historyItem)));
await Navigator.of(context).push(
slidePageRoute<void>(page: TrackMetadataScreen(item: historyItem)),
);
return;
}
@@ -1540,9 +1540,9 @@ class _CollectionTrackTile extends ConsumerWidget {
localItem ??= localState.findByTrackAndArtist(track.name, track.artistName);
if (localItem != null) {
await Navigator.of(
context,
).push(slidePageRoute(page: TrackMetadataScreen(localItem: localItem)));
await Navigator.of(context).push(
slidePageRoute<void>(page: TrackMetadataScreen(localItem: localItem)),
);
return;
}
+1 -1
View File
@@ -1180,7 +1180,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
? '320k'
: (selectedFormat == 'Opus' ? '128k' : '320k');
showModalBottomSheet(
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
shape: const RoundedRectangleBorder(
+3 -3
View File
@@ -79,7 +79,7 @@ class _MainShellState extends ConsumerState<MainShell>
_log.d('Received shared URL from stream: $url');
_handleSharedUrl(url);
},
onError: (error) {
onError: (Object error) {
_log.e('Share stream error: $error');
},
cancelOnError: false,
@@ -92,7 +92,7 @@ class _MainShellState extends ConsumerState<MainShell>
if (!extState.isInitialized) {
_log.d('Waiting for extensions to initialize before handling URL...');
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 (ref.read(extensionProvider).isInitialized) {
_log.d('Extensions initialized, proceeding with URL handling');
@@ -177,7 +177,7 @@ class _MainShellState extends ConsumerState<MainShell>
final colorScheme = Theme.of(context).colorScheme;
showDialog(
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
+1 -1
View File
@@ -578,7 +578,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
void _confirmDownloadAll(BuildContext context) {
if (_tracks.isEmpty) return;
showDialog(
showDialog<void>(
context: context,
builder: (dialogContext) {
final colorScheme = Theme.of(dialogContext).colorScheme;
+29 -17
View File
@@ -656,8 +656,8 @@ final _queueFilteredAlbumsProvider =
});
Map<String, List<String>> _filterHistoryInIsolate(Map<String, Object> payload) {
final entries = (payload['entries'] as List).cast<List>();
final albumCounts = (payload['albumCounts'] as Map).cast<String, int>();
final entries = (payload['entries'] as List).cast<List<Object?>>();
final albumCounts = Map<String, int>.from(payload['albumCounts'] as Map);
final query = (payload['query'] as String?) ?? '';
final hasQuery = query.isNotEmpty;
@@ -1968,7 +1968,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
String? tempFormat = _filterFormat;
String tempSortMode = _sortMode;
showModalBottomSheet(
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
@@ -2280,7 +2280,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final beforeModTime = await _readFileModTimeMillis(historyItem.filePath);
if (!mounted) return;
final result = await navigator.push(
slidePageRoute(page: TrackMetadataScreen(item: historyItem)),
slidePageRoute<bool>(page: TrackMetadataScreen(item: historyItem)),
);
_searchFocusNode.unfocus();
if (result == true) {
@@ -2306,7 +2306,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final beforeModTime = await _readFileModTimeMillis(item.filePath);
if (!mounted) return;
final result = await navigator.push(
slidePageRoute(page: TrackMetadataScreen(item: item)),
slidePageRoute<bool>(page: TrackMetadataScreen(item: item)),
);
_searchFocusNode.unfocus();
if (result == true) {
@@ -2327,7 +2327,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
_searchFocusNode.unfocus();
Navigator.push(
context,
slidePageRoute(page: TrackMetadataScreen(localItem: item)),
slidePageRoute<void>(page: TrackMetadataScreen(localItem: item)),
).then((_) => _searchFocusNode.unfocus());
}
@@ -4711,7 +4711,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
_hideSelectionOverlay();
_hidePlaylistSelectionOverlay();
await showModalBottomSheet(
await showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
shape: const RoundedRectangleBorder(
@@ -5537,17 +5537,29 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
const SizedBox(width: 8),
Text(
// When progress is 0 (unknown size, e.g. YouTube tunnel mode),
// show bytes downloaded instead of percentage
item.progress > 0
? (item.speedMBps > 0
? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s'
: '${(item.progress * 100).toStringAsFixed(0)}%')
item.bytesTotal > 0 && item.bytesReceived > 0
? (() {
final receivedMB =
item.bytesReceived / (1024 * 1024);
final totalMB =
item.bytesTotal / (1024 * 1024);
final progressLabel = item.progress > 0
? '${(item.progress * 100).toStringAsFixed(0)}% • '
: '';
final speedLabel = item.speedMBps > 0
? '${item.speedMBps.toStringAsFixed(1)} MB/s'
: '';
return '$progressLabel${receivedMB.toStringAsFixed(1)} / ${totalMB.toStringAsFixed(1)} MB$speedLabel';
})()
: (item.bytesReceived > 0
? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB${item.speedMBps.toStringAsFixed(1)} MB/s'
: (item.speedMBps > 0
? 'Downloading • ${item.speedMBps.toStringAsFixed(1)} MB/s'
: 'Starting...')),
? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB${item.speedMBps > 0 ? '${item.speedMBps.toStringAsFixed(1)} MB/s' : ''}'
: (item.progress > 0
? (item.speedMBps > 0
? '${(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
?.copyWith(
color: colorScheme.primary,
@@ -770,7 +770,7 @@ class _LanguageSelector extends StatelessWidget {
void _showLanguagePicker(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surface,
@@ -510,7 +510,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
MaterialPageRoute<void>(
builder: (_) => const LyricsProviderPriorityPage(),
),
),
@@ -853,7 +853,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
WidgetRef ref,
String current,
) {
showModalBottomSheet(
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
builder: (context) => SafeArea(
@@ -1002,7 +1002,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
);
}
showModalBottomSheet(
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
@@ -1220,7 +1220,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
final settings = ref.read(settingsProvider);
final isSafMode =
settings.storageMode == 'saf' && settings.downloadTreeUri.isNotEmpty;
showModalBottomSheet(
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -1298,7 +1298,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
void _showIOSDirectoryOptions(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -1493,7 +1493,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
String current,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -1598,7 +1598,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
String current,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -1685,7 +1685,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
final colorScheme = Theme.of(context).colorScheme;
final controller = TextEditingController(text: currentLanguage);
showModalBottomSheet(
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -1771,7 +1771,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
String current,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -1843,7 +1843,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
) {
final colorScheme = Theme.of(context).colorScheme;
final normalizedCurrent = current.trim().toUpperCase();
showModalBottomSheet(
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -1911,7 +1911,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
String current,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -832,9 +832,9 @@ class _SettingItemState extends State<_SettingItem> {
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarError(e.toString()))));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarError(e.toString()))),
);
}
} finally {
if (mounted) {
@@ -849,7 +849,7 @@ class _SettingItemState extends State<_SettingItem> {
);
final colorScheme = Theme.of(context).colorScheme;
showDialog(
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: Text(widget.setting.label),
+10 -7
View File
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:path_provider/path_provider.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/explore_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,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
MaterialPageRoute<void>(
builder: (_) =>
ExtensionDetailPage(extensionId: ext.id),
),
@@ -469,7 +470,9 @@ class _DownloadPriorityItem extends ConsumerWidget {
onTap: hasDownloadExtensions
? () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const ProviderPriorityPage()),
MaterialPageRoute<void>(
builder: (_) => const ProviderPriorityPage(),
),
)
: null,
child: Padding(
@@ -534,7 +537,7 @@ class _MetadataPriorityItem extends ConsumerWidget {
onTap: hasMetadataExtensions
? () => Navigator.push(
context,
MaterialPageRoute(
MaterialPageRoute<void>(
builder: (_) => const MetadataProviderPriorityPage(),
),
)
@@ -678,12 +681,12 @@ class _SearchProviderSelector extends ConsumerWidget {
void _showSearchProviderPicker(
BuildContext context,
WidgetRef ref,
dynamic settings,
AppSettings settings,
List<Extension> searchProviders,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -859,12 +862,12 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
void _showHomeFeedProviderPicker(
BuildContext context,
WidgetRef ref,
dynamic settings,
AppSettings settings,
List<Extension> homeFeedProviders,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -255,7 +255,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
void _showAutoScanPicker(BuildContext context, String current) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
+1 -1
View File
@@ -92,7 +92,7 @@ class _LogScreenState extends State<LogScreen> {
}
void _clearLogs() {
showDialog(
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.logClearLogsTitle),
@@ -241,7 +241,7 @@ class OptionsSettingsPage extends ConsumerWidget {
WidgetRef ref,
ColorScheme colorScheme,
) {
showDialog(
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.dialogClearHistoryTitle),
@@ -273,7 +273,7 @@ class OptionsSettingsPage extends ConsumerWidget {
BuildContext context,
WidgetRef ref,
) async {
showDialog(
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
+1 -1
View File
@@ -151,6 +151,6 @@ class SettingsTab extends ConsumerWidget {
void _navigateTo(BuildContext context, Widget page) {
FocusManager.instance.primaryFocus?.unfocus();
Navigator.of(context).push(slidePageRoute(page: page));
Navigator.of(context).push(slidePageRoute<void>(page: page));
}
}
+3 -3
View File
@@ -124,7 +124,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
final shouldOpen = await _showAndroid11StorageDialog();
if (shouldOpen == true) {
await Permission.manageExternalStorage.request();
await Future.delayed(const Duration(milliseconds: 500));
await Future<void>.delayed(const Duration(milliseconds: 500));
manageStatus = await Permission.manageExternalStorage.status;
}
}
@@ -203,7 +203,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
}
Future<void> _showPermissionDeniedDialog(String permissionType) async {
await showDialog(
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.setupPermissionRequired(permissionType)),
@@ -286,7 +286,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Future<void> _showIOSDirectoryOptions() async {
final colorScheme = Theme.of(context).colorScheme;
await showModalBottomSheet(
await showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
+2 -2
View File
@@ -416,7 +416,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
void _showChangeRepoDialog(String currentUrl) {
final changeUrlController = TextEditingController(text: currentUrl);
showDialog(
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.storeRepoDialogTitle),
@@ -583,7 +583,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
void _showExtensionDetails(StoreExtension ext) {
Navigator.of(context).push(
MaterialPageRoute(
MaterialPageRoute<void>(
builder: (context) => ExtensionDetailsScreen(extension: ext),
),
);
+7 -7
View File
@@ -2135,7 +2135,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
treeUri: treeUri,
relativeDir: relativeDir,
fileName: '$baseName.lrc',
mimeType: 'text/plain',
mimeType: 'application/octet-stream',
srcPath: tempOutput,
);
try {
@@ -2533,7 +2533,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
WidgetRef ref,
ColorScheme colorScheme,
) {
showModalBottomSheet(
showModalBottomSheet<void>(
context: screenContext,
useRootNavigator: true,
shape: const RoundedRectangleBorder(
@@ -2824,7 +2824,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool isLosslessTarget =
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
showModalBottomSheet(
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
shape: const RoundedRectangleBorder(
@@ -3023,7 +3023,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (!mounted) return;
showModalBottomSheet(
showModalBottomSheet<void>(
context: this.context,
useRootNavigator: true,
isScrollControlled: true,
@@ -3186,7 +3186,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
required String date,
required List<CueSplitTrackInfo> tracks,
}) {
showDialog(
showDialog<void>(
context: context,
builder: (dialogContext) {
return AlertDialog(
@@ -3442,7 +3442,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final isLossless =
targetFormat.toUpperCase() == 'ALAC' ||
targetFormat.toUpperCase() == 'FLAC';
showDialog(
showDialog<void>(
context: context,
builder: (dialogContext) {
return AlertDialog(
@@ -3792,7 +3792,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
WidgetRef ref,
ColorScheme colorScheme,
) {
showDialog(
showDialog<void>(
context: screenContext,
useRootNavigator: true,
builder: (dialogContext) => AlertDialog(
+2 -2
View File
@@ -527,7 +527,7 @@ class _InteractiveDownloadExampleState
for (int i = 0; i <= 100; i += 5) {
if (!mounted) return;
await Future.delayed(const Duration(milliseconds: 50));
await Future<void>.delayed(const Duration(milliseconds: 50));
setState(() => _progress = i / 100);
}
@@ -536,7 +536,7 @@ class _InteractiveDownloadExampleState
_isCompleted = true;
});
await Future.delayed(const Duration(seconds: 2));
await Future<void>.delayed(const Duration(seconds: 2));
if (mounted) {
setState(() {
_isCompleted = false;
+2 -2
View File
@@ -119,7 +119,7 @@ class AppStateDatabase {
final db = await database;
await db.transaction((txn) async {
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 id = map['id'] as String?;
if (id == null || id.isEmpty) continue;
@@ -179,7 +179,7 @@ class AppStateDatabase {
final decoded = jsonDecode(rawRecent);
if (decoded is List) {
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 type = map['type'] as String?;
final id = map['id'] as String?;
+1 -1
View File
@@ -124,7 +124,7 @@ class CsvImportService {
);
if (i < tracks.length - 1) {
await Future.delayed(const Duration(milliseconds: 100));
await Future<void>.delayed(const Duration(milliseconds: 100));
}
continue;
}
+2 -2
View File
@@ -224,7 +224,7 @@ class HistoryDatabase {
}
try {
final List<dynamic> jsonList = jsonDecode(jsonStr);
final jsonList = List<dynamic>.from(jsonDecode(jsonStr) as List);
_log.i(
'Migrating ${jsonList.length} items from SharedPreferences to SQLite',
);
@@ -233,7 +233,7 @@ class HistoryDatabase {
final batch = db.batch();
for (final json in jsonList) {
final map = json as Map<String, dynamic>;
final map = Map<String, dynamic>.from(json as Map);
batch.insert(
'history',
_jsonToDbRow(map),
@@ -155,11 +155,11 @@ class LibraryCollectionsDatabase {
final db = await database;
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 trackKey = map['key'] as String?;
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;
await txn.insert(_tableWishlist, {
'track_key': trackKey,
@@ -168,11 +168,11 @@ class LibraryCollectionsDatabase {
}, 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 trackKey = map['key'] as String?;
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;
await txn.insert(_tableLoved, {
'track_key': trackKey,
@@ -181,7 +181,8 @@ class LibraryCollectionsDatabase {
}, 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 playlistId = playlist['id'] as String?;
if (playlistId == null || playlistId.isEmpty) continue;
@@ -197,11 +198,12 @@ class LibraryCollectionsDatabase {
}, conflictAlgorithm: ConflictAlgorithm.replace);
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 trackKey = trackMap['key'] as String?;
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;
await txn.insert(_tablePlaylistTracks, {
'playlist_id': playlistId,
+2 -2
View File
@@ -67,8 +67,8 @@ class PlatformBridge {
if (response['success'] == true) {
final service = response['service'] ?? payload.service;
final filePath = response['file_path'] ?? '';
final bitDepth = response['actual_bit_depth'];
final sampleRate = response['actual_sample_rate'];
final bitDepth = response['actual_bit_depth'] as num?;
final sampleRate = response['actual_sample_rate'] as num?;
final qualityStr = bitDepth != null && sampleRate != null
? ' ($bitDepth-bit/${(sampleRate / 1000).toStringAsFixed(1)}kHz)'
: '';
+1 -1
View File
@@ -65,7 +65,7 @@ class ShareIntentService {
_mediaSubscription = ReceiveSharingIntent.instance.getMediaStream().listen(
_handleSharedMedia,
onError: (err) => _log.e('Error: $err'),
onError: (Object err) => _log.e('Error: $err'),
);
final initialMedia = await ReceiveSharingIntent.instance.getInitialMedia();
+146 -24
View File
@@ -1,11 +1,26 @@
import 'dart:convert';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:http/http.dart' as http;
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/utils/logger.dart';
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 {
final String version;
final String changelog;
@@ -94,32 +109,15 @@ class UpdateChecker {
DateTime.tryParse(releaseData['published_at'] as String? ?? '') ??
DateTime.now();
String? arm64Url;
String? universalUrl;
final assets = releaseData['assets'] as List<dynamic>? ?? [];
for (final asset in assets) {
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;
final assets = _collectApkAssets(
releaseData['assets'] as List<dynamic>? ?? const [],
);
final selectedAsset = await _selectApkForCurrentDevice(assets);
final apkUrl = selectedAsset?.url;
_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(
@@ -169,4 +167,128 @@ class UpdateChecker {
}
static String get currentVersion => AppInfo.version;
static List<_ApkAsset> _collectApkAssets(List<dynamic> assets) {
final apkAssets = <_ApkAsset>[];
for (final asset in assets.whereType<Map<Object?, Object?>>()) {
final assetMap = Map<String, dynamic>.from(asset);
final name = (assetMap['name'] as String? ?? '').trim();
final normalizedName = name.toLowerCase();
if (!normalizedName.endsWith('.apk')) {
continue;
}
final downloadUrl = assetMap['browser_download_url'] as String?;
final uri = downloadUrl != null ? Uri.tryParse(downloadUrl) : null;
if (uri == null || uri.scheme != 'https') {
_log.w('Skipping non-HTTPS APK URL: $downloadUrl');
continue;
}
final variant = _apkVariantFromName(normalizedName);
if (variant == null) {
_log.w('Skipping APK with unknown variant: $name');
continue;
}
apkAssets.add(
_ApkAsset(name: name, url: uri.toString(), variant: variant),
);
}
return apkAssets;
}
static _ApkVariant? _apkVariantFromName(String name) {
if (name.contains('universal')) {
return _ApkVariant.universal;
}
if (name.contains('arm64') || name.contains('arm64-v8a')) {
return _ApkVariant.arm64;
}
if (name.contains('arm32') ||
name.contains('armeabi') ||
name.contains('armv7') ||
name.contains('v7a')) {
return _ApkVariant.arm32;
}
return null;
}
static Future<_ApkAsset?> _selectApkForCurrentDevice(
List<_ApkAsset> assets,
) async {
if (assets.isEmpty) {
return null;
}
_ApkAsset? arm64Asset;
_ApkAsset? arm32Asset;
_ApkAsset? universalAsset;
for (final asset in assets) {
switch (asset.variant) {
case _ApkVariant.arm64:
arm64Asset ??= asset;
break;
case _ApkVariant.arm32:
arm32Asset ??= asset;
break;
case _ApkVariant.universal:
universalAsset ??= asset;
break;
}
}
final supportedAbis = await _getSupportedAndroidAbis();
final hasArm64 = supportedAbis.any(_isArm64Abi);
final hasArm32 = supportedAbis.any(_isArm32Abi);
if (hasArm64) {
return arm64Asset ?? universalAsset ?? arm32Asset;
}
if (hasArm32) {
return arm32Asset ?? universalAsset;
}
if (universalAsset != null) {
_log.w(
'Could not match APK asset to supported ABIs ${supportedAbis.join(', ')}; '
'falling back to universal APK.',
);
return universalAsset;
}
_log.w(
'Could not match APK asset to supported ABIs ${supportedAbis.join(', ')}; '
'no universal APK available.',
);
return null;
}
static Future<List<String>> _getSupportedAndroidAbis() async {
if (!Platform.isAndroid) {
return const [];
}
try {
final androidInfo = await DeviceInfoPlugin().androidInfo;
final supportedAbis = androidInfo.supportedAbis
.map((abi) => abi.toLowerCase())
.where((abi) => abi.isNotEmpty)
.toSet()
.toList();
_log.i('Detected supported Android ABIs: ${supportedAbis.join(', ')}');
return supportedAbis;
} catch (e) {
_log.w('Failed to detect supported Android ABIs: $e');
return const [];
}
}
static bool _isArm64Abi(String abi) =>
abi.contains('arm64') || abi.contains('aarch64');
static bool _isArm32Abi(String abi) =>
abi.contains('armeabi') || abi.contains('armv7') || abi.contains('arm');
}
+3 -3
View File
@@ -252,7 +252,7 @@ void _pushViaPreferredNavigator(BuildContext context, WidgetBuilder builder) {
identical(currentNavigator, rootNavigator) && activeTabNavigator != null;
if (!shouldRouteToTabNavigator) {
currentNavigator.push(MaterialPageRoute(builder: builder));
currentNavigator.push(MaterialPageRoute<void>(builder: builder));
return;
}
@@ -264,12 +264,12 @@ void _pushViaPreferredNavigator(BuildContext context, WidgetBuilder builder) {
currentNavigator.pop();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!activeTabNavigator.mounted) return;
activeTabNavigator.push(MaterialPageRoute(builder: builder));
activeTabNavigator.push(MaterialPageRoute<void>(builder: builder));
});
return;
}
activeTabNavigator.push(MaterialPageRoute(builder: builder));
activeTabNavigator.push(MaterialPageRoute<void>(builder: builder));
}
void _showLoadingSnackBar(BuildContext context, String message) {
+6 -5
View File
@@ -179,15 +179,16 @@ class LogBuffer extends ChangeNotifier {
final nextIndex = result['next_index'] as int? ?? _lastGoLogIndex;
final keepNonErrorLogs = _loggingEnabled;
for (final log in logs) {
final level = log['level'] as String? ?? 'INFO';
for (final log in logs.whereType<Map<Object?, Object?>>()) {
final logMap = Map<String, dynamic>.from(log);
final level = logMap['level'] as String? ?? 'INFO';
if (!keepNonErrorLogs && level != 'ERROR' && level != 'FATAL') {
continue;
}
final timestamp = log['timestamp'] as String? ?? '';
final tag = log['tag'] as String? ?? 'Go';
final message = log['message'] as String? ?? '';
final timestamp = logMap['timestamp'] as String? ?? '';
final tag = logMap['tag'] as String? ?? 'Go';
final message = logMap['message'] as String? ?? '';
DateTime parsedTime = DateTime.now();
if (timestamp.isNotEmpty) {
+69 -54
View File
@@ -405,16 +405,19 @@ class GridSkeleton extends StatelessWidget {
crossAxisCount: crossAxisCount,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.78,
childAspectRatio: 0.75,
),
itemCount: itemCount,
itemBuilder: (context, index) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const AspectRatio(
aspectRatio: 1,
child: SkeletonBox(width: double.infinity, height: 0),
const Expanded(
child: SkeletonBox(
width: double.infinity,
height: double.infinity,
borderRadius: 12,
),
),
const SizedBox(height: 8),
SkeletonBox(
@@ -443,11 +446,15 @@ class GridSkeleton extends StatelessWidget {
class ArtistScreenSkeleton extends StatelessWidget {
final int popularCount;
final int albumCount;
final bool showCoverHeader;
final bool showPopularSection;
const ArtistScreenSkeleton({
super.key,
this.popularCount = 5,
this.albumCount = 5,
this.showCoverHeader = true,
this.showPopularSection = true,
});
@override
@@ -459,11 +466,13 @@ class ArtistScreenSkeleton extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SkeletonBox(
width: screenWidth,
height: screenWidth * 0.75,
borderRadius: 0,
),
if (showCoverHeader) ...[
SkeletonBox(
width: screenWidth,
height: screenWidth * 0.75,
borderRadius: 0,
),
],
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 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),
child: SkeletonBox(width: 120, height: 14, borderRadius: 4),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: SkeletonBox(width: 90, height: 20, borderRadius: 4),
),
...List.generate(popularCount, (index) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Row(
children: [
SizedBox(
width: 24,
child: Center(
child: SkeletonBox(
width: 12,
height: 14,
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,
if (showPopularSection) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: SkeletonBox(width: 90, height: 20, borderRadius: 4),
),
...List.generate(popularCount, (index) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Row(
children: [
SizedBox(
width: 24,
child: Center(
child: SkeletonBox(
width: 12,
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),
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,
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: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: SkeletonBox(width: 80, height: 20, borderRadius: 4),
+63 -15
View File
@@ -150,14 +150,12 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
_data = cached;
_checkingCache = false;
});
if (cached.spectrum != null && cached.spectrum!.sliceCount > 0) {
final image = await _renderSpectrogramToImage(cached.spectrum!);
if (mounted) {
setState(() {
_spectrogramImage?.dispose();
_spectrogramImage = image;
});
}
final image = await _loadSpectrogramFromCache(widget.filePath);
if (image != null && mounted) {
setState(() {
_spectrogramImage?.dispose();
_spectrogramImage = image;
});
}
return;
}
@@ -177,17 +175,25 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
try {
final cached = await _loadFromCache(widget.filePath);
AudioAnalysisData data;
bool fromCache = false;
if (cached != null) {
data = cached;
fromCache = true;
} else {
data = await _runAnalysis(widget.filePath);
_saveToCache(widget.filePath, data);
}
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!);
_saveSpectrogramToCache(widget.filePath, image);
}
if (mounted) {
@@ -233,7 +239,9 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
final file = File('${dir.path}/$key.json');
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;
if (!filePath.startsWith('content://')) {
@@ -259,6 +267,37 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
} 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 {
await FFmpegKitConfig.setLogLevel(Level.avLogError);
@@ -556,7 +595,11 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
_AudioInfoCard(data: data),
if (_spectrogramImage != null) ...[
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 {
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
Widget build(BuildContext context) {
@@ -955,12 +1003,12 @@ class _SpectrogramView extends StatelessWidget {
child: Row(
children: [
Text(
'${context.l10n.audioAnalysisSampleRate}: ${spectrum.sampleRate} Hz',
'${context.l10n.audioAnalysisSampleRate}: $sampleRate Hz',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
),
const Spacer(),
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),
),
],
+1 -1
View File
@@ -110,7 +110,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
}) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
@@ -19,7 +19,7 @@ class TrackCollectionQuickActions extends ConsumerWidget {
Track track,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
+66 -2
View File
@@ -9,14 +9,22 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
name: analyzer
sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08
sha256: a40a0cee526a7e1f387c6847bd8a5ccbf510a75952ef8a28338e989558072cb0
url: "https://pub.dev"
source: hosted
version: "8.4.1"
version: "8.4.0"
analyzer_buffer:
dependency: transitive
description:
@@ -25,6 +33,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -145,6 +161,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.4"
ci:
dependency: transitive
description:
name: ci
sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13"
url: "https://pub.dev"
source: hosted
version: "0.1.0"
cli_config:
dependency: transitive
description:
@@ -241,6 +265,30 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -925,6 +973,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -1402,6 +1458,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dart: ">=3.10.0 <4.0.0"
flutter: ">=3.38.1"
+4 -2
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer
publish_to: "none"
version: 4.1.0+117
version: 4.1.1+118
environment:
sdk: ^3.10.0
@@ -13,7 +13,7 @@ dependencies:
# Localization
flutter_localizations:
sdk: flutter
intl: any
intl: ^0.20.2
# State Management
flutter_riverpod: ^3.1.0
@@ -68,7 +68,9 @@ dev_dependencies:
sdk: flutter
flutter_lints: ^6.0.0
build_runner: ^2.10.4
custom_lint: ^0.8.1
riverpod_generator: ^4.0.0
riverpod_lint: ^3.1.0
json_serializable: ^6.11.2
flutter_launcher_icons: ^0.14.3