mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 11:18:04 +02:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd73eb292d | |||
| 8ee2919934 | |||
| f29177216d | |||
| 18d3612674 | |||
| f7c0e417d7 | |||
| 3fd13e9930 | |||
| 0b20cb895e | |||
| 8979210804 | |||
| e9b24712c5 | |||
| 3d6e5615fa | |||
| fc7220b572 | |||
| 198ed5ce6f | |||
| b48462a945 |
@@ -17,7 +17,7 @@
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/cc11355330c76f97548b8d26452b91746db9d9c1edbcfc4c18250133484d1487)
|
||||
[](https://www.virustotal.com/gui/file/31d1bf3c3b2015c13e83c4f909a7c6093a9423e3e702f0c582a3e0035c849424)
|
||||
[](https://crowdin.com/project/spotiflac-mobile)
|
||||
|
||||
[](https://t.me/spotiflac)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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, ®istry); 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 ®istry, 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 := ®istry.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()
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1180,7 +1180,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
? '320k'
|
||||
: (selectedFormat == 'Opus' ? '128k' : '320k');
|
||||
|
||||
showModalBottomSheet(
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)'
|
||||
: '';
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user