mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-06-13 01:47:52 +02:00
perf: reduce bridge and UI churn
This commit is contained in:
@@ -43,6 +43,8 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
"com.zarz.spotiflac/library_scan_progress_stream"
|
||||
private val DOWNLOAD_PROGRESS_STREAM_POLLING_INTERVAL_MS = 1200L
|
||||
private val LIBRARY_SCAN_PROGRESS_STREAM_POLLING_INTERVAL_MS = 200L
|
||||
private val LARGE_JSON_RESULT_FILE_KEY = "__json_file"
|
||||
private val LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES = 256 * 1024
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private var pendingSafTreeResult: MethodChannel.Result? = null
|
||||
private val safScanLock = Any()
|
||||
@@ -51,6 +53,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
private var downloadProgressStreamJob: Job? = null
|
||||
private var downloadProgressEventSink: EventChannel.EventSink? = null
|
||||
private var lastDownloadProgressPayload: String? = null
|
||||
private var lastDownloadProgressSeq = 0L
|
||||
private var libraryScanProgressStreamJob: Job? = null
|
||||
private var libraryScanProgressEventSink: EventChannel.EventSink? = null
|
||||
private var lastLibraryScanProgressPayload: String? = null
|
||||
@@ -504,17 +507,46 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun bridgeJsonResult(payload: String): Any {
|
||||
if (payload.toByteArray(Charsets.UTF_8).size < LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES) {
|
||||
return payload
|
||||
}
|
||||
|
||||
return try {
|
||||
val file = File(cacheDir, "bridge_json_${System.nanoTime()}.json")
|
||||
file.writeText(payload, Charsets.UTF_8)
|
||||
mapOf(LARGE_JSON_RESULT_FILE_KEY to file.absolutePath)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(
|
||||
"SpotiFLAC",
|
||||
"Failed to spill large bridge JSON result to file: ${e.message}",
|
||||
)
|
||||
payload
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateDownloadProgressSeq(payload: String) {
|
||||
try {
|
||||
val seq = JSONObject(payload).optLong("seq", lastDownloadProgressSeq)
|
||||
if (seq > lastDownloadProgressSeq) {
|
||||
lastDownloadProgressSeq = seq
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
private fun startDownloadProgressStream(sink: EventChannel.EventSink) {
|
||||
stopDownloadProgressStream()
|
||||
downloadProgressEventSink = sink
|
||||
lastDownloadProgressPayload = null
|
||||
lastDownloadProgressSeq = 0L
|
||||
downloadProgressStreamJob = scope.launch {
|
||||
while (isActive && downloadProgressEventSink === sink) {
|
||||
try {
|
||||
val payload = withContext(Dispatchers.IO) {
|
||||
Gobackend.getAllDownloadProgress()
|
||||
Gobackend.getAllDownloadProgressDelta(lastDownloadProgressSeq)
|
||||
}
|
||||
if (payload != lastDownloadProgressPayload) {
|
||||
if (payload.isNotEmpty() && payload != lastDownloadProgressPayload) {
|
||||
updateDownloadProgressSeq(payload)
|
||||
lastDownloadProgressPayload = payload
|
||||
sink.success(parseJsonPayload(payload))
|
||||
}
|
||||
@@ -534,6 +566,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
downloadProgressStreamJob = null
|
||||
downloadProgressEventSink = null
|
||||
lastDownloadProgressPayload = null
|
||||
lastDownloadProgressSeq = 0L
|
||||
}
|
||||
|
||||
private fun startLibraryScanProgressStream(sink: EventChannel.EventSink) {
|
||||
@@ -580,17 +613,17 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
lastLibraryScanProgressPayload = null
|
||||
}
|
||||
|
||||
private fun loadExistingFilesJsonFromSnapshot(snapshotPath: String): String {
|
||||
private fun loadExistingFilesFromSnapshot(snapshotPath: String): MutableMap<String, Long> {
|
||||
val result = mutableMapOf<String, Long>()
|
||||
if (snapshotPath.isBlank()) {
|
||||
return "{}"
|
||||
return result
|
||||
}
|
||||
|
||||
val snapshotFile = File(snapshotPath)
|
||||
if (!snapshotFile.exists()) {
|
||||
return "{}"
|
||||
return result
|
||||
}
|
||||
|
||||
val result = JSONObject()
|
||||
snapshotFile.forEachLine { line ->
|
||||
if (line.isBlank()) return@forEachLine
|
||||
val separatorIndex = line.indexOf('\t')
|
||||
@@ -600,10 +633,10 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val modTime = line.substring(0, separatorIndex).toLongOrNull() ?: 0L
|
||||
val filePath = line.substring(separatorIndex + 1)
|
||||
if (filePath.isNotEmpty()) {
|
||||
result.put(filePath, modTime)
|
||||
result[filePath] = modTime
|
||||
}
|
||||
}
|
||||
return result.toString()
|
||||
return result
|
||||
}
|
||||
|
||||
private fun resolveSafFile(treeUriStr: String, relativeDir: String, fileName: String): String {
|
||||
@@ -1452,6 +1485,22 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
* @return JSON object with new/changed files and removed URIs
|
||||
*/
|
||||
private fun scanSafTreeIncremental(treeUriStr: String, existingFilesJson: String): String {
|
||||
val existingFiles = mutableMapOf<String, Long>()
|
||||
try {
|
||||
val obj = JSONObject(existingFilesJson)
|
||||
val keys = obj.keys()
|
||||
while (keys.hasNext()) {
|
||||
val key = keys.next()
|
||||
existingFiles[key] = obj.optLong(key, 0)
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
return scanSafTreeIncremental(treeUriStr, existingFiles)
|
||||
}
|
||||
|
||||
private fun scanSafTreeIncremental(
|
||||
treeUriStr: String,
|
||||
existingFiles: Map<String, Long>,
|
||||
): String {
|
||||
if (treeUriStr.isBlank()) {
|
||||
val result = JSONObject()
|
||||
result.put("files", JSONArray())
|
||||
@@ -1471,16 +1520,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
val existingFiles = mutableMapOf<String, Long>()
|
||||
try {
|
||||
val obj = JSONObject(existingFilesJson)
|
||||
val keys = obj.keys()
|
||||
while (keys.hasNext()) {
|
||||
val key = keys.next()
|
||||
existingFiles[key] = obj.optLong(key, 0)
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
|
||||
resetSafScanProgress()
|
||||
safScanCancel = false
|
||||
safScanActive = true
|
||||
@@ -3222,11 +3261,19 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val optionsJson = call.argument<String>("options") ?: ""
|
||||
val requestId = call.argument<String>("request_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.customSearchWithExtensionJSON(extensionId, query, optionsJson)
|
||||
Gobackend.customSearchWithExtensionJSONWithRequestID(extensionId, query, optionsJson, requestId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"cancelExtensionRequest" -> {
|
||||
val requestId = call.argument<String>("request_id") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.cancelExtensionRequestJSON(requestId)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"getSearchProviders" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getSearchProvidersJSON()
|
||||
@@ -3359,8 +3406,9 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
"getExtensionHomeFeed" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val requestId = call.argument<String>("request_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getExtensionHomeFeedJSON(extensionId)
|
||||
Gobackend.getExtensionHomeFeedJSONWithRequestID(extensionId, requestId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
@@ -3382,7 +3430,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val folderPath = call.argument<String>("folder_path") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
safScanActive = false
|
||||
Gobackend.scanLibraryFolderJSON(folderPath)
|
||||
bridgeJsonResult(Gobackend.scanLibraryFolderJSON(folderPath))
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
@@ -3391,7 +3439,9 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val existingFiles = call.argument<String>("existing_files") ?: "{}"
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
safScanActive = false
|
||||
Gobackend.scanLibraryFolderIncrementalJSON(folderPath, existingFiles)
|
||||
bridgeJsonResult(
|
||||
Gobackend.scanLibraryFolderIncrementalJSON(folderPath, existingFiles)
|
||||
)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
@@ -3400,9 +3450,11 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val snapshotPath = call.argument<String>("snapshot_path") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
safScanActive = false
|
||||
Gobackend.scanLibraryFolderIncrementalFromSnapshotJSON(
|
||||
folderPath,
|
||||
snapshotPath,
|
||||
bridgeJsonResult(
|
||||
Gobackend.scanLibraryFolderIncrementalFromSnapshotJSON(
|
||||
folderPath,
|
||||
snapshotPath,
|
||||
)
|
||||
)
|
||||
}
|
||||
result.success(response)
|
||||
@@ -3410,7 +3462,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
"scanSafTree" -> {
|
||||
val treeUri = call.argument<String>("tree_uri") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
scanSafTree(treeUri)
|
||||
bridgeJsonResult(scanSafTree(treeUri))
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
@@ -3418,7 +3470,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val treeUri = call.argument<String>("tree_uri") ?: ""
|
||||
val existingFiles = call.argument<String>("existing_files") ?: "{}"
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
scanSafTreeIncremental(treeUri, existingFiles)
|
||||
bridgeJsonResult(scanSafTreeIncremental(treeUri, existingFiles))
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
@@ -3426,9 +3478,9 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val treeUri = call.argument<String>("tree_uri") ?: ""
|
||||
val snapshotPath = call.argument<String>("snapshot_path") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
val existingFilesJson =
|
||||
loadExistingFilesJsonFromSnapshot(snapshotPath)
|
||||
scanSafTreeIncremental(treeUri, existingFilesJson)
|
||||
val existingFiles =
|
||||
loadExistingFilesFromSnapshot(snapshotPath)
|
||||
bridgeJsonResult(scanSafTreeIncremental(treeUri, existingFiles))
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ import (
|
||||
// ErrDownloadCancelled is returned when a download is cancelled by the user.
|
||||
var ErrDownloadCancelled = errors.New("download cancelled")
|
||||
|
||||
// ErrExtensionRequestCancelled is returned when a UI-driven extension request
|
||||
// is superseded by a newer home/search request.
|
||||
var ErrExtensionRequestCancelled = errors.New("extension request cancelled")
|
||||
|
||||
type cancelEntry struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
@@ -19,6 +23,9 @@ type cancelEntry struct {
|
||||
var (
|
||||
cancelMu sync.Mutex
|
||||
cancelMap = make(map[string]*cancelEntry)
|
||||
|
||||
extensionRequestCancelMu sync.Mutex
|
||||
extensionRequestCancelMap = make(map[string]*cancelEntry)
|
||||
)
|
||||
|
||||
func initDownloadCancel(itemID string) context.Context {
|
||||
@@ -98,3 +105,78 @@ func clearDownloadCancel(itemID string) {
|
||||
}
|
||||
cancelMu.Unlock()
|
||||
}
|
||||
|
||||
func initExtensionRequestCancel(requestID string) context.Context {
|
||||
if requestID == "" {
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
extensionRequestCancelMu.Lock()
|
||||
defer extensionRequestCancelMu.Unlock()
|
||||
|
||||
if entry, ok := extensionRequestCancelMap[requestID]; ok {
|
||||
if entry.ctx == nil {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
entry.ctx = ctx
|
||||
entry.cancel = cancel
|
||||
if entry.canceled && entry.cancel != nil {
|
||||
entry.cancel()
|
||||
}
|
||||
}
|
||||
entry.refs++
|
||||
return entry.ctx
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
extensionRequestCancelMap[requestID] = &cancelEntry{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
canceled: false,
|
||||
refs: 1,
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func cancelExtensionRequest(requestID string) {
|
||||
if requestID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
extensionRequestCancelMu.Lock()
|
||||
if entry, ok := extensionRequestCancelMap[requestID]; ok {
|
||||
entry.canceled = true
|
||||
if entry.cancel != nil {
|
||||
entry.cancel()
|
||||
}
|
||||
} else {
|
||||
extensionRequestCancelMap[requestID] = &cancelEntry{canceled: true}
|
||||
}
|
||||
extensionRequestCancelMu.Unlock()
|
||||
}
|
||||
|
||||
func isExtensionRequestCancelled(requestID string) bool {
|
||||
if requestID == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
extensionRequestCancelMu.Lock()
|
||||
entry, ok := extensionRequestCancelMap[requestID]
|
||||
canceled := ok && entry.canceled
|
||||
extensionRequestCancelMu.Unlock()
|
||||
return canceled
|
||||
}
|
||||
|
||||
func clearExtensionRequestCancel(requestID string) {
|
||||
if requestID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
extensionRequestCancelMu.Lock()
|
||||
if entry, ok := extensionRequestCancelMap[requestID]; ok {
|
||||
entry.refs--
|
||||
if entry.refs <= 0 {
|
||||
delete(extensionRequestCancelMap, requestID)
|
||||
}
|
||||
}
|
||||
extensionRequestCancelMu.Unlock()
|
||||
}
|
||||
|
||||
+52
-4
@@ -3,6 +3,7 @@ package gobackend
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -1059,6 +1060,10 @@ func GetAllDownloadProgress() string {
|
||||
return GetMultiProgress()
|
||||
}
|
||||
|
||||
func GetAllDownloadProgressDelta(sinceSeq int64) string {
|
||||
return GetMultiProgressDelta(sinceSeq)
|
||||
}
|
||||
|
||||
func InitItemProgress(itemID string) {
|
||||
StartItemProgress(itemID)
|
||||
}
|
||||
@@ -3217,6 +3222,10 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error)
|
||||
}
|
||||
|
||||
func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string) (string, error) {
|
||||
return CustomSearchWithExtensionJSONWithRequestID(extensionID, query, optionsJSON, "")
|
||||
}
|
||||
|
||||
func CustomSearchWithExtensionJSONWithRequestID(extensionID, query string, optionsJSON string, requestID string) (string, error) {
|
||||
manager := getExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
@@ -3235,7 +3244,7 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string
|
||||
}
|
||||
|
||||
provider := newExtensionProviderWrapper(ext)
|
||||
tracks, err := provider.CustomSearch(query, options)
|
||||
tracks, err := provider.CustomSearchForRequestID(query, options, requestID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -3714,6 +3723,10 @@ func ClearStoreCacheJSON() error {
|
||||
}
|
||||
|
||||
func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Duration) (string, error) {
|
||||
return callExtensionFunctionJSONWithRequestID(extensionID, functionName, timeout, "")
|
||||
}
|
||||
|
||||
func callExtensionFunctionJSONWithRequestID(extensionID, functionName string, timeout time.Duration, requestID string) (string, error) {
|
||||
manager := getExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
@@ -3723,11 +3736,27 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du
|
||||
if !ext.Enabled {
|
||||
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
|
||||
}
|
||||
perf := newExtensionCallPerf(extensionID, functionName)
|
||||
defer perf.finish()
|
||||
initStartedAt := time.Now()
|
||||
vm, err := ext.lockReadyVM()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
perf.recordInit(time.Since(initStartedAt))
|
||||
defer ext.VMMu.Unlock()
|
||||
requestCtx := context.Background()
|
||||
if requestID != "" {
|
||||
if ext.runtime != nil {
|
||||
ext.runtime.setActiveRequestID(requestID)
|
||||
defer ext.runtime.clearActiveRequestID()
|
||||
}
|
||||
requestCtx = initExtensionRequestCancel(requestID)
|
||||
defer clearExtensionRequestCancel(requestID)
|
||||
if isExtensionRequestCancelled(requestID) {
|
||||
return "", ErrExtensionRequestCancelled
|
||||
}
|
||||
}
|
||||
|
||||
// Goja runtime is not thread-safe; guard direct extension.*() calls with VMMu
|
||||
// to avoid races with other provider calls (e.g. getAlbum/getPlaylist).
|
||||
@@ -3740,20 +3769,31 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du
|
||||
})()
|
||||
`, functionName, functionName)
|
||||
|
||||
result, err := RunWithTimeoutAndRecover(vm, script, timeout)
|
||||
jsStartedAt := time.Now()
|
||||
result, err := RunWithTimeoutContextAndRecover(requestCtx, vm, script, timeout)
|
||||
perf.recordJS(time.Since(jsStartedAt))
|
||||
if err != nil {
|
||||
if isExtensionRequestCancelled(requestID) || errors.Is(err, ErrExtensionRequestCancelled) {
|
||||
return "", ErrExtensionRequestCancelled
|
||||
}
|
||||
return "", fmt.Errorf("%s failed: %w", functionName, err)
|
||||
}
|
||||
if isExtensionRequestCancelled(requestID) {
|
||||
return "", ErrExtensionRequestCancelled
|
||||
}
|
||||
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
return "", fmt.Errorf("%s returned null", functionName)
|
||||
}
|
||||
|
||||
exported := result.Export()
|
||||
jsonBytes, err := json.Marshal(exported)
|
||||
parseStartedAt := time.Now()
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
perf.recordParse(time.Since(parseStartedAt))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal result: %w", err)
|
||||
}
|
||||
perf.setPayloadBytes(len(jsonBytes))
|
||||
perf.setItems(countExtensionTopLevelItems(vm, result))
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
@@ -3762,10 +3802,18 @@ func GetExtensionHomeFeedJSON(extensionID string) (string, error) {
|
||||
return callExtensionFunctionJSON(extensionID, "getHomeFeed", 60*time.Second)
|
||||
}
|
||||
|
||||
func GetExtensionHomeFeedJSONWithRequestID(extensionID, requestID string) (string, error) {
|
||||
return callExtensionFunctionJSONWithRequestID(extensionID, "getHomeFeed", 60*time.Second, requestID)
|
||||
}
|
||||
|
||||
func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) {
|
||||
return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second)
|
||||
}
|
||||
|
||||
func CancelExtensionRequestJSON(requestID string) {
|
||||
cancelExtensionRequest(requestID)
|
||||
}
|
||||
|
||||
func SetLibraryCoverCacheDirJSON(cacheDir string) {
|
||||
SetLibraryCoverCacheDir(cacheDir)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
type extensionCallPerf struct {
|
||||
extensionID string
|
||||
operation string
|
||||
startedAt time.Time
|
||||
initMs float64
|
||||
jsMs float64
|
||||
parseMs float64
|
||||
items int
|
||||
payloadBytes int
|
||||
}
|
||||
|
||||
func newExtensionCallPerf(extensionID, operation string) *extensionCallPerf {
|
||||
if !GetLogBuffer().IsLoggingEnabled() {
|
||||
return nil
|
||||
}
|
||||
return &extensionCallPerf{
|
||||
extensionID: extensionID,
|
||||
operation: operation,
|
||||
startedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func extensionDurationMs(duration time.Duration) float64 {
|
||||
return float64(duration.Microseconds()) / 1000.0
|
||||
}
|
||||
|
||||
func (p *extensionCallPerf) recordInit(duration time.Duration) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.initMs += extensionDurationMs(duration)
|
||||
}
|
||||
|
||||
func (p *extensionCallPerf) recordJS(duration time.Duration) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.jsMs += extensionDurationMs(duration)
|
||||
}
|
||||
|
||||
func (p *extensionCallPerf) recordParse(duration time.Duration) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.parseMs += extensionDurationMs(duration)
|
||||
}
|
||||
|
||||
func (p *extensionCallPerf) recordPayload(value goja.Value) {
|
||||
if p == nil || gojaValueIsEmpty(value) {
|
||||
return
|
||||
}
|
||||
if payload, err := json.Marshal(value); err == nil {
|
||||
p.payloadBytes = len(payload)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *extensionCallPerf) setPayloadBytes(payloadBytes int) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.payloadBytes = payloadBytes
|
||||
}
|
||||
|
||||
func (p *extensionCallPerf) setItems(items int) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.items = items
|
||||
}
|
||||
|
||||
func (p *extensionCallPerf) finish() {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
LogDebug(
|
||||
"ExtensionPerf",
|
||||
"extension=%s op=%s totalMs=%.1f initMs=%.1f jsMs=%.1f parseMs=%.1f items=%d payloadBytes=%d",
|
||||
p.extensionID,
|
||||
p.operation,
|
||||
extensionDurationMs(time.Since(p.startedAt)),
|
||||
p.initMs,
|
||||
p.jsMs,
|
||||
p.parseMs,
|
||||
p.items,
|
||||
p.payloadBytes,
|
||||
)
|
||||
}
|
||||
|
||||
func countExtensionTopLevelItems(vm *goja.Runtime, value goja.Value) int {
|
||||
if gojaValueIsEmpty(value) {
|
||||
return 0
|
||||
}
|
||||
|
||||
if length, err := gojaArrayLength(value, vm); err == nil && length > 0 {
|
||||
return length
|
||||
}
|
||||
|
||||
obj := value.ToObject(vm)
|
||||
for _, key := range []string{"items", "tracks", "sections", "albums", "artists", "playlists", "results"} {
|
||||
child := obj.Get(key)
|
||||
if gojaValueIsEmpty(child) {
|
||||
continue
|
||||
}
|
||||
if length, err := gojaArrayLength(child, vm); err == nil && length > 0 {
|
||||
return length
|
||||
}
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
+697
-173
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,8 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func TestSetMetadataProviderPriorityStripsRetiredBuiltIns(t *testing.T) {
|
||||
@@ -396,3 +398,243 @@ func TestSearchTracksWithMetadataProvidersIgnoresRetiredBuiltIns(t *testing.T) {
|
||||
t.Fatalf("expected retired built-in provider not to be queried, got %v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExtensionSearchResultAcceptsObjectAndArrayShapes(t *testing.T) {
|
||||
vm := goja.New()
|
||||
value, err := vm.RunString(`({
|
||||
tracks: [{
|
||||
id: "track-1",
|
||||
name: "Song",
|
||||
artists: "Artist",
|
||||
album_name: "Album",
|
||||
duration_ms: 123000,
|
||||
cover_url: "https://img.test/cover.jpg",
|
||||
external_links: { spotify: "spotify:track:1" },
|
||||
audio_quality: "LOSSLESS"
|
||||
}],
|
||||
total: 9
|
||||
})`)
|
||||
if err != nil {
|
||||
t.Fatalf("build object search result: %v", err)
|
||||
}
|
||||
|
||||
result, err := parseExtensionSearchResult(vm, value)
|
||||
if err != nil {
|
||||
t.Fatalf("parse object search result: %v", err)
|
||||
}
|
||||
if result.Total != 9 || len(result.Tracks) != 1 {
|
||||
t.Fatalf("unexpected object result: %+v", result)
|
||||
}
|
||||
track := result.Tracks[0]
|
||||
if track.ID != "track-1" ||
|
||||
track.AlbumName != "Album" ||
|
||||
track.DurationMS != 123000 ||
|
||||
track.CoverURL != "https://img.test/cover.jpg" ||
|
||||
track.ExternalLinks["spotify"] != "spotify:track:1" ||
|
||||
track.AudioQuality != "LOSSLESS" {
|
||||
t.Fatalf("unexpected parsed track: %+v", track)
|
||||
}
|
||||
|
||||
arrayValue, err := vm.RunString(`[
|
||||
{id: "track-2", name: "Other Song", artists: "Other Artist", albumName: "Other Album", durationMs: 456000}
|
||||
]`)
|
||||
if err != nil {
|
||||
t.Fatalf("build array search result: %v", err)
|
||||
}
|
||||
|
||||
arrayResult, err := parseExtensionSearchResult(vm, arrayValue)
|
||||
if err != nil {
|
||||
t.Fatalf("parse array search result: %v", err)
|
||||
}
|
||||
if arrayResult.Total != 1 ||
|
||||
len(arrayResult.Tracks) != 1 ||
|
||||
arrayResult.Tracks[0].AlbumName != "Other Album" ||
|
||||
arrayResult.Tracks[0].DurationMS != 456000 {
|
||||
t.Fatalf("unexpected array result: %+v", arrayResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExtensionMetadataAndDownloadResults(t *testing.T) {
|
||||
vm := goja.New()
|
||||
value, err := vm.RunString(`({
|
||||
id: "album-1",
|
||||
name: "Album",
|
||||
artists: "Artist",
|
||||
artistId: "artist-1",
|
||||
coverUrl: "https://img.test/album.jpg",
|
||||
releaseDate: "2024-02-03",
|
||||
totalTracks: 2,
|
||||
albumType: "album",
|
||||
tracks: [
|
||||
{id: "track-1", name: "Song 1", artists: "Artist", durationMs: 180000},
|
||||
{id: "track-2", name: "Song 2", artists: "Artist", duration_ms: 181000}
|
||||
]
|
||||
})`)
|
||||
if err != nil {
|
||||
t.Fatalf("build album value: %v", err)
|
||||
}
|
||||
|
||||
album, err := parseExtensionAlbumValue(vm, value)
|
||||
if err != nil {
|
||||
t.Fatalf("parse album: %v", err)
|
||||
}
|
||||
if album.ID != "album-1" ||
|
||||
album.ArtistID != "artist-1" ||
|
||||
album.CoverURL != "https://img.test/album.jpg" ||
|
||||
album.TotalTracks != 2 ||
|
||||
len(album.Tracks) != 2 ||
|
||||
album.Tracks[0].DurationMS != 180000 ||
|
||||
album.Tracks[1].DurationMS != 181000 {
|
||||
t.Fatalf("unexpected album: %+v", album)
|
||||
}
|
||||
|
||||
artistValue, err := vm.RunString(`({
|
||||
id: "artist-1",
|
||||
name: "Artist",
|
||||
imageUrl: "https://img.test/artist.jpg",
|
||||
headerImage: "https://img.test/header.jpg",
|
||||
listeners: 1234,
|
||||
albums: [{id: "album-1", name: "Album", tracks: [{id: "track-1", name: "Song"}]}],
|
||||
releases: [{id: "single-1", name: "Single"}],
|
||||
topTracks: [{id: "top-1", name: "Top Song"}]
|
||||
})`)
|
||||
if err != nil {
|
||||
t.Fatalf("build artist value: %v", err)
|
||||
}
|
||||
|
||||
artist, err := parseExtensionArtistValue(vm, artistValue)
|
||||
if err != nil {
|
||||
t.Fatalf("parse artist: %v", err)
|
||||
}
|
||||
if artist.ID != "artist-1" ||
|
||||
artist.ImageURL != "https://img.test/artist.jpg" ||
|
||||
artist.HeaderImage != "https://img.test/header.jpg" ||
|
||||
artist.Listeners != 1234 ||
|
||||
len(artist.Albums) != 1 ||
|
||||
len(artist.Albums[0].Tracks) != 1 ||
|
||||
len(artist.Releases) != 1 ||
|
||||
len(artist.TopTracks) != 1 {
|
||||
t.Fatalf("unexpected artist: %+v", artist)
|
||||
}
|
||||
|
||||
downloadValue, err := vm.RunString(`({
|
||||
success: true,
|
||||
filePath: "/tmp/song.flac",
|
||||
alreadyExists: true,
|
||||
bitDepth: 24,
|
||||
sampleRate: 96000,
|
||||
title: "Song",
|
||||
albumArtist: "Album Artist",
|
||||
lyricsLrc: "[00:00.00]Line",
|
||||
decryptionKey: "001122",
|
||||
decryption: {
|
||||
strategy: "mp4_decryption_key",
|
||||
key: "001122",
|
||||
inputFormat: "m4a",
|
||||
options: { map: "0:a" }
|
||||
}
|
||||
})`)
|
||||
if err != nil {
|
||||
t.Fatalf("build download value: %v", err)
|
||||
}
|
||||
|
||||
download := parseExtensionDownloadResultValue(vm, downloadValue)
|
||||
if !download.Success ||
|
||||
download.FilePath != "/tmp/song.flac" ||
|
||||
!download.AlreadyExists ||
|
||||
download.BitDepth != 24 ||
|
||||
download.SampleRate != 96000 ||
|
||||
download.AlbumArtist != "Album Artist" ||
|
||||
download.LyricsLRC != "[00:00.00]Line" ||
|
||||
download.Decryption == nil ||
|
||||
download.Decryption.InputFormat != "m4a" ||
|
||||
download.Decryption.Options["map"] != "0:a" {
|
||||
t.Fatalf("unexpected download result: %+v", download)
|
||||
}
|
||||
|
||||
availabilityValue, err := vm.RunString(`({ available: true, trackId: "track-1", skipFallback: true, reason: "direct" })`)
|
||||
if err != nil {
|
||||
t.Fatalf("build availability value: %v", err)
|
||||
}
|
||||
availability := parseExtensionAvailabilityValue(vm, availabilityValue)
|
||||
if !availability.Available || availability.TrackID != "track-1" || !availability.SkipFallback || availability.Reason != "direct" {
|
||||
t.Fatalf("unexpected availability: %+v", availability)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExtensionURLHandleResult(t *testing.T) {
|
||||
vm := goja.New()
|
||||
value, err := vm.RunString(`({
|
||||
type: "album",
|
||||
name: "Shared Album",
|
||||
coverUrl: "https://img.test/shared.jpg",
|
||||
track: { id: "track-1", name: "Song" },
|
||||
tracks: [{ id: "track-2", name: "Song 2" }],
|
||||
album: { id: "album-1", name: "Album", tracks: [{ id: "track-3", name: "Song 3" }] },
|
||||
artist: { id: "artist-1", name: "Artist", topTracks: [{ id: "track-4", name: "Song 4" }] }
|
||||
})`)
|
||||
if err != nil {
|
||||
t.Fatalf("build URL handle value: %v", err)
|
||||
}
|
||||
|
||||
result, err := parseExtensionURLHandleValue(vm, value)
|
||||
if err != nil {
|
||||
t.Fatalf("parse URL handle: %v", err)
|
||||
}
|
||||
if result.Type != "album" ||
|
||||
result.CoverURL != "https://img.test/shared.jpg" ||
|
||||
result.Track == nil ||
|
||||
result.Track.ID != "track-1" ||
|
||||
len(result.Tracks) != 1 ||
|
||||
result.Album == nil ||
|
||||
len(result.Album.Tracks) != 1 ||
|
||||
result.Artist == nil ||
|
||||
len(result.Artist.TopTracks) != 1 {
|
||||
t.Fatalf("unexpected URL handle result: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExtensionAuxiliaryResults(t *testing.T) {
|
||||
vm := goja.New()
|
||||
|
||||
matchValue, err := vm.RunString(`({ matched: true, trackId: "track-1", confidence: 0.92, reason: "isrc" })`)
|
||||
if err != nil {
|
||||
t.Fatalf("build match value: %v", err)
|
||||
}
|
||||
match := parseExtensionMatchTrackValue(vm, matchValue)
|
||||
if !match.Matched || match.TrackID != "track-1" || match.Confidence != 0.92 || match.Reason != "isrc" {
|
||||
t.Fatalf("unexpected match result: %+v", match)
|
||||
}
|
||||
|
||||
postValue, err := vm.RunString(`({ success: true, newFilePath: "/tmp/new.flac", newFileUri: "content://new", bitDepth: 24, sampleRate: 96000 })`)
|
||||
if err != nil {
|
||||
t.Fatalf("build post-process value: %v", err)
|
||||
}
|
||||
post := parseExtensionPostProcessValue(vm, postValue)
|
||||
if !post.Success || post.NewFilePath != "/tmp/new.flac" || post.NewFileURI != "content://new" || post.BitDepth != 24 || post.SampleRate != 96000 {
|
||||
t.Fatalf("unexpected post-process result: %+v", post)
|
||||
}
|
||||
|
||||
lyricsValue, err := vm.RunString(`({
|
||||
syncType: "LINE_SYNCED",
|
||||
instrumental: false,
|
||||
plainLyrics: "Line",
|
||||
provider: "Lyrics Provider",
|
||||
lines: [{ startTimeMs: 1000, words: "Line", endTimeMs: 2000 }]
|
||||
})`)
|
||||
if err != nil {
|
||||
t.Fatalf("build lyrics value: %v", err)
|
||||
}
|
||||
lyrics, err := parseExtensionLyricsValue(vm, lyricsValue)
|
||||
if err != nil {
|
||||
t.Fatalf("parse lyrics: %v", err)
|
||||
}
|
||||
if lyrics.SyncType != "LINE_SYNCED" ||
|
||||
lyrics.PlainLyrics != "Line" ||
|
||||
lyrics.Provider != "Lyrics Provider" ||
|
||||
len(lyrics.Lines) != 1 ||
|
||||
lyrics.Lines[0].StartTimeMs != 1000 ||
|
||||
lyrics.Lines[0].EndTimeMs != 2000 {
|
||||
t.Fatalf("unexpected lyrics result: %+v", lyrics)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,9 @@ type extensionRuntime struct {
|
||||
activeDownloadMu sync.RWMutex
|
||||
activeDownloadItemID string
|
||||
|
||||
activeRequestMu sync.RWMutex
|
||||
activeRequestID string
|
||||
|
||||
storageMu sync.RWMutex
|
||||
storageCache map[string]interface{}
|
||||
storageLoaded bool
|
||||
@@ -209,6 +212,24 @@ func (r *extensionRuntime) getActiveDownloadItemID() string {
|
||||
return r.activeDownloadItemID
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) setActiveRequestID(requestID string) {
|
||||
r.activeRequestMu.Lock()
|
||||
defer r.activeRequestMu.Unlock()
|
||||
r.activeRequestID = strings.TrimSpace(requestID)
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) clearActiveRequestID() {
|
||||
r.activeRequestMu.Lock()
|
||||
defer r.activeRequestMu.Unlock()
|
||||
r.activeRequestID = ""
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) getActiveRequestID() string {
|
||||
r.activeRequestMu.RLock()
|
||||
defer r.activeRequestMu.RUnlock()
|
||||
return r.activeRequestID
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) bindDownloadCancelContext(req *http.Request) *http.Request {
|
||||
if req == nil {
|
||||
return nil
|
||||
@@ -216,7 +237,11 @@ func (r *extensionRuntime) bindDownloadCancelContext(req *http.Request) *http.Re
|
||||
|
||||
itemID := r.getActiveDownloadItemID()
|
||||
if itemID == "" {
|
||||
return req
|
||||
requestID := r.getActiveRequestID()
|
||||
if requestID == "" {
|
||||
return req
|
||||
}
|
||||
return req.WithContext(initExtensionRequestCancel(requestID))
|
||||
}
|
||||
|
||||
return req.WithContext(initDownloadCancel(itemID))
|
||||
@@ -479,6 +504,7 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
utilsObj.Set("appUserAgent", r.appUserAgent)
|
||||
utilsObj.Set("sleep", r.sleep)
|
||||
utilsObj.Set("isDownloadCancelled", r.isDownloadCancelled)
|
||||
utilsObj.Set("isRequestCancelled", r.isRequestCancelled)
|
||||
utilsObj.Set("setDownloadStatus", r.setDownloadStatus)
|
||||
vm.Set("utils", utilsObj)
|
||||
|
||||
|
||||
@@ -312,6 +312,14 @@ func (r *extensionRuntime) isDownloadCancelled(call goja.FunctionCall) goja.Valu
|
||||
return r.vm.ToValue(isDownloadCancelled(itemID))
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) isRequestCancelled(call goja.FunctionCall) goja.Value {
|
||||
requestID := r.getActiveRequestID()
|
||||
if requestID == "" {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
return r.vm.ToValue(isExtensionRequestCancelled(requestID))
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) setDownloadStatus(call goja.FunctionCall) goja.Value {
|
||||
itemID := r.getActiveDownloadItemID()
|
||||
if itemID == "" || len(call.Arguments) < 1 {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -379,6 +381,48 @@ func TestExtensionRuntime_BindDownloadCancelContextPreservesPreCancelledState(t
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWithTimeoutContextCancelsExecution(t *testing.T) {
|
||||
vm := goja.New()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, err := RunWithTimeoutContextAndRecover(ctx, vm, `while (true) {}`, 5*time.Second)
|
||||
if !errors.Is(err, ErrExtensionRequestCancelled) {
|
||||
t.Fatalf("expected extension request cancellation, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_BindExtensionRequestCancelContext(t *testing.T) {
|
||||
ext := &loadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
},
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
runtime := newExtensionRuntime(ext)
|
||||
|
||||
const requestID = "test-extension-request"
|
||||
clearExtensionRequestCancel(requestID)
|
||||
defer clearExtensionRequestCancel(requestID)
|
||||
|
||||
runtime.setActiveRequestID(requestID)
|
||||
defer runtime.clearActiveRequestID()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
req = runtime.bindDownloadCancelContext(req)
|
||||
|
||||
cancelExtensionRequest(requestID)
|
||||
select {
|
||||
case <-req.Context().Done():
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected request context to be cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
||||
// Create extension with limited network permissions
|
||||
ext := &loadedExtension{
|
||||
|
||||
@@ -20,6 +20,10 @@ func (e *JSExecutionError) Error() string {
|
||||
}
|
||||
|
||||
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||
return RunWithTimeoutContext(context.Background(), vm, script, timeout)
|
||||
}
|
||||
|
||||
func RunWithTimeoutContext(ctx context.Context, vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||
if vm == nil {
|
||||
return nil, fmt.Errorf("extension runtime unavailable")
|
||||
}
|
||||
@@ -28,7 +32,10 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
timeout = DefaultJSTimeout
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
type result struct {
|
||||
@@ -67,11 +74,16 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
case res := <-resultCh:
|
||||
return res.value, res.err
|
||||
case <-ctx.Done():
|
||||
cancelled := ctx.Err() == context.Canceled
|
||||
interruptMu.Lock()
|
||||
interrupted = true
|
||||
interruptMu.Unlock()
|
||||
|
||||
vm.Interrupt("execution timeout")
|
||||
if cancelled {
|
||||
vm.Interrupt("extension request cancelled")
|
||||
} else {
|
||||
vm.Interrupt("execution timeout")
|
||||
}
|
||||
|
||||
// MUST wait for the goroutine to finish before returning.
|
||||
// The Goja VM is NOT thread-safe — if we return while the goroutine
|
||||
@@ -80,6 +92,9 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
// pointer dereference.
|
||||
select {
|
||||
case res := <-resultCh:
|
||||
if cancelled {
|
||||
return nil, ErrExtensionRequestCancelled
|
||||
}
|
||||
if res.err != nil {
|
||||
return nil, res.err
|
||||
}
|
||||
@@ -91,6 +106,9 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
// Goroutine is truly stuck (e.g. HTTP read with no timeout).
|
||||
// Log a warning — the VM should NOT be reused after this.
|
||||
GoLog("[extensionRuntime] WARNING: JS goroutine did not exit within 60s after interrupt, VM may be unsafe\n")
|
||||
if cancelled {
|
||||
return nil, ErrExtensionRequestCancelled
|
||||
}
|
||||
return nil, &JSExecutionError{
|
||||
Message: "execution timeout exceeded (force)",
|
||||
IsTimeout: true,
|
||||
@@ -102,7 +120,11 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
// RunWithTimeoutAndRecover runs JS with timeout and clears interrupt state after
|
||||
// This should be used when you want to continue using the VM after a timeout
|
||||
func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||
result, err := RunWithTimeout(vm, script, timeout)
|
||||
return RunWithTimeoutContextAndRecover(context.Background(), vm, script, timeout)
|
||||
}
|
||||
|
||||
func RunWithTimeoutContextAndRecover(ctx context.Context, vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||
result, err := RunWithTimeoutContext(ctx, vm, script, timeout)
|
||||
|
||||
if vm != nil {
|
||||
vm.ClearInterrupt()
|
||||
|
||||
+129
-10
@@ -2,6 +2,7 @@ package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -24,6 +25,7 @@ type ItemProgress struct {
|
||||
SpeedMBps float64 `json:"speed_mbps"`
|
||||
IsDownloading bool `json:"is_downloading"`
|
||||
Status string `json:"status"`
|
||||
revision int64
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -37,6 +39,22 @@ type MultiProgress struct {
|
||||
Items map[string]*ItemProgress `json:"items"`
|
||||
}
|
||||
|
||||
type MultiProgressDelta struct {
|
||||
Seq int64 `json:"seq"`
|
||||
Reset bool `json:"reset,omitempty"`
|
||||
Items map[string]*ItemProgress `json:"items,omitempty"`
|
||||
Removed []string `json:"removed,omitempty"`
|
||||
}
|
||||
|
||||
type progressBridgeState struct {
|
||||
bytesBucket int64
|
||||
bytesTotal int64
|
||||
progressPct int64
|
||||
speedDeciMBps int64
|
||||
downloading bool
|
||||
status string
|
||||
}
|
||||
|
||||
var (
|
||||
downloadDir string
|
||||
downloadDirMu sync.RWMutex
|
||||
@@ -45,12 +63,50 @@ var (
|
||||
multiMu sync.RWMutex
|
||||
multiProgressDirty = true
|
||||
cachedMultiProgress = "{\"items\":{}}"
|
||||
multiProgressSeq int64
|
||||
multiProgressReset int64
|
||||
removedProgressSeq = make(map[string]int64)
|
||||
)
|
||||
|
||||
func markMultiProgressDirtyLocked() {
|
||||
multiProgressDirty = true
|
||||
}
|
||||
|
||||
func nextMultiProgressSeqLocked() int64 {
|
||||
multiProgressSeq++
|
||||
return multiProgressSeq
|
||||
}
|
||||
|
||||
func itemProgressBridgeState(item *ItemProgress) progressBridgeState {
|
||||
progress := item.Progress
|
||||
if math.IsNaN(progress) || progress <= 0 {
|
||||
progress = 0
|
||||
} else if progress >= 1 {
|
||||
progress = 1
|
||||
}
|
||||
|
||||
speed := item.SpeedMBps
|
||||
if math.IsNaN(speed) || speed <= 0 {
|
||||
speed = 0
|
||||
}
|
||||
|
||||
return progressBridgeState{
|
||||
bytesBucket: item.BytesReceived / progressUpdateThreshold,
|
||||
bytesTotal: item.BytesTotal,
|
||||
progressPct: int64(math.Round(progress * 100)),
|
||||
speedDeciMBps: int64(math.Round(speed * 10)),
|
||||
downloading: item.IsDownloading,
|
||||
status: item.Status,
|
||||
}
|
||||
}
|
||||
|
||||
func markMultiProgressDirtyIfChangedLocked(item *ItemProgress, before progressBridgeState) {
|
||||
if itemProgressBridgeState(item) != before {
|
||||
item.revision = nextMultiProgressSeqLocked()
|
||||
markMultiProgressDirtyLocked()
|
||||
}
|
||||
}
|
||||
|
||||
func getProgress() DownloadProgress {
|
||||
multiMu.RLock()
|
||||
defer multiMu.RUnlock()
|
||||
@@ -92,6 +148,54 @@ func GetMultiProgress() string {
|
||||
return cachedMultiProgress
|
||||
}
|
||||
|
||||
func GetMultiProgressDelta(sinceSeq int64) string {
|
||||
multiMu.RLock()
|
||||
currentSeq := multiProgressSeq
|
||||
if sinceSeq >= currentSeq {
|
||||
multiMu.RUnlock()
|
||||
return ""
|
||||
}
|
||||
|
||||
reset := sinceSeq <= 0 || sinceSeq < multiProgressReset
|
||||
delta := MultiProgressDelta{
|
||||
Seq: currentSeq,
|
||||
Reset: reset,
|
||||
}
|
||||
if reset {
|
||||
if len(multiProgress.Items) > 0 {
|
||||
delta.Items = make(map[string]*ItemProgress, len(multiProgress.Items))
|
||||
for id, item := range multiProgress.Items {
|
||||
copy := *item
|
||||
copy.revision = 0
|
||||
delta.Items[id] = ©
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for id, item := range multiProgress.Items {
|
||||
if item.revision > sinceSeq {
|
||||
if delta.Items == nil {
|
||||
delta.Items = make(map[string]*ItemProgress)
|
||||
}
|
||||
copy := *item
|
||||
copy.revision = 0
|
||||
delta.Items[id] = ©
|
||||
}
|
||||
}
|
||||
for id, revision := range removedProgressSeq {
|
||||
if revision > sinceSeq {
|
||||
delta.Removed = append(delta.Removed, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
multiMu.RUnlock()
|
||||
|
||||
jsonBytes, err := json.Marshal(delta)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
func GetItemProgress(itemID string) string {
|
||||
multiMu.RLock()
|
||||
defer multiMu.RUnlock()
|
||||
@@ -114,7 +218,9 @@ func StartItemProgress(itemID string) {
|
||||
Progress: 0,
|
||||
IsDownloading: true,
|
||||
Status: itemProgressStatusDownloading,
|
||||
revision: nextMultiProgressSeqLocked(),
|
||||
}
|
||||
delete(removedProgressSeq, itemID)
|
||||
markMultiProgressDirtyLocked()
|
||||
}
|
||||
|
||||
@@ -123,13 +229,14 @@ func SetItemPreparing(itemID string) {
|
||||
defer multiMu.Unlock()
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
before := itemProgressBridgeState(item)
|
||||
item.Progress = 0
|
||||
item.BytesReceived = 0
|
||||
item.BytesTotal = 0
|
||||
item.SpeedMBps = 0
|
||||
item.IsDownloading = true
|
||||
item.Status = itemProgressStatusPreparing
|
||||
markMultiProgressDirtyLocked()
|
||||
markMultiProgressDirtyIfChangedLocked(item, before)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,9 +245,10 @@ func SetItemDownloading(itemID string) {
|
||||
defer multiMu.Unlock()
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
before := itemProgressBridgeState(item)
|
||||
item.IsDownloading = true
|
||||
item.Status = itemProgressStatusDownloading
|
||||
markMultiProgressDirtyLocked()
|
||||
markMultiProgressDirtyIfChangedLocked(item, before)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,8 +257,9 @@ func SetItemBytesTotal(itemID string, total int64) {
|
||||
defer multiMu.Unlock()
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
before := itemProgressBridgeState(item)
|
||||
item.BytesTotal = total
|
||||
markMultiProgressDirtyLocked()
|
||||
markMultiProgressDirtyIfChangedLocked(item, before)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,11 +268,12 @@ func SetItemBytesReceived(itemID string, received int64) {
|
||||
defer multiMu.Unlock()
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
before := itemProgressBridgeState(item)
|
||||
item.BytesReceived = received
|
||||
if item.BytesTotal > 0 {
|
||||
item.Progress = float64(received) / float64(item.BytesTotal)
|
||||
}
|
||||
markMultiProgressDirtyLocked()
|
||||
markMultiProgressDirtyIfChangedLocked(item, before)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,12 +282,13 @@ func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps floa
|
||||
defer multiMu.Unlock()
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
before := itemProgressBridgeState(item)
|
||||
item.BytesReceived = received
|
||||
item.SpeedMBps = speedMBps
|
||||
if item.BytesTotal > 0 {
|
||||
item.Progress = float64(received) / float64(item.BytesTotal)
|
||||
}
|
||||
markMultiProgressDirtyLocked()
|
||||
markMultiProgressDirtyIfChangedLocked(item, before)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,10 +297,11 @@ func CompleteItemProgress(itemID string) {
|
||||
defer multiMu.Unlock()
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
before := itemProgressBridgeState(item)
|
||||
item.Progress = 1.0
|
||||
item.IsDownloading = false
|
||||
item.Status = itemProgressStatusCompleted
|
||||
markMultiProgressDirtyLocked()
|
||||
markMultiProgressDirtyIfChangedLocked(item, before)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +310,7 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal
|
||||
defer multiMu.Unlock()
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
before := itemProgressBridgeState(item)
|
||||
item.Progress = progress
|
||||
if bytesReceived > 0 {
|
||||
item.BytesReceived = bytesReceived
|
||||
@@ -205,7 +318,7 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal
|
||||
if bytesTotal > 0 {
|
||||
item.BytesTotal = bytesTotal
|
||||
}
|
||||
markMultiProgressDirtyLocked()
|
||||
markMultiProgressDirtyIfChangedLocked(item, before)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,9 +327,10 @@ func SetItemFinalizing(itemID string) {
|
||||
defer multiMu.Unlock()
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
before := itemProgressBridgeState(item)
|
||||
item.Progress = 1.0
|
||||
item.Status = itemProgressStatusFinalizing
|
||||
markMultiProgressDirtyLocked()
|
||||
markMultiProgressDirtyIfChangedLocked(item, before)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +338,10 @@ func RemoveItemProgress(itemID string) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
delete(multiProgress.Items, itemID)
|
||||
if _, ok := multiProgress.Items[itemID]; ok {
|
||||
delete(multiProgress.Items, itemID)
|
||||
removedProgressSeq[itemID] = nextMultiProgressSeqLocked()
|
||||
}
|
||||
markMultiProgressDirtyLocked()
|
||||
}
|
||||
|
||||
@@ -233,6 +350,8 @@ func ClearAllItemProgress() {
|
||||
defer multiMu.Unlock()
|
||||
|
||||
multiProgress.Items = make(map[string]*ItemProgress)
|
||||
removedProgressSeq = make(map[string]int64)
|
||||
multiProgressReset = nextMultiProgressSeqLocked()
|
||||
markMultiProgressDirtyLocked()
|
||||
}
|
||||
|
||||
@@ -253,7 +372,7 @@ type ItemProgressWriter struct {
|
||||
lastBytes int64
|
||||
}
|
||||
|
||||
const progressUpdateThreshold = 64 * 1024
|
||||
const progressUpdateThreshold = 128 * 1024
|
||||
|
||||
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
||||
now := time.Now()
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestItemProgressPreparingAndDownloadingStatuses(t *testing.T) {
|
||||
const itemID = "progress-phase-item"
|
||||
@@ -57,3 +60,50 @@ func TestItemProgressFinalizingAndCompletedStatuses(t *testing.T) {
|
||||
t.Fatalf("status = %q, want %q", item.Status, itemProgressStatusCompleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiProgressDeltaResetChangedAndRemoved(t *testing.T) {
|
||||
ClearAllItemProgress()
|
||||
defer ClearAllItemProgress()
|
||||
|
||||
StartItemProgress("item-a")
|
||||
SetItemBytesTotal("item-a", 1000)
|
||||
|
||||
var initial MultiProgressDelta
|
||||
if err := json.Unmarshal([]byte(GetMultiProgressDelta(0)), &initial); err != nil {
|
||||
t.Fatalf("initial delta parse failed: %v", err)
|
||||
}
|
||||
if !initial.Reset {
|
||||
t.Fatal("initial delta should reset")
|
||||
}
|
||||
if initial.Seq <= 0 {
|
||||
t.Fatalf("initial seq = %d, want > 0", initial.Seq)
|
||||
}
|
||||
if _, ok := initial.Items["item-a"]; !ok {
|
||||
t.Fatal("initial delta missing item-a")
|
||||
}
|
||||
|
||||
if delta := GetMultiProgressDelta(initial.Seq); delta != "" {
|
||||
t.Fatalf("delta after same seq = %q, want empty", delta)
|
||||
}
|
||||
|
||||
SetItemBytesReceivedWithSpeed("item-a", 256*1024, 2.5)
|
||||
var changed MultiProgressDelta
|
||||
if err := json.Unmarshal([]byte(GetMultiProgressDelta(initial.Seq)), &changed); err != nil {
|
||||
t.Fatalf("changed delta parse failed: %v", err)
|
||||
}
|
||||
if changed.Reset {
|
||||
t.Fatal("changed delta should not reset")
|
||||
}
|
||||
if _, ok := changed.Items["item-a"]; !ok {
|
||||
t.Fatal("changed delta missing item-a")
|
||||
}
|
||||
|
||||
RemoveItemProgress("item-a")
|
||||
var removed MultiProgressDelta
|
||||
if err := json.Unmarshal([]byte(GetMultiProgressDelta(changed.Seq)), &removed); err != nil {
|
||||
t.Fatalf("removed delta parse failed: %v", err)
|
||||
}
|
||||
if len(removed.Removed) != 1 || removed.Removed[0] != "item-a" {
|
||||
t.Fatalf("removed = %#v, want item-a", removed.Removed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,13 @@ import Gobackend // Import Go framework
|
||||
private let CHANNEL = "com.zarz.spotiflac/backend"
|
||||
private let DOWNLOAD_PROGRESS_STREAM_CHANNEL = "com.zarz.spotiflac/download_progress_stream"
|
||||
private let LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL = "com.zarz.spotiflac/library_scan_progress_stream"
|
||||
private let LARGE_JSON_RESULT_FILE_KEY = "__json_file"
|
||||
private let LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES = 256 * 1024
|
||||
private let streamQueue = DispatchQueue(label: "com.zarz.spotiflac.progress_stream", qos: .utility)
|
||||
private var downloadProgressTimer: DispatchSourceTimer?
|
||||
private var downloadProgressEventSink: FlutterEventSink?
|
||||
private var lastDownloadProgressPayload: String?
|
||||
private var lastDownloadProgressSeq: Int64 = 0
|
||||
private var libraryScanProgressTimer: DispatchSourceTimer?
|
||||
private var libraryScanProgressEventSink: FlutterEventSink?
|
||||
private var lastLibraryScanProgressPayload: String?
|
||||
@@ -131,15 +134,17 @@ import Gobackend // Import Go framework
|
||||
stopDownloadProgressStream()
|
||||
downloadProgressEventSink = eventSink
|
||||
lastDownloadProgressPayload = nil
|
||||
lastDownloadProgressSeq = 0
|
||||
|
||||
let timer = DispatchSource.makeTimerSource(queue: streamQueue)
|
||||
timer.schedule(deadline: .now(), repeating: .milliseconds(800))
|
||||
timer.setEventHandler { [weak self] in
|
||||
guard let self else { return }
|
||||
let payload = GobackendGetAllDownloadProgress() as String? ?? "{}"
|
||||
if payload == self.lastDownloadProgressPayload {
|
||||
let payload = GobackendGetAllDownloadProgressDelta(self.lastDownloadProgressSeq) as String? ?? ""
|
||||
if payload.isEmpty || payload == self.lastDownloadProgressPayload {
|
||||
return
|
||||
}
|
||||
self.updateDownloadProgressSeq(payload)
|
||||
self.lastDownloadProgressPayload = payload
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.downloadProgressEventSink?(self?.parseJsonPayload(payload))
|
||||
@@ -155,6 +160,7 @@ import Gobackend // Import Go framework
|
||||
downloadProgressTimer = nil
|
||||
downloadProgressEventSink = nil
|
||||
lastDownloadProgressPayload = nil
|
||||
lastDownloadProgressSeq = 0
|
||||
}
|
||||
|
||||
private func startLibraryScanProgressStream(_ eventSink: @escaping FlutterEventSink) {
|
||||
@@ -197,6 +203,34 @@ import Gobackend // Import Go framework
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
private func updateDownloadProgressSeq(_ payload: String) {
|
||||
guard let data = payload.data(using: .utf8) else { return }
|
||||
do {
|
||||
if let obj = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) as? [String: Any],
|
||||
let seq = obj["seq"] as? NSNumber,
|
||||
seq.int64Value > lastDownloadProgressSeq {
|
||||
lastDownloadProgressSeq = seq.int64Value
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
private func bridgeJsonResult(_ payload: String) -> Any {
|
||||
if payload.utf8.count < LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES {
|
||||
return payload
|
||||
}
|
||||
|
||||
do {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("bridge_json_\(UUID().uuidString).json")
|
||||
try payload.write(to: url, atomically: true, encoding: .utf8)
|
||||
return [LARGE_JSON_RESULT_FILE_KEY: url.path]
|
||||
} catch {
|
||||
NSLog("SpotiFLAC: failed to spill large bridge JSON result to file: \(error.localizedDescription)")
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
private func handleMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
@@ -784,10 +818,17 @@ import Gobackend // Import Go framework
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let query = args["query"] as! String
|
||||
let optionsJson = args["options"] as? String ?? ""
|
||||
let response = GobackendCustomSearchWithExtensionJSON(extensionId, query, optionsJson, &error)
|
||||
let requestId = args["request_id"] as? String ?? ""
|
||||
let response = GobackendCustomSearchWithExtensionJSONWithRequestID(extensionId, query, optionsJson, requestId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
|
||||
case "cancelExtensionRequest":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let requestId = args["request_id"] as? String ?? ""
|
||||
GobackendCancelExtensionRequestJSON(requestId)
|
||||
return nil
|
||||
|
||||
case "getSearchProviders":
|
||||
let response = GobackendGetSearchProvidersJSON(&error)
|
||||
if let error = error { throw error }
|
||||
@@ -896,7 +937,8 @@ import Gobackend // Import Go framework
|
||||
case "getExtensionHomeFeed":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let response = GobackendGetExtensionHomeFeedJSON(extensionId, &error)
|
||||
let requestId = args["request_id"] as? String ?? ""
|
||||
let response = GobackendGetExtensionHomeFeedJSONWithRequestID(extensionId, requestId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
@@ -919,7 +961,7 @@ import Gobackend // Import Go framework
|
||||
let folderPath = args["folder_path"] as! String
|
||||
let response = GobackendScanLibraryFolderJSON(folderPath, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
return bridgeJsonResult(response as String? ?? "[]")
|
||||
|
||||
case "scanLibraryFolderIncremental":
|
||||
let args = call.arguments as! [String: Any]
|
||||
@@ -927,7 +969,7 @@ import Gobackend // Import Go framework
|
||||
let existingFiles = args["existing_files"] as? String ?? "{}"
|
||||
let response = GobackendScanLibraryFolderIncrementalJSON(folderPath, existingFiles, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
return bridgeJsonResult(response as String? ?? "{}")
|
||||
|
||||
case "getLibraryScanProgress":
|
||||
let response = GobackendGetLibraryScanProgressJSON()
|
||||
|
||||
@@ -213,6 +213,7 @@ List<ExploreSection> _buildExploreSectionsFromNormalizedPayload(
|
||||
class ExploreNotifier extends Notifier<ExploreState> {
|
||||
static const _cacheKey = 'explore_home_feed_cache';
|
||||
static const _cacheTsKey = 'explore_home_feed_ts';
|
||||
int _homeFeedRequestId = 0;
|
||||
|
||||
@override
|
||||
ExploreState build() {
|
||||
@@ -281,6 +282,8 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
if (ref.read(settingsProvider).homeFeedProvider ==
|
||||
AppSettings.homeFeedProviderOff) {
|
||||
_log.d('Home feed disabled by user setting');
|
||||
_homeFeedRequestId++;
|
||||
PlatformBridge.cancelExtensionHomeFeedRequests();
|
||||
state = const ExploreState();
|
||||
return;
|
||||
}
|
||||
@@ -293,11 +296,12 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.isLoading) {
|
||||
if (state.isLoading && !forceRefresh) {
|
||||
_log.d('Home feed fetch already in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
final requestId = ++_homeFeedRequestId;
|
||||
final showLoading = !state.hasContent;
|
||||
state = state.copyWith(isLoading: showLoading, error: null);
|
||||
|
||||
@@ -330,6 +334,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
|
||||
if (targetExt == null) {
|
||||
_log.w('No extension with homeFeed capability found');
|
||||
if (requestId != _homeFeedRequestId) return;
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: 'No extension with home feed support enabled',
|
||||
@@ -338,7 +343,11 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
}
|
||||
|
||||
_log.i('Fetching home feed from ${targetExt.id}...');
|
||||
final result = await PlatformBridge.getExtensionHomeFeed(targetExt.id);
|
||||
final result = await PlatformBridge.getExtensionHomeFeed(
|
||||
targetExt.id,
|
||||
cancelPrevious: forceRefresh,
|
||||
);
|
||||
if (requestId != _homeFeedRequestId) return;
|
||||
|
||||
if (result == null) {
|
||||
state = state.copyWith(
|
||||
@@ -362,6 +371,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
_normalizeExploreSectionsPayload,
|
||||
sectionsData,
|
||||
);
|
||||
if (requestId != _homeFeedRequestId) return;
|
||||
final sections = _buildExploreSectionsFromNormalizedPayload(
|
||||
normalizedSections,
|
||||
);
|
||||
@@ -388,11 +398,14 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
_saveToCache(normalizedSections);
|
||||
} catch (e, stack) {
|
||||
_log.e('Error fetching home feed: $e', e, stack);
|
||||
if (requestId != _homeFeedRequestId) return;
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_homeFeedRequestId++;
|
||||
PlatformBridge.cancelExtensionHomeFeedRequests();
|
||||
state = const ExploreState();
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,15 @@ const _metadataProviderPriorityKey = 'metadata_provider_priority';
|
||||
const _providerPriorityKey = 'provider_priority';
|
||||
const _spotifyWebExtensionId = 'spotify-web';
|
||||
|
||||
bool _stringListEquals(List<String> a, List<String> b) {
|
||||
if (identical(a, b)) return true;
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
class BuiltInProviderSpec {
|
||||
final String id;
|
||||
final String displayName;
|
||||
@@ -1033,7 +1042,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
|
||||
final sanitized = _sanitizeDownloadProviderPriority(state.providerPriority);
|
||||
if (jsonEncode(sanitized) == jsonEncode(state.providerPriority)) {
|
||||
if (_stringListEquals(sanitized, state.providerPriority)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1053,7 +1062,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
state.metadataProviderPriority,
|
||||
);
|
||||
final sanitized = _sanitizeMetadataProviderPriority(replaced);
|
||||
if (jsonEncode(sanitized) == jsonEncode(state.metadataProviderPriority)) {
|
||||
if (_stringListEquals(sanitized, state.metadataProviderPriority)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -778,6 +778,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
extensionId,
|
||||
query,
|
||||
options: options,
|
||||
cancelPrevious: true,
|
||||
);
|
||||
|
||||
if (!_isRequestValid(requestId)) {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
@@ -24,6 +22,7 @@ import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||
|
||||
class _ArtistCache {
|
||||
static final Map<String, _CacheEntry> _cache = {};
|
||||
@@ -1164,12 +1163,11 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (hasValidImage)
|
||||
CachedNetworkImage(
|
||||
CachedCoverImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
alignment: Alignment.topCenter,
|
||||
memCacheWidth: 800,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (context, url) =>
|
||||
Container(color: colorScheme.surfaceContainerHighest),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
@@ -1477,33 +1475,18 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: track.coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: track.coverUrl!,
|
||||
track.coverUrl != null
|
||||
? CachedCoverImage(
|
||||
imageUrl: track.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
placeholder: (context, url) => Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 96,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (context, url) => Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
@@ -1513,7 +1496,17 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -1801,13 +1794,12 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: album.coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
? CachedCoverImage(
|
||||
imageUrl: album.coverUrl!,
|
||||
width: tileSize,
|
||||
height: tileSize,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: (tileSize * 2).round(),
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (context, url) => Container(
|
||||
width: tileSize,
|
||||
height: tileSize,
|
||||
|
||||
+35
-28
@@ -4,8 +4,6 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
@@ -33,6 +31,7 @@ import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
import 'package:spotiflac_android/utils/provider_ui_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
|
||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||
|
||||
part 'home_tab_helpers.dart';
|
||||
part 'home_tab_widgets.dart';
|
||||
@@ -1182,7 +1181,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final screenHeight = mediaQuery.size.height;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
final historyItems = ref.watch(_homeHistoryPreviewProvider);
|
||||
final hasHistoryItems = ref.watch(
|
||||
_homeHistoryPreviewProvider.select((items) => items.isNotEmpty),
|
||||
);
|
||||
|
||||
final recentModeRequested = isShowingRecentAccess || isSearchFocused;
|
||||
final showRecentAccess =
|
||||
@@ -1212,7 +1213,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
!hasHomeFeedExtension &&
|
||||
!hasExploreContent &&
|
||||
!hasResults &&
|
||||
historyItems.isEmpty;
|
||||
!hasHistoryItems;
|
||||
|
||||
ref.listen<String>(settingsProvider.select((s) => s.defaultSearchTab), (
|
||||
previous,
|
||||
@@ -1393,18 +1394,25 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
),
|
||||
),
|
||||
),
|
||||
if (historyItems.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
24,
|
||||
32,
|
||||
24,
|
||||
24,
|
||||
),
|
||||
child: _buildRecentDownloads(
|
||||
historyItems,
|
||||
colorScheme,
|
||||
),
|
||||
if (hasHistoryItems)
|
||||
Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final historyItems = ref.watch(
|
||||
_homeHistoryPreviewProvider,
|
||||
);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
24,
|
||||
32,
|
||||
24,
|
||||
24,
|
||||
),
|
||||
child: _buildRecentDownloads(
|
||||
historyItems,
|
||||
colorScheme,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1717,7 +1725,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
|
||||
final sectionIndex = index - sectionOffset;
|
||||
if (sectionIndex < sections.length) {
|
||||
return _buildExploreSection(sections[sectionIndex], colorScheme);
|
||||
final section = sections[sectionIndex];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey('explore-section-${section.uri}-${section.title}'),
|
||||
child: _buildExploreSection(section, colorScheme),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox(height: 24);
|
||||
@@ -1753,6 +1765,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
itemBuilder: (context, index) {
|
||||
final item = section.items[index];
|
||||
return StaggeredListItem(
|
||||
key: ValueKey(
|
||||
'explore-item-${item.type}-${item.id}-${item.uri}',
|
||||
),
|
||||
index: index,
|
||||
staggerDelay: const Duration(milliseconds: 50),
|
||||
child: _buildExploreItem(item, colorScheme),
|
||||
@@ -1806,14 +1821,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
isArtist ? cardSize / 2 : 10,
|
||||
),
|
||||
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
? CachedCoverImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
width: cardSize,
|
||||
height: cardSize,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: (cardSize * 2).round(),
|
||||
memCacheHeight: (cardSize * 2).round(),
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
errorWidget: (context, url, error) => Container(
|
||||
width: cardSize,
|
||||
height: cardSize,
|
||||
@@ -1968,13 +1980,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
? CachedCoverImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
width: 64,
|
||||
height: 64,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 128,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
)
|
||||
: Container(
|
||||
width: 64,
|
||||
@@ -2474,10 +2484,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
final targetSize = (360 * dpr).round().clamp(512, 1024).toInt();
|
||||
precacheImage(
|
||||
ResizeImage(
|
||||
CachedNetworkImageProvider(
|
||||
url,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
),
|
||||
cachedCoverImageProvider(url),
|
||||
width: targetSize,
|
||||
height: targetSize,
|
||||
),
|
||||
|
||||
@@ -25,8 +25,12 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
final rawCurrentProvider = ref.watch(
|
||||
settingsProvider.select((s) => s.searchProvider),
|
||||
);
|
||||
final extensionState = ref.watch(extensionProvider);
|
||||
final extensions = extensionState.extensions;
|
||||
final extensions = ref.watch(extensionProvider.select((s) => s.extensions));
|
||||
final providerReadiness = ref.watch(
|
||||
extensionProvider.select(
|
||||
(s) => (isInitialized: s.isInitialized, error: s.error),
|
||||
),
|
||||
);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final searchProviders = extensions
|
||||
@@ -36,7 +40,7 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
final hasAnyProvider =
|
||||
searchProviders.isNotEmpty || builtInProviders.isNotEmpty;
|
||||
final isProviderLoading =
|
||||
!extensionState.isInitialized && extensionState.error == null;
|
||||
!providerReadiness.isInitialized && providerReadiness.error == null;
|
||||
|
||||
if (!hasAnyProvider) {
|
||||
return Padding(
|
||||
@@ -324,14 +328,11 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: track.coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
? CachedCoverImage(
|
||||
imageUrl: track.coverUrl!,
|
||||
width: thumbWidth,
|
||||
height: thumbHeight,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: (thumbWidth * 2).toInt(),
|
||||
memCacheHeight: (thumbHeight * 2).toInt(),
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
)
|
||||
: Container(
|
||||
width: thumbWidth,
|
||||
@@ -518,14 +519,11 @@ class _CollectionItemWidget extends StatelessWidget {
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(isArtist ? 28 : 10),
|
||||
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
? CachedCoverImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 112,
|
||||
memCacheHeight: 112,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
)
|
||||
: Container(
|
||||
width: 56,
|
||||
@@ -623,14 +621,11 @@ class _SearchArtistItemWidget extends StatelessWidget {
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
child: hasValidImage
|
||||
? CachedNetworkImage(
|
||||
? CachedCoverImage(
|
||||
imageUrl: artist.imageUrl!,
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 112,
|
||||
memCacheHeight: 112,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
)
|
||||
: Container(
|
||||
width: 56,
|
||||
@@ -724,14 +719,11 @@ class _SearchAlbumItemWidget extends StatelessWidget {
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: hasValidImage
|
||||
? CachedNetworkImage(
|
||||
? CachedCoverImage(
|
||||
imageUrl: album.imageUrl!,
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 112,
|
||||
memCacheHeight: 112,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
)
|
||||
: Container(
|
||||
width: 56,
|
||||
@@ -828,14 +820,11 @@ class _SearchPlaylistItemWidget extends StatelessWidget {
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: hasValidImage
|
||||
? CachedNetworkImage(
|
||||
? CachedCoverImage(
|
||||
imageUrl: playlist.imageUrl!,
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 112,
|
||||
memCacheHeight: 112,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
)
|
||||
: Container(
|
||||
width: 56,
|
||||
@@ -996,14 +985,13 @@ class _DownloadedOrRemoteCoverState extends State<_DownloadedOrRemoteCover> {
|
||||
errorBuilder: (_, _, _) => _fallback(),
|
||||
);
|
||||
} else if (widget.imageUrl != null && widget.imageUrl!.isNotEmpty) {
|
||||
child = CachedNetworkImage(
|
||||
child = CachedCoverImage(
|
||||
imageUrl: widget.imageUrl!,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheWidth,
|
||||
memCacheHeight: cacheHeight,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
errorWidget: (_, _, _) => _fallback(),
|
||||
);
|
||||
} else {
|
||||
@@ -1617,7 +1605,12 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> {
|
||||
return Column(
|
||||
children: List.generate(pageItemCount, (index) {
|
||||
final item = widget.section.items[startIndex + index];
|
||||
return _buildQuickPickItem(item);
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(
|
||||
'quick-pick-${item.type}-${item.id}-${item.uri}',
|
||||
),
|
||||
child: _buildQuickPickItem(item),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -1661,14 +1654,11 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> {
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
? CachedCoverImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 96,
|
||||
memCacheHeight: 96,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
errorWidget: (context, url, error) => Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
@@ -19,6 +17,7 @@ import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
|
||||
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
|
||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||
|
||||
class PlaylistScreen extends ConsumerStatefulWidget {
|
||||
final String playlistName;
|
||||
@@ -297,11 +296,10 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (_coverUrl != null)
|
||||
CachedNetworkImage(
|
||||
CachedCoverImage(
|
||||
imageUrl: _highResCoverUrl(_coverUrl) ?? _coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheWidth,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (_, _) =>
|
||||
Container(color: colorScheme.surface),
|
||||
errorWidget: (_, _, _) =>
|
||||
@@ -838,16 +836,11 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
leading: track.coverUrl != null
|
||||
? ClipRRect(
|
||||
? CachedCoverImage(
|
||||
imageUrl: track.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: track.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 96,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 48,
|
||||
|
||||
+22
-42
@@ -4,10 +4,8 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
@@ -31,6 +29,7 @@ import 'package:spotiflac_android/screens/favorite_artists_screen.dart';
|
||||
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
|
||||
import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||
import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart';
|
||||
import 'package:spotiflac_android/screens/local_album_screen.dart';
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
@@ -1841,10 +1840,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
final targetSize = (360 * dpr).round().clamp(512, 1024).toInt();
|
||||
precacheImage(
|
||||
ResizeImage(
|
||||
CachedNetworkImageProvider(
|
||||
url,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
),
|
||||
cachedCoverImageProvider(url),
|
||||
width: targetSize,
|
||||
height: targetSize,
|
||||
),
|
||||
@@ -2166,18 +2162,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
);
|
||||
}
|
||||
return ClipRRect(
|
||||
return CachedCoverImage(
|
||||
imageUrl: firstCoverUrl,
|
||||
width: size,
|
||||
height: size,
|
||||
memCacheWidth: cacheExtent,
|
||||
borderRadius: borderRadius,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: firstCoverUrl,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheExtent,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (_, _) => placeholder,
|
||||
errorWidget: (_, _, _) => placeholder,
|
||||
),
|
||||
placeholder: (_, _) => placeholder,
|
||||
errorWidget: (_, _, _) => placeholder,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3799,14 +3791,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
_albumPlaceholder(colorScheme),
|
||||
)
|
||||
: album.coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
? CachedCoverImage(
|
||||
imageUrl: album.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
memCacheWidth: 300,
|
||||
memCacheHeight: 300,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
)
|
||||
: null,
|
||||
badgeColor: colorScheme.primaryContainer,
|
||||
@@ -5383,20 +5374,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
|
||||
Widget _buildCoverArt(DownloadItem item, ColorScheme colorScheme) {
|
||||
final coverSize = _queueCoverSize();
|
||||
final memCacheSize = (coverSize * 2).round();
|
||||
|
||||
return item.track.coverUrl != null
|
||||
? ClipRRect(
|
||||
? CachedCoverImage(
|
||||
imageUrl: item.track.coverUrl!,
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: item.track.coverUrl!,
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: memCacheSize,
|
||||
memCacheHeight: memCacheSize,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: coverSize,
|
||||
@@ -5651,19 +5635,15 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
|
||||
if (item.coverUrl != null) {
|
||||
return ClipRRect(
|
||||
return CachedCoverImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
width: size,
|
||||
height: size,
|
||||
memCacheWidth: cacheSize,
|
||||
memCacheHeight: cacheSize,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheSize,
|
||||
memCacheHeight: cacheSize,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (context, url) => buildPlaceholder(),
|
||||
errorWidget: (context, url, error) => buildPlaceholder(),
|
||||
),
|
||||
placeholder: (context, url) => buildPlaceholder(),
|
||||
errorWidget: (context, url, error) => buildPlaceholder(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||
@@ -12,6 +10,7 @@ import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
|
||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||
|
||||
class SearchScreen extends ConsumerStatefulWidget {
|
||||
final String query;
|
||||
@@ -49,30 +48,8 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _downloadTrack(Track track) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
final service = resolveEffectiveDownloadService(
|
||||
settings.defaultService,
|
||||
extensionState,
|
||||
);
|
||||
if (service.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tracks = ref.watch(trackProvider.select((s) => s.tracks));
|
||||
final isLoading = ref.watch(trackProvider.select((s) => s.isLoading));
|
||||
final error = ref.watch(trackProvider.select((s) => s.error));
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
@@ -98,36 +75,61 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
if (isLoading) LinearProgressIndicator(color: colorScheme.primary),
|
||||
if (error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(error, style: TextStyle(color: colorScheme.error)),
|
||||
),
|
||||
Expanded(
|
||||
child: AnimatedStateSwitcher(
|
||||
child: isLoading && tracks.isEmpty
|
||||
? const TrackListSkeleton(key: ValueKey('loading'))
|
||||
: tracks.isEmpty
|
||||
? _buildEmptyState(colorScheme)
|
||||
: ListView.builder(
|
||||
key: const ValueKey('results'),
|
||||
itemCount: tracks.length,
|
||||
itemBuilder: (context, index) => StaggeredListItem(
|
||||
index: index,
|
||||
child: _buildTrackTile(tracks[index], colorScheme),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: const _SearchResultsBody(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(ColorScheme colorScheme) {
|
||||
class _SearchResultsBody extends ConsumerWidget {
|
||||
const _SearchResultsBody();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tracks = ref.watch(trackProvider.select((s) => s.tracks));
|
||||
final isLoading = ref.watch(trackProvider.select((s) => s.isLoading));
|
||||
final error = ref.watch(trackProvider.select((s) => s.error));
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (isLoading) LinearProgressIndicator(color: colorScheme.primary),
|
||||
if (error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(error, style: TextStyle(color: colorScheme.error)),
|
||||
),
|
||||
Expanded(
|
||||
child: AnimatedStateSwitcher(
|
||||
child: isLoading && tracks.isEmpty
|
||||
? const TrackListSkeleton(key: ValueKey('loading'))
|
||||
: tracks.isEmpty
|
||||
? _SearchEmptyState(
|
||||
key: const ValueKey('empty'),
|
||||
colorScheme: colorScheme,
|
||||
)
|
||||
: ListView.builder(
|
||||
key: const ValueKey('results'),
|
||||
itemCount: tracks.length,
|
||||
itemBuilder: (context, index) => StaggeredListItem(
|
||||
key: ValueKey('search-track-${tracks[index].id}-$index'),
|
||||
index: index,
|
||||
child: _SearchTrackTile(track: tracks[index]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchEmptyState extends StatelessWidget {
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
const _SearchEmptyState({super.key, required this.colorScheme});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -144,20 +146,41 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildTrackTile(Track track, ColorScheme colorScheme) {
|
||||
class _SearchTrackTile extends ConsumerWidget {
|
||||
final Track track;
|
||||
|
||||
const _SearchTrackTile({required this.track});
|
||||
|
||||
void _downloadTrack(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
final service = resolveEffectiveDownloadService(
|
||||
settings.defaultService,
|
||||
extensionState,
|
||||
);
|
||||
if (service.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final coverWidget = track.coverUrl != null
|
||||
? ClipRRect(
|
||||
? CachedCoverImage(
|
||||
imageUrl: track.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: track.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 144,
|
||||
memCacheHeight: 144,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 48,
|
||||
@@ -218,11 +241,11 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
IconButton(
|
||||
icon: const Icon(Icons.download_rounded),
|
||||
tooltip: context.l10n.dialogDownload,
|
||||
onPressed: () => _downloadTrack(track),
|
||||
onPressed: () => _downloadTrack(context, ref),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => _downloadTrack(track),
|
||||
onTap: () => _downloadTrack(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,61 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/services/download_request_payload.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('PlatformBridge');
|
||||
|
||||
class _BridgeCacheEntry {
|
||||
final Map<String, dynamic> value;
|
||||
final DateTime expiresAt;
|
||||
|
||||
const _BridgeCacheEntry({required this.value, required this.expiresAt});
|
||||
|
||||
bool get isExpired => DateTime.now().isAfter(expiresAt);
|
||||
}
|
||||
|
||||
class _BridgeInFlight<T> {
|
||||
final String requestId;
|
||||
final String scopeKey;
|
||||
final Future<T> future;
|
||||
|
||||
const _BridgeInFlight({
|
||||
required this.requestId,
|
||||
required this.scopeKey,
|
||||
required this.future,
|
||||
});
|
||||
}
|
||||
|
||||
class PlatformBridge {
|
||||
static const _channel = MethodChannel('com.zarz.spotiflac/backend');
|
||||
static const _jsonResultFileKey = '__json_file';
|
||||
static const _metadataCacheTtl = Duration(minutes: 20);
|
||||
static const _availabilityCacheTtl = Duration(minutes: 15);
|
||||
static const _bridgeCacheMaxEntries = 256;
|
||||
static const _metadataPersistentCacheKey = 'bridge_metadata_lookup_cache_v1';
|
||||
static const _availabilityPersistentCacheKey =
|
||||
'bridge_availability_lookup_cache_v1';
|
||||
static const _downloadProgressEvents = EventChannel(
|
||||
'com.zarz.spotiflac/download_progress_stream',
|
||||
);
|
||||
static const _libraryScanProgressEvents = EventChannel(
|
||||
'com.zarz.spotiflac/library_scan_progress_stream',
|
||||
);
|
||||
static final Map<String, _BridgeCacheEntry> _metadataCache = {};
|
||||
static final Map<String, _BridgeCacheEntry> _availabilityCache = {};
|
||||
static final Map<String, Future<Map<String, dynamic>>> _metadataInFlight = {};
|
||||
static final Map<String, Future<Map<String, dynamic>>> _availabilityInFlight =
|
||||
{};
|
||||
static final Map<String, _BridgeInFlight<List<Map<String, dynamic>>>>
|
||||
_customSearchInFlight = {};
|
||||
static final Map<String, _BridgeInFlight<Map<String, dynamic>?>>
|
||||
_homeFeedInFlight = {};
|
||||
static Future<void>? _persistentLookupCacheLoadFuture;
|
||||
static int _lookupCacheGeneration = 0;
|
||||
static int _extensionRequestSequence = 0;
|
||||
|
||||
static bool get supportsCoreBackend => Platform.isAndroid || Platform.isIOS;
|
||||
|
||||
@@ -24,12 +66,324 @@ class PlatformBridge {
|
||||
String spotifyId,
|
||||
String isrc,
|
||||
) async {
|
||||
_log.d('checkAvailability: $spotifyId (ISRC: $isrc)');
|
||||
final result = await _channel.invokeMethod('checkAvailability', {
|
||||
'spotify_id': spotifyId,
|
||||
'isrc': isrc,
|
||||
final cacheKey = _availabilityCacheKey(spotifyId, isrc);
|
||||
if (cacheKey.isEmpty) {
|
||||
_log.d('checkAvailability: $spotifyId (ISRC: $isrc)');
|
||||
final result = await _channel.invokeMethod('checkAvailability', {
|
||||
'spotify_id': spotifyId,
|
||||
'isrc': isrc,
|
||||
});
|
||||
return _decodeRequiredMapResult(result, 'checkAvailability');
|
||||
}
|
||||
await _ensurePersistentLookupCachesLoaded();
|
||||
final cached = _getCachedMap(_availabilityCache, cacheKey);
|
||||
if (cached != null) return cached;
|
||||
|
||||
final inFlight = _availabilityInFlight[cacheKey];
|
||||
if (inFlight != null) return _copyStringMap(await inFlight);
|
||||
|
||||
final generation = _lookupCacheGeneration;
|
||||
final future = _invokeCachedMap(
|
||||
cacheKey,
|
||||
_availabilityCache,
|
||||
() async {
|
||||
_log.d('checkAvailability: $spotifyId (ISRC: $isrc)');
|
||||
final result = await _channel.invokeMethod('checkAvailability', {
|
||||
'spotify_id': spotifyId,
|
||||
'isrc': isrc,
|
||||
});
|
||||
return _decodeRequiredMapResult(result, 'checkAvailability');
|
||||
},
|
||||
_availabilityCacheTtl,
|
||||
generation,
|
||||
_availabilityPersistentCacheKey,
|
||||
);
|
||||
_availabilityInFlight[cacheKey] = future;
|
||||
try {
|
||||
return _copyStringMap(await future);
|
||||
} finally {
|
||||
_availabilityInFlight.remove(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> _invokeCachedMap(
|
||||
String key,
|
||||
Map<String, _BridgeCacheEntry> cache,
|
||||
Future<Map<String, dynamic>> Function() loader,
|
||||
Duration ttl,
|
||||
int generation,
|
||||
String persistentCacheKey,
|
||||
) async {
|
||||
final value = await loader();
|
||||
if (generation == _lookupCacheGeneration) {
|
||||
_putCachedMap(cache, key, value, ttl, persistentCacheKey);
|
||||
}
|
||||
return _copyStringMap(value);
|
||||
}
|
||||
|
||||
static String _availabilityCacheKey(String spotifyId, String isrc) {
|
||||
final normalizedIsrc = isrc.trim().toUpperCase();
|
||||
if (normalizedIsrc.isNotEmpty) {
|
||||
return 'isrc:$normalizedIsrc';
|
||||
}
|
||||
final normalizedSpotifyId = spotifyId.trim();
|
||||
if (normalizedSpotifyId.isEmpty) return '';
|
||||
return 'spotify:$normalizedSpotifyId';
|
||||
}
|
||||
|
||||
static String _providerMetadataCacheKey(
|
||||
String providerId,
|
||||
String resourceType,
|
||||
String resourceId,
|
||||
) {
|
||||
return [
|
||||
providerId.trim().toLowerCase(),
|
||||
resourceType.trim().toLowerCase(),
|
||||
resourceId.trim(),
|
||||
].join(':');
|
||||
}
|
||||
|
||||
static Map<String, dynamic>? _getCachedMap(
|
||||
Map<String, _BridgeCacheEntry> cache,
|
||||
String key,
|
||||
) {
|
||||
_pruneExpiredBridgeCache(cache);
|
||||
final entry = cache[key];
|
||||
if (entry == null) return null;
|
||||
if (entry.isExpired) {
|
||||
cache.remove(key);
|
||||
return null;
|
||||
}
|
||||
return _copyStringMap(entry.value);
|
||||
}
|
||||
|
||||
static void _putCachedMap(
|
||||
Map<String, _BridgeCacheEntry> cache,
|
||||
String key,
|
||||
Map<String, dynamic> value,
|
||||
Duration ttl,
|
||||
String persistentCacheKey,
|
||||
) {
|
||||
_pruneExpiredBridgeCache(cache);
|
||||
while (cache.length >= _bridgeCacheMaxEntries && cache.isNotEmpty) {
|
||||
cache.remove(cache.keys.first);
|
||||
}
|
||||
cache[key] = _BridgeCacheEntry(
|
||||
value: _copyStringMap(value),
|
||||
expiresAt: DateTime.now().add(ttl),
|
||||
);
|
||||
unawaited(
|
||||
_persistLookupCache(cache, persistentCacheKey, _lookupCacheGeneration),
|
||||
);
|
||||
}
|
||||
|
||||
static void _pruneExpiredBridgeCache(Map<String, _BridgeCacheEntry> cache) {
|
||||
if (cache.isEmpty) return;
|
||||
final now = DateTime.now();
|
||||
cache.removeWhere((_, entry) => now.isAfter(entry.expiresAt));
|
||||
}
|
||||
|
||||
static dynamic _copyJsonLike(dynamic value) {
|
||||
if (value is Map) {
|
||||
return <String, dynamic>{
|
||||
for (final entry in value.entries)
|
||||
entry.key.toString(): _copyJsonLike(entry.value),
|
||||
};
|
||||
}
|
||||
if (value is List) {
|
||||
return value.map(_copyJsonLike).toList(growable: false);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _copyStringMap(Map<String, dynamic> value) {
|
||||
return <String, dynamic>{
|
||||
for (final entry in value.entries) entry.key: _copyJsonLike(entry.value),
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, dynamic>? _copyNullableStringMap(
|
||||
Map<String, dynamic>? value,
|
||||
) {
|
||||
if (value == null) return null;
|
||||
return _copyStringMap(value);
|
||||
}
|
||||
|
||||
static List<Map<String, dynamic>> _copyMapList(
|
||||
List<Map<String, dynamic>> value,
|
||||
) {
|
||||
return value.map(_copyStringMap).toList(growable: false);
|
||||
}
|
||||
|
||||
static dynamic _canonicalizeJsonLike(dynamic value) {
|
||||
if (value is Map) {
|
||||
final entries = value.entries.toList()
|
||||
..sort((a, b) => a.key.toString().compareTo(b.key.toString()));
|
||||
return <String, dynamic>{
|
||||
for (final entry in entries)
|
||||
entry.key.toString(): _canonicalizeJsonLike(entry.value),
|
||||
};
|
||||
}
|
||||
if (value is List) {
|
||||
return value.map(_canonicalizeJsonLike).toList(growable: false);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static Future<void> _ensurePersistentLookupCachesLoaded() {
|
||||
return _persistentLookupCacheLoadFuture ??= _loadPersistentLookupCaches(
|
||||
_lookupCacheGeneration,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> _loadPersistentLookupCaches(int generation) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (generation != _lookupCacheGeneration) return;
|
||||
_restorePersistentCache(
|
||||
prefs,
|
||||
_metadataPersistentCacheKey,
|
||||
_metadataCache,
|
||||
);
|
||||
_restorePersistentCache(
|
||||
prefs,
|
||||
_availabilityPersistentCacheKey,
|
||||
_availabilityCache,
|
||||
);
|
||||
} catch (e) {
|
||||
_log.w('Failed to load bridge lookup cache: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static void _restorePersistentCache(
|
||||
SharedPreferences prefs,
|
||||
String prefsKey,
|
||||
Map<String, _BridgeCacheEntry> target,
|
||||
) {
|
||||
final raw = prefs.getString(prefsKey);
|
||||
if (raw == null || raw.isEmpty) return;
|
||||
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
for (final entry in decoded.entries) {
|
||||
if (target.length >= _bridgeCacheMaxEntries) break;
|
||||
final key = entry.key.toString();
|
||||
final rawEntry = entry.value;
|
||||
if (key.isEmpty || rawEntry is! Map) continue;
|
||||
|
||||
final expiresAtMs = rawEntry['expires_at'];
|
||||
final value = rawEntry['value'];
|
||||
if (expiresAtMs is! int || value is! Map) continue;
|
||||
|
||||
final expiresAt = DateTime.fromMillisecondsSinceEpoch(expiresAtMs);
|
||||
if (!expiresAt.isAfter(now)) continue;
|
||||
|
||||
target[key] = _BridgeCacheEntry(
|
||||
value: _copyStringMap(Map<String, dynamic>.from(value)),
|
||||
expiresAt: expiresAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _persistLookupCache(
|
||||
Map<String, _BridgeCacheEntry> cache,
|
||||
String prefsKey,
|
||||
int generation,
|
||||
) async {
|
||||
try {
|
||||
_pruneExpiredBridgeCache(cache);
|
||||
final data = <String, dynamic>{
|
||||
for (final entry in cache.entries)
|
||||
entry.key: {
|
||||
'expires_at': entry.value.expiresAt.millisecondsSinceEpoch,
|
||||
'value': entry.value.value,
|
||||
},
|
||||
};
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (generation != _lookupCacheGeneration) return;
|
||||
await prefs.setString(prefsKey, jsonEncode(data));
|
||||
} catch (e) {
|
||||
_log.w('Failed to persist bridge lookup cache: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _clearPersistentLookupCaches() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_metadataPersistentCacheKey);
|
||||
await prefs.remove(_availabilityPersistentCacheKey);
|
||||
} catch (e) {
|
||||
_log.w('Failed to clear bridge lookup cache: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _clearLookupCaches() async {
|
||||
_lookupCacheGeneration++;
|
||||
_persistentLookupCacheLoadFuture = null;
|
||||
_metadataCache.clear();
|
||||
_availabilityCache.clear();
|
||||
_metadataInFlight.clear();
|
||||
_availabilityInFlight.clear();
|
||||
for (final inFlight in _customSearchInFlight.values) {
|
||||
_cancelExtensionRequestUnawaited(inFlight.requestId);
|
||||
}
|
||||
for (final inFlight in _homeFeedInFlight.values) {
|
||||
_cancelExtensionRequestUnawaited(inFlight.requestId);
|
||||
}
|
||||
_customSearchInFlight.clear();
|
||||
_homeFeedInFlight.clear();
|
||||
await _clearPersistentLookupCaches();
|
||||
}
|
||||
|
||||
static String _nextExtensionRequestId(String kind, String extensionId) {
|
||||
_extensionRequestSequence++;
|
||||
return [
|
||||
kind,
|
||||
DateTime.now().microsecondsSinceEpoch,
|
||||
_extensionRequestSequence,
|
||||
extensionId.trim(),
|
||||
].join(':');
|
||||
}
|
||||
|
||||
static void _cancelExtensionRequestUnawaited(String requestId) {
|
||||
if (requestId.isEmpty) return;
|
||||
unawaited(
|
||||
cancelExtensionRequest(requestId).catchError((Object e) {
|
||||
_log.w('Failed to cancel extension request $requestId: $e');
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> cancelExtensionRequest(String requestId) async {
|
||||
if (requestId.isEmpty) return;
|
||||
await _channel.invokeMethod('cancelExtensionRequest', {
|
||||
'request_id': requestId,
|
||||
});
|
||||
return _decodeRequiredMapResult(result, 'checkAvailability');
|
||||
}
|
||||
|
||||
static void _cancelCustomSearchInFlightForScope(
|
||||
String scopeKey, {
|
||||
String? exceptKey,
|
||||
}) {
|
||||
for (final entry in _customSearchInFlight.entries.toList()) {
|
||||
if (entry.key == exceptKey || entry.value.scopeKey != scopeKey) continue;
|
||||
_cancelExtensionRequestUnawaited(entry.value.requestId);
|
||||
}
|
||||
}
|
||||
|
||||
static void cancelExtensionHomeFeedRequests() {
|
||||
for (final inFlight in _homeFeedInFlight.values) {
|
||||
_cancelExtensionRequestUnawaited(inFlight.requestId);
|
||||
}
|
||||
_homeFeedInFlight.clear();
|
||||
}
|
||||
|
||||
static int _lookupCacheSize() {
|
||||
_pruneExpiredBridgeCache(_metadataCache);
|
||||
_pruneExpiredBridgeCache(_availabilityCache);
|
||||
return _metadataCache.length + _availabilityCache.length;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> _invokeDownloadMethod(
|
||||
@@ -485,11 +839,13 @@ class PlatformBridge {
|
||||
}
|
||||
|
||||
static Future<int> getTrackCacheSize() async {
|
||||
await _ensurePersistentLookupCachesLoaded();
|
||||
final result = await _channel.invokeMethod('getTrackCacheSize');
|
||||
return result as int;
|
||||
return (result as int) + _lookupCacheSize();
|
||||
}
|
||||
|
||||
static Future<void> clearTrackCache() async {
|
||||
await _clearLookupCaches();
|
||||
await _channel.invokeMethod('clearTrackCache');
|
||||
}
|
||||
|
||||
@@ -533,17 +889,45 @@ class PlatformBridge {
|
||||
String resourceType,
|
||||
String resourceId,
|
||||
) async {
|
||||
final result = await _channel.invokeMethod('getProviderMetadata', {
|
||||
'provider_id': providerId,
|
||||
'resource_type': resourceType,
|
||||
'resource_id': resourceId,
|
||||
});
|
||||
if (result == null) {
|
||||
throw Exception(
|
||||
'getProviderMetadata returned null for $providerId:$resourceType:$resourceId',
|
||||
);
|
||||
final cacheKey = _providerMetadataCacheKey(
|
||||
providerId,
|
||||
resourceType,
|
||||
resourceId,
|
||||
);
|
||||
await _ensurePersistentLookupCachesLoaded();
|
||||
final cached = _getCachedMap(_metadataCache, cacheKey);
|
||||
if (cached != null) return cached;
|
||||
|
||||
final inFlight = _metadataInFlight[cacheKey];
|
||||
if (inFlight != null) return _copyStringMap(await inFlight);
|
||||
|
||||
final generation = _lookupCacheGeneration;
|
||||
final future = _invokeCachedMap(
|
||||
cacheKey,
|
||||
_metadataCache,
|
||||
() async {
|
||||
final result = await _channel.invokeMethod('getProviderMetadata', {
|
||||
'provider_id': providerId,
|
||||
'resource_type': resourceType,
|
||||
'resource_id': resourceId,
|
||||
});
|
||||
if (result == null) {
|
||||
throw Exception(
|
||||
'getProviderMetadata returned null for $providerId:$resourceType:$resourceId',
|
||||
);
|
||||
}
|
||||
return _decodeRequiredMapResult(result, 'getProviderMetadata');
|
||||
},
|
||||
_metadataCacheTtl,
|
||||
generation,
|
||||
_metadataPersistentCacheKey,
|
||||
);
|
||||
_metadataInFlight[cacheKey] = future;
|
||||
try {
|
||||
return _copyStringMap(await future);
|
||||
} finally {
|
||||
_metadataInFlight.remove(cacheKey);
|
||||
}
|
||||
return _decodeRequiredMapResult(result, 'getProviderMetadata');
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> searchDeezerByISRC(
|
||||
@@ -584,11 +968,39 @@ class PlatformBridge {
|
||||
String resourceType,
|
||||
String spotifyId,
|
||||
) async {
|
||||
final result = await _channel.invokeMethod('convertSpotifyToDeezer', {
|
||||
'resource_type': resourceType,
|
||||
'spotify_id': spotifyId,
|
||||
});
|
||||
return _decodeRequiredMapResult(result, 'convertSpotifyToDeezer');
|
||||
final cacheKey = _providerMetadataCacheKey(
|
||||
'spotify-to-deezer',
|
||||
resourceType,
|
||||
spotifyId,
|
||||
);
|
||||
await _ensurePersistentLookupCachesLoaded();
|
||||
final cached = _getCachedMap(_metadataCache, cacheKey);
|
||||
if (cached != null) return cached;
|
||||
|
||||
final inFlight = _metadataInFlight[cacheKey];
|
||||
if (inFlight != null) return _copyStringMap(await inFlight);
|
||||
|
||||
final generation = _lookupCacheGeneration;
|
||||
final future = _invokeCachedMap(
|
||||
cacheKey,
|
||||
_metadataCache,
|
||||
() async {
|
||||
final result = await _channel.invokeMethod('convertSpotifyToDeezer', {
|
||||
'resource_type': resourceType,
|
||||
'spotify_id': spotifyId,
|
||||
});
|
||||
return _decodeRequiredMapResult(result, 'convertSpotifyToDeezer');
|
||||
},
|
||||
_metadataCacheTtl,
|
||||
generation,
|
||||
_metadataPersistentCacheKey,
|
||||
);
|
||||
_metadataInFlight[cacheKey] = future;
|
||||
try {
|
||||
return _copyStringMap(await future);
|
||||
} finally {
|
||||
_metadataInFlight.remove(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> getGoLogs() async {
|
||||
@@ -641,6 +1053,7 @@ class PlatformBridge {
|
||||
String filePath,
|
||||
) async {
|
||||
_log.d('loadExtensionFromPath: $filePath');
|
||||
await _clearLookupCaches();
|
||||
final result = await _channel.invokeMethod('loadExtensionFromPath', {
|
||||
'file_path': filePath,
|
||||
});
|
||||
@@ -649,6 +1062,7 @@ class PlatformBridge {
|
||||
|
||||
static Future<void> unloadExtension(String extensionId) async {
|
||||
_log.d('unloadExtension: $extensionId');
|
||||
await _clearLookupCaches();
|
||||
await _channel.invokeMethod('unloadExtension', {
|
||||
'extension_id': extensionId,
|
||||
});
|
||||
@@ -656,6 +1070,7 @@ class PlatformBridge {
|
||||
|
||||
static Future<void> removeExtension(String extensionId) async {
|
||||
_log.d('removeExtension: $extensionId');
|
||||
await _clearLookupCaches();
|
||||
await _channel.invokeMethod('removeExtension', {
|
||||
'extension_id': extensionId,
|
||||
});
|
||||
@@ -663,6 +1078,7 @@ class PlatformBridge {
|
||||
|
||||
static Future<Map<String, dynamic>> upgradeExtension(String filePath) async {
|
||||
_log.d('upgradeExtension: $filePath');
|
||||
await _clearLookupCaches();
|
||||
final result = await _channel.invokeMethod('upgradeExtension', {
|
||||
'file_path': filePath,
|
||||
});
|
||||
@@ -689,6 +1105,7 @@ class PlatformBridge {
|
||||
bool enabled,
|
||||
) async {
|
||||
_log.d('setExtensionEnabled: $extensionId = $enabled');
|
||||
await _clearLookupCaches();
|
||||
await _channel.invokeMethod('setExtensionEnabled', {
|
||||
'extension_id': extensionId,
|
||||
'enabled': enabled,
|
||||
@@ -697,6 +1114,7 @@ class PlatformBridge {
|
||||
|
||||
static Future<void> setProviderPriority(List<String> providerIds) async {
|
||||
_log.d('setProviderPriority: $providerIds');
|
||||
await _clearLookupCaches();
|
||||
await _channel.invokeMethod('setProviderPriority', {
|
||||
'priority': jsonEncode(providerIds),
|
||||
});
|
||||
@@ -711,6 +1129,7 @@ class PlatformBridge {
|
||||
List<String>? extensionIds,
|
||||
) async {
|
||||
_log.d('setDownloadFallbackExtensionIds: $extensionIds');
|
||||
await _clearLookupCaches();
|
||||
await _channel.invokeMethod('setDownloadFallbackExtensionIds', {
|
||||
'extension_ids': extensionIds == null ? '' : jsonEncode(extensionIds),
|
||||
});
|
||||
@@ -720,6 +1139,7 @@ class PlatformBridge {
|
||||
List<String> providerIds,
|
||||
) async {
|
||||
_log.d('setMetadataProviderPriority: $providerIds');
|
||||
await _clearLookupCaches();
|
||||
await _channel.invokeMethod('setMetadataProviderPriority', {
|
||||
'priority': jsonEncode(providerIds),
|
||||
});
|
||||
@@ -744,6 +1164,7 @@ class PlatformBridge {
|
||||
Map<String, dynamic> settings,
|
||||
) async {
|
||||
_log.d('setExtensionSettings: $extensionId');
|
||||
await _clearLookupCaches();
|
||||
await _channel.invokeMethod('setExtensionSettings', {
|
||||
'extension_id': extensionId,
|
||||
'settings': jsonEncode(settings),
|
||||
@@ -883,13 +1304,45 @@ class PlatformBridge {
|
||||
String extensionId,
|
||||
String query, {
|
||||
Map<String, dynamic>? options,
|
||||
bool cancelPrevious = false,
|
||||
}) async {
|
||||
final result = await _channel.invokeMethod('customSearchWithExtension', {
|
||||
'extension_id': extensionId,
|
||||
'query': query,
|
||||
'options': options != null ? jsonEncode(options) : '',
|
||||
});
|
||||
return _decodeMapListResult(result, 'customSearchWithExtension');
|
||||
final optionsJson = options != null ? jsonEncode(options) : '';
|
||||
final scopeKey = 'customSearch:${extensionId.trim()}';
|
||||
final cacheKey = [
|
||||
scopeKey,
|
||||
query,
|
||||
jsonEncode(_canonicalizeJsonLike(options ?? const <String, dynamic>{})),
|
||||
].join('\n');
|
||||
final inFlight = _customSearchInFlight[cacheKey];
|
||||
if (inFlight != null) return _copyMapList(await inFlight.future);
|
||||
if (cancelPrevious) {
|
||||
_cancelCustomSearchInFlightForScope(scopeKey, exceptKey: cacheKey);
|
||||
}
|
||||
|
||||
final requestId = _nextExtensionRequestId('customSearch', extensionId);
|
||||
final future = (() async {
|
||||
final result = await _channel.invokeMethod('customSearchWithExtension', {
|
||||
'extension_id': extensionId,
|
||||
'query': query,
|
||||
'options': optionsJson,
|
||||
'request_id': requestId,
|
||||
});
|
||||
return _decodeMapListResult(result, 'customSearchWithExtension');
|
||||
})();
|
||||
|
||||
final entry = _BridgeInFlight<List<Map<String, dynamic>>>(
|
||||
requestId: requestId,
|
||||
scopeKey: scopeKey,
|
||||
future: future,
|
||||
);
|
||||
_customSearchInFlight[cacheKey] = entry;
|
||||
try {
|
||||
return _copyMapList(await future);
|
||||
} finally {
|
||||
if (identical(_customSearchInFlight[cacheKey], entry)) {
|
||||
_customSearchInFlight.remove(cacheKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> getSearchProviders() async {
|
||||
@@ -927,16 +1380,44 @@ class PlatformBridge {
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>?> getExtensionHomeFeed(
|
||||
String extensionId,
|
||||
) async {
|
||||
String extensionId, {
|
||||
bool cancelPrevious = false,
|
||||
}) async {
|
||||
final cacheKey = 'homeFeed:${extensionId.trim()}';
|
||||
final inFlight = _homeFeedInFlight[cacheKey];
|
||||
if (inFlight != null) {
|
||||
if (!cancelPrevious) {
|
||||
return _copyNullableStringMap(await inFlight.future);
|
||||
}
|
||||
_cancelExtensionRequestUnawaited(inFlight.requestId);
|
||||
_homeFeedInFlight.remove(cacheKey);
|
||||
}
|
||||
|
||||
final requestId = _nextExtensionRequestId('homeFeed', extensionId);
|
||||
final future = (() async {
|
||||
try {
|
||||
final result = await _channel.invokeMethod('getExtensionHomeFeed', {
|
||||
'extension_id': extensionId,
|
||||
'request_id': requestId,
|
||||
});
|
||||
return _decodeNullableMapResult(result, 'getExtensionHomeFeed');
|
||||
} catch (e) {
|
||||
_log.e('getExtensionHomeFeed failed: $e');
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
final entry = _BridgeInFlight<Map<String, dynamic>?>(
|
||||
requestId: requestId,
|
||||
scopeKey: cacheKey,
|
||||
future: future,
|
||||
);
|
||||
_homeFeedInFlight[cacheKey] = entry;
|
||||
try {
|
||||
final result = await _channel.invokeMethod('getExtensionHomeFeed', {
|
||||
'extension_id': extensionId,
|
||||
});
|
||||
return _decodeNullableMapResult(result, 'getExtensionHomeFeed');
|
||||
} catch (e) {
|
||||
_log.e('getExtensionHomeFeed failed: $e');
|
||||
return null;
|
||||
return _copyNullableStringMap(await future);
|
||||
} finally {
|
||||
if (identical(_homeFeedInFlight[cacheKey], entry)) {
|
||||
_homeFeedInFlight.remove(cacheKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -969,7 +1450,7 @@ class PlatformBridge {
|
||||
final result = await _channel.invokeMethod('scanLibraryFolder', {
|
||||
'folder_path': folderPath,
|
||||
});
|
||||
return _decodeMapListResult(result, 'scanLibraryFolder');
|
||||
return _decodeMapListResultAsync(result, 'scanLibraryFolder');
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> scanLibraryFolderIncremental(
|
||||
@@ -983,7 +1464,10 @@ class PlatformBridge {
|
||||
'folder_path': folderPath,
|
||||
'existing_files': jsonEncode(existingFiles),
|
||||
});
|
||||
return _decodeRequiredMapResult(result, 'scanLibraryFolderIncremental');
|
||||
return _decodeRequiredMapResultAsync(
|
||||
result,
|
||||
'scanLibraryFolderIncremental',
|
||||
);
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> scanLibraryFolderIncrementalFromSnapshot(
|
||||
@@ -994,7 +1478,7 @@ class PlatformBridge {
|
||||
'scanLibraryFolderIncrementalFromSnapshot',
|
||||
{'folder_path': folderPath, 'snapshot_path': snapshotPath},
|
||||
);
|
||||
return _decodeRequiredMapResult(
|
||||
return _decodeRequiredMapResultAsync(
|
||||
result,
|
||||
'scanLibraryFolderIncrementalFromSnapshot',
|
||||
);
|
||||
@@ -1005,7 +1489,7 @@ class PlatformBridge {
|
||||
final result = await _channel.invokeMethod('scanSafTree', {
|
||||
'tree_uri': treeUri,
|
||||
});
|
||||
return _decodeMapListResult(result, 'scanSafTree');
|
||||
return _decodeMapListResultAsync(result, 'scanSafTree');
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> scanSafTreeIncremental(
|
||||
@@ -1019,7 +1503,7 @@ class PlatformBridge {
|
||||
'tree_uri': treeUri,
|
||||
'existing_files': jsonEncode(existingFiles),
|
||||
});
|
||||
return _decodeRequiredMapResult(result, 'scanSafTreeIncremental');
|
||||
return _decodeRequiredMapResultAsync(result, 'scanSafTreeIncremental');
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> scanSafTreeIncrementalFromSnapshot(
|
||||
@@ -1030,7 +1514,7 @@ class PlatformBridge {
|
||||
'scanSafTreeIncrementalFromSnapshot',
|
||||
{'tree_uri': treeUri, 'snapshot_path': snapshotPath},
|
||||
);
|
||||
return _decodeRequiredMapResult(
|
||||
return _decodeRequiredMapResultAsync(
|
||||
result,
|
||||
'scanSafTreeIncrementalFromSnapshot',
|
||||
);
|
||||
@@ -1067,6 +1551,22 @@ class PlatformBridge {
|
||||
return result;
|
||||
}
|
||||
|
||||
static Future<Object?> _decodeJsonResultAsync(dynamic result) async {
|
||||
if (result is Map && result[_jsonResultFileKey] is String) {
|
||||
final file = File(result[_jsonResultFileKey] as String);
|
||||
try {
|
||||
final contents = await file.readAsString();
|
||||
if (contents.isEmpty) return null;
|
||||
return jsonDecode(contents);
|
||||
} finally {
|
||||
try {
|
||||
await file.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
return _decodeJsonResult(result);
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _decodeRequiredMapResult(
|
||||
dynamic result,
|
||||
String method,
|
||||
@@ -1094,6 +1594,19 @@ class PlatformBridge {
|
||||
);
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> _decodeRequiredMapResultAsync(
|
||||
dynamic result,
|
||||
String method,
|
||||
) async {
|
||||
final decoded = await _decodeJsonResultAsync(result);
|
||||
if (decoded is Map) {
|
||||
return decoded.cast<String, dynamic>();
|
||||
}
|
||||
throw FormatException(
|
||||
'Expected map result from $method, got ${decoded.runtimeType}',
|
||||
);
|
||||
}
|
||||
|
||||
static List<dynamic> _decodeRequiredListResult(
|
||||
dynamic result,
|
||||
String method,
|
||||
@@ -1105,6 +1618,17 @@ class PlatformBridge {
|
||||
);
|
||||
}
|
||||
|
||||
static Future<List<dynamic>> _decodeRequiredListResultAsync(
|
||||
dynamic result,
|
||||
String method,
|
||||
) async {
|
||||
final decoded = await _decodeJsonResultAsync(result);
|
||||
if (decoded is List) return decoded;
|
||||
throw FormatException(
|
||||
'Expected list result from $method, got ${decoded.runtimeType}',
|
||||
);
|
||||
}
|
||||
|
||||
static List<Map<String, dynamic>> _decodeMapListResult(
|
||||
dynamic result,
|
||||
String method,
|
||||
@@ -1117,6 +1641,19 @@ class PlatformBridge {
|
||||
}).toList();
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> _decodeMapListResultAsync(
|
||||
dynamic result,
|
||||
String method,
|
||||
) async {
|
||||
final decoded = await _decodeRequiredListResultAsync(result, method);
|
||||
return decoded.map((entry) {
|
||||
if (entry is Map) return entry.cast<String, dynamic>();
|
||||
throw FormatException(
|
||||
'Expected map entry from $method, got ${entry.runtimeType}',
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
static List<String> _decodeStringListResult(dynamic result, String method) {
|
||||
return _decodeRequiredListResult(result, method).map((entry) {
|
||||
if (entry is String) return entry;
|
||||
|
||||
@@ -3,10 +3,14 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
|
||||
class CachedCoverImage extends StatelessWidget {
|
||||
static const int _defaultMinCacheExtent = 64;
|
||||
static const int _defaultMaxCacheExtent = 512;
|
||||
|
||||
final String imageUrl;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final BoxFit fit;
|
||||
final Alignment alignment;
|
||||
final int? memCacheWidth;
|
||||
final int? memCacheHeight;
|
||||
final Widget Function(BuildContext, String, Object)? errorWidget;
|
||||
@@ -19,6 +23,7 @@ class CachedCoverImage extends StatelessWidget {
|
||||
this.width,
|
||||
this.height,
|
||||
this.fit = BoxFit.cover,
|
||||
this.alignment = Alignment.center,
|
||||
this.memCacheWidth,
|
||||
this.memCacheHeight,
|
||||
this.errorWidget,
|
||||
@@ -28,36 +33,65 @@ class CachedCoverImage extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final autoMemCacheWidth =
|
||||
memCacheWidth ?? _cacheExtentForLogicalSize(context, width);
|
||||
final autoMemCacheHeight =
|
||||
memCacheHeight ?? _cacheExtentForLogicalSize(context, height);
|
||||
final image = CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
memCacheWidth: memCacheWidth,
|
||||
memCacheHeight: memCacheHeight,
|
||||
cacheManager: CoverCacheManager.isInitialized
|
||||
? CoverCacheManager.instance
|
||||
alignment: alignment,
|
||||
memCacheWidth: autoMemCacheWidth,
|
||||
memCacheHeight: autoMemCacheHeight,
|
||||
maxWidthDiskCache: autoMemCacheWidth,
|
||||
maxHeightDiskCache: autoMemCacheHeight,
|
||||
cacheManager: CoverCacheManager.isInitialized
|
||||
? CoverCacheManager.instance
|
||||
: null,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
useOldImageOnUrlChange: true,
|
||||
filterQuality: FilterQuality.low,
|
||||
errorWidget: errorWidget,
|
||||
placeholder: placeholder,
|
||||
);
|
||||
|
||||
if (borderRadius != null) {
|
||||
return ClipRRect(
|
||||
borderRadius: borderRadius!,
|
||||
child: image,
|
||||
);
|
||||
return ClipRRect(borderRadius: borderRadius!, child: image);
|
||||
}
|
||||
|
||||
return image;
|
||||
}
|
||||
|
||||
static int? _cacheExtentForLogicalSize(BuildContext context, double? size) {
|
||||
if (size == null || !size.isFinite || size <= 0) return null;
|
||||
final dpr = MediaQuery.devicePixelRatioOf(
|
||||
context,
|
||||
).clamp(1.0, 3.0).toDouble();
|
||||
return (size * dpr)
|
||||
.round()
|
||||
.clamp(_defaultMinCacheExtent, _defaultMaxCacheExtent)
|
||||
.toInt();
|
||||
}
|
||||
}
|
||||
|
||||
CachedNetworkImageProvider cachedCoverImageProvider(String url) {
|
||||
return CachedNetworkImageProvider(
|
||||
url,
|
||||
cacheManager: CoverCacheManager.isInitialized
|
||||
? CoverCacheManager.instance
|
||||
cacheManager: CoverCacheManager.isInitialized
|
||||
? CoverCacheManager.instance
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
int coverImageCacheExtent(
|
||||
BuildContext context,
|
||||
double logicalSize, {
|
||||
int min = 64,
|
||||
int max = 512,
|
||||
}) {
|
||||
final dpr = MediaQuery.devicePixelRatioOf(context).clamp(1.0, 3.0).toDouble();
|
||||
return (logicalSize * dpr).round().clamp(min, max).toInt();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user