mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-15 13:18:02 +02:00
perf+security: polling guards, sensitive data redaction, SAF path sanitization
Go backend: - Add sensitive data redaction in log buffer (tokens, keys, passwords) - Validate extension auth URLs (HTTPS only, no private IPs, no embedded creds) - Block embedded credentials in extension HTTP requests - Tighten extension storage file permissions (0644 -> 0600) - Sanitize extension ID in store download path - Summarize auth URLs in logs to prevent token leakage Android (Kotlin): - Add sanitizeRelativeDir to prevent path traversal in SAF operations - Apply sanitizeFilename to all user-provided file names in SAF Flutter: - Add sensitive data redaction in Dart logger (mirrors Go patterns) - Mask device ID in log exports - Add in-flight guard to progress polling (download queue + local library) - Remove redundant _downloadedSpotifyIds Set, use _bySpotifyId map - Remove redundant _isrcSet, use _byIsrc map - Expand DownloadQueueLookup with byItemId and itemIds - Lazy search index building in queue tab - Bound embedded cover cache in queue tab (max 180) - Coalesce embedded cover refresh callbacks via postFrameCallback - Cache album track filtering in downloaded album screen - Cache thumbnail sizes by extension ID in home tab - Simplify recent access aggregation (single-pass) - Remove unused _isTyping state in home tab - Cap pre-warm track batch size to 80 - Skip setShowingRecentAccess if value unchanged - Use downloadQueueLookupProvider for granular queue selectors - Move grouped album filtering before content data computation
This commit is contained in:
@@ -300,8 +300,18 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
return name.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim()
|
||||
}
|
||||
|
||||
private fun sanitizeRelativeDir(relativeDir: String): String {
|
||||
if (relativeDir.isBlank()) return ""
|
||||
return relativeDir
|
||||
.split("/")
|
||||
.map { sanitizeFilename(it) }
|
||||
.filter { it.isNotBlank() && it != "." && it != ".." }
|
||||
.joinToString("/")
|
||||
}
|
||||
|
||||
private fun ensureDocumentDir(treeUri: Uri, relativeDir: String): DocumentFile? {
|
||||
if (relativeDir.isBlank()) {
|
||||
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||
if (safeRelativeDir.isBlank()) {
|
||||
return DocumentFile.fromTreeUri(this, treeUri)
|
||||
}
|
||||
|
||||
@@ -310,7 +320,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
synchronized(safDirLock) {
|
||||
var current = DocumentFile.fromTreeUri(this, treeUri) ?: return null
|
||||
|
||||
val parts = relativeDir.split("/").filter { it.isNotBlank() }
|
||||
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
|
||||
for (part in parts) {
|
||||
val existing = current.findFile(part)
|
||||
current = if (existing != null && existing.isDirectory) {
|
||||
@@ -335,9 +345,10 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
|
||||
private fun findDocumentDir(treeUri: Uri, relativeDir: String): DocumentFile? {
|
||||
var current = DocumentFile.fromTreeUri(this, treeUri) ?: return null
|
||||
if (relativeDir.isBlank()) return current
|
||||
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||
if (safeRelativeDir.isBlank()) return current
|
||||
|
||||
val parts = relativeDir.split("/").filter { it.isNotBlank() }
|
||||
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
|
||||
for (part in parts) {
|
||||
val existing = current.findFile(part)
|
||||
if (existing == null || !existing.isDirectory) return null
|
||||
@@ -377,14 +388,21 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
obj.put("relative_dir", "")
|
||||
return obj.toString()
|
||||
}
|
||||
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||
val safeFileName = sanitizeFilename(fileName)
|
||||
if (safeFileName.isBlank()) {
|
||||
obj.put("uri", "")
|
||||
obj.put("relative_dir", "")
|
||||
return obj.toString()
|
||||
}
|
||||
|
||||
val treeUri = Uri.parse(treeUriStr)
|
||||
val targetDir = findDocumentDir(treeUri, relativeDir)
|
||||
val targetDir = findDocumentDir(treeUri, safeRelativeDir)
|
||||
if (targetDir != null) {
|
||||
val direct = targetDir.findFile(fileName)
|
||||
val direct = targetDir.findFile(safeFileName)
|
||||
if (direct != null && direct.isFile) {
|
||||
obj.put("uri", direct.uri.toString())
|
||||
obj.put("relative_dir", relativeDir)
|
||||
obj.put("relative_dir", safeRelativeDir)
|
||||
return obj.toString()
|
||||
}
|
||||
}
|
||||
@@ -410,7 +428,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val childPath = if (path.isBlank()) childName else "$path/$childName"
|
||||
queue.add(child to childPath)
|
||||
} else if (child.isFile) {
|
||||
if (child.name == fileName) {
|
||||
if (child.name == safeFileName) {
|
||||
obj.put("uri", child.uri.toString())
|
||||
obj.put("relative_dir", path)
|
||||
return obj.toString()
|
||||
@@ -426,7 +444,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
|
||||
private fun buildSafFileName(req: JSONObject, outputExt: String): String {
|
||||
val provided = req.optString("saf_file_name", "")
|
||||
if (provided.isNotBlank()) return provided
|
||||
if (provided.isNotBlank()) return sanitizeFilename(provided)
|
||||
|
||||
val trackName = req.optString("track_name", "track")
|
||||
val artistName = req.optString("artist_name", "")
|
||||
@@ -617,7 +635,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
|
||||
val treeUri = Uri.parse(treeUriStr)
|
||||
val relativeDir = req.optString("saf_relative_dir", "")
|
||||
val relativeDir = sanitizeRelativeDir(req.optString("saf_relative_dir", ""))
|
||||
val outputExt = normalizeExt(req.optString("saf_output_ext", ""))
|
||||
val mimeType = mimeTypeForExt(outputExt)
|
||||
val fileName = buildSafFileName(req, outputExt)
|
||||
@@ -1474,11 +1492,12 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
"safCreateFromPath" -> {
|
||||
val treeUriStr = call.argument<String>("tree_uri") ?: ""
|
||||
val relativeDir = call.argument<String>("relative_dir") ?: ""
|
||||
val fileName = call.argument<String>("file_name") ?: ""
|
||||
val fileName = sanitizeFilename(call.argument<String>("file_name") ?: "")
|
||||
val mimeType = call.argument<String>("mime_type") ?: "application/octet-stream"
|
||||
val srcPath = call.argument<String>("src_path") ?: ""
|
||||
val createdUri = withContext(Dispatchers.IO) {
|
||||
if (treeUriStr.isBlank()) return@withContext null
|
||||
if (fileName.isBlank()) return@withContext null
|
||||
val dir = ensureDocumentDir(Uri.parse(treeUriStr), relativeDir) ?: return@withContext null
|
||||
val existing = dir.findFile(fileName)
|
||||
val createdNew = existing == null
|
||||
|
||||
+14
-2
@@ -2906,14 +2906,26 @@ func GetStoreCategoriesJSON() (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func buildStoreExtensionDestPath(destDir, extensionID string) (string, error) {
|
||||
if strings.TrimSpace(extensionID) == "" {
|
||||
return "", fmt.Errorf("invalid extension id")
|
||||
}
|
||||
|
||||
safeExtensionID := sanitizeFilename(extensionID)
|
||||
return filepath.Join(destDir, safeExtensionID+".spotiflac-ext"), nil
|
||||
}
|
||||
|
||||
func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
|
||||
store := GetExtensionStore()
|
||||
if store == nil {
|
||||
return "", fmt.Errorf("extension store not initialized")
|
||||
}
|
||||
|
||||
destPath := fmt.Sprintf("%s/%s.spotiflac-ext", destDir, extensionID)
|
||||
err := store.DownloadExtension(extensionID, destPath)
|
||||
destPath, err := buildStoreExtensionDestPath(destDir, extensionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
err = store.DownloadExtension(extensionID, destPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -18,6 +18,43 @@ import (
|
||||
|
||||
// ==================== Auth API (OAuth Support) ====================
|
||||
|
||||
func validateExtensionAuthURL(urlStr string) error {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid auth URL: %w", err)
|
||||
}
|
||||
|
||||
if parsed.Scheme != "https" {
|
||||
return fmt.Errorf("invalid auth URL: only https is allowed")
|
||||
}
|
||||
|
||||
host := parsed.Hostname()
|
||||
if host == "" {
|
||||
return fmt.Errorf("invalid auth URL: hostname is required")
|
||||
}
|
||||
|
||||
if parsed.User != nil {
|
||||
return fmt.Errorf("invalid auth URL: embedded credentials are not allowed")
|
||||
}
|
||||
|
||||
if isPrivateIP(host) {
|
||||
return fmt.Errorf("invalid auth URL: private/local network is not allowed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func summarizeURLForLog(urlStr string) string {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return urlStr
|
||||
}
|
||||
if parsed.Host == "" {
|
||||
return parsed.Scheme + "://"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, parsed.Path)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -32,6 +69,13 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
callbackURL = call.Arguments[1].String()
|
||||
}
|
||||
|
||||
if err := validateExtensionAuthURL(authURL); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
pendingAuthRequestsMu.Lock()
|
||||
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||
ExtensionID: r.extensionID,
|
||||
@@ -50,7 +94,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
state.AuthCode = ""
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, authURL)
|
||||
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, summarizeURLForLog(authURL))
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
@@ -273,6 +317,12 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
"error": "authUrl, clientId, and redirectUri are required",
|
||||
})
|
||||
}
|
||||
if err := validateExtensionAuthURL(authURL); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
scope, _ := config["scope"].(string)
|
||||
extraParams, _ := config["extraParams"].(map[string]interface{})
|
||||
@@ -331,7 +381,7 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
}
|
||||
pendingAuthRequestsMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, fullAuthURL)
|
||||
GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, summarizeURLForLog(fullAuthURL))
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
@@ -441,13 +491,17 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
bodyPreview := sanitizeSensitiveLogText(string(body))
|
||||
if len(bodyPreview) > 1000 {
|
||||
bodyPreview = bodyPreview[:1000] + "...[truncated]"
|
||||
}
|
||||
|
||||
var tokenResp map[string]interface{}
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to parse token response: %v", err),
|
||||
"body": string(body),
|
||||
"body": bodyPreview,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -468,7 +522,7 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "no access_token in response",
|
||||
"body": string(body),
|
||||
"body": bodyPreview,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
||||
if parsed.Scheme != "https" {
|
||||
return fmt.Errorf("network access denied: only https is allowed")
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return fmt.Errorf("invalid URL: embedded credentials are not allowed")
|
||||
}
|
||||
|
||||
domain := parsed.Hostname()
|
||||
if domain == "" {
|
||||
|
||||
@@ -46,7 +46,7 @@ func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(storagePath, data, 0644)
|
||||
return os.WriteFile(storagePath, data, 0600)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||
|
||||
@@ -3,6 +3,7 @@ package gobackend
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -30,8 +31,22 @@ const (
|
||||
var (
|
||||
globalLogBuffer *LogBuffer
|
||||
logBufferOnce sync.Once
|
||||
|
||||
authorizationBearerPattern = regexp.MustCompile(`(?i)\bAuthorization\b\s*[:=]\s*Bearer\s+[A-Za-z0-9._~+/\-]+=*`)
|
||||
genericKeyValuePattern = regexp.MustCompile(`(?i)\b(access[_\s-]?token|refresh[_\s-]?token|id[_\s-]?token|client[_\s-]?secret|authorization|password|api[_\s-]?key)\b(\s*[:=]\s*)([^\s,;]+)`)
|
||||
queryTokenPattern = regexp.MustCompile(`(?i)([?&](?:access_token|refresh_token|id_token|token|client_secret|api_key|apikey|password)=)[^&\s]+`)
|
||||
bearerTokenPattern = regexp.MustCompile(`(?i)\bBearer\s+[A-Za-z0-9._~+/\-]+=*`)
|
||||
)
|
||||
|
||||
func sanitizeSensitiveLogText(message string) string {
|
||||
redacted := message
|
||||
redacted = authorizationBearerPattern.ReplaceAllString(redacted, "Authorization: Bearer [REDACTED]")
|
||||
redacted = genericKeyValuePattern.ReplaceAllString(redacted, `${1}${2}[REDACTED]`)
|
||||
redacted = queryTokenPattern.ReplaceAllString(redacted, `${1}[REDACTED]`)
|
||||
redacted = bearerTokenPattern.ReplaceAllString(redacted, "Bearer [REDACTED]")
|
||||
return redacted
|
||||
}
|
||||
|
||||
func GetLogBuffer() *LogBuffer {
|
||||
logBufferOnce.Do(func() {
|
||||
globalLogBuffer = &LogBuffer{
|
||||
@@ -71,6 +86,7 @@ func (lb *LogBuffer) Add(level, tag, message string) {
|
||||
return
|
||||
}
|
||||
|
||||
message = sanitizeSensitiveLogText(message)
|
||||
message = truncateLogMessage(message)
|
||||
|
||||
entry := LogEntry{
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeSensitiveLogText(t *testing.T) {
|
||||
input := "access_token=abc123 Authorization:Bearer xyz456 https://api.example.com/cb?refresh_token=zzz"
|
||||
redacted := sanitizeSensitiveLogText(input)
|
||||
|
||||
if strings.Contains(redacted, "abc123") || strings.Contains(redacted, "xyz456") || strings.Contains(redacted, "zzz") {
|
||||
t.Fatalf("expected sensitive values to be redacted, got: %s", redacted)
|
||||
}
|
||||
if !strings.Contains(redacted, "[REDACTED]") {
|
||||
t.Fatalf("expected redaction marker in output, got: %s", redacted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateExtensionAuthURL(t *testing.T) {
|
||||
if err := validateExtensionAuthURL("https://accounts.example.com/oauth/authorize"); err != nil {
|
||||
t.Fatalf("expected valid auth URL, got error: %v", err)
|
||||
}
|
||||
|
||||
blocked := []string{
|
||||
"http://accounts.example.com/oauth/authorize",
|
||||
"https://user:pass@accounts.example.com/oauth/authorize",
|
||||
"https://localhost/oauth/authorize",
|
||||
}
|
||||
|
||||
for _, rawURL := range blocked {
|
||||
if err := validateExtensionAuthURL(rawURL); err == nil {
|
||||
t.Fatalf("expected URL to be blocked: %s", rawURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDomainRejectsEmbeddedCredentials(t *testing.T) {
|
||||
ext := &LoadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
Permissions: ExtensionPermissions{
|
||||
Network: []string{"api.example.com"},
|
||||
},
|
||||
},
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
if err := runtime.validateDomain("https://user:pass@api.example.com/resource"); err == nil {
|
||||
t.Fatal("expected embedded URL credentials to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildStoreExtensionDestPath(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
|
||||
destPath, err := buildStoreExtensionDestPath(baseDir, "../evil/name")
|
||||
if err != nil {
|
||||
t.Fatalf("expected sanitized path to be generated, got error: %v", err)
|
||||
}
|
||||
|
||||
if !isPathWithinBase(baseDir, destPath) {
|
||||
t.Fatalf("expected destination path to remain under base dir: %s", destPath)
|
||||
}
|
||||
|
||||
baseName := filepath.Base(destPath)
|
||||
if strings.Contains(baseName, "/") || strings.Contains(baseName, `\`) {
|
||||
t.Fatalf("expected filename to be sanitized, got: %s", baseName)
|
||||
}
|
||||
if !strings.HasSuffix(baseName, ".spotiflac-ext") {
|
||||
t.Fatalf("expected .spotiflac-ext suffix, got: %s", baseName)
|
||||
}
|
||||
|
||||
if _, err := buildStoreExtensionDestPath(baseDir, " "); err == nil {
|
||||
t.Fatal("expected empty extension id to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -208,16 +208,11 @@ class DownloadHistoryItem {
|
||||
|
||||
class DownloadHistoryState {
|
||||
final List<DownloadHistoryItem> items;
|
||||
final Set<String> _downloadedSpotifyIds;
|
||||
final Map<String, DownloadHistoryItem> _bySpotifyId;
|
||||
final Map<String, DownloadHistoryItem> _byIsrc;
|
||||
|
||||
DownloadHistoryState({this.items = const []})
|
||||
: _downloadedSpotifyIds = items
|
||||
.where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty)
|
||||
.map((item) => item.spotifyId!)
|
||||
.toSet(),
|
||||
_bySpotifyId = Map.fromEntries(
|
||||
: _bySpotifyId = Map.fromEntries(
|
||||
items
|
||||
.where(
|
||||
(item) => item.spotifyId != null && item.spotifyId!.isNotEmpty,
|
||||
@@ -230,8 +225,7 @@ class DownloadHistoryState {
|
||||
.map((item) => MapEntry(item.isrc!, item)),
|
||||
);
|
||||
|
||||
bool isDownloaded(String spotifyId) =>
|
||||
_downloadedSpotifyIds.contains(spotifyId);
|
||||
bool isDownloaded(String spotifyId) => _bySpotifyId.containsKey(spotifyId);
|
||||
|
||||
DownloadHistoryItem? getBySpotifyId(String spotifyId) =>
|
||||
_bySpotifyId[spotifyId];
|
||||
@@ -682,6 +676,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
bool _isLoaded = false;
|
||||
final Set<String> _ensuredDirs = {};
|
||||
int _progressPollingErrorCount = 0;
|
||||
bool _isProgressPollingInFlight = false;
|
||||
String? _lastServiceTrackName;
|
||||
String? _lastServiceArtistName;
|
||||
int _lastServicePercent = -1;
|
||||
@@ -832,6 +827,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
void _startMultiProgressPolling() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = Timer.periodic(_progressPollingInterval, (timer) async {
|
||||
if (_isProgressPollingInFlight) return;
|
||||
_isProgressPollingInFlight = true;
|
||||
try {
|
||||
final allProgress = await PlatformBridge.getAllDownloadProgress();
|
||||
final items = allProgress['items'] as Map<String, dynamic>? ?? {};
|
||||
@@ -915,16 +912,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
bytesReceived: normalizedBytes,
|
||||
);
|
||||
|
||||
final mbReceived = bytesReceived / (1024 * 1024);
|
||||
final mbTotal = bytesTotal / (1024 * 1024);
|
||||
if (bytesTotal > 0) {
|
||||
_log.d(
|
||||
'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s',
|
||||
);
|
||||
} else {
|
||||
_log.d(
|
||||
'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (DASH segments/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s',
|
||||
);
|
||||
if (LogBuffer.loggingEnabled) {
|
||||
final mbReceived = bytesReceived / (1024 * 1024);
|
||||
final mbTotal = bytesTotal / (1024 * 1024);
|
||||
if (bytesTotal > 0) {
|
||||
_log.d(
|
||||
'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s',
|
||||
);
|
||||
} else {
|
||||
_log.d(
|
||||
'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (DASH segments/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1039,6 +1038,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (_progressPollingErrorCount <= 3) {
|
||||
_log.w('Progress polling failed: $e');
|
||||
}
|
||||
} finally {
|
||||
_isProgressPollingInFlight = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1088,6 +1089,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = null;
|
||||
_progressPollingErrorCount = 0;
|
||||
_isProgressPollingInFlight = false;
|
||||
_lastServiceTrackName = null;
|
||||
_lastServiceArtistName = null;
|
||||
_lastServicePercent = -1;
|
||||
@@ -4011,15 +4013,29 @@ final downloadQueueProvider =
|
||||
|
||||
class DownloadQueueLookup {
|
||||
final Map<String, DownloadItem> byTrackId;
|
||||
final Map<String, DownloadItem> byItemId;
|
||||
final List<String> itemIds;
|
||||
|
||||
DownloadQueueLookup._(this.byTrackId);
|
||||
DownloadQueueLookup._({
|
||||
required this.byTrackId,
|
||||
required this.byItemId,
|
||||
required this.itemIds,
|
||||
});
|
||||
|
||||
factory DownloadQueueLookup.fromItems(List<DownloadItem> items) {
|
||||
final map = <String, DownloadItem>{};
|
||||
final byTrackId = <String, DownloadItem>{};
|
||||
final byItemId = <String, DownloadItem>{};
|
||||
final itemIds = <String>[];
|
||||
for (final item in items) {
|
||||
map.putIfAbsent(item.track.id, () => item);
|
||||
byTrackId.putIfAbsent(item.track.id, () => item);
|
||||
byItemId[item.id] = item;
|
||||
itemIds.add(item.id);
|
||||
}
|
||||
return DownloadQueueLookup._(map);
|
||||
return DownloadQueueLookup._(
|
||||
byTrackId: byTrackId,
|
||||
byItemId: byItemId,
|
||||
itemIds: itemIds,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ class LocalLibraryState {
|
||||
final bool scanWasCancelled;
|
||||
final DateTime? lastScannedAt;
|
||||
final int excludedDownloadedCount;
|
||||
final Set<String> _isrcSet;
|
||||
final Set<String> _trackKeySet;
|
||||
final Map<String, LocalLibraryItem> _byIsrc;
|
||||
|
||||
@@ -39,16 +38,9 @@ class LocalLibraryState {
|
||||
this.scanWasCancelled = false,
|
||||
this.lastScannedAt,
|
||||
this.excludedDownloadedCount = 0,
|
||||
Set<String>? isrcSet,
|
||||
Set<String>? trackKeySet,
|
||||
Map<String, LocalLibraryItem>? byIsrc,
|
||||
}) : _isrcSet =
|
||||
isrcSet ??
|
||||
items
|
||||
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
|
||||
.map((item) => item.isrc!)
|
||||
.toSet(),
|
||||
_trackKeySet = trackKeySet ?? items.map((item) => item.matchKey).toSet(),
|
||||
}) : _trackKeySet = trackKeySet ?? items.map((item) => item.matchKey).toSet(),
|
||||
_byIsrc =
|
||||
byIsrc ??
|
||||
Map.fromEntries(
|
||||
@@ -57,7 +49,7 @@ class LocalLibraryState {
|
||||
.map((item) => MapEntry(item.isrc!, item)),
|
||||
);
|
||||
|
||||
bool hasIsrc(String isrc) => _isrcSet.contains(isrc);
|
||||
bool hasIsrc(String isrc) => _byIsrc.containsKey(isrc);
|
||||
|
||||
bool hasTrack(String trackName, String artistName) {
|
||||
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
|
||||
@@ -108,7 +100,6 @@ class LocalLibraryState {
|
||||
lastScannedAt: lastScannedAt ?? this.lastScannedAt,
|
||||
excludedDownloadedCount:
|
||||
excludedDownloadedCount ?? this.excludedDownloadedCount,
|
||||
isrcSet: keepDerivedIndex ? _isrcSet : null,
|
||||
trackKeySet: keepDerivedIndex ? _trackKeySet : null,
|
||||
byIsrc: keepDerivedIndex ? _byIsrc : null,
|
||||
);
|
||||
@@ -123,6 +114,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
bool _isLoaded = false;
|
||||
bool _scanCancelRequested = false;
|
||||
int _progressPollingErrorCount = 0;
|
||||
bool _isProgressPollingInFlight = false;
|
||||
|
||||
@override
|
||||
LocalLibraryState build() {
|
||||
@@ -408,6 +400,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
void _startProgressPolling() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = Timer.periodic(_progressPollingInterval, (_) async {
|
||||
if (_isProgressPollingInFlight) return;
|
||||
_isProgressPollingInFlight = true;
|
||||
try {
|
||||
final progress = await PlatformBridge.getLibraryScanProgress();
|
||||
final nextProgress =
|
||||
@@ -447,6 +441,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
if (_progressPollingErrorCount <= 3) {
|
||||
_log.w('Library scan progress polling failed: $e');
|
||||
}
|
||||
} finally {
|
||||
_isProgressPollingInFlight = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -455,6 +451,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = null;
|
||||
_progressPollingErrorCount = 0;
|
||||
_isProgressPollingInFlight = false;
|
||||
}
|
||||
|
||||
Future<void> cancelScan() async {
|
||||
|
||||
+243
-110
@@ -26,8 +26,10 @@ class TrackState {
|
||||
final List<SearchPlaylist>? searchPlaylists; // For search results (playlists)
|
||||
final bool hasSearchText; // For back button handling
|
||||
final bool isShowingRecentAccess; // For recent access mode
|
||||
final String? searchExtensionId; // Extension ID used for current search results
|
||||
final String? selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
|
||||
final String?
|
||||
searchExtensionId; // Extension ID used for current search results
|
||||
final String?
|
||||
selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
|
||||
|
||||
const TrackState({
|
||||
this.tracks = const [],
|
||||
@@ -52,7 +54,12 @@ class TrackState {
|
||||
this.selectedSearchFilter,
|
||||
});
|
||||
|
||||
bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty) || (searchAlbums != null && searchAlbums!.isNotEmpty) || (searchPlaylists != null && searchPlaylists!.isNotEmpty);
|
||||
bool get hasContent =>
|
||||
tracks.isNotEmpty ||
|
||||
artistAlbums != null ||
|
||||
(searchArtists != null && searchArtists!.isNotEmpty) ||
|
||||
(searchAlbums != null && searchAlbums!.isNotEmpty) ||
|
||||
(searchPlaylists != null && searchPlaylists!.isNotEmpty);
|
||||
|
||||
TrackState copyWith({
|
||||
List<Track>? tracks,
|
||||
@@ -95,9 +102,12 @@ class TrackState {
|
||||
searchAlbums: searchAlbums ?? this.searchAlbums,
|
||||
searchPlaylists: searchPlaylists ?? this.searchPlaylists,
|
||||
hasSearchText: hasSearchText ?? this.hasSearchText,
|
||||
isShowingRecentAccess: isShowingRecentAccess ?? this.isShowingRecentAccess,
|
||||
isShowingRecentAccess:
|
||||
isShowingRecentAccess ?? this.isShowingRecentAccess,
|
||||
searchExtensionId: searchExtensionId,
|
||||
selectedSearchFilter: clearSelectedSearchFilter ? null : (selectedSearchFilter ?? this.selectedSearchFilter),
|
||||
selectedSearchFilter: clearSelectedSearchFilter
|
||||
? null
|
||||
: (selectedSearchFilter ?? this.selectedSearchFilter),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -178,6 +188,7 @@ class SearchPlaylist {
|
||||
|
||||
class TrackNotifier extends Notifier<TrackState> {
|
||||
int _currentRequestId = 0;
|
||||
static const int _maxPreWarmTracksPerRequest = 80;
|
||||
|
||||
@override
|
||||
TrackState build() {
|
||||
@@ -197,39 +208,42 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
final extensionHandler = await PlatformBridge.findURLHandler(url);
|
||||
if (extensionHandler != null) {
|
||||
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
|
||||
|
||||
|
||||
// Retry logic for extension URL handlers (up to 3 attempts)
|
||||
Map<String, dynamic>? result;
|
||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||
result = await PlatformBridge.handleURLWithExtension(url);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
|
||||
// Check if we got valid data
|
||||
if (result != null && result['type'] == 'track' && result['track'] != null) {
|
||||
if (result != null &&
|
||||
result['type'] == 'track' &&
|
||||
result['track'] != null) {
|
||||
final trackData = result['track'] as Map<String, dynamic>;
|
||||
final name = trackData['name']?.toString() ?? '';
|
||||
if (name.isNotEmpty) {
|
||||
break;
|
||||
}
|
||||
} else if (result != null && (result['type'] == 'album' || result['type'] == 'playlist')) {
|
||||
} else if (result != null &&
|
||||
(result['type'] == 'album' || result['type'] == 'playlist')) {
|
||||
break;
|
||||
} else if (result != null && result['type'] == 'artist') {
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
if (attempt < 3) {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (result != null) {
|
||||
final type = result['type'] as String?;
|
||||
final extensionId = result['extension_id'] as String?;
|
||||
|
||||
|
||||
if (type == 'track' && result['track'] != null) {
|
||||
final trackData = result['track'] as Map<String, dynamic>;
|
||||
final track = _parseSearchTrack(trackData, source: extensionId);
|
||||
|
||||
|
||||
if (track.name.isEmpty) {
|
||||
state = TrackState(
|
||||
isLoading: false,
|
||||
@@ -237,7 +251,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
state = TrackState(
|
||||
tracks: [track],
|
||||
isLoading: false,
|
||||
@@ -245,15 +259,27 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
searchExtensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
} else if ((type == 'album' || type == 'playlist') && result['tracks'] != null) {
|
||||
} else if ((type == 'album' || type == 'playlist') &&
|
||||
result['tracks'] != null) {
|
||||
final trackList = result['tracks'] as List<dynamic>;
|
||||
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList();
|
||||
final tracks = trackList
|
||||
.map(
|
||||
(t) => _parseSearchTrack(
|
||||
t as Map<String, dynamic>,
|
||||
source: extensionId,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
albumId: result['album']?['id'] as String?,
|
||||
albumName: result['name'] as String? ?? result['album']?['name'] as String?,
|
||||
playlistName: type == 'playlist' ? result['name'] as String? : null,
|
||||
albumName:
|
||||
result['name'] as String? ??
|
||||
result['album']?['name'] as String?,
|
||||
playlistName: type == 'playlist'
|
||||
? result['name'] as String?
|
||||
: null,
|
||||
coverUrl: result['cover_url'] as String?,
|
||||
searchExtensionId: extensionId,
|
||||
);
|
||||
@@ -261,17 +287,29 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
} else if (type == 'artist' && result['artist'] != null) {
|
||||
final artistData = result['artist'] as Map<String, dynamic>;
|
||||
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
|
||||
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||
|
||||
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
|
||||
final topTracks = topTracksList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList();
|
||||
|
||||
final albums = albumsList
|
||||
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
final topTracksList =
|
||||
artistData['top_tracks'] as List<dynamic>? ?? [];
|
||||
final topTracks = topTracksList
|
||||
.map(
|
||||
(t) => _parseSearchTrack(
|
||||
t as Map<String, dynamic>,
|
||||
source: extensionId,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
state = TrackState(
|
||||
tracks: [],
|
||||
isLoading: false,
|
||||
artistId: artistData['id'] as String?,
|
||||
artistName: artistData['name'] as String?,
|
||||
coverUrl: artistData['image_url'] as String? ?? artistData['images'] as String?,
|
||||
coverUrl:
|
||||
artistData['image_url'] as String? ??
|
||||
artistData['images'] as String?,
|
||||
headerImageUrl: artistData['header_image'] as String?,
|
||||
monthlyListeners: artistData['listeners'] as int?,
|
||||
artistAlbums: albums,
|
||||
@@ -282,19 +320,19 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Step 2: Try Deezer URL parsing
|
||||
if (url.contains('deezer.com') || url.contains('deezer.page.link')) {
|
||||
_log.i('Detected Deezer URL, parsing...');
|
||||
final parsed = await PlatformBridge.parseDeezerUrl(url);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
|
||||
final type = parsed['type'] as String;
|
||||
final id = parsed['id'] as String;
|
||||
|
||||
|
||||
final metadata = await PlatformBridge.getDeezerMetadata(type, id);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
|
||||
if (type == 'track') {
|
||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||
final track = _parseTrack(trackData);
|
||||
@@ -306,7 +344,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
} else if (type == 'album') {
|
||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
final tracks = trackList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
@@ -316,9 +356,12 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
);
|
||||
_preWarmCacheForTracks(tracks);
|
||||
} else if (type == 'playlist') {
|
||||
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
||||
final playlistInfo =
|
||||
metadata['playlist_info'] as Map<String, dynamic>;
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
final tracks = trackList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
@@ -329,7 +372,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
} else if (type == 'artist') {
|
||||
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
||||
final albumsList = metadata['albums'] as List<dynamic>;
|
||||
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||
final albums = albumsList
|
||||
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
||||
.toList();
|
||||
state = TrackState(
|
||||
tracks: [],
|
||||
isLoading: false,
|
||||
@@ -341,33 +386,38 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Step 3: Try Tidal URL parsing
|
||||
if (url.contains('tidal.com')) {
|
||||
_log.i('Detected Tidal URL, parsing...');
|
||||
final parsed = await PlatformBridge.parseTidalUrl(url);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
|
||||
final type = parsed['type'] as String;
|
||||
final id = parsed['id'] as String;
|
||||
|
||||
|
||||
_log.i('Tidal URL parsed: type=$type, id=$id');
|
||||
|
||||
|
||||
// For track URLs, convert to Spotify/Deezer and fetch metadata from there
|
||||
if (type == 'track') {
|
||||
try {
|
||||
_log.i('Converting Tidal track to Spotify/Deezer via SongLink...');
|
||||
final conversion = await PlatformBridge.convertTidalToSpotifyDeezer(url);
|
||||
final conversion = await PlatformBridge.convertTidalToSpotifyDeezer(
|
||||
url,
|
||||
);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
|
||||
final spotifyUrl = conversion['spotify_url'] as String?;
|
||||
final deezerUrl = conversion['deezer_url'] as String?;
|
||||
|
||||
|
||||
if (spotifyUrl != null && spotifyUrl.isNotEmpty) {
|
||||
_log.i('Found Spotify URL: $spotifyUrl, fetching metadata...');
|
||||
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(spotifyUrl);
|
||||
final metadata =
|
||||
await PlatformBridge.getSpotifyMetadataWithFallback(
|
||||
spotifyUrl,
|
||||
);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
|
||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||
final track = _parseTrack(trackData);
|
||||
state = TrackState(
|
||||
@@ -378,10 +428,15 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
return;
|
||||
} else if (deezerUrl != null && deezerUrl.isNotEmpty) {
|
||||
_log.i('Found Deezer URL: $deezerUrl, fetching metadata...');
|
||||
final deezerParsed = await PlatformBridge.parseDeezerUrl(deezerUrl);
|
||||
final metadata = await PlatformBridge.getDeezerMetadata('track', deezerParsed['id'] as String);
|
||||
final deezerParsed = await PlatformBridge.parseDeezerUrl(
|
||||
deezerUrl,
|
||||
);
|
||||
final metadata = await PlatformBridge.getDeezerMetadata(
|
||||
'track',
|
||||
deezerParsed['id'] as String,
|
||||
);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
|
||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||
final track = _parseTrack(trackData);
|
||||
state = TrackState(
|
||||
@@ -395,30 +450,31 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
_log.w('Failed to convert Tidal URL via SongLink: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// For album/artist/playlist, not yet supported
|
||||
state = TrackState(
|
||||
isLoading: false,
|
||||
error: 'Tidal $type links are not fully supported yet. Only track links work via SongLink conversion.',
|
||||
error:
|
||||
'Tidal $type links are not fully supported yet. Only track links work via SongLink conversion.',
|
||||
hasSearchText: state.hasSearchText,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Step 4: Fall back to Spotify parsing
|
||||
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
|
||||
final type = parsed['type'] as String;
|
||||
|
||||
Map<String, dynamic> metadata;
|
||||
|
||||
|
||||
try {
|
||||
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
|
||||
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
if (type == 'track') {
|
||||
@@ -432,7 +488,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
} else if (type == 'album') {
|
||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
final tracks = trackList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
@@ -444,7 +502,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
} else if (type == 'playlist') {
|
||||
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
final tracks = trackList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
@@ -456,7 +516,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
} else if (type == 'artist') {
|
||||
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
||||
final albumsList = metadata['albums'] as List<dynamic>;
|
||||
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||
final albums = albumsList
|
||||
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
||||
.toList();
|
||||
state = TrackState(
|
||||
tracks: [],
|
||||
isLoading: false,
|
||||
@@ -468,17 +530,29 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
||||
state = TrackState(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
hasSearchText: state.hasSearchText,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> search(String query, {String? metadataSource, String? filterOverride}) async {
|
||||
Future<void> search(
|
||||
String query, {
|
||||
String? metadataSource,
|
||||
String? filterOverride,
|
||||
}) async {
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
|
||||
// Preserve selected filter during loading
|
||||
final currentFilter = filterOverride ?? state.selectedSearchFilter;
|
||||
|
||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText, selectedSearchFilter: currentFilter);
|
||||
state = TrackState(
|
||||
isLoading: true,
|
||||
hasSearchText: state.hasSearchText,
|
||||
selectedSearchFilter: currentFilter,
|
||||
);
|
||||
|
||||
try {
|
||||
final settings = ref.read(settingsProvider);
|
||||
@@ -494,20 +568,23 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
searchProvider.isNotEmpty;
|
||||
|
||||
final source = metadataSource ?? 'deezer';
|
||||
|
||||
|
||||
_log.i(
|
||||
'Search started: source=$source, query="$query", useExtensions=$useExtensions, filter=$currentFilter',
|
||||
);
|
||||
|
||||
|
||||
Map<String, dynamic> results;
|
||||
List<Track> extensionTracks = [];
|
||||
|
||||
|
||||
if (useExtensions) {
|
||||
try {
|
||||
_log.d('Calling extension search API...');
|
||||
final extResults = await PlatformBridge.searchTracksWithExtensions(query, limit: 20);
|
||||
final extResults = await PlatformBridge.searchTracksWithExtensions(
|
||||
query,
|
||||
limit: 20,
|
||||
);
|
||||
_log.i('Extensions returned ${extResults.length} tracks');
|
||||
|
||||
|
||||
for (final t in extResults) {
|
||||
try {
|
||||
extensionTracks.add(_parseSearchTrack(t));
|
||||
@@ -519,37 +596,52 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
_log.w('Extension search failed, falling back to built-in: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (source == 'deezer') {
|
||||
_log.d('Calling Deezer search API...');
|
||||
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 2, filter: currentFilter);
|
||||
_log.i('Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums');
|
||||
results = await PlatformBridge.searchDeezerAll(
|
||||
query,
|
||||
trackLimit: 20,
|
||||
artistLimit: 2,
|
||||
filter: currentFilter,
|
||||
);
|
||||
_log.i(
|
||||
'Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums',
|
||||
);
|
||||
} else {
|
||||
_log.d('Calling Spotify search API...');
|
||||
results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 2);
|
||||
_log.i('Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists');
|
||||
results = await PlatformBridge.searchSpotifyAll(
|
||||
query,
|
||||
trackLimit: 20,
|
||||
artistLimit: 2,
|
||||
);
|
||||
_log.i(
|
||||
'Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (!_isRequestValid(requestId)) {
|
||||
_log.w('Search request cancelled (requestId=$requestId)');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
||||
final artistList = results['artists'] as List<dynamic>? ?? [];
|
||||
final albumList = results['albums'] as List<dynamic>? ?? [];
|
||||
|
||||
_log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists, ${albumList.length} albums');
|
||||
|
||||
|
||||
_log.d(
|
||||
'Raw results: ${trackList.length} tracks, ${artistList.length} artists, ${albumList.length} albums',
|
||||
);
|
||||
|
||||
final tracks = <Track>[];
|
||||
|
||||
|
||||
tracks.addAll(extensionTracks);
|
||||
|
||||
|
||||
final existingIsrcs = extensionTracks
|
||||
.where((t) => t.isrc != null && t.isrc!.isNotEmpty)
|
||||
.map((t) => t.isrc!)
|
||||
.toSet();
|
||||
|
||||
|
||||
for (int i = 0; i < trackList.length; i++) {
|
||||
final t = trackList[i];
|
||||
try {
|
||||
@@ -566,7 +658,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
_log.e('Failed to parse track[$i]: $e', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final artists = <SearchArtist>[];
|
||||
for (int i = 0; i < artistList.length; i++) {
|
||||
final a = artistList[i];
|
||||
@@ -580,7 +672,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
_log.e('Failed to parse artist[$i]: $e', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final albums = <SearchAlbum>[];
|
||||
for (int i = 0; i < albumList.length; i++) {
|
||||
final a = albumList[i];
|
||||
@@ -594,7 +686,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
_log.e('Failed to parse album[$i]: $e', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final playlistList = results['playlists'] as List<dynamic>? ?? [];
|
||||
final playlists = <SearchPlaylist>[];
|
||||
for (int i = 0; i < playlistList.length; i++) {
|
||||
@@ -609,9 +701,11 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
_log.e('Failed to parse playlist[$i]: $e', e);
|
||||
}
|
||||
}
|
||||
|
||||
_log.i('Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists, ${albums.length} albums, ${playlists.length} playlists parsed successfully');
|
||||
|
||||
|
||||
_log.i(
|
||||
'Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists, ${albums.length} albums, ${playlists.length} playlists parsed successfully',
|
||||
);
|
||||
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
searchArtists: artists,
|
||||
@@ -624,31 +718,45 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
} catch (e, stackTrace) {
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
_log.e('Search failed: $e', e, stackTrace);
|
||||
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText, selectedSearchFilter: currentFilter);
|
||||
state = TrackState(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
hasSearchText: state.hasSearchText,
|
||||
selectedSearchFilter: currentFilter,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> customSearch(String extensionId, String query, {Map<String, dynamic>? options}) async {
|
||||
Future<void> customSearch(
|
||||
String extensionId,
|
||||
String query, {
|
||||
Map<String, dynamic>? options,
|
||||
}) async {
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
state = TrackState(
|
||||
isLoading: true,
|
||||
hasSearchText: state.hasSearchText,
|
||||
selectedSearchFilter: state.selectedSearchFilter, // Preserve filter during loading
|
||||
selectedSearchFilter:
|
||||
state.selectedSearchFilter, // Preserve filter during loading
|
||||
);
|
||||
|
||||
try {
|
||||
_log.i('Custom search started: extension=$extensionId, query="$query"');
|
||||
|
||||
final results = await PlatformBridge.customSearchWithExtension(extensionId, query, options: options);
|
||||
|
||||
|
||||
final results = await PlatformBridge.customSearchWithExtension(
|
||||
extensionId,
|
||||
query,
|
||||
options: options,
|
||||
);
|
||||
|
||||
if (!_isRequestValid(requestId)) {
|
||||
_log.w('Custom search request cancelled (requestId=$requestId)');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
_log.i('Custom search returned ${results.length} tracks');
|
||||
|
||||
|
||||
final tracks = <Track>[];
|
||||
for (int i = 0; i < results.length; i++) {
|
||||
final t = results[i];
|
||||
@@ -658,21 +766,28 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
_log.e('Failed to parse custom search track[$i]: $e', e);
|
||||
}
|
||||
}
|
||||
|
||||
_log.i('Custom search complete: ${tracks.length} tracks parsed (source=$extensionId)');
|
||||
|
||||
|
||||
_log.i(
|
||||
'Custom search complete: ${tracks.length} tracks parsed (source=$extensionId)',
|
||||
);
|
||||
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
searchArtists: [],
|
||||
isLoading: false,
|
||||
hasSearchText: state.hasSearchText,
|
||||
searchExtensionId: extensionId, // Store which extension was used
|
||||
selectedSearchFilter: state.selectedSearchFilter, // Preserve selected filter
|
||||
selectedSearchFilter:
|
||||
state.selectedSearchFilter, // Preserve selected filter
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
_log.e('Custom search failed: $e', e, stackTrace);
|
||||
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
||||
state = TrackState(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
hasSearchText: state.hasSearchText,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -683,7 +798,10 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
if (track.isrc == null || track.isrc!.isEmpty) return;
|
||||
|
||||
try {
|
||||
final availability = await PlatformBridge.checkAvailability(track.id, track.isrc!);
|
||||
final availability = await PlatformBridge.checkAvailability(
|
||||
track.id,
|
||||
track.isrc!,
|
||||
);
|
||||
final updatedTrack = Track(
|
||||
id: track.id,
|
||||
name: track.name,
|
||||
@@ -736,11 +854,14 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
state = state.copyWith(hasSearchText: hasText);
|
||||
}
|
||||
|
||||
|
||||
void setShowingRecentAccess(bool showing) {
|
||||
if (state.isShowingRecentAccess == showing) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(isShowingRecentAccess: showing);
|
||||
}
|
||||
|
||||
|
||||
/// Set tracks from a collection (album/playlist) opened from search results
|
||||
void setTracksFromCollection({
|
||||
required List<Track> tracks,
|
||||
@@ -782,9 +903,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
} else if (durationValue is double) {
|
||||
durationMs = durationValue.toInt();
|
||||
}
|
||||
|
||||
|
||||
final itemType = data['item_type']?.toString();
|
||||
|
||||
|
||||
return Track(
|
||||
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
||||
name: (data['name'] ?? '').toString(),
|
||||
@@ -797,7 +918,10 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
source: source ?? data['source']?.toString() ?? data['provider_id']?.toString(),
|
||||
source:
|
||||
source ??
|
||||
data['source']?.toString() ??
|
||||
data['provider_id']?.toString(),
|
||||
albumType: data['album_type']?.toString(),
|
||||
itemType: itemType,
|
||||
);
|
||||
@@ -849,16 +973,25 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
|
||||
void _preWarmCacheForTracks(List<Track> tracks) {
|
||||
final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList();
|
||||
if (tracksWithIsrc.isEmpty) return;
|
||||
|
||||
final cacheRequests = tracksWithIsrc.map((t) => {
|
||||
'isrc': t.isrc!,
|
||||
'track_name': t.name,
|
||||
'artist_name': t.artistName,
|
||||
'spotify_id': t.id, // Include Spotify ID for Amazon lookup
|
||||
'service': 'tidal',
|
||||
}).toList();
|
||||
if (tracks.isEmpty) return;
|
||||
final cacheRequests = <Map<String, String>>[];
|
||||
for (final track in tracks) {
|
||||
final isrc = track.isrc;
|
||||
if (isrc == null || isrc.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
cacheRequests.add({
|
||||
'isrc': isrc,
|
||||
'track_name': track.name,
|
||||
'artist_name': track.artistName,
|
||||
'spotify_id': track.id, // Include Spotify ID for Amazon lookup
|
||||
'service': 'tidal',
|
||||
});
|
||||
if (cacheRequests.length >= _maxPreWarmTracksPerRequest) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (cacheRequests.isEmpty) return;
|
||||
|
||||
PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) {});
|
||||
}
|
||||
|
||||
@@ -34,6 +34,12 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
final Set<String> _selectedIds = {};
|
||||
bool _showTitleInAppBar = false;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
bool _embeddedCoverRefreshScheduled = false;
|
||||
List<DownloadHistoryItem>? _albumTracksSourceCache;
|
||||
List<DownloadHistoryItem>? _albumTracksCache;
|
||||
|
||||
String get _albumLookupKey =>
|
||||
'${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -48,6 +54,16 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant DownloadedAlbumScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.albumName != widget.albumName ||
|
||||
oldWidget.artistName != widget.artistName) {
|
||||
_albumTracksSourceCache = null;
|
||||
_albumTracksCache = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
final shouldShow = _scrollController.offset > 280;
|
||||
if (shouldShow != _showTitleInAppBar) {
|
||||
@@ -59,28 +75,36 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
List<DownloadHistoryItem> _getAlbumTracks(
|
||||
List<DownloadHistoryItem> allItems,
|
||||
) {
|
||||
return allItems.where((item) {
|
||||
// Use albumArtist if available and not empty, otherwise artistName
|
||||
final itemArtist =
|
||||
(item.albumArtist != null && item.albumArtist!.isNotEmpty)
|
||||
? item.albumArtist!
|
||||
: item.artistName;
|
||||
// Use lowercase for case-insensitive matching
|
||||
final itemKey =
|
||||
'${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
|
||||
final albumKey =
|
||||
'${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}';
|
||||
return itemKey == albumKey;
|
||||
}).toList()..sort((a, b) {
|
||||
// Sort by disc number first, then by track number
|
||||
final aDisc = a.discNumber ?? 1;
|
||||
final bDisc = b.discNumber ?? 1;
|
||||
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
|
||||
final aNum = a.trackNumber ?? 999;
|
||||
final bNum = b.trackNumber ?? 999;
|
||||
if (aNum != bNum) return aNum.compareTo(bNum);
|
||||
return a.trackName.compareTo(b.trackName);
|
||||
});
|
||||
final cached = _albumTracksCache;
|
||||
if (cached != null && identical(allItems, _albumTracksSourceCache)) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
final tracks =
|
||||
allItems.where((item) {
|
||||
// Use albumArtist if available and not empty, otherwise artistName
|
||||
final itemArtist =
|
||||
(item.albumArtist != null && item.albumArtist!.isNotEmpty)
|
||||
? item.albumArtist!
|
||||
: item.artistName;
|
||||
// Use lowercase for case-insensitive matching
|
||||
final itemKey =
|
||||
'${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
|
||||
return itemKey == _albumLookupKey;
|
||||
}).toList()..sort((a, b) {
|
||||
// Sort by disc number first, then by track number
|
||||
final aDisc = a.discNumber ?? 1;
|
||||
final bDisc = b.discNumber ?? 1;
|
||||
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
|
||||
final aNum = a.trackNumber ?? 999;
|
||||
final bNum = b.trackNumber ?? 999;
|
||||
if (aNum != bNum) return aNum.compareTo(bNum);
|
||||
return a.trackName.compareTo(b.trackName);
|
||||
});
|
||||
|
||||
_albumTracksSourceCache = allItems;
|
||||
_albumTracksCache = tracks;
|
||||
return tracks;
|
||||
}
|
||||
|
||||
Map<int, List<DownloadHistoryItem>> _groupTracksByDisc(
|
||||
@@ -194,8 +218,14 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
}
|
||||
|
||||
void _onEmbeddedCoverChanged() {
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
if (!mounted || _embeddedCoverRefreshScheduled) return;
|
||||
_embeddedCoverRefreshScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_embeddedCoverRefreshScheduled = false;
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _navigateToMetadataScreen(DownloadHistoryItem item) async {
|
||||
|
||||
+85
-69
@@ -35,18 +35,25 @@ class HomeTab extends ConsumerStatefulWidget {
|
||||
|
||||
class _RecentAccessView {
|
||||
final List<RecentAccessItem> uniqueItems;
|
||||
final List<RecentAccessItem> downloadItems;
|
||||
final List<String> downloadIds;
|
||||
final Map<String, String> downloadFilePathByRecentKey;
|
||||
final bool hasHiddenDownloads;
|
||||
|
||||
const _RecentAccessView({
|
||||
required this.uniqueItems,
|
||||
required this.downloadItems,
|
||||
required this.downloadIds,
|
||||
required this.downloadFilePathByRecentKey,
|
||||
required this.hasHiddenDownloads,
|
||||
});
|
||||
}
|
||||
|
||||
class _RecentAlbumAggregate {
|
||||
int count;
|
||||
DownloadHistoryItem mostRecent;
|
||||
|
||||
_RecentAlbumAggregate({required this.count, required this.mostRecent});
|
||||
}
|
||||
|
||||
class _CsvImportOptions {
|
||||
final bool confirmed;
|
||||
final bool skipDownloaded;
|
||||
@@ -60,7 +67,6 @@ class _CsvImportOptions {
|
||||
class _HomeTabState extends ConsumerState<HomeTab>
|
||||
with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
|
||||
final _urlController = TextEditingController();
|
||||
bool _isTyping = false;
|
||||
final FocusNode _searchFocusNode = FocusNode();
|
||||
String? _lastSearchQuery;
|
||||
late final ProviderSubscription<TrackState> _trackStateSub;
|
||||
@@ -77,6 +83,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
List<RecentAccessItem>? _recentAccessItemsCache;
|
||||
Set<String>? _recentAccessHiddenIdsCache;
|
||||
_RecentAccessView? _recentAccessViewCache;
|
||||
bool _embeddedCoverRefreshScheduled = false;
|
||||
List<Extension>? _thumbnailSizesExtensionsCache;
|
||||
Map<String, (double, double)>? _thumbnailSizesCache;
|
||||
|
||||
double _responsiveScale({
|
||||
required BuildContext context,
|
||||
@@ -200,6 +209,27 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Map<String, (double, double)> _getThumbnailSizesByExtensionId(
|
||||
List<Extension> extensions,
|
||||
) {
|
||||
final cached = _thumbnailSizesCache;
|
||||
if (cached != null &&
|
||||
identical(extensions, _thumbnailSizesExtensionsCache)) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
final map = <String, (double, double)>{
|
||||
for (final extension in extensions)
|
||||
if (extension.searchBehavior != null)
|
||||
extension.id: extension.searchBehavior!.getThumbnailSize(
|
||||
defaultSize: 56,
|
||||
),
|
||||
};
|
||||
_thumbnailSizesExtensionsCache = extensions;
|
||||
_thumbnailSizesCache = map;
|
||||
return map;
|
||||
}
|
||||
|
||||
void _onSearchFocusChanged() {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
@@ -217,7 +247,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
_urlController.text.isNotEmpty &&
|
||||
!_searchFocusNode.hasFocus) {
|
||||
_urlController.clear();
|
||||
setState(() => _isTyping = false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,10 +269,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
|
||||
ref.read(trackProvider.notifier).setSearchText(text.isNotEmpty);
|
||||
|
||||
if (text.isNotEmpty && !_isTyping) {
|
||||
setState(() => _isTyping = true);
|
||||
} else if (text.isEmpty && _isTyping) {
|
||||
setState(() => _isTyping = false);
|
||||
if (text.isEmpty) {
|
||||
_liveSearchDebounce?.cancel();
|
||||
return;
|
||||
}
|
||||
@@ -350,7 +376,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
_urlController.clear();
|
||||
_searchFocusNode.unfocus();
|
||||
_lastSearchQuery = null;
|
||||
setState(() => _isTyping = false);
|
||||
ref.read(trackProvider.notifier).clear();
|
||||
}
|
||||
|
||||
@@ -390,7 +415,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
);
|
||||
ref.read(trackProvider.notifier).clear();
|
||||
_urlController.clear();
|
||||
setState(() => _isTyping = false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -416,7 +440,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
);
|
||||
ref.read(trackProvider.notifier).clear();
|
||||
_urlController.clear();
|
||||
setState(() => _isTyping = false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -438,7 +461,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
);
|
||||
ref.read(trackProvider.notifier).clear();
|
||||
_urlController.clear();
|
||||
setState(() => _isTyping = false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -781,13 +803,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
);
|
||||
final showLocalLibraryIndicator =
|
||||
localLibrarySettings.$1 && localLibrarySettings.$2;
|
||||
final thumbnailSizesByExtensionId = <String, (double, double)>{
|
||||
for (final extension in extensions)
|
||||
if (extension.searchBehavior != null)
|
||||
extension.id: extension.searchBehavior!.getThumbnailSize(
|
||||
defaultSize: 56,
|
||||
),
|
||||
};
|
||||
final thumbnailSizesByExtensionId = _getThumbnailSizesByExtensionId(
|
||||
extensions,
|
||||
);
|
||||
Extension? currentSearchExtension;
|
||||
List<SearchFilter> searchFilters = [];
|
||||
|
||||
@@ -1028,8 +1046,14 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
}
|
||||
|
||||
void _onEmbeddedCoverChanged() {
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
if (!mounted || _embeddedCoverRefreshScheduled) return;
|
||||
_embeddedCoverRefreshScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_embeddedCoverRefreshScheduled = false;
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildRecentDownloads(
|
||||
@@ -1148,66 +1172,58 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
return cached;
|
||||
}
|
||||
|
||||
final albumGroups = <String, List<DownloadHistoryItem>>{};
|
||||
final albumGroups = <String, _RecentAlbumAggregate>{};
|
||||
for (final h in historyItems) {
|
||||
final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty)
|
||||
? h.albumArtist!
|
||||
: h.artistName;
|
||||
final albumKey = '${h.albumName}|$artistForKey';
|
||||
albumGroups.putIfAbsent(albumKey, () => []).add(h);
|
||||
final existing = albumGroups[albumKey];
|
||||
if (existing == null) {
|
||||
albumGroups[albumKey] = _RecentAlbumAggregate(count: 1, mostRecent: h);
|
||||
} else {
|
||||
existing.count++;
|
||||
if (h.downloadedAt.isAfter(existing.mostRecent.downloadedAt)) {
|
||||
existing.mostRecent = h;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final downloadItems = <RecentAccessItem>[];
|
||||
final downloadIds = <String>[];
|
||||
final visibleDownloads = <RecentAccessItem>[];
|
||||
final downloadFilePathByRecentKey = <String, String>{};
|
||||
for (final entry in albumGroups.entries) {
|
||||
final tracks = entry.value;
|
||||
final mostRecent = tracks.reduce(
|
||||
(a, b) => a.downloadedAt.isAfter(b.downloadedAt) ? a : b,
|
||||
);
|
||||
for (final aggregate in albumGroups.values) {
|
||||
final mostRecent = aggregate.mostRecent;
|
||||
final artistForKey =
|
||||
(mostRecent.albumArtist != null && mostRecent.albumArtist!.isNotEmpty)
|
||||
? mostRecent.albumArtist!
|
||||
: mostRecent.artistName;
|
||||
|
||||
if (tracks.length == 1) {
|
||||
final recent = RecentAccessItem(
|
||||
id: mostRecent.spotifyId ?? mostRecent.id,
|
||||
name: mostRecent.trackName,
|
||||
subtitle: mostRecent.artistName,
|
||||
imageUrl: mostRecent.coverUrl,
|
||||
type: RecentAccessType.track,
|
||||
accessedAt: mostRecent.downloadedAt,
|
||||
providerId: 'download',
|
||||
);
|
||||
downloadItems.add(recent);
|
||||
downloadFilePathByRecentKey['${recent.type.name}:${recent.id}'] =
|
||||
mostRecent.filePath;
|
||||
} else {
|
||||
final recent = RecentAccessItem(
|
||||
id: '${mostRecent.albumName}|$artistForKey',
|
||||
name: mostRecent.albumName,
|
||||
subtitle: artistForKey,
|
||||
imageUrl: mostRecent.coverUrl,
|
||||
type: RecentAccessType.album,
|
||||
accessedAt: mostRecent.downloadedAt,
|
||||
providerId: 'download',
|
||||
);
|
||||
downloadItems.add(recent);
|
||||
downloadFilePathByRecentKey['${recent.type.name}:${recent.id}'] =
|
||||
mostRecent.filePath;
|
||||
final isSingleTrack = aggregate.count == 1;
|
||||
final recentId = isSingleTrack
|
||||
? (mostRecent.spotifyId ?? mostRecent.id)
|
||||
: '${mostRecent.albumName}|$artistForKey';
|
||||
final recent = RecentAccessItem(
|
||||
id: recentId,
|
||||
name: isSingleTrack ? mostRecent.trackName : mostRecent.albumName,
|
||||
subtitle: isSingleTrack ? mostRecent.artistName : artistForKey,
|
||||
imageUrl: mostRecent.coverUrl,
|
||||
type: isSingleTrack ? RecentAccessType.track : RecentAccessType.album,
|
||||
accessedAt: mostRecent.downloadedAt,
|
||||
providerId: 'download',
|
||||
);
|
||||
|
||||
downloadIds.add(recentId);
|
||||
downloadFilePathByRecentKey['${recent.type.name}:${recent.id}'] =
|
||||
mostRecent.filePath;
|
||||
if (!hiddenIds.contains(recentId)) {
|
||||
visibleDownloads.add(recent);
|
||||
}
|
||||
}
|
||||
|
||||
downloadItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt));
|
||||
|
||||
final visibleDownloads = <RecentAccessItem>[];
|
||||
for (final item in downloadItems) {
|
||||
if (!hiddenIds.contains(item.id)) {
|
||||
visibleDownloads.add(item);
|
||||
if (visibleDownloads.length >= 10) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
visibleDownloads.sort((a, b) => b.accessedAt.compareTo(a.accessedAt));
|
||||
if (visibleDownloads.length > 10) {
|
||||
visibleDownloads.removeRange(10, visibleDownloads.length);
|
||||
}
|
||||
|
||||
final allItems = <RecentAccessItem>[...items, ...visibleDownloads];
|
||||
@@ -1227,7 +1243,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
|
||||
final view = _RecentAccessView(
|
||||
uniqueItems: uniqueItems,
|
||||
downloadItems: downloadItems,
|
||||
downloadIds: downloadIds,
|
||||
downloadFilePathByRecentKey: downloadFilePathByRecentKey,
|
||||
hasHiddenDownloads: hiddenIds.isNotEmpty,
|
||||
);
|
||||
@@ -1641,7 +1657,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
|
||||
Widget _buildRecentAccess(_RecentAccessView view, ColorScheme colorScheme) {
|
||||
final uniqueItems = view.uniqueItems;
|
||||
final downloadItems = view.downloadItems;
|
||||
final downloadIds = view.downloadIds;
|
||||
final hasHiddenDownloads = view.hasHiddenDownloads;
|
||||
|
||||
return Padding(
|
||||
@@ -1661,10 +1677,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
if (uniqueItems.isNotEmpty)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
for (final item in downloadItems) {
|
||||
for (final id in downloadIds) {
|
||||
ref
|
||||
.read(recentAccessProvider.notifier)
|
||||
.hideDownloadFromRecents(item.id);
|
||||
.hideDownloadFromRecents(id);
|
||||
}
|
||||
ref.read(recentAccessProvider.notifier).clearHistory();
|
||||
},
|
||||
|
||||
+82
-69
@@ -228,6 +228,7 @@ Map<String, List<String>> _filterHistoryInIsolate(Map<String, Object> payload) {
|
||||
final entries = (payload['entries'] as List).cast<List>();
|
||||
final albumCounts = (payload['albumCounts'] as Map).cast<String, int>();
|
||||
final query = (payload['query'] as String?) ?? '';
|
||||
final hasQuery = query.isNotEmpty;
|
||||
|
||||
final allIds = <String>[];
|
||||
final albumIds = <String>[];
|
||||
@@ -236,10 +237,11 @@ Map<String, List<String>> _filterHistoryInIsolate(Map<String, Object> payload) {
|
||||
for (final entry in entries) {
|
||||
final id = entry[0] as String;
|
||||
final albumKey = entry[1] as String;
|
||||
final searchKey = entry[2] as String;
|
||||
|
||||
if (query.isNotEmpty && !searchKey.contains(query)) {
|
||||
continue;
|
||||
if (hasQuery) {
|
||||
final searchKey = entry[2] as String;
|
||||
if (!searchKey.contains(query)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
allIds.add(id);
|
||||
@@ -276,6 +278,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
final ValueNotifier<bool> _alwaysMissingFileNotifier = ValueNotifier(false);
|
||||
final Set<String> _pendingChecks = {};
|
||||
static const int _maxCacheSize = 500;
|
||||
static const int _maxSearchIndexCacheSize = 4000;
|
||||
static const int _maxDownloadedEmbeddedCoverCacheSize = 180;
|
||||
|
||||
bool _isSelectionMode = false;
|
||||
final Set<String> _selectedIds = {};
|
||||
@@ -311,8 +315,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
final Set<String> _pendingDownloadedCoverExtract = {};
|
||||
final Set<String> _pendingDownloadedCoverRefresh = {};
|
||||
final Set<String> _failedDownloadedCoverExtract = {};
|
||||
Map<String, DownloadHistoryItem> _historyItemsById = {};
|
||||
List<List<String>> _historyFilterEntries = const [];
|
||||
Map<String, List<DownloadHistoryItem>> _filteredHistoryCache = const {};
|
||||
List<DownloadHistoryItem>? _filterItemsCache;
|
||||
String _filterQueryCache = '';
|
||||
@@ -407,32 +409,16 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
_historyItemsCache = items;
|
||||
_localLibraryItemsCache = localItems;
|
||||
_historyStatsCache = _buildHistoryStats(items, localItems);
|
||||
_searchIndexCache
|
||||
..clear()
|
||||
..addEntries(
|
||||
items.map((item) => MapEntry(item.id, _buildSearchKey(item))),
|
||||
);
|
||||
if (historyChanged) {
|
||||
_searchIndexCache.clear();
|
||||
}
|
||||
if (localChanged) {
|
||||
_localSearchIndexCache
|
||||
..clear()
|
||||
..addEntries(
|
||||
localItems.map(
|
||||
(item) => MapEntry(item.id, _buildLocalSearchKey(item)),
|
||||
),
|
||||
);
|
||||
_localSearchIndexCache.clear();
|
||||
_localFilterItemsCache = null;
|
||||
_localFilterQueryCache = '';
|
||||
_filteredLocalItemsCache = const [];
|
||||
}
|
||||
_unifiedItemsCache.clear();
|
||||
_historyItemsById = {for (final item in items) item.id: item};
|
||||
_historyFilterEntries = List<List<String>>.generate(items.length, (index) {
|
||||
final item = items[index];
|
||||
final searchKey = _searchIndexCache[item.id] ?? _buildSearchKey(item);
|
||||
final albumKey =
|
||||
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
|
||||
return [item.id, albumKey, searchKey];
|
||||
}, growable: false);
|
||||
|
||||
if (historyChanged) {
|
||||
final validPaths = items
|
||||
@@ -459,6 +445,30 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
String _historySearchKeyForItem(DownloadHistoryItem item) {
|
||||
final cached = _searchIndexCache[item.id];
|
||||
if (cached != null) return cached;
|
||||
|
||||
final searchKey = _buildSearchKey(item);
|
||||
_searchIndexCache[item.id] = searchKey;
|
||||
while (_searchIndexCache.length > _maxSearchIndexCacheSize) {
|
||||
_searchIndexCache.remove(_searchIndexCache.keys.first);
|
||||
}
|
||||
return searchKey;
|
||||
}
|
||||
|
||||
String _localSearchKeyForItem(LocalLibraryItem item) {
|
||||
final cached = _localSearchIndexCache[item.id];
|
||||
if (cached != null) return cached;
|
||||
|
||||
final searchKey = _buildLocalSearchKey(item);
|
||||
_localSearchIndexCache[item.id] = searchKey;
|
||||
while (_localSearchIndexCache.length > _maxSearchIndexCacheSize) {
|
||||
_localSearchIndexCache.remove(_localSearchIndexCache.keys.first);
|
||||
}
|
||||
return searchKey;
|
||||
}
|
||||
|
||||
List<LocalLibraryItem> _filterLocalItems(
|
||||
List<LocalLibraryItem> items,
|
||||
String query,
|
||||
@@ -471,11 +481,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
|
||||
final filtered = items
|
||||
.where((item) {
|
||||
final searchKey =
|
||||
_localSearchIndexCache[item.id] ?? _buildLocalSearchKey(item);
|
||||
if (!_localSearchIndexCache.containsKey(item.id)) {
|
||||
_localSearchIndexCache[item.id] = searchKey;
|
||||
}
|
||||
final searchKey = _localSearchKeyForItem(item);
|
||||
return searchKey.contains(query);
|
||||
})
|
||||
.toList(growable: false);
|
||||
@@ -548,15 +554,26 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
|
||||
final requestId = ++_filterRequestId;
|
||||
final includeSearchKey = query.isNotEmpty;
|
||||
final entries = List<List<String>>.generate(items.length, (index) {
|
||||
final item = items[index];
|
||||
final albumKey =
|
||||
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
|
||||
if (!includeSearchKey) {
|
||||
return [item.id, albumKey];
|
||||
}
|
||||
final searchKey = _historySearchKeyForItem(item);
|
||||
return [item.id, albumKey, searchKey];
|
||||
}, growable: false);
|
||||
final payload = <String, Object>{
|
||||
'entries': _historyFilterEntries,
|
||||
'entries': entries,
|
||||
'albumCounts': albumCounts,
|
||||
'query': query,
|
||||
};
|
||||
|
||||
compute(_filterHistoryInIsolate, payload).then((result) {
|
||||
if (!mounted || requestId != _filterRequestId) return;
|
||||
final itemsById = _historyItemsById;
|
||||
final itemsById = {for (final item in items) item.id: item};
|
||||
final filtered = <String, List<DownloadHistoryItem>>{};
|
||||
for (final entry in result.entries) {
|
||||
filtered[entry.key] = entry.value
|
||||
@@ -604,10 +621,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
final query = searchQuery;
|
||||
return items
|
||||
.where((item) {
|
||||
final searchKey = _searchIndexCache[item.id] ?? _buildSearchKey(item);
|
||||
if (!_searchIndexCache.containsKey(item.id)) {
|
||||
_searchIndexCache[item.id] = searchKey;
|
||||
}
|
||||
final searchKey = _historySearchKeyForItem(item);
|
||||
return searchKey.contains(query);
|
||||
})
|
||||
.toList(growable: false);
|
||||
@@ -812,6 +826,18 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
_cleanupTempCoverPathSync(cachedPath);
|
||||
}
|
||||
|
||||
void _trimDownloadedEmbeddedCoverCache() {
|
||||
while (_downloadedEmbeddedCoverCache.length >
|
||||
_maxDownloadedEmbeddedCoverCacheSize) {
|
||||
final oldestKey = _downloadedEmbeddedCoverCache.keys.first;
|
||||
final removedPath = _downloadedEmbeddedCoverCache.remove(oldestKey);
|
||||
_pendingDownloadedCoverExtract.remove(oldestKey);
|
||||
_pendingDownloadedCoverRefresh.remove(oldestKey);
|
||||
_failedDownloadedCoverExtract.remove(oldestKey);
|
||||
_cleanupTempCoverPathSync(removedPath);
|
||||
}
|
||||
}
|
||||
|
||||
Future<int?> _readFileModTimeMillis(String? filePath) async {
|
||||
final cleanPath = _cleanFilePath(filePath);
|
||||
if (cleanPath.isEmpty) return null;
|
||||
@@ -918,6 +944,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
final previous = _downloadedEmbeddedCoverCache[cleanPath];
|
||||
_downloadedEmbeddedCoverCache[cleanPath] = outputPath;
|
||||
_failedDownloadedCoverExtract.remove(cleanPath);
|
||||
_trimDownloadedEmbeddedCoverCache();
|
||||
if (previous != null && previous != outputPath) {
|
||||
_cleanupTempCoverPathSync(previous);
|
||||
}
|
||||
@@ -1607,10 +1634,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
if (searchQuery.isNotEmpty) {
|
||||
final query = searchQuery;
|
||||
filteredItems = items.where((item) {
|
||||
final searchKey = _searchIndexCache[item.id] ?? _buildSearchKey(item);
|
||||
if (!_searchIndexCache.containsKey(item.id)) {
|
||||
_searchIndexCache[item.id] = searchKey;
|
||||
}
|
||||
final searchKey = _historySearchKeyForItem(item);
|
||||
return searchKey.contains(query);
|
||||
}).toList();
|
||||
}
|
||||
@@ -1797,7 +1821,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
_initializePageController();
|
||||
|
||||
final hasQueueItems = ref.watch(
|
||||
downloadQueueProvider.select((s) => s.items.isNotEmpty),
|
||||
downloadQueueLookupProvider.select((lookup) => lookup.itemIds.isNotEmpty),
|
||||
);
|
||||
final allHistoryItems = ref.watch(
|
||||
downloadHistoryProvider.select((s) => s.items),
|
||||
@@ -1825,6 +1849,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
_buildHistoryStats(allHistoryItems, localLibraryItems);
|
||||
final groupedAlbums = historyStats.groupedAlbums;
|
||||
final groupedLocalAlbums = historyStats.groupedLocalAlbums;
|
||||
final filteredGroupedAlbums = _filterGroupedAlbums(
|
||||
groupedAlbums,
|
||||
_searchQuery,
|
||||
);
|
||||
final filteredGroupedLocalAlbums = _filterGroupedLocalAlbums(
|
||||
groupedLocalAlbums,
|
||||
_searchQuery,
|
||||
);
|
||||
final albumCount = historyStats.totalAlbumCount;
|
||||
final singleCount = historyStats.totalSingleTracks;
|
||||
final filterDataCache = <String, _FilterContentData>{};
|
||||
@@ -1835,8 +1867,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
() => _computeFilterContentData(
|
||||
filterMode: filterMode,
|
||||
allHistoryItems: allHistoryItems,
|
||||
groupedAlbums: groupedAlbums,
|
||||
groupedLocalAlbums: groupedLocalAlbums,
|
||||
filteredGroupedAlbums: filteredGroupedAlbums,
|
||||
filteredGroupedLocalAlbums: filteredGroupedLocalAlbums,
|
||||
albumCounts: historyStats.albumCounts,
|
||||
localAlbumCounts: historyStats.localAlbumCounts,
|
||||
localLibraryItems: localLibraryItems,
|
||||
@@ -2227,8 +2259,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
_FilterContentData _computeFilterContentData({
|
||||
required String filterMode,
|
||||
required List<DownloadHistoryItem> allHistoryItems,
|
||||
required List<_GroupedAlbum> groupedAlbums,
|
||||
required List<_GroupedLocalAlbum> groupedLocalAlbums,
|
||||
required List<_GroupedAlbum> filteredGroupedAlbums,
|
||||
required List<_GroupedLocalAlbum> filteredGroupedLocalAlbums,
|
||||
required Map<String, int> albumCounts,
|
||||
required Map<String, int> localAlbumCounts,
|
||||
required List<LocalLibraryItem> localLibraryItems,
|
||||
@@ -2243,16 +2275,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
filterMode: filterMode,
|
||||
);
|
||||
|
||||
final searchQuery = _searchQuery;
|
||||
final filteredGroupedAlbums = _filterGroupedAlbums(
|
||||
groupedAlbums,
|
||||
searchQuery,
|
||||
);
|
||||
final filteredGroupedLocalAlbums = _filterGroupedLocalAlbums(
|
||||
groupedLocalAlbums,
|
||||
searchQuery,
|
||||
);
|
||||
|
||||
final unifiedItems = _getUnifiedItems(
|
||||
filterMode: filterMode,
|
||||
historyItems: historyItems,
|
||||
@@ -2278,7 +2300,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
return Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final queueCount = ref.watch(
|
||||
downloadQueueProvider.select((s) => s.items.length),
|
||||
downloadQueueLookupProvider.select((lookup) => lookup.itemIds.length),
|
||||
);
|
||||
if (queueCount == 0) {
|
||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
@@ -2310,10 +2332,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
return Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final queueIdsSnapshot = ref.watch(
|
||||
downloadQueueProvider.select(
|
||||
(s) => _QueueItemIdsSnapshot(
|
||||
s.items.map((item) => item.id).toList(growable: false),
|
||||
),
|
||||
downloadQueueLookupProvider.select(
|
||||
(lookup) => _QueueItemIdsSnapshot(lookup.itemIds),
|
||||
),
|
||||
);
|
||||
if (queueIdsSnapshot.ids.isEmpty) {
|
||||
@@ -4011,14 +4031,7 @@ class _QueueItemSliverRow extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final item = ref.watch(
|
||||
downloadQueueProvider.select((state) {
|
||||
for (final current in state.items) {
|
||||
if (current.id == itemId) {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
downloadQueueLookupProvider.select((lookup) => lookup.byItemId[itemId]),
|
||||
);
|
||||
if (item == null) {
|
||||
return const SizedBox.shrink();
|
||||
|
||||
+73
-7
@@ -8,6 +8,27 @@ import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
|
||||
const int _maxLogMessageLength = 500;
|
||||
const String _redactedValue = '[REDACTED]';
|
||||
|
||||
final RegExp _authorizationBearerPattern = RegExp(
|
||||
r'\bAuthorization\b\s*[:=]\s*Bearer\s+[A-Za-z0-9._~+/\-]+=*',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
final RegExp _genericSensitiveKeyValuePattern = RegExp(
|
||||
r'\b(access[_\s-]?token|refresh[_\s-]?token|id[_\s-]?token|client[_\s-]?secret|authorization|password|api[_\s-]?key)\b(\s*[:=]\s*)([^\s,;]+)',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
final RegExp _sensitiveQueryPattern = RegExp(
|
||||
r'([?&](?:access_token|refresh_token|id_token|token|client_secret|api_key|apikey|password)=)[^&\s]+',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
final RegExp _bearerTokenPattern = RegExp(
|
||||
r'\bBearer\s+[A-Za-z0-9._~+/\-]+=*',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
String _truncateLogText(String value, {int maxLength = _maxLogMessageLength}) {
|
||||
if (value.length <= maxLength) {
|
||||
@@ -16,6 +37,39 @@ String _truncateLogText(String value, {int maxLength = _maxLogMessageLength}) {
|
||||
return '${value.substring(0, maxLength)}...[truncated]';
|
||||
}
|
||||
|
||||
String _redactSensitiveText(String value) {
|
||||
var redacted = value;
|
||||
|
||||
redacted = redacted.replaceAllMapped(_authorizationBearerPattern, (_) {
|
||||
return 'Authorization: Bearer $_redactedValue';
|
||||
});
|
||||
|
||||
redacted = redacted.replaceAllMapped(_genericSensitiveKeyValuePattern, (
|
||||
match,
|
||||
) {
|
||||
final key = match.group(1) ?? '';
|
||||
final delimiter = match.group(2) ?? '=';
|
||||
return '$key$delimiter$_redactedValue';
|
||||
});
|
||||
|
||||
redacted = redacted.replaceAllMapped(_sensitiveQueryPattern, (match) {
|
||||
final prefix = match.group(1) ?? '';
|
||||
return '$prefix$_redactedValue';
|
||||
});
|
||||
|
||||
redacted = redacted.replaceAllMapped(_bearerTokenPattern, (_) {
|
||||
return 'Bearer $_redactedValue';
|
||||
});
|
||||
|
||||
return redacted;
|
||||
}
|
||||
|
||||
String _maskIdentifier(String value) {
|
||||
if (value.isEmpty) return value;
|
||||
if (value.length <= 4) return '***';
|
||||
return '${value.substring(0, 2)}***${value.substring(value.length - 2)}';
|
||||
}
|
||||
|
||||
class LogEntry {
|
||||
final DateTime timestamp;
|
||||
final String level;
|
||||
@@ -59,6 +113,7 @@ class LogBuffer extends ChangeNotifier {
|
||||
final Queue<LogEntry> _entries = Queue<LogEntry>();
|
||||
Timer? _goLogTimer;
|
||||
int _lastGoLogIndex = 0;
|
||||
bool _isFetchingGoLogs = false;
|
||||
|
||||
static bool _loggingEnabled = false;
|
||||
static bool get loggingEnabled => _loggingEnabled;
|
||||
@@ -79,9 +134,11 @@ class LogBuffer extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
final sanitizedMessage = _truncateLogText(entry.message);
|
||||
final sanitizedMessage = _truncateLogText(
|
||||
_redactSensitiveText(entry.message),
|
||||
);
|
||||
final sanitizedError = entry.error != null
|
||||
? _truncateLogText(entry.error!)
|
||||
? _truncateLogText(_redactSensitiveText(entry.error!))
|
||||
: null;
|
||||
final sanitizedEntry =
|
||||
(sanitizedMessage == entry.message && sanitizedError == entry.error)
|
||||
@@ -105,13 +162,20 @@ class LogBuffer extends ChangeNotifier {
|
||||
void startGoLogPolling() {
|
||||
_goLogTimer?.cancel();
|
||||
_goLogTimer = Timer.periodic(_goLogPollingInterval, (_) async {
|
||||
await _fetchGoLogs();
|
||||
if (_isFetchingGoLogs) return;
|
||||
_isFetchingGoLogs = true;
|
||||
try {
|
||||
await _fetchGoLogs();
|
||||
} finally {
|
||||
_isFetchingGoLogs = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void stopGoLogPolling() {
|
||||
_goLogTimer?.cancel();
|
||||
_goLogTimer = null;
|
||||
_isFetchingGoLogs = false;
|
||||
}
|
||||
|
||||
Future<void> _fetchGoLogs() async {
|
||||
@@ -216,7 +280,7 @@ class LogBuffer extends ChangeNotifier {
|
||||
buffer.writeln(
|
||||
'Android Version: ${android.version.release} (SDK ${android.version.sdkInt})',
|
||||
);
|
||||
buffer.writeln('Device ID: ${android.id}');
|
||||
buffer.writeln('Device ID: ${_maskIdentifier(android.id)}');
|
||||
buffer.writeln('Hardware: ${android.hardware}');
|
||||
buffer.writeln('Product: ${android.product}');
|
||||
buffer.writeln('Supported ABIs: ${android.supportedAbis.join(', ')}');
|
||||
@@ -313,12 +377,14 @@ class BufferedOutput extends LogOutput {
|
||||
void output(OutputEvent event) {
|
||||
if (kDebugMode) {
|
||||
for (final line in event.lines) {
|
||||
debugPrint(_truncateLogText(line));
|
||||
debugPrint(_truncateLogText(_redactSensitiveText(line)));
|
||||
}
|
||||
}
|
||||
|
||||
final level = _levelToString(event.level);
|
||||
final message = _truncateLogText(event.lines.join('\n'));
|
||||
final message = _truncateLogText(
|
||||
_redactSensitiveText(event.lines.join('\n')),
|
||||
);
|
||||
|
||||
LogBuffer().add(
|
||||
LogEntry(
|
||||
@@ -421,7 +487,7 @@ class AppLogger {
|
||||
_addToBuffer('ERROR', message, error: error.toString());
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'[$_tag] ERROR: ${_truncateLogText(message)} | ${_truncateLogText(error.toString())}',
|
||||
'[$_tag] ERROR: ${_truncateLogText(_redactSensitiveText(message))} | ${_truncateLogText(_redactSensitiveText(error.toString()))}',
|
||||
);
|
||||
if (stackTrace != null) {
|
||||
debugPrint(stackTrace.toString());
|
||||
|
||||
Reference in New Issue
Block a user