perf: reduce bridge and UI churn

This commit is contained in:
zarzet
2026-05-03 14:12:53 +07:00
parent b329acd710
commit 34894faabf
24 changed files with 2451 additions and 494 deletions
@@ -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)
}
+82
View File
@@ -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
View File
@@ -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)
}
+119
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+242
View File
@@ -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)
}
}
+27 -1
View File
@@ -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)
+8
View File
@@ -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 {
+44
View File
@@ -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{
+25 -3
View File
@@ -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
View File
@@ -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] = &copy
}
}
} 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] = &copy
}
}
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()
+51 -1
View File
@@ -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)
}
}
+49 -7
View File
@@ -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()
+15 -2
View File
@@ -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();
}
+11 -2
View File
@@ -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;
}
+1
View File
@@ -778,6 +778,7 @@ class TrackNotifier extends Notifier<TrackState> {
extensionId,
query,
options: options,
cancelPrevious: true,
);
if (!_isRequestValid(requestId)) {
+24 -32
View File
@@ -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
View File
@@ -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,
),
+20 -30
View File
@@ -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,
+6 -13
View File
@@ -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
View File
@@ -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(),
);
}
+87 -64
View File
@@ -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),
);
}
}
+579 -42
View File
@@ -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;
+44 -10
View File
@@ -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();
}