mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-19 22:54:43 +02:00
perf: lazy extension VM init, incremental startup maintenance, and UI optimizations
- Defer extension VM initialization until first use with lockReadyVM() pattern to eliminate TOCTOU races and reduce startup overhead - Add validateExtensionLoad() to catch JS errors at install time without keeping VM alive - Teardown VM on extension disable to free resources; re-init lazily on re-enable - Replace full orphan cleanup with incremental cursor-based pagination across launches - Batch DB writes (upsertBatch, replaceAll) with transactions for atomicity - Parse JSON natively on Kotlin side to avoid double-serialization over MethodChannel - Add identity-based memoization caches for unified items and path match keys in queue tab - Use ValueListenableBuilder for targeted embedded cover refreshes instead of full setState - Extract shared widgets (_buildAlbumGridItemCore, _buildFilterButton, _navigateWithUnfocus) - Use libraryCollectionsProvider selector and MediaQuery.paddingOf for fewer rebuilds - Simplify supporter chip tiers and localize remaining hardcoded strings
This commit is contained in:
@@ -141,6 +141,11 @@ In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link
|
||||
|
||||
</details>
|
||||
|
||||
> [!NOTE]
|
||||
> If SpotiFLAC is useful to you, consider supporting development:
|
||||
>
|
||||
> [](https://ko-fi.com/zarzet)
|
||||
|
||||
---
|
||||
|
||||
## Contributors
|
||||
@@ -165,10 +170,5 @@ Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md)
|
||||
| [dabmusic.xyz](https://dabmusic.xyz) | [AfkarXYZ](https://github.com/afkarxyz) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) |
|
||||
| [qwkuns.me](https://qwkuns.me) | [SpotubeDL](https://spotubedl.com) | [Song.link](https://song.link) | [IDHS](https://github.com/sjdonado/idonthavespotify) | |
|
||||
|
||||
> [!NOTE]
|
||||
> If SpotiFLAC is useful to you, consider supporting development:
|
||||
>
|
||||
> [](https://ko-fi.com/zarzet)
|
||||
|
||||
> [!TIP]
|
||||
> **Star the repo** to get notified about all new releases directly from GitHub.
|
||||
|
||||
@@ -27,6 +27,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.json.JSONTokener
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
@@ -413,6 +414,38 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseJsonValue(value: Any?): Any? {
|
||||
return when (value) {
|
||||
null, JSONObject.NULL -> null
|
||||
is JSONObject -> {
|
||||
val map = LinkedHashMap<String, Any?>()
|
||||
val keys = value.keys()
|
||||
while (keys.hasNext()) {
|
||||
val key = keys.next()
|
||||
map[key] = parseJsonValue(value.opt(key))
|
||||
}
|
||||
map
|
||||
}
|
||||
is JSONArray -> {
|
||||
val list = ArrayList<Any?>()
|
||||
for (i in 0 until value.length()) {
|
||||
list.add(parseJsonValue(value.opt(i)))
|
||||
}
|
||||
list
|
||||
}
|
||||
is Number, is Boolean, is String -> value
|
||||
else -> value.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseJsonPayload(payload: String): Any {
|
||||
return try {
|
||||
parseJsonValue(JSONTokener(payload).nextValue()) ?: payload
|
||||
} catch (_: Exception) {
|
||||
payload
|
||||
}
|
||||
}
|
||||
|
||||
private fun startDownloadProgressStream(sink: EventChannel.EventSink) {
|
||||
stopDownloadProgressStream()
|
||||
downloadProgressEventSink = sink
|
||||
@@ -425,7 +458,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
if (payload != lastDownloadProgressPayload) {
|
||||
lastDownloadProgressPayload = payload
|
||||
sink.success(payload)
|
||||
sink.success(parseJsonPayload(payload))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(
|
||||
@@ -457,7 +490,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
if (payload != lastLibraryScanProgressPayload) {
|
||||
lastLibraryScanProgressPayload = payload
|
||||
sink.success(payload)
|
||||
sink.success(parseJsonPayload(payload))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(
|
||||
@@ -2000,13 +2033,13 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getDownloadProgress()
|
||||
}
|
||||
result.success(response)
|
||||
result.success(parseJsonPayload(response))
|
||||
}
|
||||
"getAllDownloadProgress" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getAllDownloadProgress()
|
||||
}
|
||||
result.success(response)
|
||||
result.success(parseJsonPayload(response))
|
||||
}
|
||||
"initItemProgress" -> {
|
||||
val itemId = call.argument<String>("item_id") ?: ""
|
||||
@@ -3298,7 +3331,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
Gobackend.getLibraryScanProgressJSON()
|
||||
}
|
||||
}
|
||||
result.success(response)
|
||||
result.success(parseJsonPayload(response))
|
||||
}
|
||||
"cancelLibraryScan" -> {
|
||||
withContext(Dispatchers.IO) {
|
||||
|
||||
+6
-16
@@ -2187,12 +2187,6 @@ func LoadExtensionFromPath(filePath string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
settingsStore := GetExtensionSettingsStore()
|
||||
settings := settingsStore.GetAll(ext.ID)
|
||||
if len(settings) > 0 {
|
||||
manager.InitializeExtension(ext.ID, settings)
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"id": ext.ID,
|
||||
"name": ext.Manifest.Name,
|
||||
@@ -2226,12 +2220,6 @@ func UpgradeExtensionFromPath(filePath string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
settingsStore := GetExtensionSettingsStore()
|
||||
settings := settingsStore.GetAll(ext.ID)
|
||||
if len(settings) > 0 {
|
||||
manager.InitializeExtension(ext.ID, settings)
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"id": ext.ID,
|
||||
"display_name": ext.Manifest.DisplayName,
|
||||
@@ -3324,12 +3312,14 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du
|
||||
if !ext.Enabled {
|
||||
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
|
||||
}
|
||||
vm, err := ext.lockReadyVM()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer ext.VMMu.Unlock()
|
||||
|
||||
// Goja runtime is not thread-safe; guard direct extension.*() calls with VMMu
|
||||
// to avoid races with other provider calls (e.g. getAlbum/getPlaylist).
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
||||
@@ -3339,7 +3329,7 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du
|
||||
})()
|
||||
`, functionName, functionName)
|
||||
|
||||
result, err := RunWithTimeoutAndRecover(ext.VM, script, timeout)
|
||||
result, err := RunWithTimeoutAndRecover(vm, script, timeout)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s failed: %w", functionName, err)
|
||||
}
|
||||
|
||||
+241
-114
@@ -44,16 +44,76 @@ func compareVersions(v1, v2 string) int {
|
||||
}
|
||||
|
||||
type LoadedExtension struct {
|
||||
ID string `json:"id"`
|
||||
Manifest *ExtensionManifest `json:"manifest"`
|
||||
VM *goja.Runtime `json:"-"`
|
||||
VMMu sync.Mutex `json:"-"`
|
||||
runtime *ExtensionRuntime
|
||||
Enabled bool `json:"enabled"`
|
||||
Error string `json:"error,omitempty"`
|
||||
DataDir string `json:"data_dir"`
|
||||
SourceDir string `json:"source_dir"`
|
||||
IconPath string `json:"icon_path"`
|
||||
ID string `json:"id"`
|
||||
Manifest *ExtensionManifest `json:"manifest"`
|
||||
VM *goja.Runtime `json:"-"`
|
||||
VMMu sync.Mutex `json:"-"`
|
||||
runtime *ExtensionRuntime
|
||||
initialized bool
|
||||
Enabled bool `json:"enabled"`
|
||||
Error string `json:"error,omitempty"`
|
||||
DataDir string `json:"data_dir"`
|
||||
SourceDir string `json:"source_dir"`
|
||||
IconPath string `json:"icon_path"`
|
||||
}
|
||||
|
||||
func getExtensionInitSettings(extensionID string) map[string]interface{} {
|
||||
settings := GetExtensionSettingsStore().GetAll(extensionID)
|
||||
if len(settings) == 0 {
|
||||
return settings
|
||||
}
|
||||
|
||||
filtered := make(map[string]interface{}, len(settings))
|
||||
for key, value := range settings {
|
||||
if strings.HasPrefix(key, "_") {
|
||||
continue
|
||||
}
|
||||
filtered[key] = value
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func ensureRuntimeReadyLocked(ext *LoadedExtension, applyStoredSettings bool) error {
|
||||
if ext.VM == nil || ext.runtime == nil {
|
||||
if err := initializeVMLocked(ext); err != nil {
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if applyStoredSettings && !ext.initialized {
|
||||
settings := getExtensionInitSettings(ext.ID)
|
||||
if len(settings) > 0 {
|
||||
if err := initializeExtensionWithSettingsLocked(ext, settings); err != nil {
|
||||
teardownVMLocked(ext)
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
ext.initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
ext.Error = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ext *LoadedExtension) ensureRuntimeReady() error {
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
|
||||
return ensureRuntimeReadyLocked(ext, true)
|
||||
}
|
||||
|
||||
func (ext *LoadedExtension) lockReadyVM() (*goja.Runtime, error) {
|
||||
ext.VMMu.Lock()
|
||||
if err := ensureRuntimeReadyLocked(ext, true); err != nil {
|
||||
ext.VMMu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
return ext.VM, nil
|
||||
}
|
||||
|
||||
type ExtensionManager struct {
|
||||
@@ -220,10 +280,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
SourceDir: extDir,
|
||||
}
|
||||
|
||||
if err := m.initializeVM(ext); err != nil {
|
||||
if err := validateExtensionLoad(ext); err != nil {
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err)
|
||||
GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err)
|
||||
}
|
||||
|
||||
m.extensions[manifest.Name] = ext
|
||||
@@ -232,7 +292,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||
func initializeVMLocked(ext *LoadedExtension) error {
|
||||
ext.VM = nil
|
||||
ext.runtime = nil
|
||||
ext.initialized = false
|
||||
vm := goja.New()
|
||||
ext.VM = vm
|
||||
|
||||
@@ -279,6 +342,136 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
return initializeVMLocked(ext)
|
||||
}
|
||||
|
||||
func initializeExtensionWithSettingsLocked(
|
||||
ext *LoadedExtension,
|
||||
settings map[string]interface{},
|
||||
) error {
|
||||
if ext.VM == nil {
|
||||
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
||||
}
|
||||
|
||||
settingsJSON, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to save settings")
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
var settings = %s;
|
||||
if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
|
||||
try {
|
||||
extension.initialize(settings);
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
}
|
||||
}
|
||||
return { success: true, message: 'no initialize function' };
|
||||
})()
|
||||
`, string(settingsJSON))
|
||||
|
||||
result, err := ext.VM.RunString(script)
|
||||
if err != nil {
|
||||
ext.Error = fmt.Sprintf("initialize failed: %v", err)
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Initialize error for %s: %v\n", ext.ID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if result != nil && !goja.IsUndefined(result) {
|
||||
exported := result.Export()
|
||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||
errMsg := "unknown error"
|
||||
if e, ok := resultMap["error"].(string); ok {
|
||||
errMsg = e
|
||||
}
|
||||
ext.Error = errMsg
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Initialize failed for %s: %s\n", ext.ID, errMsg)
|
||||
return fmt.Errorf("initialize failed: %s", errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ext.initialized = true
|
||||
GoLog("[Extension] Initialized %s\n", ext.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCleanupLocked(ext *LoadedExtension) error {
|
||||
if ext.VM != nil {
|
||||
script := `
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
||||
try {
|
||||
extension.cleanup();
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
}
|
||||
}
|
||||
return { success: true, message: 'no cleanup function' };
|
||||
})()
|
||||
`
|
||||
|
||||
result, err := ext.VM.RunString(script)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result != nil && !goja.IsUndefined(result) {
|
||||
exported := result.Export()
|
||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||
errMsg := "unknown error"
|
||||
if e, ok := resultMap["error"].(string); ok {
|
||||
errMsg = e
|
||||
}
|
||||
return fmt.Errorf("cleanup failed: %s", errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result != nil && !goja.IsUndefined(result) && !goja.IsNull(result) {
|
||||
GoLog("[Extension] Cleanup called for %s\n", ext.ID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func teardownVMLocked(ext *LoadedExtension) {
|
||||
if err := runCleanupLocked(ext); err != nil {
|
||||
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
|
||||
}
|
||||
if ext.runtime != nil {
|
||||
if err := ext.runtime.flushStorageNow(); err != nil {
|
||||
GoLog("[Extension] Failed to flush storage for %s: %v\n", ext.ID, err)
|
||||
}
|
||||
ext.runtime.closeStorageFlusher()
|
||||
}
|
||||
ext.runtime = nil
|
||||
ext.VM = nil
|
||||
ext.initialized = false
|
||||
}
|
||||
|
||||
func validateExtensionLoad(ext *LoadedExtension) error {
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
|
||||
if err := initializeVMLocked(ext); err != nil {
|
||||
return err
|
||||
}
|
||||
teardownVMLocked(ext)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -288,21 +481,9 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||
return fmt.Errorf("Extension not found")
|
||||
}
|
||||
|
||||
if ext.VM != nil {
|
||||
cleanup, err := ext.VM.RunString("typeof extension !== 'undefined' && typeof extension.cleanup === 'function' ? extension.cleanup() : null")
|
||||
if err != nil {
|
||||
GoLog("[Extension] Error calling cleanup for %s: %v\n", extensionID, err)
|
||||
} else if cleanup != nil && !goja.IsUndefined(cleanup) && !goja.IsNull(cleanup) {
|
||||
GoLog("[Extension] Cleanup called for %s\n", extensionID)
|
||||
}
|
||||
}
|
||||
if ext.runtime != nil {
|
||||
if err := ext.runtime.flushStorageNow(); err != nil {
|
||||
GoLog("[Extension] Failed to flush storage for %s: %v\n", extensionID, err)
|
||||
}
|
||||
ext.runtime.closeStorageFlusher()
|
||||
ext.runtime = nil
|
||||
}
|
||||
ext.VMMu.Lock()
|
||||
teardownVMLocked(ext)
|
||||
ext.VMMu.Unlock()
|
||||
|
||||
delete(m.extensions, extensionID)
|
||||
GoLog("[Extension] Unloaded extension: %s\n", extensionID)
|
||||
@@ -341,7 +522,21 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
|
||||
return fmt.Errorf("Extension not found")
|
||||
}
|
||||
|
||||
ext.Enabled = enabled
|
||||
if enabled {
|
||||
ext.Enabled = true
|
||||
if err := ext.ensureRuntimeReady(); err != nil {
|
||||
store := GetExtensionSettingsStore()
|
||||
ext.Enabled = false
|
||||
_ = store.Set(extensionID, "_enabled", false)
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
ext.Enabled = false
|
||||
ext.Error = ""
|
||||
ext.VMMu.Lock()
|
||||
teardownVMLocked(ext)
|
||||
ext.VMMu.Unlock()
|
||||
}
|
||||
GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled])
|
||||
|
||||
store := GetExtensionSettingsStore()
|
||||
@@ -436,10 +631,10 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.initializeVM(ext); err != nil {
|
||||
if err := validateExtensionLoad(ext); err != nil {
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err)
|
||||
GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err)
|
||||
}
|
||||
|
||||
m.extensions[manifest.Name] = ext
|
||||
@@ -590,10 +785,14 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
||||
SourceDir: extDir,
|
||||
}
|
||||
|
||||
if err := m.initializeVM(ext); err != nil {
|
||||
if wasEnabled {
|
||||
if err := ext.ensureRuntimeReady(); err != nil {
|
||||
GoLog("[Extension] Failed to initialize upgraded extension %s: %v\n", newManifest.Name, err)
|
||||
}
|
||||
} else if err := validateExtensionLoad(ext); err != nil {
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Failed to initialize VM for %s: %v\n", newManifest.Name, err)
|
||||
GoLog("[Extension] Failed to validate upgraded extension %s: %v\n", newManifest.Name, err)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
@@ -790,56 +989,13 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
|
||||
return fmt.Errorf("Extension not found")
|
||||
}
|
||||
|
||||
if ext.VM == nil {
|
||||
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
||||
}
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
|
||||
settingsJSON, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to save settings")
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
var settings = %s;
|
||||
if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
|
||||
try {
|
||||
extension.initialize(settings);
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
}
|
||||
}
|
||||
return { success: true, message: 'no initialize function' };
|
||||
})()
|
||||
`, string(settingsJSON))
|
||||
|
||||
result, err := ext.VM.RunString(script)
|
||||
if err != nil {
|
||||
ext.Error = fmt.Sprintf("initialize failed: %v", err)
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Initialize error for %s: %v\n", extensionID, err)
|
||||
if err := ensureRuntimeReadyLocked(ext, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result != nil && !goja.IsUndefined(result) {
|
||||
exported := result.Export()
|
||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||
errMsg := "unknown error"
|
||||
if e, ok := resultMap["error"].(string); ok {
|
||||
errMsg = e
|
||||
}
|
||||
ext.Error = errMsg
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Initialize failed for %s: %s\n", extensionID, errMsg)
|
||||
return fmt.Errorf("initialize failed: %s", errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Extension] Initialized %s\n", extensionID)
|
||||
return nil
|
||||
return initializeExtensionWithSettingsLocked(ext, settings)
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
||||
@@ -854,41 +1010,12 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
||||
if ext.VM == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
script := `
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
||||
try {
|
||||
extension.cleanup();
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
}
|
||||
}
|
||||
return { success: true, message: 'no cleanup function' };
|
||||
})()
|
||||
`
|
||||
|
||||
result, err := ext.VM.RunString(script)
|
||||
if err != nil {
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
if err := runCleanupLocked(ext); err != nil {
|
||||
GoLog("[Extension] Cleanup error for %s: %v\n", extensionID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if result != nil && !goja.IsUndefined(result) {
|
||||
exported := result.Export()
|
||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||
errMsg := "unknown error"
|
||||
if e, ok := resultMap["error"].(string); ok {
|
||||
errMsg = e
|
||||
}
|
||||
GoLog("[Extension] Cleanup failed for %s: %s\n", extensionID, errMsg)
|
||||
return fmt.Errorf("cleanup failed: %s", errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Extension] Cleaned up %s\n", extensionID)
|
||||
return nil
|
||||
}
|
||||
@@ -917,8 +1044,8 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
|
||||
return nil, fmt.Errorf("extension not found: %s", extensionID)
|
||||
}
|
||||
|
||||
if ext.VM == nil {
|
||||
return nil, fmt.Errorf("extension VM not initialized")
|
||||
if err := ext.ensureRuntimeReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !ext.Enabled {
|
||||
|
||||
@@ -125,6 +125,15 @@ func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ExtensionProviderWrapper) lockReadyVM() error {
|
||||
vm, err := p.extension.lockReadyVM()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.vm = vm
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||
@@ -133,8 +142,9 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -192,8 +202,9 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -240,8 +251,9 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -291,8 +303,9 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -345,8 +358,10 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
|
||||
if !p.extension.Enabled {
|
||||
return track, nil
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
GoLog("[Extension] EnrichTrack init error for %s: %v\n", p.extension.ID, err)
|
||||
return track, nil
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
trackJSON, err := json.Marshal(track)
|
||||
@@ -405,8 +420,9 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -452,8 +468,9 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -501,8 +518,13 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return &ExtDownloadResult{
|
||||
Success: false,
|
||||
ErrorMessage: err.Error(),
|
||||
ErrorType: "init_error",
|
||||
}, nil
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
|
||||
@@ -1626,8 +1648,9 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
if options == nil {
|
||||
@@ -1707,8 +1730,9 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -1792,8 +1816,9 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
sourceJSON, _ := json.Marshal(sourceTrack)
|
||||
@@ -1862,8 +1887,9 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return &PostProcessResult{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
metadataJSON, _ := json.Marshal(metadata)
|
||||
@@ -1924,8 +1950,9 @@ func (p *ExtensionProviderWrapper) PostProcessV2(input PostProcessInput, metadat
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return &PostProcessResult{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
metadataJSON, _ := json.Marshal(metadata)
|
||||
@@ -2182,8 +2209,9 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
// Use global variables to avoid JS injection issues with special characters in track/artist names
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'dart:io';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
@@ -262,8 +263,14 @@ class DownloadHistoryState {
|
||||
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
static const int _safRepairBatchSize = 20;
|
||||
static const int _safRepairMaxPerLaunch = 60;
|
||||
static const int _orphanCleanupMaxPerLaunch = 80;
|
||||
static const int _audioMetadataBackfillMaxPerLaunch = 24;
|
||||
static const _startupMaintenanceDelay = Duration(seconds: 2);
|
||||
static const _startupMaintenanceDelay = Duration(seconds: 4);
|
||||
static const _startupMaintenanceStepGap = Duration(milliseconds: 250);
|
||||
static const _startupSafRepairCursorKey =
|
||||
'history_startup_saf_repair_cursor_v1';
|
||||
static const _startupOrphanCursorKey = 'history_startup_orphan_cursor_v1';
|
||||
static const _startupAudioCursorKey = 'history_startup_audio_cursor_v1';
|
||||
final HistoryDatabase _db = HistoryDatabase.instance;
|
||||
bool _isLoaded = false;
|
||||
bool _isSafRepairInProgress = false;
|
||||
@@ -320,20 +327,29 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
unawaited(
|
||||
Future<void>.delayed(_startupMaintenanceDelay, () async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
await _repairMissingSafEntries(
|
||||
initialItems,
|
||||
maxItems: _safRepairMaxPerLaunch,
|
||||
prefs: prefs,
|
||||
);
|
||||
await Future<void>.delayed(_startupMaintenanceStepGap);
|
||||
}
|
||||
|
||||
await cleanupOrphanedDownloads();
|
||||
await _cleanupOrphanedDownloadsIncremental(
|
||||
maxItems: _orphanCleanupMaxPerLaunch,
|
||||
prefs: prefs,
|
||||
);
|
||||
await Future<void>.delayed(_startupMaintenanceStepGap);
|
||||
|
||||
final currentItems = state.items;
|
||||
if (currentItems.isNotEmpty) {
|
||||
await _backfillAudioMetadata(
|
||||
currentItems,
|
||||
maxItems: _audioMetadataBackfillMaxPerLaunch,
|
||||
prefs: prefs,
|
||||
);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
@@ -344,6 +360,34 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
);
|
||||
}
|
||||
|
||||
int _readStartupCursor(
|
||||
SharedPreferences prefs,
|
||||
String key,
|
||||
int totalCount,
|
||||
) {
|
||||
if (totalCount <= 0) {
|
||||
return 0;
|
||||
}
|
||||
final cursor = prefs.getInt(key) ?? 0;
|
||||
if (cursor < 0 || cursor >= totalCount) {
|
||||
return 0;
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
Future<void> _writeStartupCursor(
|
||||
SharedPreferences prefs,
|
||||
String key,
|
||||
int nextCursor,
|
||||
int totalCount,
|
||||
) async {
|
||||
if (totalCount <= 0 || nextCursor <= 0 || nextCursor >= totalCount) {
|
||||
await prefs.remove(key);
|
||||
return;
|
||||
}
|
||||
await prefs.setInt(key, nextCursor);
|
||||
}
|
||||
|
||||
String _fileNameFromUri(String uri) {
|
||||
try {
|
||||
final parsed = Uri.parse(uri);
|
||||
@@ -357,6 +401,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
Future<void> _repairMissingSafEntries(
|
||||
List<DownloadHistoryItem> items, {
|
||||
required int maxItems,
|
||||
required SharedPreferences prefs,
|
||||
}) async {
|
||||
if (_isSafRepairInProgress || items.isEmpty) {
|
||||
return;
|
||||
@@ -378,22 +423,37 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
continue;
|
||||
}
|
||||
candidateIndexes.add(i);
|
||||
if (candidateIndexes.length >= maxItems) break;
|
||||
}
|
||||
|
||||
if (candidateIndexes.isEmpty) {
|
||||
await prefs.remove(_startupSafRepairCursorKey);
|
||||
_isSafRepairInProgress = false;
|
||||
return;
|
||||
}
|
||||
|
||||
final startCursor = _readStartupCursor(
|
||||
prefs,
|
||||
_startupSafRepairCursorKey,
|
||||
candidateIndexes.length,
|
||||
);
|
||||
final endCursor = (startCursor + maxItems).clamp(0, candidateIndexes.length);
|
||||
final selectedIndexes = candidateIndexes.sublist(startCursor, endCursor);
|
||||
|
||||
if (selectedIndexes.isEmpty) {
|
||||
await prefs.remove(_startupSafRepairCursorKey);
|
||||
_isSafRepairInProgress = false;
|
||||
return;
|
||||
}
|
||||
|
||||
final updatedItems = [...items];
|
||||
final persistedUpdates = <Map<String, dynamic>>[];
|
||||
var changed = false;
|
||||
var repairedCount = 0;
|
||||
var verifiedCount = 0;
|
||||
|
||||
try {
|
||||
for (var c = 0; c < candidateIndexes.length; c++) {
|
||||
final i = candidateIndexes[c];
|
||||
for (var c = 0; c < selectedIndexes.length; c++) {
|
||||
final i = selectedIndexes[c];
|
||||
final item = items[i];
|
||||
final rawPath = item.filePath.trim();
|
||||
final isDirectSafUri = rawPath.isNotEmpty && isContentUri(rawPath);
|
||||
@@ -408,7 +468,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
updatedItems[i] = verified;
|
||||
changed = true;
|
||||
verifiedCount++;
|
||||
await _db.upsert(verified.toJson());
|
||||
persistedUpdates.add(verified.toJson());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -445,7 +505,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
updatedItems[i] = updated;
|
||||
changed = true;
|
||||
repairedCount++;
|
||||
await _db.upsert(updated.toJson());
|
||||
persistedUpdates.add(updated.toJson());
|
||||
} catch (e) {
|
||||
_historyLog.w('Failed to repair SAF URI: $e');
|
||||
}
|
||||
@@ -456,11 +516,18 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await _db.upsertBatch(persistedUpdates);
|
||||
state = state.copyWith(items: updatedItems);
|
||||
_historyLog.i(
|
||||
'SAF repair pass: verified=$verifiedCount, repaired=$repairedCount, checked=${candidateIndexes.length}',
|
||||
'SAF repair pass: verified=$verifiedCount, repaired=$repairedCount, checked=${selectedIndexes.length}',
|
||||
);
|
||||
}
|
||||
await _writeStartupCursor(
|
||||
prefs,
|
||||
_startupSafRepairCursorKey,
|
||||
endCursor,
|
||||
candidateIndexes.length,
|
||||
);
|
||||
} finally {
|
||||
_isSafRepairInProgress = false;
|
||||
}
|
||||
@@ -556,6 +623,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
Future<void> _backfillAudioMetadata(
|
||||
List<DownloadHistoryItem> items, {
|
||||
required int maxItems,
|
||||
required SharedPreferences prefs,
|
||||
}) async {
|
||||
if (_isAudioMetadataBackfillInProgress || items.isEmpty) {
|
||||
return;
|
||||
@@ -563,15 +631,37 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
_isAudioMetadataBackfillInProgress = true;
|
||||
|
||||
try {
|
||||
final candidateIndexes = <int>[];
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (_shouldBackfillAudioMetadata(items[i])) {
|
||||
candidateIndexes.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidateIndexes.isEmpty) {
|
||||
await prefs.remove(_startupAudioCursorKey);
|
||||
return;
|
||||
}
|
||||
|
||||
final startCursor = _readStartupCursor(
|
||||
prefs,
|
||||
_startupAudioCursorKey,
|
||||
candidateIndexes.length,
|
||||
);
|
||||
final endCursor = (startCursor + maxItems).clamp(0, candidateIndexes.length);
|
||||
final selectedIndexes = candidateIndexes.sublist(startCursor, endCursor);
|
||||
|
||||
if (selectedIndexes.isEmpty) {
|
||||
await prefs.remove(_startupAudioCursorKey);
|
||||
return;
|
||||
}
|
||||
|
||||
List<DownloadHistoryItem>? updatedItems;
|
||||
final persistedUpdates = <Map<String, dynamic>>[];
|
||||
var refreshedCount = 0;
|
||||
|
||||
for (final item in items) {
|
||||
if (refreshedCount >= maxItems) {
|
||||
break;
|
||||
}
|
||||
if (!_shouldBackfillAudioMetadata(item)) {
|
||||
continue;
|
||||
}
|
||||
for (final index in selectedIndexes) {
|
||||
final item = items[index];
|
||||
|
||||
final probed = await _probeAudioMetadata(
|
||||
item.filePath,
|
||||
@@ -598,15 +688,29 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
continue;
|
||||
}
|
||||
|
||||
await updateAudioMetadataForItem(
|
||||
id: item.id,
|
||||
final updated = item.copyWith(
|
||||
quality: resolvedQuality,
|
||||
bitDepth: resolvedBitDepth,
|
||||
sampleRate: resolvedSampleRate,
|
||||
);
|
||||
updatedItems ??= [...items];
|
||||
updatedItems[index] = updated;
|
||||
persistedUpdates.add(updated.toJson());
|
||||
refreshedCount++;
|
||||
}
|
||||
|
||||
if (persistedUpdates.isNotEmpty && updatedItems != null) {
|
||||
await _db.upsertBatch(persistedUpdates);
|
||||
state = state.copyWith(items: updatedItems);
|
||||
}
|
||||
|
||||
await _writeStartupCursor(
|
||||
prefs,
|
||||
_startupAudioCursorKey,
|
||||
endCursor,
|
||||
candidateIndexes.length,
|
||||
);
|
||||
|
||||
if (refreshedCount > 0) {
|
||||
_historyLog.i(
|
||||
'Audio metadata backfill refreshed $refreshedCount items',
|
||||
@@ -801,11 +905,15 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<int> cleanupOrphanedDownloads() async {
|
||||
_historyLog.i('Starting orphaned downloads cleanup...');
|
||||
|
||||
final entries = await _db.getAllEntriesWithPaths();
|
||||
Future<
|
||||
({
|
||||
List<String> orphanedIds,
|
||||
Map<String, String> replacementPaths,
|
||||
Map<String, String> pathById,
|
||||
})
|
||||
> _inspectOrphanedEntries(List<Map<String, dynamic>> entries) async {
|
||||
final orphanedIds = <String>[];
|
||||
final replacementPaths = <String, String>{};
|
||||
final pathById = <String, String>{};
|
||||
const checkChunkSize = 16;
|
||||
|
||||
@@ -824,14 +932,10 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
try {
|
||||
if (await fileExists(filePath)) return MapEntry(id, true);
|
||||
|
||||
// Original file missing -- check for a converted sibling.
|
||||
final sibling = await _findConvertedSibling(filePath);
|
||||
if (sibling != null) {
|
||||
_historyLog.i(
|
||||
'Found converted sibling for $id: $filePath → $sibling',
|
||||
);
|
||||
// Update the stored path so future checks succeed immediately.
|
||||
await _db.updateFilePath(id, sibling);
|
||||
_historyLog.i('Found converted sibling for $id: $filePath -> $sibling');
|
||||
replacementPaths[id] = sibling;
|
||||
pathById[id] = sibling;
|
||||
return MapEntry(id, true);
|
||||
}
|
||||
@@ -853,21 +957,127 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
}
|
||||
}
|
||||
|
||||
if (orphanedIds.isEmpty) {
|
||||
return (
|
||||
orphanedIds: orphanedIds,
|
||||
replacementPaths: replacementPaths,
|
||||
pathById: pathById,
|
||||
);
|
||||
}
|
||||
|
||||
void _applyHistoryPathAndDeletionChanges({
|
||||
required List<String> deletedIds,
|
||||
required Map<String, String> replacementPaths,
|
||||
}) {
|
||||
if (deletedIds.isEmpty && replacementPaths.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final deletedSet = deletedIds.toSet();
|
||||
final updatedItems = <DownloadHistoryItem>[];
|
||||
for (final item in state.items) {
|
||||
if (deletedSet.contains(item.id)) {
|
||||
continue;
|
||||
}
|
||||
final replacementPath = replacementPaths[item.id];
|
||||
if (replacementPath != null && replacementPath != item.filePath) {
|
||||
updatedItems.add(item.copyWith(filePath: replacementPath));
|
||||
} else {
|
||||
updatedItems.add(item);
|
||||
}
|
||||
}
|
||||
state = state.copyWith(items: updatedItems);
|
||||
}
|
||||
|
||||
Future<int> _cleanupOrphanedDownloadsIncremental({
|
||||
required int maxItems,
|
||||
required SharedPreferences prefs,
|
||||
}) async {
|
||||
final cursor = prefs.getInt(_startupOrphanCursorKey) ?? 0;
|
||||
final safeCursor = cursor < 0 ? 0 : cursor;
|
||||
final entries = await _db.getEntriesWithPathsPage(
|
||||
limit: maxItems,
|
||||
offset: safeCursor,
|
||||
);
|
||||
if (entries.isEmpty) {
|
||||
await prefs.remove(_startupOrphanCursorKey);
|
||||
return 0;
|
||||
}
|
||||
|
||||
final result = await _inspectOrphanedEntries(entries);
|
||||
for (final replacement in result.replacementPaths.entries) {
|
||||
await _db.updateFilePath(replacement.key, replacement.value);
|
||||
}
|
||||
|
||||
final deletedCount = result.orphanedIds.isEmpty
|
||||
? 0
|
||||
: await _db.deleteByIds(result.orphanedIds);
|
||||
|
||||
_applyHistoryPathAndDeletionChanges(
|
||||
deletedIds: result.orphanedIds,
|
||||
replacementPaths: result.replacementPaths,
|
||||
);
|
||||
|
||||
if (entries.length < maxItems) {
|
||||
await prefs.remove(_startupOrphanCursorKey);
|
||||
} else {
|
||||
final nextCursor =
|
||||
safeCursor + entries.length - result.orphanedIds.length;
|
||||
await prefs.setInt(_startupOrphanCursorKey, nextCursor);
|
||||
}
|
||||
|
||||
if (deletedCount > 0 || result.replacementPaths.isNotEmpty) {
|
||||
_historyLog.i(
|
||||
'Startup orphan cleanup pass: removed=$deletedCount, repaired=${result.replacementPaths.length}, checked=${entries.length}',
|
||||
);
|
||||
}
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
Future<int> cleanupOrphanedDownloads() async {
|
||||
_historyLog.i('Starting orphaned downloads cleanup...');
|
||||
final orphanedIds = <String>[];
|
||||
final replacementPaths = <String, String>{};
|
||||
const pageSize = 256;
|
||||
var offset = 0;
|
||||
|
||||
while (true) {
|
||||
final entries = await _db.getEntriesWithPathsPage(
|
||||
limit: pageSize,
|
||||
offset: offset,
|
||||
);
|
||||
if (entries.isEmpty) {
|
||||
break;
|
||||
}
|
||||
|
||||
final result = await _inspectOrphanedEntries(entries);
|
||||
orphanedIds.addAll(result.orphanedIds);
|
||||
replacementPaths.addAll(result.replacementPaths);
|
||||
|
||||
if (entries.length < pageSize) {
|
||||
break;
|
||||
}
|
||||
offset += entries.length - result.orphanedIds.length;
|
||||
}
|
||||
|
||||
for (final replacement in replacementPaths.entries) {
|
||||
await _db.updateFilePath(replacement.key, replacement.value);
|
||||
}
|
||||
|
||||
if (orphanedIds.isEmpty && replacementPaths.isEmpty) {
|
||||
_historyLog.i('No orphaned entries found');
|
||||
return 0;
|
||||
}
|
||||
|
||||
final deletedCount = await _db.deleteByIds(orphanedIds);
|
||||
|
||||
final orphanedSet = orphanedIds.toSet();
|
||||
state = state.copyWith(
|
||||
items: state.items
|
||||
.where((item) => !orphanedSet.contains(item.id))
|
||||
.toList(),
|
||||
final deletedCount = orphanedIds.isEmpty
|
||||
? 0
|
||||
: await _db.deleteByIds(orphanedIds);
|
||||
_applyHistoryPathAndDeletionChanges(
|
||||
deletedIds: orphanedIds,
|
||||
replacementPaths: replacementPaths,
|
||||
);
|
||||
|
||||
_historyLog.i('Cleaned up $deletedCount orphaned entries');
|
||||
_historyLog.i(
|
||||
'Cleaned up $deletedCount orphaned entries and repaired ${replacementPaths.length} paths',
|
||||
);
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
|
||||
@@ -324,16 +324,9 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
_log.i('Skipped $skippedDownloads files already in download history');
|
||||
}
|
||||
|
||||
// Full scan should replace library index entirely.
|
||||
await _db.clearAll();
|
||||
if (items.isNotEmpty) {
|
||||
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
|
||||
}
|
||||
final persistedItems =
|
||||
(await _db.getAll())
|
||||
.map(LocalLibraryItem.fromJson)
|
||||
.toList(growable: false)
|
||||
..sort(_compareLibraryItems);
|
||||
// Full scan should replace library index atomically.
|
||||
await _db.replaceAll(items.map((e) => e.toJson()).toList());
|
||||
final persistedItems = [...items]..sort(_compareLibraryItems);
|
||||
|
||||
final now = DateTime.now();
|
||||
try {
|
||||
@@ -502,11 +495,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
_log.i('Deleted $deleteCount items from database');
|
||||
}
|
||||
|
||||
final items =
|
||||
(await _db.getAll())
|
||||
.map(LocalLibraryItem.fromJson)
|
||||
.toList(growable: false)
|
||||
..sort(_compareLibraryItems);
|
||||
final items = currentByPath.values.toList(growable: false)
|
||||
..sort(_compareLibraryItems);
|
||||
|
||||
final now = DateTime.now();
|
||||
try {
|
||||
|
||||
@@ -280,13 +280,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
double _exploreCardSize(BuildContext context) {
|
||||
final scale = _responsiveScale(context: context, min: 0.82, max: 1.08);
|
||||
final textScale = _effectiveTextScale(context);
|
||||
return 120 * scale * (1 + (textScale - 1) * 0.12);
|
||||
return 145 * scale * (1 + (textScale - 1) * 0.12);
|
||||
}
|
||||
|
||||
double _exploreSectionHeight(BuildContext context) {
|
||||
final cardSize = _exploreCardSize(context);
|
||||
final textScale = _effectiveTextScale(context);
|
||||
return cardSize + 55 + ((textScale - 1) * 12);
|
||||
return cardSize + 58 + ((textScale - 1) * 12);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1485,7 +1485,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
if (hasGreeting && index == 0) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Text(
|
||||
greeting,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
@@ -1500,7 +1500,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
return _buildExploreSection(sections[sectionIndex], colorScheme);
|
||||
}
|
||||
|
||||
return const SizedBox(height: 16);
|
||||
return const SizedBox(height: 24);
|
||||
}, childCount: totalCount),
|
||||
),
|
||||
];
|
||||
@@ -1516,7 +1516,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 12),
|
||||
child: Text(
|
||||
section.title,
|
||||
style: Theme.of(
|
||||
@@ -1579,7 +1579,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
isArtist ? cardSize / 2 : 8,
|
||||
isArtist ? cardSize / 2 : 10,
|
||||
),
|
||||
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
@@ -1618,8 +1618,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: isArtist ? TextAlign.center : TextAlign.start,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
@@ -1632,7 +1632,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/widgets/bottom_sheet_option_tile.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
|
||||
class LibraryPlaylistsScreen extends ConsumerWidget {
|
||||
@@ -210,7 +211,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
|
||||
_PlaylistOptionTile(
|
||||
BottomSheetOptionTile(
|
||||
icon: Icons.edit_outlined,
|
||||
title: context.l10n.collectionRenamePlaylist,
|
||||
onTap: () {
|
||||
@@ -224,7 +225,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
||||
},
|
||||
),
|
||||
|
||||
_PlaylistOptionTile(
|
||||
BottomSheetOptionTile(
|
||||
icon: Icons.image_outlined,
|
||||
title: context.l10n.collectionPlaylistChangeCover,
|
||||
onTap: () {
|
||||
@@ -233,7 +234,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
||||
},
|
||||
),
|
||||
|
||||
_PlaylistOptionTile(
|
||||
BottomSheetOptionTile(
|
||||
icon: Icons.delete_outline,
|
||||
iconColor: colorScheme.error,
|
||||
title: context.l10n.collectionDeletePlaylist,
|
||||
@@ -543,40 +544,3 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Styled like _OptionTile in track_collection_quick_actions.dart
|
||||
class _PlaylistOptionTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color? iconColor;
|
||||
final String title;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _PlaylistOptionTile({
|
||||
required this.icon,
|
||||
this.iconColor,
|
||||
required this.title,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: iconColor ?? colorScheme.onPrimaryContainer,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/widgets/bottom_sheet_option_tile.dart';
|
||||
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
|
||||
|
||||
class LibraryTracksFolderScreen extends ConsumerStatefulWidget {
|
||||
@@ -1392,7 +1393,7 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
|
||||
// Add to playlist (hidden in wishlist unless already downloaded)
|
||||
if (showAddToPlaylist)
|
||||
_CollectionOptionTile(
|
||||
BottomSheetOptionTile(
|
||||
icon: Icons.playlist_add,
|
||||
title: context.l10n.collectionAddToPlaylist,
|
||||
onTap: () {
|
||||
@@ -1402,7 +1403,7 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
),
|
||||
|
||||
// Remove from folder / playlist
|
||||
_CollectionOptionTile(
|
||||
BottomSheetOptionTile(
|
||||
icon: Icons.remove_circle_outline,
|
||||
iconColor: colorScheme.error,
|
||||
title: mode == LibraryTracksFolderMode.playlist
|
||||
@@ -1542,43 +1543,6 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Styled like _OptionTile in track_collection_quick_actions.dart
|
||||
class _CollectionOptionTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color? iconColor;
|
||||
final String title;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _CollectionOptionTile({
|
||||
required this.icon,
|
||||
this.iconColor,
|
||||
required this.title,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: iconColor ?? colorScheme.onPrimaryContainer,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SelectionActionButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
+502
-609
File diff suppressed because it is too large
Load Diff
@@ -477,122 +477,40 @@ class _CryptoWalletItem extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
int _cr(String v) {
|
||||
int r = 0x1F;
|
||||
for (final c in v.codeUnits) {
|
||||
r = (r * 31 + c) & 0x7FFFFFFF;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
// Highlighted supporters (hashes of names).
|
||||
const _cv = <int>{1211573191, 1003219236};
|
||||
|
||||
// Diamond tier supporters ($50+ donors).
|
||||
const _dv = <int>{560908930};
|
||||
|
||||
enum _SupporterTier { normal, gold, diamond }
|
||||
|
||||
_SupporterTier _tierOf(String name) {
|
||||
final h = _cr(name);
|
||||
if (_dv.contains(h)) return _SupporterTier.diamond;
|
||||
if (_cv.contains(h)) return _SupporterTier.gold;
|
||||
return _SupporterTier.normal;
|
||||
}
|
||||
|
||||
class _SupporterChip extends StatefulWidget {
|
||||
class _SupporterChip extends StatelessWidget {
|
||||
final String name;
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
const _SupporterChip({required this.name, required this.colorScheme});
|
||||
|
||||
@override
|
||||
State<_SupporterChip> createState() => _SupporterChipState();
|
||||
}
|
||||
|
||||
class _SupporterChipState extends State<_SupporterChip>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final _SupporterTier _tier;
|
||||
AnimationController? _shimmerController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tier = _tierOf(widget.name);
|
||||
if (_tier == _SupporterTier.diamond) {
|
||||
_shimmerController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 2400),
|
||||
)..repeat();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_shimmerController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
if (_tier == _SupporterTier.diamond) {
|
||||
return _buildDiamondChip(isDark);
|
||||
}
|
||||
|
||||
final isGold = _tier == _SupporterTier.gold;
|
||||
const goldChipColor = Color(0xFFFFF8DC);
|
||||
const goldAccentColor = Color(0xFFB8860B);
|
||||
const goldDarkChipColor = Color(0xFF3A3000);
|
||||
|
||||
final chipColor = isGold
|
||||
? goldChipColor
|
||||
: widget.colorScheme.secondaryContainer;
|
||||
final accentColor = isGold ? goldAccentColor : widget.colorScheme.primary;
|
||||
final effectiveChipColor = isGold && isDark ? goldDarkChipColor : chipColor;
|
||||
|
||||
return Material(
|
||||
color: effectiveChipColor,
|
||||
color: colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Container(
|
||||
decoration: isGold
|
||||
? BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: accentColor.withValues(alpha: 0.4),
|
||||
width: 1,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 10,
|
||||
backgroundColor: accentColor.withValues(alpha: 0.2),
|
||||
child: isGold
|
||||
? Icon(Icons.star_rounded, size: 12, color: accentColor)
|
||||
: Text(
|
||||
widget.name.isNotEmpty
|
||||
? widget.name[0].toUpperCase()
|
||||
: '?',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: accentColor,
|
||||
),
|
||||
),
|
||||
backgroundColor: colorScheme.primary.withValues(alpha: 0.2),
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
widget.name,
|
||||
name,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: isGold
|
||||
? accentColor
|
||||
: widget.colorScheme.onSecondaryContainer,
|
||||
fontWeight: isGold ? FontWeight.w600 : FontWeight.w500,
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -600,92 +518,6 @@ class _SupporterChipState extends State<_SupporterChip>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDiamondChip(bool isDark) {
|
||||
const diamondLight = Color(0xFFE8F4FD);
|
||||
const diamondDark = Color(0xFF0D2B3E);
|
||||
const diamondAccent = Color(0xFF4FC3F7);
|
||||
const diamondHighlight = Color(0xFFB3E5FC);
|
||||
|
||||
final chipBg = isDark ? diamondDark : diamondLight;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _shimmerController!,
|
||||
builder: (context, child) {
|
||||
final t = _shimmerController!.value;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment(-2.0 + 4.0 * t, 0.0),
|
||||
end: Alignment(-1.0 + 4.0 * t, 0.0),
|
||||
colors: [
|
||||
chipBg,
|
||||
isDark
|
||||
? diamondAccent.withValues(alpha: 0.18)
|
||||
: diamondHighlight.withValues(alpha: 0.7),
|
||||
chipBg,
|
||||
],
|
||||
stops: const [0.0, 0.5, 1.0],
|
||||
),
|
||||
border: Border.all(
|
||||
color: diamondAccent.withValues(
|
||||
alpha: 0.5 + 0.3 * (0.5 - (t - 0.5).abs()),
|
||||
),
|
||||
width: 1.2,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: diamondAccent.withValues(
|
||||
alpha: 0.15 + 0.1 * (0.5 - (t - 0.5).abs()),
|
||||
),
|
||||
blurRadius: 8,
|
||||
spreadRadius: 0,
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
diamondAccent.withValues(alpha: 0.3),
|
||||
diamondAccent.withValues(alpha: 0.15),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.diamond_rounded,
|
||||
size: 12,
|
||||
color: diamondAccent,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
widget.name,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: isDark ? diamondHighlight : diamondAccent,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NoticeLine extends StatelessWidget {
|
||||
|
||||
@@ -328,6 +328,20 @@ class HistoryDatabase {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> upsertBatch(List<Map<String, dynamic>> items) async {
|
||||
if (items.isEmpty) return;
|
||||
final db = await database;
|
||||
final batch = db.batch();
|
||||
for (final json in items) {
|
||||
batch.insert(
|
||||
'history',
|
||||
_jsonToDbRow(json),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
/// Get all history items ordered by download date (newest first)
|
||||
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
|
||||
final db = await database;
|
||||
@@ -532,6 +546,29 @@ class HistoryDatabase {
|
||||
return rows.map((r) => Map<String, dynamic>.from(r)).toList();
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getEntriesWithPathsPage({
|
||||
required int limit,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
'history',
|
||||
columns: [
|
||||
'id',
|
||||
'file_path',
|
||||
'storage_mode',
|
||||
'download_tree_uri',
|
||||
'saf_relative_dir',
|
||||
'saf_file_name',
|
||||
],
|
||||
where: 'file_path IS NOT NULL AND file_path != ""',
|
||||
orderBy: 'downloaded_at DESC, id DESC',
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
);
|
||||
return rows.map((r) => Map<String, dynamic>.from(r)).toList();
|
||||
}
|
||||
|
||||
/// Delete multiple entries by IDs
|
||||
Future<int> deleteByIds(List<String> ids) async {
|
||||
if (ids.isEmpty) return 0;
|
||||
|
||||
@@ -255,20 +255,41 @@ class LibraryDatabase {
|
||||
Future<void> upsertBatch(List<Map<String, dynamic>> items) async {
|
||||
if (items.isEmpty) return;
|
||||
final db = await database;
|
||||
final batch = db.batch();
|
||||
|
||||
for (final json in items) {
|
||||
batch.insert(
|
||||
'library',
|
||||
_jsonToDbRow(json),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
await batch.commit(noResult: true);
|
||||
await db.transaction((txn) async {
|
||||
final batch = txn.batch();
|
||||
for (final json in items) {
|
||||
batch.insert(
|
||||
'library',
|
||||
_jsonToDbRow(json),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
});
|
||||
_log.i('Batch inserted ${items.length} items');
|
||||
}
|
||||
|
||||
Future<void> replaceAll(List<Map<String, dynamic>> items) async {
|
||||
final db = await database;
|
||||
await db.transaction((txn) async {
|
||||
await txn.delete('library');
|
||||
if (items.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final batch = txn.batch();
|
||||
for (final json in items) {
|
||||
batch.insert(
|
||||
'library',
|
||||
_jsonToDbRow(json),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
});
|
||||
_log.i('Replaced library with ${items.length} items');
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
|
||||
@@ -83,24 +83,18 @@ class PlatformBridge {
|
||||
|
||||
static Future<Map<String, dynamic>> getDownloadProgress() async {
|
||||
final result = await _channel.invokeMethod('getDownloadProgress');
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
return _decodeMapResult(result);
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> getAllDownloadProgress() async {
|
||||
final result = await _channel.invokeMethod('getAllDownloadProgress');
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
return _decodeMapResult(result);
|
||||
}
|
||||
|
||||
static Stream<Map<String, dynamic>> downloadProgressStream() {
|
||||
return _downloadProgressEvents.receiveBroadcastStream().map((event) {
|
||||
if (event is String) {
|
||||
return jsonDecode(event) as Map<String, dynamic>;
|
||||
}
|
||||
if (event is Map) {
|
||||
return Map<String, dynamic>.from(event);
|
||||
}
|
||||
return const <String, dynamic>{};
|
||||
});
|
||||
return _downloadProgressEvents
|
||||
.receiveBroadcastStream()
|
||||
.map(_decodeMapResult);
|
||||
}
|
||||
|
||||
static Future<void> exitApp() async {
|
||||
@@ -1186,19 +1180,13 @@ class PlatformBridge {
|
||||
/// Get current library scan progress
|
||||
static Future<Map<String, dynamic>> getLibraryScanProgress() async {
|
||||
final result = await _channel.invokeMethod('getLibraryScanProgress');
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
return _decodeMapResult(result);
|
||||
}
|
||||
|
||||
static Stream<Map<String, dynamic>> libraryScanProgressStream() {
|
||||
return _libraryScanProgressEvents.receiveBroadcastStream().map((event) {
|
||||
if (event is String) {
|
||||
return jsonDecode(event) as Map<String, dynamic>;
|
||||
}
|
||||
if (event is Map) {
|
||||
return Map<String, dynamic>.from(event);
|
||||
}
|
||||
return const <String, dynamic>{};
|
||||
});
|
||||
return _libraryScanProgressEvents
|
||||
.receiveBroadcastStream()
|
||||
.map(_decodeMapResult);
|
||||
}
|
||||
|
||||
/// Cancel ongoing library scan
|
||||
@@ -1206,6 +1194,20 @@ class PlatformBridge {
|
||||
await _channel.invokeMethod('cancelLibraryScan');
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _decodeMapResult(dynamic result) {
|
||||
if (result is Map) {
|
||||
return Map<String, dynamic>.from(result);
|
||||
}
|
||||
if (result is String) {
|
||||
if (result.isEmpty) return const <String, dynamic>{};
|
||||
final decoded = jsonDecode(result);
|
||||
if (decoded is Map) {
|
||||
return Map<String, dynamic>.from(decoded);
|
||||
}
|
||||
}
|
||||
return const <String, dynamic>{};
|
||||
}
|
||||
|
||||
// MARK: - iOS Security-Scoped Bookmark
|
||||
|
||||
/// Create a security-scoped bookmark from a filesystem path picked by
|
||||
|
||||
@@ -22,6 +22,9 @@ const _audioExtensions = <String>[
|
||||
'.aac',
|
||||
];
|
||||
|
||||
const _maxPathMatchKeyCacheSize = 6000;
|
||||
final Map<String, Set<String>> _pathMatchKeyCache = <String, Set<String>>{};
|
||||
|
||||
/// Strips a trailing audio extension from [path] if present.
|
||||
/// Returns the path without extension, or `null` if no known audio extension
|
||||
/// was found.
|
||||
@@ -41,6 +44,11 @@ Set<String> buildPathMatchKeys(String? filePath) {
|
||||
|
||||
final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7).trim() : raw;
|
||||
if (cleaned.isEmpty) return const {};
|
||||
final cached = _pathMatchKeyCache.remove(cleaned);
|
||||
if (cached != null) {
|
||||
_pathMatchKeyCache[cleaned] = cached;
|
||||
return cached;
|
||||
}
|
||||
|
||||
final keys = <String>{};
|
||||
final visited = <String>{};
|
||||
@@ -118,7 +126,12 @@ Set<String> buildPathMatchKeys(String? filePath) {
|
||||
}
|
||||
keys.addAll(extensionStrippedKeys);
|
||||
|
||||
return keys;
|
||||
final result = Set<String>.unmodifiable(keys);
|
||||
_pathMatchKeyCache[cleaned] = result;
|
||||
while (_pathMatchKeyCache.length > _maxPathMatchKeyCacheSize) {
|
||||
_pathMatchKeyCache.remove(_pathMatchKeyCache.keys.first);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Iterable<String> _androidEquivalentPaths(String path) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Reusable option tile for bottom sheets.
|
||||
/// Used in playlist options, track options, cover options, etc.
|
||||
class BottomSheetOptionTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color? iconColor;
|
||||
final String title;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const BottomSheetOptionTile({
|
||||
super.key,
|
||||
required this.icon,
|
||||
this.iconColor,
|
||||
required this.title,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: iconColor ?? colorScheme.onPrimaryContainer,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -303,7 +303,9 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
),
|
||||
for (final ext in downloadExtensions)
|
||||
_ServiceChip(
|
||||
label: ext.displayName,
|
||||
label: widget.recommendedService == ext.id
|
||||
? '${ext.displayName} (Recommended)'
|
||||
: ext.displayName,
|
||||
isSelected: _selectedService == ext.id,
|
||||
onTap: () => setState(() => _selectedService = ext.id),
|
||||
iconPath: ext.iconPath,
|
||||
|
||||
Reference in New Issue
Block a user