mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-17 07:15:33 +02:00
Add complete metadata reads with a display-name hint in Go and both native bridges. Read seekable SAF descriptors directly and use temporary copies only when direct reading fails. Add format parity tests for MP3, FLAC, M4A, and WAV plus a Dart bridge hint regression.
2584 lines
80 KiB
Dart
2584 lines
80 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:spotiflac_android/services/download_request_payload.dart';
|
|
import 'package:spotiflac_android/utils/audio_format_utils.dart';
|
|
import 'package:spotiflac_android/utils/logger.dart';
|
|
|
|
final _log = AppLogger('PlatformBridge');
|
|
|
|
const foregroundServiceStartNotAllowedCode =
|
|
'foreground_service_start_not_allowed';
|
|
|
|
bool isForegroundServiceStartNotAllowed(Object error) {
|
|
return error is PlatformException &&
|
|
error.code == foregroundServiceStartNotAllowedCode;
|
|
}
|
|
|
|
Object? _decodeJsonInBackground(String json) => jsonDecode(json);
|
|
String _encodeJsonInBackground(Object? value) => jsonEncode(value);
|
|
|
|
class LibraryScanNDJSONFile {
|
|
final File file;
|
|
final int expectedCount;
|
|
|
|
const LibraryScanNDJSONFile({
|
|
required this.file,
|
|
required this.expectedCount,
|
|
});
|
|
|
|
Stream<Map<String, dynamic>> rows() async* {
|
|
await for (final line
|
|
in file
|
|
.openRead()
|
|
.transform(utf8.decoder)
|
|
.transform(const LineSplitter())) {
|
|
if (line.trim().isEmpty) continue;
|
|
final decoded = jsonDecode(line);
|
|
if (decoded is! Map) {
|
|
throw const FormatException('Library scan NDJSON row is not an object');
|
|
}
|
|
yield Map<String, dynamic>.from(decoded);
|
|
}
|
|
}
|
|
|
|
Future<void> delete() async {
|
|
try {
|
|
if (await file.exists()) await file.delete();
|
|
} catch (_) {}
|
|
// Remove the resumable scan checkpoint after ingestion.
|
|
try {
|
|
final checkpoint = File('${file.path}.state');
|
|
if (await checkpoint.exists()) await checkpoint.delete();
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
class ExtensionSessionGrantEvent {
|
|
final String extensionId;
|
|
final bool success;
|
|
|
|
const ExtensionSessionGrantEvent({
|
|
required this.extensionId,
|
|
required this.success,
|
|
});
|
|
}
|
|
|
|
/// Folder picked via the native iOS document picker: the filesystem path plus
|
|
/// the security-scoped bookmark that re-grants access across app launches.
|
|
class IosPickedDirectory {
|
|
final String path;
|
|
final String bookmark;
|
|
|
|
const IosPickedDirectory({required this.path, required this.bookmark});
|
|
}
|
|
|
|
class IosSecurityScopedAccess {
|
|
final String path;
|
|
final String token;
|
|
|
|
const IosSecurityScopedAccess({required this.path, required this.token});
|
|
}
|
|
|
|
class ContentUriPlaybackLease {
|
|
final String path;
|
|
final String token;
|
|
|
|
const ContentUriPlaybackLease({required this.path, required this.token});
|
|
}
|
|
|
|
class InstallationState {
|
|
final bool markerExisted;
|
|
final bool markerCreated;
|
|
final bool freshPackageInstall;
|
|
|
|
const InstallationState({
|
|
required this.markerExisted,
|
|
required this.markerCreated,
|
|
required this.freshPackageInstall,
|
|
});
|
|
|
|
const InstallationState.unsupported()
|
|
: markerExisted = true,
|
|
markerCreated = true,
|
|
freshPackageInstall = false;
|
|
|
|
factory InstallationState.fromMap(Map<String, dynamic> map) {
|
|
return InstallationState(
|
|
markerExisted: map['marker_existed'] == true,
|
|
markerCreated: map['marker_created'] == true,
|
|
freshPackageInstall: map['fresh_package_install'] == true,
|
|
);
|
|
}
|
|
}
|
|
|
|
bool shouldResetRestoredInstallation({
|
|
required bool hasPersistedSettings,
|
|
required InstallationState installState,
|
|
}) {
|
|
return hasPersistedSettings &&
|
|
!installState.markerExisted &&
|
|
installState.markerCreated &&
|
|
installState.freshPackageInstall;
|
|
}
|
|
|
|
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 _BridgeListCacheEntry {
|
|
final List<Map<String, dynamic>> value;
|
|
final DateTime expiresAt;
|
|
|
|
const _BridgeListCacheEntry({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 {
|
|
/// Sentinel shared with Android's foreground service for progress reports
|
|
/// that contain a percentage but no byte counts.
|
|
static const int notificationPercentTotal = 10000;
|
|
|
|
static const _channel = MethodChannel('com.zarz.spotiflac/backend');
|
|
static const _jsonResultFileKey = '__json_file';
|
|
static const _backgroundJsonDecodeThresholdBytes = 128 * 1024;
|
|
static const _metadataCacheTtl = Duration(minutes: 20);
|
|
static const _urlHandleCacheTtl = Duration(minutes: 5);
|
|
static const _customSearchCacheTtl = Duration(minutes: 2);
|
|
static const _bridgeCacheMaxEntries = 256;
|
|
static const _lookupCachePersistDebounce = Duration(milliseconds: 500);
|
|
static const _metadataPersistentCacheKey = 'bridge_metadata_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> _urlHandleCache = {};
|
|
static final Map<String, _BridgeListCacheEntry> _customSearchCache = {};
|
|
static final Map<String, Future<Map<String, dynamic>>> _metadataInFlight = {};
|
|
static final Map<String, Future<Map<String, dynamic>?>> _urlHandleInFlight =
|
|
{};
|
|
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 Timer? _lookupCachePersistTimer;
|
|
static Future<void>? _lookupCachePersistInFlight;
|
|
static bool _lookupCachePersistDirty = false;
|
|
static int _extensionRequestSequence = 0;
|
|
static final StreamController<ExtensionSessionGrantEvent>
|
|
_extensionSessionGrantEvents =
|
|
StreamController<ExtensionSessionGrantEvent>.broadcast();
|
|
static final StreamController<void> _libraryStorageEvents =
|
|
StreamController<void>.broadcast();
|
|
static final StreamController<List<String>>
|
|
_iosBackgroundDownloadExpirationEvents =
|
|
StreamController<List<String>>.broadcast();
|
|
static bool _backendEventHandlerInstalled = false;
|
|
|
|
static bool get supportsCoreBackend => Platform.isAndroid || Platform.isIOS;
|
|
|
|
static bool get supportsExtensionSystem =>
|
|
Platform.isAndroid || Platform.isIOS;
|
|
|
|
static Stream<ExtensionSessionGrantEvent> extensionSessionGrantEvents() {
|
|
_ensureBackendEventHandler();
|
|
return _extensionSessionGrantEvents.stream;
|
|
}
|
|
|
|
static Stream<void> libraryStorageEvents() {
|
|
_ensureBackendEventHandler();
|
|
return _libraryStorageEvents.stream;
|
|
}
|
|
|
|
static Stream<List<String>> iosBackgroundDownloadExpirationEvents() {
|
|
_ensureBackendEventHandler();
|
|
return _iosBackgroundDownloadExpirationEvents.stream;
|
|
}
|
|
|
|
static void _ensureBackendEventHandler() {
|
|
if (_backendEventHandlerInstalled) return;
|
|
_backendEventHandlerInstalled = true;
|
|
_channel.setMethodCallHandler((call) async {
|
|
switch (call.method) {
|
|
case 'extensionSessionGrantCompleted':
|
|
final args = call.arguments;
|
|
if (args is Map) {
|
|
final extensionId = args['extension_id']?.toString().trim() ?? '';
|
|
if (extensionId.isNotEmpty) {
|
|
_extensionSessionGrantEvents.add(
|
|
ExtensionSessionGrantEvent(
|
|
extensionId: extensionId,
|
|
success: args['success'] != false,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
return null;
|
|
case 'libraryStorageChanged':
|
|
_libraryStorageEvents.add(null);
|
|
return null;
|
|
case 'iosBackgroundDownloadExpired':
|
|
final raw = call.arguments;
|
|
var itemIds = const <String>[];
|
|
try {
|
|
final decoded = raw is String ? jsonDecode(raw) : raw;
|
|
if (decoded is List) {
|
|
itemIds = decoded
|
|
.map((value) => value.toString().trim())
|
|
.where((value) => value.isNotEmpty)
|
|
.toSet()
|
|
.toList(growable: false);
|
|
}
|
|
} catch (_) {
|
|
// Older/native-mismatched builds may send no payload. The queue
|
|
// still pauses its currently visible active items below.
|
|
}
|
|
_iosBackgroundDownloadExpirationEvents.add(itemIds);
|
|
return null;
|
|
default:
|
|
return null;
|
|
}
|
|
});
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> _invokeMap(
|
|
String method, [
|
|
dynamic args,
|
|
]) async {
|
|
final result = await _channel.invokeMethod(method, args);
|
|
return _decodeRequiredMapResult(result, method);
|
|
}
|
|
|
|
static Future<InstallationState> ensureInstallMarker() async {
|
|
if (!Platform.isAndroid) return const InstallationState.unsupported();
|
|
final result = await _invokeMap('ensureInstallMarker');
|
|
return InstallationState.fromMap(result);
|
|
}
|
|
|
|
static Future<void> prepareRuntimeState(String dataDir) async {
|
|
if (!Platform.isAndroid) return;
|
|
await _invokeMap('prepareRuntimeState', {'data_dir': dataDir});
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> _cachedInvoke(
|
|
String cacheKey,
|
|
Map<String, _BridgeCacheEntry> cache,
|
|
Map<String, Future<Map<String, dynamic>>> inFlight,
|
|
Duration ttl,
|
|
String persistentCacheKey,
|
|
Future<Map<String, dynamic>> Function() loader,
|
|
) async {
|
|
await _ensurePersistentLookupCachesLoaded();
|
|
final cached = _getCachedMap(cache, cacheKey);
|
|
if (cached != null) return cached;
|
|
|
|
final pending = inFlight[cacheKey];
|
|
if (pending != null) return _copyStringMap(await pending);
|
|
|
|
final generation = _lookupCacheGeneration;
|
|
final future = () async {
|
|
final value = await loader();
|
|
if (generation == _lookupCacheGeneration) {
|
|
_putCachedMap(cache, cacheKey, value, ttl, persistentCacheKey);
|
|
}
|
|
return _copyStringMap(value);
|
|
}();
|
|
inFlight[cacheKey] = future;
|
|
try {
|
|
return _copyStringMap(await future);
|
|
} finally {
|
|
inFlight.remove(cacheKey);
|
|
}
|
|
}
|
|
|
|
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),
|
|
);
|
|
_scheduleLookupCachePersist(
|
|
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,
|
|
);
|
|
} 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 encoded = data.length >= 32
|
|
? await compute(_encodeJsonInBackground, data)
|
|
: jsonEncode(data);
|
|
if (generation != _lookupCacheGeneration) return;
|
|
final prefs = await SharedPreferences.getInstance();
|
|
if (generation != _lookupCacheGeneration) return;
|
|
await prefs.setString(prefsKey, encoded);
|
|
} catch (e) {
|
|
_log.w('Failed to persist bridge lookup cache: $e');
|
|
}
|
|
}
|
|
|
|
static void _scheduleLookupCachePersist(
|
|
Map<String, _BridgeCacheEntry> cache,
|
|
String prefsKey,
|
|
int generation,
|
|
) {
|
|
_lookupCachePersistDirty = true;
|
|
if (_lookupCachePersistInFlight != null) return;
|
|
_lookupCachePersistTimer?.cancel();
|
|
_lookupCachePersistTimer = Timer(_lookupCachePersistDebounce, () {
|
|
_lookupCachePersistTimer = null;
|
|
unawaited(_flushLookupCachePersist(cache, prefsKey, generation));
|
|
});
|
|
}
|
|
|
|
static Future<void> _flushLookupCachePersist(
|
|
Map<String, _BridgeCacheEntry> cache,
|
|
String prefsKey,
|
|
int generation,
|
|
) {
|
|
final active = _lookupCachePersistInFlight;
|
|
if (active != null) {
|
|
_lookupCachePersistDirty = true;
|
|
return active;
|
|
}
|
|
|
|
late final Future<void> flush;
|
|
flush =
|
|
() async {
|
|
do {
|
|
_lookupCachePersistDirty = false;
|
|
if (generation != _lookupCacheGeneration) return;
|
|
await _persistLookupCache(cache, prefsKey, generation);
|
|
} while (_lookupCachePersistDirty &&
|
|
generation == _lookupCacheGeneration);
|
|
}().whenComplete(() {
|
|
if (identical(_lookupCachePersistInFlight, flush)) {
|
|
_lookupCachePersistInFlight = null;
|
|
}
|
|
});
|
|
_lookupCachePersistInFlight = flush;
|
|
return flush;
|
|
}
|
|
|
|
static Future<void> _cancelLookupCachePersistence() async {
|
|
_lookupCachePersistTimer?.cancel();
|
|
_lookupCachePersistTimer = null;
|
|
_lookupCachePersistDirty = false;
|
|
final active = _lookupCachePersistInFlight;
|
|
if (active != null) await active;
|
|
}
|
|
|
|
static Future<void> _clearPersistentLookupCaches() async {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.remove(_metadataPersistentCacheKey);
|
|
// Legacy key from the removed availability-check feature.
|
|
await prefs.remove('bridge_availability_lookup_cache_v1');
|
|
} catch (e) {
|
|
_log.w('Failed to clear bridge lookup cache: $e');
|
|
}
|
|
}
|
|
|
|
static Future<void> _clearLookupCaches() async {
|
|
_lookupCacheGeneration++;
|
|
await _cancelLookupCachePersistence();
|
|
_persistentLookupCacheLoadFuture = null;
|
|
_metadataCache.clear();
|
|
_urlHandleCache.clear();
|
|
_customSearchCache.clear();
|
|
_metadataInFlight.clear();
|
|
_urlHandleInFlight.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,
|
|
});
|
|
}
|
|
|
|
static void _cancelCustomSearchInFlightForScope(
|
|
String scopeKey, {
|
|
String? exceptKey,
|
|
}) {
|
|
for (final entry in _customSearchInFlight.entries.toList()) {
|
|
if (entry.key == exceptKey || entry.value.scopeKey != scopeKey) continue;
|
|
// A cancelled request must not be reused if the user types its query
|
|
// again before the native cancellation finishes.
|
|
_customSearchInFlight.remove(entry.key);
|
|
_cancelExtensionRequestUnawaited(entry.value.requestId);
|
|
}
|
|
}
|
|
|
|
static void cancelExtensionHomeFeedRequests() {
|
|
for (final inFlight in _homeFeedInFlight.values) {
|
|
_cancelExtensionRequestUnawaited(inFlight.requestId);
|
|
}
|
|
_homeFeedInFlight.clear();
|
|
}
|
|
|
|
static int _lookupCacheSize() {
|
|
_pruneExpiredBridgeCache(_metadataCache);
|
|
return _metadataCache.length;
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> _invokeDownloadMethod(
|
|
String method,
|
|
DownloadRequestPayload payload,
|
|
) {
|
|
return _invokeMap(method, jsonEncode(payload.toJson()));
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> downloadByStrategy({
|
|
required DownloadRequestPayload payload,
|
|
bool? useExtensions,
|
|
bool? useFallback,
|
|
}) async {
|
|
final routedPayload = payload.withStrategy(
|
|
useExtensions: useExtensions,
|
|
useFallback: useFallback,
|
|
);
|
|
final response = await _invokeDownloadMethod(
|
|
'downloadByStrategy',
|
|
routedPayload,
|
|
);
|
|
if (response['success'] != true) {
|
|
final error = response['error'] ?? 'Unknown error';
|
|
final errorType = response['error_type'] ?? '';
|
|
_log.e('Download failed: $error (type: $errorType)');
|
|
}
|
|
return response;
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> getAllDownloadProgress() async {
|
|
final result = await _channel.invokeMethod('getAllDownloadProgress');
|
|
return _decodeMapResult(result);
|
|
}
|
|
|
|
static Stream<Map<String, dynamic>> downloadProgressStream() {
|
|
return _downloadProgressEvents.receiveBroadcastStream().map(
|
|
_decodeMapResult,
|
|
);
|
|
}
|
|
|
|
static Future<void> exitApp() async {
|
|
await _channel.invokeMethod('exitApp');
|
|
}
|
|
|
|
static Future<void> clearItemProgress(String itemId) async {
|
|
await _channel.invokeMethod('clearItemProgress', {'item_id': itemId});
|
|
}
|
|
|
|
static Future<void> cancelDownload(String itemId) async {
|
|
await _channel.invokeMethod('cancelDownload', {'item_id': itemId});
|
|
}
|
|
|
|
/// Drops a stale pre-registered cancel flag for an item with no active
|
|
/// download, so a user-initiated retry does not abort instantly.
|
|
static Future<void> resetDownloadCancel(String itemId) async {
|
|
await _channel.invokeMethod('resetDownloadCancel', {'item_id': itemId});
|
|
}
|
|
|
|
/// iOS only: run a verification/OAuth page inside ASWebAuthenticationSession.
|
|
/// The session intercepts the callback scheme in-process, so the flow
|
|
/// completes even where the app's URL scheme is not registered with the OS
|
|
/// (e.g. sideload containers like LiveContainer). Returns true when the
|
|
/// session was presented; the callback is delivered through the native side
|
|
/// exactly like an OS deep link.
|
|
static Future<bool> startIosWebAuthSession(
|
|
String url, {
|
|
String callbackScheme = 'spotiflac',
|
|
}) async {
|
|
final result = await _channel.invokeMethod('startWebAuthSession', {
|
|
'url': url,
|
|
'callback_scheme': callbackScheme,
|
|
});
|
|
return result == true;
|
|
}
|
|
|
|
static Future<void> setDownloadDirectory(String path) async {
|
|
await _channel.invokeMethod('setDownloadDirectory', {'path': path});
|
|
}
|
|
|
|
static Future<void> setNetworkCompatibilityOptions({
|
|
required bool allowHttp,
|
|
required bool insecureTls,
|
|
}) async {
|
|
await _channel.invokeMethod('setNetworkCompatibilityOptions', {
|
|
'allow_http': allowHttp,
|
|
'insecure_tls': insecureTls,
|
|
});
|
|
}
|
|
|
|
static Future<void> setAllowPrivateNetwork(bool allowed) async {
|
|
await _channel.invokeMethod('setAllowPrivateNetwork', {'allowed': allowed});
|
|
}
|
|
|
|
static Future<String> buildFilename(
|
|
String template,
|
|
Map<String, dynamic> metadata,
|
|
) async {
|
|
final result = await _channel.invokeMethod('buildFilename', {
|
|
'template': template,
|
|
'metadata': jsonEncode(metadata),
|
|
});
|
|
return result as String;
|
|
}
|
|
|
|
static Future<String> sanitizeFilename(String filename) async {
|
|
final result = await _channel.invokeMethod('sanitizeFilename', {
|
|
'filename': filename,
|
|
});
|
|
return result as String;
|
|
}
|
|
|
|
static Future<Map<String, dynamic>?> pickSafTree() async {
|
|
final result = await _channel.invokeMethod('pickSafTree');
|
|
return _decodeNullableMapResult(result, 'pickSafTree');
|
|
}
|
|
|
|
static Future<bool> safExists(String uri) async {
|
|
final result = await _channel.invokeMethod('safExists', {'uri': uri});
|
|
return result as bool;
|
|
}
|
|
|
|
/// Exact-path checks. Only explicit `missing` results authorize cleanup;
|
|
/// absent responses and permission/provider failures remain unknown.
|
|
static Future<Map<String, bool?>> safExistsBatch(List<String> uris) async {
|
|
if (uris.isEmpty) return const {};
|
|
final result = await _invokeMap('safExistsBatch', {
|
|
'uris_json': jsonEncode(uris),
|
|
});
|
|
return {
|
|
for (final uri in uris)
|
|
uri: switch (result[uri]) {
|
|
'found' => true,
|
|
'missing' => false,
|
|
_ => null,
|
|
},
|
|
};
|
|
}
|
|
|
|
/// Whether the persisted SAF grant for [treeUri] is still usable: the
|
|
/// permission is present in the system's persisted list and the tree
|
|
/// document still exists and is writable. Returns true on channel errors
|
|
/// so a bridge problem never raises a false "access lost" alarm.
|
|
static Future<bool> isSafTreeAccessible(String treeUri) async {
|
|
try {
|
|
return await validateSafTreeAccess(treeUri);
|
|
} catch (e) {
|
|
_log.w('Failed to check SAF tree accessibility: $e');
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/// Strict SAF validation for startup and download preflight. Unlike
|
|
/// [isSafTreeAccessible], channel failures are surfaced to the caller so a
|
|
/// write cannot proceed on an unverified destination.
|
|
static Future<bool> validateSafTreeAccess(String treeUri) async {
|
|
final result = await _channel.invokeMethod('isSafTreeAccessible', {
|
|
'tree_uri': treeUri,
|
|
});
|
|
return result as bool? ?? false;
|
|
}
|
|
|
|
static Future<bool> safDelete(String uri) async {
|
|
final result = await _channel.invokeMethod('safDelete', {'uri': uri});
|
|
return result as bool;
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> safStat(String uri) {
|
|
return _invokeMap('safStat', {'uri': uri});
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> resolveSafFile({
|
|
required String treeUri,
|
|
required String fileName,
|
|
String relativeDir = '',
|
|
}) {
|
|
return _invokeMap('resolveSafFile', {
|
|
'tree_uri': treeUri,
|
|
'relative_dir': relativeDir,
|
|
'file_name': fileName,
|
|
});
|
|
}
|
|
|
|
/// Checks and repairs many SAF file references in a single native pass.
|
|
/// Android groups requests by tree and scans each tree no more than once.
|
|
static Future<List<Map<String, dynamic>>> inspectSafFiles(
|
|
List<Map<String, dynamic>> requests,
|
|
) async {
|
|
if (requests.isEmpty) return const [];
|
|
final response = await _invokeMap('inspectSafFiles', {
|
|
'requests_json': jsonEncode(requests),
|
|
});
|
|
final results = response['results'];
|
|
if (results is! List) return const [];
|
|
return results
|
|
.whereType<Map<Object?, Object?>>()
|
|
.map((result) => result.map((key, value) => MapEntry('$key', value)))
|
|
.toList(growable: false);
|
|
}
|
|
|
|
static Future<String?> copyContentUriToTemp(String uri) async {
|
|
final result = await _channel.invokeMethod('safCopyToTemp', {'uri': uri});
|
|
return result as String?;
|
|
}
|
|
|
|
static Future<ContentUriPlaybackLease?> openContentUriPlaybackLease(
|
|
String uri,
|
|
) async {
|
|
if (!Platform.isAndroid || !uri.startsWith('content://')) return null;
|
|
final result = await _channel.invokeMethod('safOpenPlaybackLease', {
|
|
'uri': uri,
|
|
});
|
|
if (result is! Map) return null;
|
|
final map = Map<String, dynamic>.from(result);
|
|
final path = map['path']?.toString() ?? '';
|
|
final token = map['token']?.toString() ?? '';
|
|
if (path.isEmpty || token.isEmpty) return null;
|
|
return ContentUriPlaybackLease(path: path, token: token);
|
|
}
|
|
|
|
static Future<void> closeContentUriPlaybackLease(String token) async {
|
|
if (!Platform.isAndroid || token.isEmpty) return;
|
|
try {
|
|
await _channel.invokeMethod('safClosePlaybackLease', {'token': token});
|
|
} catch (e) {
|
|
_log.w('Failed to close SAF playback lease: $e');
|
|
}
|
|
}
|
|
|
|
static Future<String?> createSafFileFromPath({
|
|
required String treeUri,
|
|
required String relativeDir,
|
|
required String fileName,
|
|
required String mimeType,
|
|
required String srcPath,
|
|
}) async {
|
|
final result = await _channel.invokeMethod('safCreateFromPath', {
|
|
'tree_uri': treeUri,
|
|
'relative_dir': relativeDir,
|
|
'file_name': fileName,
|
|
'mime_type': mimeType,
|
|
'src_path': srcPath,
|
|
});
|
|
return result as String?;
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> createSafFileIfAbsentFromPath({
|
|
required String treeUri,
|
|
required String relativeDir,
|
|
required String fileName,
|
|
required String mimeType,
|
|
required String srcPath,
|
|
}) {
|
|
return _invokeMap('safCreateIfAbsentFromPath', {
|
|
'tree_uri': treeUri,
|
|
'relative_dir': relativeDir,
|
|
'file_name': fileName,
|
|
'mime_type': mimeType,
|
|
'src_path': srcPath,
|
|
});
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> createUniqueSafFileFromPath({
|
|
required String treeUri,
|
|
required String relativeDir,
|
|
required String fileName,
|
|
required String mimeType,
|
|
required String srcPath,
|
|
String preservedSuffix = '',
|
|
}) {
|
|
return _invokeMap('safCreateUniqueFromPath', {
|
|
'tree_uri': treeUri,
|
|
'relative_dir': relativeDir,
|
|
'file_name': fileName,
|
|
'mime_type': mimeType,
|
|
'src_path': srcPath,
|
|
'preserved_suffix': preservedSuffix,
|
|
});
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> createCollisionAwareSafFileFromPath({
|
|
required String treeUri,
|
|
required String relativeDir,
|
|
required String cleanFileName,
|
|
required String variantFileName,
|
|
required String mimeType,
|
|
required String srcPath,
|
|
String preservedSuffix = '',
|
|
}) {
|
|
return _invokeMap('safCreateCollisionAwareFromPath', {
|
|
'tree_uri': treeUri,
|
|
'relative_dir': relativeDir,
|
|
'clean_file_name': cleanFileName,
|
|
'variant_file_name': variantFileName,
|
|
'mime_type': mimeType,
|
|
'src_path': srcPath,
|
|
'preserved_suffix': preservedSuffix,
|
|
});
|
|
}
|
|
|
|
static Future<void> openContentUri(String uri, {String mimeType = ''}) async {
|
|
await _channel.invokeMethod('openContentUri', {
|
|
'uri': uri,
|
|
'mime_type': mimeType,
|
|
});
|
|
}
|
|
|
|
static Future<bool> shareContentUri(String uri, {String title = ''}) async {
|
|
final result = await _channel.invokeMethod('shareContentUri', {
|
|
'uri': uri,
|
|
'title': title,
|
|
});
|
|
return result as bool? ?? false;
|
|
}
|
|
|
|
static Future<bool> shareMultipleContentUris(
|
|
List<String> uris, {
|
|
String title = '',
|
|
}) async {
|
|
final result = await _channel.invokeMethod('shareMultipleContentUris', {
|
|
'uris': uris,
|
|
'title': title,
|
|
});
|
|
return result as bool? ?? false;
|
|
}
|
|
|
|
static Future<String> getLyricsLRC(
|
|
String spotifyId,
|
|
String trackName,
|
|
String artistName, {
|
|
String? filePath,
|
|
int durationMs = 0,
|
|
}) async {
|
|
final result = await _channel.invokeMethod('getLyricsLRC', {
|
|
'spotify_id': spotifyId,
|
|
'track_name': trackName,
|
|
'artist_name': artistName,
|
|
'file_path': filePath ?? '',
|
|
'duration_ms': durationMs,
|
|
});
|
|
return result as String;
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> getLyricsLRCWithSource(
|
|
String spotifyId,
|
|
String trackName,
|
|
String artistName, {
|
|
String? filePath,
|
|
int durationMs = 0,
|
|
}) {
|
|
return _invokeMap('getLyricsLRCWithSource', {
|
|
'spotify_id': spotifyId,
|
|
'track_name': trackName,
|
|
'artist_name': artistName,
|
|
'file_path': filePath ?? '',
|
|
'duration_ms': durationMs,
|
|
});
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> embedLyricsToFile(
|
|
String filePath,
|
|
String lyrics,
|
|
) {
|
|
return _invokeMap('embedLyricsToFile', {
|
|
'file_path': filePath,
|
|
'lyrics': lyrics,
|
|
});
|
|
}
|
|
|
|
static Future<void> cleanupConnections() async {
|
|
await _channel.invokeMethod('cleanupConnections');
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> downloadCoverToFile(
|
|
String coverUrl,
|
|
String outputPath, {
|
|
int maxDimension = 0,
|
|
}) {
|
|
return _invokeMap('downloadCoverToFile', {
|
|
'cover_url': coverUrl,
|
|
'output_path': outputPath,
|
|
'max_dimension': maxDimension,
|
|
});
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> extractCoverToFile(
|
|
String audioPath,
|
|
String outputPath,
|
|
) {
|
|
return _invokeMap('extractCoverToFile', {
|
|
'audio_path': audioPath,
|
|
'output_path': outputPath,
|
|
});
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> fetchAndSaveLyrics({
|
|
required String trackName,
|
|
required String artistName,
|
|
required String spotifyId,
|
|
required int durationMs,
|
|
required String outputPath,
|
|
String audioFilePath = '',
|
|
}) {
|
|
return _invokeMap('fetchAndSaveLyrics', {
|
|
'track_name': trackName,
|
|
'artist_name': artistName,
|
|
'spotify_id': spotifyId,
|
|
'duration_ms': durationMs,
|
|
'output_path': outputPath,
|
|
'audio_file_path': audioFilePath,
|
|
});
|
|
}
|
|
|
|
/// Providers not in the list are disabled.
|
|
static Future<void> setLyricsProviders(List<String> providers) async {
|
|
final providersJSON = jsonEncode(providers);
|
|
await _channel.invokeMethod('setLyricsProviders', {
|
|
'providers_json': providersJSON,
|
|
});
|
|
}
|
|
|
|
static Future<List<String>> getLyricsProviders() async {
|
|
final result = await _channel.invokeMethod('getLyricsProviders');
|
|
return _decodeStringListResult(result, 'getLyricsProviders');
|
|
}
|
|
|
|
static Future<List<Map<String, dynamic>>>
|
|
getAvailableLyricsProviders() async {
|
|
final result = await _channel.invokeMethod('getAvailableLyricsProviders');
|
|
return _decodeMapListResult(result, 'getAvailableLyricsProviders');
|
|
}
|
|
|
|
/// Sets advanced lyrics fetch options used by provider-specific integrations.
|
|
static Future<void> setLyricsFetchOptions(
|
|
Map<String, dynamic> options,
|
|
) async {
|
|
final optionsJSON = jsonEncode(options);
|
|
await _channel.invokeMethod('setLyricsFetchOptions', {
|
|
'options_json': optionsJSON,
|
|
});
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> getLyricsFetchOptions() {
|
|
return _invokeMap('getLyricsFetchOptions');
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> reEnrichFile(
|
|
Map<String, dynamic> request,
|
|
) {
|
|
return _invokeMap('reEnrichFile', {'request_json': jsonEncode(request)});
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> readFileMetadata(
|
|
String filePath, {
|
|
String? displayName,
|
|
}) {
|
|
return _invokeMap('readFileMetadata', {
|
|
'file_path': filePath,
|
|
'display_name': ?displayName,
|
|
});
|
|
}
|
|
|
|
/// Reads the tags and quality fields used for automatic Library display.
|
|
///
|
|
/// Android's readAudioMetadata implementation can inspect most SAF files
|
|
/// through /proc/self/fd, avoiding the full-file cache copy required by tag
|
|
/// editing. Keep the complete reader as a compatibility fallback for
|
|
/// malformed or incorrectly named files that the lightweight scanner could
|
|
/// only identify from their filename.
|
|
static Future<Map<String, dynamic>> readDisplayAudioMetadata(
|
|
String filePath,
|
|
) async {
|
|
final scanned = await readAudioMetadata(filePath);
|
|
if (scanned != null &&
|
|
scanned['error'] == null &&
|
|
scanned['metadataFromFilename'] != true) {
|
|
return normalizeScannedAudioMetadata(scanned);
|
|
}
|
|
return readFileMetadata(filePath);
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> editFileMetadata(
|
|
String filePath,
|
|
Map<String, String> metadata,
|
|
) {
|
|
return _invokeMap('editFileMetadata', {
|
|
'file_path': filePath,
|
|
'metadata_json': jsonEncode(metadata),
|
|
});
|
|
}
|
|
|
|
/// Writes ISRC and label into an M4A/MP4 file as iTunes freeform atoms.
|
|
/// FFmpeg's MP4 muxer drops these keys, so they must be written natively
|
|
/// after the FFmpeg metadata pass. [filePath] must be a local file path.
|
|
/// Only the keys present in [fields] are touched; an empty value clears it.
|
|
static Future<Map<String, dynamic>> writeM4AFreeformTags(
|
|
String filePath,
|
|
Map<String, String> fields,
|
|
) {
|
|
return _invokeMap('writeM4AFreeformTags', {
|
|
'file_path': filePath,
|
|
'metadata_json': jsonEncode(fields),
|
|
});
|
|
}
|
|
|
|
/// Normalizes a decrypted AC-4 file to a standards-compliant ISO MP4 and
|
|
/// injects the dac4 configuration box from the encrypted [sourcePath]. The
|
|
/// FFmpeg mov muxer drops dac4 and writes a QuickTime-flavored container that
|
|
/// players reject, so this repair is required for AC-4 to be playable.
|
|
static Future<Map<String, dynamic>> ensureAC4Config(
|
|
String filePath,
|
|
String sourcePath,
|
|
) {
|
|
return _invokeMap('ensureAC4Config', {
|
|
'file_path': filePath,
|
|
'source_path': sourcePath,
|
|
});
|
|
}
|
|
|
|
/// Writes iTunes-style metadata (and cover art) into an AC-4 MP4. Returns a
|
|
/// map whose `handled` flag is `true` when the file was AC-4 and metadata was
|
|
/// written natively, signalling the caller to skip the FFmpeg metadata pass
|
|
/// (which would re-wrap the file as QuickTime).
|
|
static Future<Map<String, dynamic>> writeAC4Metadata(
|
|
String filePath,
|
|
Map<String, String> metadata,
|
|
String coverPath,
|
|
) {
|
|
return _invokeMap('writeAC4Metadata', {
|
|
'file_path': filePath,
|
|
'metadata_json': jsonEncode(metadata),
|
|
'cover_path': coverPath,
|
|
});
|
|
}
|
|
|
|
/// Rewrites ARTIST/ALBUMARTIST Vorbis comments as multiple split entries
|
|
/// using the native Go FLAC writer, fixing FFmpeg's tag deduplication.
|
|
static Future<Map<String, dynamic>> rewriteSplitArtistTags(
|
|
String filePath,
|
|
String artist,
|
|
String albumArtist,
|
|
) {
|
|
return _invokeMap('rewriteSplitArtistTags', {
|
|
'file_path': filePath,
|
|
'artist': artist,
|
|
'album_artist': albumArtist,
|
|
});
|
|
}
|
|
|
|
static Future<bool> writeTempToSaf(String tempPath, String safUri) async {
|
|
final map = await _invokeMap('writeTempToSaf', {
|
|
'temp_path': tempPath,
|
|
'saf_uri': safUri,
|
|
});
|
|
return map['success'] == true;
|
|
}
|
|
|
|
static Future<bool> writeSafSidecarLrc(String safUri, String lyrics) async {
|
|
final map = await _invokeMap('writeSafSidecarLrc', {
|
|
'saf_uri': safUri,
|
|
'lyrics': lyrics,
|
|
});
|
|
return map['success'] == true;
|
|
}
|
|
|
|
static Future<void> startDownloadService({
|
|
String trackName = '',
|
|
String artistName = '',
|
|
int queueCount = 0,
|
|
}) async {
|
|
await _channel.invokeMethod('startDownloadService', {
|
|
'track_name': trackName,
|
|
'artist_name': artistName,
|
|
'queue_count': queueCount,
|
|
});
|
|
}
|
|
|
|
static Future<void> stopDownloadService() async {
|
|
await _channel.invokeMethod('stopDownloadService');
|
|
}
|
|
|
|
static Future<void> updateDownloadServiceProgress({
|
|
required String trackName,
|
|
required String artistName,
|
|
required int progress,
|
|
required int total,
|
|
required int queueCount,
|
|
String status = 'downloading',
|
|
}) async {
|
|
await _channel.invokeMethod('updateDownloadServiceProgress', {
|
|
'track_name': trackName,
|
|
'artist_name': artistName,
|
|
'progress': progress,
|
|
'total': total,
|
|
'queue_count': queueCount,
|
|
'status': status,
|
|
});
|
|
}
|
|
|
|
static Future<bool> isDownloadServiceRunning() async {
|
|
final result = await _channel.invokeMethod('isDownloadServiceRunning');
|
|
return result as bool;
|
|
}
|
|
|
|
/// iOS only: keep in-flight downloads running briefly after backgrounding.
|
|
static Future<void> beginBackgroundDownloadTask() async {
|
|
if (!Platform.isIOS) return;
|
|
try {
|
|
await _channel.invokeMethod('beginBackgroundDownloadTask');
|
|
} catch (_) {}
|
|
}
|
|
|
|
/// iOS only: stop the background-time extension (queue finished or paused).
|
|
static Future<void> endBackgroundDownloadTask() async {
|
|
if (!Platform.isIOS) return;
|
|
try {
|
|
await _channel.invokeMethod('endBackgroundDownloadTask');
|
|
} catch (_) {}
|
|
}
|
|
|
|
static Future<void> startNativeDownloadWorker({
|
|
required List<Map<String, dynamic>> requests,
|
|
Map<String, dynamic> settings = const {},
|
|
}) async {
|
|
final requestsJson = jsonEncode(requests);
|
|
final settingsJson = jsonEncode(settings);
|
|
final payloadDir = await _nativeWorkerPayloadDir();
|
|
await _cleanupNativeWorkerPayloads(payloadDir);
|
|
final stamp = DateTime.now().microsecondsSinceEpoch;
|
|
final requestPath = '${payloadDir.path}/requests_$stamp.json';
|
|
final settingsPath = '${payloadDir.path}/settings_$stamp.json';
|
|
await File(requestPath).writeAsString(requestsJson, flush: true);
|
|
await File(settingsPath).writeAsString(settingsJson, flush: true);
|
|
try {
|
|
await _channel.invokeMethod('startNativeDownloadWorker', {
|
|
'requests_path': requestPath,
|
|
'settings_path': settingsPath,
|
|
});
|
|
} catch (_) {
|
|
unawaited(_deleteFileIfExists(requestPath));
|
|
unawaited(_deleteFileIfExists(settingsPath));
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
static Future<void> appendNativeDownloadWorkerRequests({
|
|
required String runId,
|
|
required List<Map<String, dynamic>> requests,
|
|
}) async {
|
|
if (requests.isEmpty) return;
|
|
final payloadDir = await _nativeWorkerPayloadDir();
|
|
final stamp = DateTime.now().microsecondsSinceEpoch;
|
|
final requestPath = '${payloadDir.path}/append_$stamp.json';
|
|
await File(requestPath).writeAsString(jsonEncode(requests), flush: true);
|
|
try {
|
|
await _channel.invokeMethod('appendNativeDownloadWorkerRequests', {
|
|
'run_id': runId,
|
|
'requests_path': requestPath,
|
|
});
|
|
} catch (_) {
|
|
unawaited(_deleteFileIfExists(requestPath));
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
static Future<void> finishNativeDownloadWorkerPreparation({
|
|
required String runId,
|
|
}) async {
|
|
await _channel.invokeMethod('finishNativeDownloadWorkerPreparation', {
|
|
'run_id': runId,
|
|
});
|
|
}
|
|
|
|
static Future<void> acknowledgeNativeDownloadWorkerItems({
|
|
required String runId,
|
|
required List<String> itemIds,
|
|
}) async {
|
|
if (runId.isEmpty || itemIds.isEmpty) return;
|
|
await _channel.invokeMethod('acknowledgeNativeDownloadWorkerItems', {
|
|
'run_id': runId,
|
|
'item_ids_json': jsonEncode(itemIds),
|
|
});
|
|
}
|
|
|
|
static Future<void> _deleteFileIfExists(String path) async {
|
|
try {
|
|
final file = File(path);
|
|
if (await file.exists()) {
|
|
await file.delete();
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
static Future<Directory> _nativeWorkerPayloadDir() async {
|
|
final tempDir = await getTemporaryDirectory();
|
|
final payloadDir = Directory('${tempDir.path}/native_worker_payloads');
|
|
if (!await payloadDir.exists()) {
|
|
await payloadDir.create(recursive: true);
|
|
}
|
|
return payloadDir;
|
|
}
|
|
|
|
static Future<void> _cleanupNativeWorkerPayloads(Directory payloadDir) async {
|
|
final cutoff = DateTime.now().subtract(const Duration(days: 1));
|
|
try {
|
|
await for (final entity in payloadDir.list(followLinks: false)) {
|
|
if (entity is! File || !entity.path.endsWith('.json')) continue;
|
|
final stat = await entity.stat();
|
|
if (stat.modified.isBefore(cutoff)) {
|
|
try {
|
|
await entity.delete();
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
static Future<void> pauseNativeDownloadWorker() async {
|
|
await _channel.invokeMethod('pauseNativeDownloadWorker');
|
|
}
|
|
|
|
static Future<void> resumeNativeDownloadWorker() async {
|
|
await _channel.invokeMethod('resumeNativeDownloadWorker');
|
|
}
|
|
|
|
static Future<void> cancelNativeDownloadWorker() async {
|
|
await _channel.invokeMethod('cancelNativeDownloadWorker');
|
|
}
|
|
|
|
/// [sinceStateSerial]: pass the `state_serial` of the last fully-processed
|
|
/// snapshot; when the native state hasn't advanced past it, the heavy
|
|
/// per-item payload (which grows with every completed item) is omitted and
|
|
/// only compact progress fields are returned.
|
|
static Future<Map<String, dynamic>> getNativeDownloadWorkerSnapshot({
|
|
int sinceStateSerial = 0,
|
|
}) async {
|
|
final result = await _channel.invokeMethod(
|
|
'getNativeDownloadWorkerSnapshot',
|
|
{'since_state_serial': sinceStateSerial},
|
|
);
|
|
return _decodeMapResult(result);
|
|
}
|
|
|
|
static Future<int> getTrackCacheSize() async {
|
|
await _ensurePersistentLookupCachesLoaded();
|
|
final result = await _channel.invokeMethod('getTrackCacheSize');
|
|
return (result as int) + _lookupCacheSize();
|
|
}
|
|
|
|
static Future<void> clearTrackCache() async {
|
|
await _clearLookupCaches();
|
|
await _channel.invokeMethod('clearTrackCache');
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> getProviderMetadata(
|
|
String providerId,
|
|
String resourceType,
|
|
String resourceId,
|
|
) {
|
|
return _cachedInvoke(
|
|
_providerMetadataCacheKey(providerId, resourceType, resourceId),
|
|
_metadataCache,
|
|
_metadataInFlight,
|
|
_metadataCacheTtl,
|
|
_metadataPersistentCacheKey,
|
|
() 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');
|
|
},
|
|
);
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> searchDeezerByISRC(
|
|
String isrc, {
|
|
String? itemId,
|
|
}) {
|
|
return _invokeMap('searchDeezerByISRC', {
|
|
'isrc': isrc,
|
|
'item_id': itemId ?? '',
|
|
});
|
|
}
|
|
|
|
static Future<Map<String, String>?> getDeezerExtendedMetadata(
|
|
String trackId,
|
|
) async {
|
|
try {
|
|
final result = await _channel.invokeMethod('getDeezerExtendedMetadata', {
|
|
'track_id': trackId,
|
|
});
|
|
if (result == null) return null;
|
|
final data = _decodeRequiredMapResult(
|
|
result,
|
|
'getDeezerExtendedMetadata',
|
|
);
|
|
return {
|
|
'genre': data['genre'] as String? ?? '',
|
|
'label': data['label'] as String? ?? '',
|
|
'copyright': data['copyright'] as String? ?? '',
|
|
};
|
|
} catch (e) {
|
|
_log.w('Failed to get Deezer extended metadata for $trackId: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> convertSpotifyToDeezer(
|
|
String resourceType,
|
|
String spotifyId,
|
|
) {
|
|
return _cachedInvoke(
|
|
_providerMetadataCacheKey('spotify-to-deezer', resourceType, spotifyId),
|
|
_metadataCache,
|
|
_metadataInFlight,
|
|
_metadataCacheTtl,
|
|
_metadataPersistentCacheKey,
|
|
() => _invokeMap('convertSpotifyToDeezer', {
|
|
'resource_type': resourceType,
|
|
'spotify_id': spotifyId,
|
|
}),
|
|
);
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> getGoLogsSince(int index) async {
|
|
final result = await _channel.invokeMethod('getLogsSince', {
|
|
'index': index,
|
|
});
|
|
return _decodeRequiredMapResult(result, 'getGoLogsSince');
|
|
}
|
|
|
|
static Future<void> clearGoLogs() async {
|
|
await _channel.invokeMethod('clearLogs');
|
|
}
|
|
|
|
/// Ask the Go backend to GC and return freed heap to the OS. Best-effort:
|
|
/// safe to call on memory pressure or when the app is backgrounded.
|
|
static Future<void> releaseNativeMemory({bool underPressure = false}) async {
|
|
try {
|
|
await _channel.invokeMethod(
|
|
underPressure ? 'releaseMemoryUnderPressure' : 'releaseMemory',
|
|
);
|
|
} catch (_) {}
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> getGoRuntimeMetrics() async {
|
|
final result = await _channel.invokeMethod('getGoRuntimeMetrics');
|
|
return _decodeRequiredMapResult(result, 'getGoRuntimeMetrics');
|
|
}
|
|
|
|
/// Tells the backend the app's display language so metadata providers
|
|
/// localize by it instead of IP geolocation. Best-effort.
|
|
static Future<void> setMetadataLanguage(String tag) async {
|
|
try {
|
|
await _channel.invokeMethod('setMetadataLanguage', {'tag': tag});
|
|
} catch (_) {}
|
|
}
|
|
|
|
static Future<void> setGoLoggingEnabled(bool enabled) async {
|
|
await _channel.invokeMethod('setLoggingEnabled', {'enabled': enabled});
|
|
}
|
|
|
|
static Future<void> initExtensionSystem(
|
|
String extensionsDir,
|
|
String dataDir, {
|
|
required String masterKey,
|
|
}) async {
|
|
_log.d('initExtensionSystem: $extensionsDir, $dataDir');
|
|
await _channel.invokeMethod('initExtensionSystem', {
|
|
'extensions_dir': extensionsDir,
|
|
'data_dir': dataDir,
|
|
'master_key': masterKey,
|
|
});
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> loadExtensionsFromDir(String dirPath) {
|
|
_log.d('loadExtensionsFromDir: $dirPath');
|
|
return _invokeMap('loadExtensionsFromDir', {'dir_path': dirPath});
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> loadExtensionFromPath(
|
|
String filePath,
|
|
) async {
|
|
_log.d('loadExtensionFromPath: $filePath');
|
|
await _clearLookupCaches();
|
|
return _invokeMap('loadExtensionFromPath', {'file_path': filePath});
|
|
}
|
|
|
|
static Future<void> unloadExtension(String extensionId) async {
|
|
_log.d('unloadExtension: $extensionId');
|
|
await _clearLookupCaches();
|
|
await _channel.invokeMethod('unloadExtension', {
|
|
'extension_id': extensionId,
|
|
});
|
|
}
|
|
|
|
static Future<void> removeExtension(String extensionId) async {
|
|
_log.d('removeExtension: $extensionId');
|
|
await _clearLookupCaches();
|
|
await _channel.invokeMethod('removeExtension', {
|
|
'extension_id': extensionId,
|
|
});
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> upgradeExtension(String filePath) async {
|
|
_log.d('upgradeExtension: $filePath');
|
|
await _clearLookupCaches();
|
|
return _invokeMap('upgradeExtension', {'file_path': filePath});
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> checkExtensionUpgrade(String filePath) {
|
|
_log.d('checkExtensionUpgrade: $filePath');
|
|
return _invokeMap('checkExtensionUpgrade', {'file_path': filePath});
|
|
}
|
|
|
|
static Future<List<Map<String, dynamic>>> getInstalledExtensions() async {
|
|
final result = await _channel.invokeMethod('getInstalledExtensions');
|
|
return _decodeMapListResult(result, 'getInstalledExtensions');
|
|
}
|
|
|
|
static Future<void> setExtensionEnabled(
|
|
String extensionId,
|
|
bool enabled,
|
|
) async {
|
|
_log.d('setExtensionEnabled: $extensionId = $enabled');
|
|
await _clearLookupCaches();
|
|
await _channel.invokeMethod('setExtensionEnabled', {
|
|
'extension_id': extensionId,
|
|
'enabled': enabled,
|
|
});
|
|
}
|
|
|
|
static Future<void> setProviderPriority(List<String> providerIds) async {
|
|
_log.d('setProviderPriority: $providerIds');
|
|
await _clearLookupCaches();
|
|
await _channel.invokeMethod('setProviderPriority', {
|
|
'priority': jsonEncode(providerIds),
|
|
});
|
|
}
|
|
|
|
static Future<List<String>> getProviderPriority() async {
|
|
final result = await _channel.invokeMethod('getProviderPriority');
|
|
return _decodeStringListResult(result, 'getProviderPriority');
|
|
}
|
|
|
|
static Future<void> setDownloadFallbackExtensionIds(
|
|
List<String>? extensionIds,
|
|
) async {
|
|
_log.d('setDownloadFallbackExtensionIds: $extensionIds');
|
|
await _clearLookupCaches();
|
|
await _channel.invokeMethod('setDownloadFallbackExtensionIds', {
|
|
'extension_ids': extensionIds == null ? '' : jsonEncode(extensionIds),
|
|
});
|
|
}
|
|
|
|
static Future<void> setMetadataProviderPriority(
|
|
List<String> providerIds,
|
|
) async {
|
|
_log.d('setMetadataProviderPriority: $providerIds');
|
|
await _clearLookupCaches();
|
|
await _channel.invokeMethod('setMetadataProviderPriority', {
|
|
'priority': jsonEncode(providerIds),
|
|
});
|
|
}
|
|
|
|
static Future<List<String>> getMetadataProviderPriority() async {
|
|
final result = await _channel.invokeMethod('getMetadataProviderPriority');
|
|
return _decodeStringListResult(result, 'getMetadataProviderPriority');
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> getExtensionSettings(String extensionId) {
|
|
return _invokeMap('getExtensionSettings', {'extension_id': extensionId});
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> checkExtensionHealth(String extensionId) {
|
|
return _invokeMap('checkExtensionHealth', {'extension_id': extensionId});
|
|
}
|
|
|
|
static Future<void> setExtensionSettings(
|
|
String extensionId,
|
|
Map<String, dynamic> settings,
|
|
) async {
|
|
_log.d('setExtensionSettings: $extensionId');
|
|
await _clearLookupCaches();
|
|
await _channel.invokeMethod('setExtensionSettings', {
|
|
'extension_id': extensionId,
|
|
'settings': jsonEncode(settings),
|
|
});
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> invokeExtensionAction(
|
|
String extensionId,
|
|
String actionName,
|
|
) async {
|
|
_log.d('invokeExtensionAction: $extensionId.$actionName');
|
|
final result = await _channel.invokeMethod('invokeExtensionAction', {
|
|
'extension_id': extensionId,
|
|
'action': actionName,
|
|
});
|
|
if (result == null || (result as String).isEmpty) {
|
|
return {'success': true};
|
|
}
|
|
return _decodeRequiredMapResult(result, 'invokeExtensionAction');
|
|
}
|
|
|
|
static Future<List<Map<String, dynamic>>> searchTracksWithMetadataProviders(
|
|
String query, {
|
|
int limit = 20,
|
|
bool includeExtensions = true,
|
|
}) async {
|
|
_log.d(
|
|
'searchTracksWithMetadataProviders: "$query", includeExtensions=$includeExtensions',
|
|
);
|
|
final result = await _channel.invokeMethod(
|
|
'searchTracksWithMetadataProviders',
|
|
{'query': query, 'limit': limit, 'include_extensions': includeExtensions},
|
|
);
|
|
return _decodeMapListResult(result, 'searchTracksWithMetadataProviders');
|
|
}
|
|
|
|
static Future<List<Map<String, dynamic>>> searchTracksWithMetadataProvider(
|
|
String extensionId,
|
|
String query, {
|
|
int limit = 20,
|
|
}) async {
|
|
_log.d('searchTracksWithMetadataProvider: $extensionId, query="$query"');
|
|
final result = await _channel.invokeMethod(
|
|
'searchTracksWithMetadataProvider',
|
|
{'extension_id': extensionId, 'query': query, 'limit': limit},
|
|
);
|
|
return _decodeMapListResult(result, 'searchTracksWithMetadataProvider');
|
|
}
|
|
|
|
static Future<List<Map<String, dynamic>>> findCollectionAcrossExtensions({
|
|
required String name,
|
|
required String artists,
|
|
required String type,
|
|
required String sourceExtensionId,
|
|
}) async {
|
|
final requestJson = jsonEncode({
|
|
'name': name,
|
|
'artists': artists,
|
|
'type': type,
|
|
'source_extension_id': sourceExtensionId,
|
|
});
|
|
final result = await _channel.invokeMethod(
|
|
'findCollectionAcrossExtensions',
|
|
requestJson,
|
|
);
|
|
return _decodeMapListResult(result, 'findCollectionAcrossExtensions');
|
|
}
|
|
|
|
static Future<void> cleanupExtensions() async {
|
|
_log.d('cleanupExtensions');
|
|
await _channel.invokeMethod('cleanupExtensions');
|
|
}
|
|
|
|
static Future<Map<String, dynamic>?> getExtensionPendingAuth(
|
|
String extensionId,
|
|
) async {
|
|
final result = await _channel.invokeMethod('getExtensionPendingAuth', {
|
|
'extension_id': extensionId,
|
|
});
|
|
return _decodeNullableMapResult(result, 'getExtensionPendingAuth');
|
|
}
|
|
|
|
static Future<void> setExtensionAuthCode(
|
|
String extensionId,
|
|
String authCode,
|
|
) async {
|
|
_log.d('setExtensionAuthCode: $extensionId');
|
|
await _channel.invokeMethod('setExtensionAuthCode', {
|
|
'extension_id': extensionId,
|
|
'auth_code': authCode,
|
|
});
|
|
}
|
|
|
|
static final Map<String, Future<bool>> _extensionSessionGrantCompletions =
|
|
<String, Future<bool>>{};
|
|
|
|
static Future<bool> completeExtensionSessionGrant(
|
|
String extensionId,
|
|
String grant,
|
|
) {
|
|
final normalizedExtensionId = extensionId.trim();
|
|
final key = normalizedExtensionId.toLowerCase();
|
|
final activeCompletion = _extensionSessionGrantCompletions[key];
|
|
if (activeCompletion != null) {
|
|
_log.d(
|
|
'Joining active completeExtensionSessionGrant: $normalizedExtensionId',
|
|
);
|
|
return activeCompletion;
|
|
}
|
|
|
|
late final Future<bool> completion;
|
|
completion = _completeExtensionSessionGrant(normalizedExtensionId, grant)
|
|
.whenComplete(() {
|
|
if (identical(_extensionSessionGrantCompletions[key], completion)) {
|
|
_extensionSessionGrantCompletions.remove(key);
|
|
}
|
|
});
|
|
_extensionSessionGrantCompletions[key] = completion;
|
|
return completion;
|
|
}
|
|
|
|
static Future<bool> _completeExtensionSessionGrant(
|
|
String extensionId,
|
|
String grant,
|
|
) async {
|
|
_log.d('completeExtensionSessionGrant: $extensionId');
|
|
final result = await _channel.invokeMethod(
|
|
'completeExtensionSessionGrant',
|
|
{'extension_id': extensionId, 'grant': grant},
|
|
);
|
|
final success = result != false;
|
|
_extensionSessionGrantEvents.add(
|
|
ExtensionSessionGrantEvent(extensionId: extensionId, success: success),
|
|
);
|
|
return success;
|
|
}
|
|
|
|
static Future<void> setExtensionTokens(
|
|
String extensionId, {
|
|
required String accessToken,
|
|
String? refreshToken,
|
|
int? expiresIn,
|
|
}) async {
|
|
_log.d('setExtensionTokens: $extensionId');
|
|
await _channel.invokeMethod('setExtensionTokens', {
|
|
'extension_id': extensionId,
|
|
'access_token': accessToken,
|
|
'refresh_token': refreshToken ?? '',
|
|
'expires_in': expiresIn ?? 0,
|
|
});
|
|
}
|
|
|
|
static Future<void> clearExtensionPendingAuth(String extensionId) async {
|
|
await _channel.invokeMethod('clearExtensionPendingAuth', {
|
|
'extension_id': extensionId,
|
|
});
|
|
}
|
|
|
|
static Future<bool> isExtensionAuthenticated(String extensionId) async {
|
|
final result = await _channel.invokeMethod('isExtensionAuthenticated', {
|
|
'extension_id': extensionId,
|
|
});
|
|
return result as bool;
|
|
}
|
|
|
|
static Future<List<Map<String, dynamic>>> getAllPendingAuthRequests() async {
|
|
final result = await _channel.invokeMethod('getAllPendingAuthRequests');
|
|
return _decodeMapListResult(result, 'getAllPendingAuthRequests');
|
|
}
|
|
|
|
static Future<Map<String, dynamic>?> getPendingFFmpegCommand(
|
|
String commandId,
|
|
) async {
|
|
final result = await _channel.invokeMethod('getPendingFFmpegCommand', {
|
|
'command_id': commandId,
|
|
});
|
|
return _decodeNullableMapResult(result, 'getPendingFFmpegCommand');
|
|
}
|
|
|
|
static Future<void> setFFmpegCommandResult(
|
|
String commandId, {
|
|
required bool success,
|
|
String output = '',
|
|
String error = '',
|
|
}) async {
|
|
await _channel.invokeMethod('setFFmpegCommandResult', {
|
|
'command_id': commandId,
|
|
'success': success,
|
|
'output': output,
|
|
'error': error,
|
|
});
|
|
}
|
|
|
|
static Future<List<Map<String, dynamic>>>
|
|
getAllPendingFFmpegCommands() async {
|
|
final result = await _channel.invokeMethod('getAllPendingFFmpegCommands');
|
|
return _decodeMapListResult(result, 'setFFmpegCommandResult');
|
|
}
|
|
|
|
static Future<List<Map<String, dynamic>>> customSearchWithExtension(
|
|
String extensionId,
|
|
String query, {
|
|
Map<String, dynamic>? options,
|
|
bool cancelPrevious = false,
|
|
}) async {
|
|
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 cached = _getCachedMapList(_customSearchCache, cacheKey);
|
|
if (cached != null) return cached;
|
|
|
|
final requestId = _nextExtensionRequestId('customSearch', extensionId);
|
|
final generation = _lookupCacheGeneration;
|
|
final future = (() async {
|
|
final result = await _channel.invokeMethod('customSearchWithExtension', {
|
|
'extension_id': extensionId,
|
|
'query': query,
|
|
'options': optionsJson,
|
|
'request_id': requestId,
|
|
});
|
|
final decoded = await _decodeMapListResultAsync(
|
|
result,
|
|
'customSearchWithExtension',
|
|
);
|
|
if (generation == _lookupCacheGeneration &&
|
|
_customSearchInFlight[cacheKey]?.requestId == requestId) {
|
|
_putMemoryCachedMapList(
|
|
_customSearchCache,
|
|
cacheKey,
|
|
decoded,
|
|
_customSearchCacheTtl,
|
|
);
|
|
}
|
|
return decoded;
|
|
})();
|
|
|
|
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<Map<String, dynamic>?> handleURLWithExtension(
|
|
String url,
|
|
) async {
|
|
final cacheKey = url.trim();
|
|
if (cacheKey.isEmpty) return null;
|
|
|
|
final cached = _getCachedMap(_urlHandleCache, cacheKey);
|
|
if (cached != null) return cached;
|
|
|
|
final inFlight = _urlHandleInFlight[cacheKey];
|
|
if (inFlight != null) return _copyNullableStringMap(await inFlight);
|
|
|
|
final generation = _lookupCacheGeneration;
|
|
final future = (() async {
|
|
try {
|
|
final result = await _channel.invokeMethod('handleURLWithExtension', {
|
|
'url': url,
|
|
});
|
|
final decoded = _decodeNullableMapResult(
|
|
result,
|
|
'handleURLWithExtension',
|
|
);
|
|
if (generation == _lookupCacheGeneration &&
|
|
decoded != null &&
|
|
_isCacheableURLHandleResult(decoded)) {
|
|
_putMemoryCachedMap(
|
|
_urlHandleCache,
|
|
cacheKey,
|
|
decoded,
|
|
_urlHandleCacheTtl,
|
|
);
|
|
}
|
|
return decoded;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
})();
|
|
|
|
_urlHandleInFlight[cacheKey] = future;
|
|
try {
|
|
return _copyNullableStringMap(await future);
|
|
} finally {
|
|
if (identical(_urlHandleInFlight[cacheKey], future)) {
|
|
_urlHandleInFlight.remove(cacheKey);
|
|
}
|
|
}
|
|
}
|
|
|
|
static bool _isCacheableURLHandleResult(Map<String, dynamic> result) {
|
|
final type = result['type']?.toString();
|
|
if (type == null || type.isEmpty) return false;
|
|
if (type == 'track') {
|
|
final track = result['track'];
|
|
if (track is! Map) return false;
|
|
final name = track['name']?.toString().trim() ?? '';
|
|
return name.isNotEmpty;
|
|
}
|
|
return type == 'album' || type == 'playlist' || type == 'artist';
|
|
}
|
|
|
|
static void _putMemoryCachedMap(
|
|
Map<String, _BridgeCacheEntry> cache,
|
|
String key,
|
|
Map<String, dynamic> value,
|
|
Duration ttl,
|
|
) {
|
|
_pruneExpiredBridgeCache(cache);
|
|
while (cache.length >= _bridgeCacheMaxEntries && cache.isNotEmpty) {
|
|
cache.remove(cache.keys.first);
|
|
}
|
|
cache[key] = _BridgeCacheEntry(
|
|
value: _copyStringMap(value),
|
|
expiresAt: DateTime.now().add(ttl),
|
|
);
|
|
}
|
|
|
|
static List<Map<String, dynamic>>? _getCachedMapList(
|
|
Map<String, _BridgeListCacheEntry> cache,
|
|
String key,
|
|
) {
|
|
_pruneExpiredBridgeListCache(cache);
|
|
final entry = cache[key];
|
|
if (entry == null) return null;
|
|
if (entry.isExpired) {
|
|
cache.remove(key);
|
|
return null;
|
|
}
|
|
return _copyMapList(entry.value);
|
|
}
|
|
|
|
static void _putMemoryCachedMapList(
|
|
Map<String, _BridgeListCacheEntry> cache,
|
|
String key,
|
|
List<Map<String, dynamic>> value,
|
|
Duration ttl,
|
|
) {
|
|
_pruneExpiredBridgeListCache(cache);
|
|
while (cache.length >= _bridgeCacheMaxEntries && cache.isNotEmpty) {
|
|
cache.remove(cache.keys.first);
|
|
}
|
|
cache[key] = _BridgeListCacheEntry(
|
|
value: _copyMapList(value),
|
|
expiresAt: DateTime.now().add(ttl),
|
|
);
|
|
}
|
|
|
|
static void _pruneExpiredBridgeListCache(
|
|
Map<String, _BridgeListCacheEntry> cache,
|
|
) {
|
|
if (cache.isEmpty) return;
|
|
final now = DateTime.now();
|
|
cache.removeWhere((_, entry) => now.isAfter(entry.expiresAt));
|
|
}
|
|
|
|
static Future<String?> findURLHandler(String url) async {
|
|
final result = await _channel.invokeMethod('findURLHandler', {'url': url});
|
|
if (result == null || result == '') return null;
|
|
return result as String;
|
|
}
|
|
|
|
/// MusicBrainz genre and album artist for an ISRC; empty strings when a
|
|
/// lookup finds nothing. One channel call covers both fields to spare
|
|
/// MusicBrainz's 1 req/s budget.
|
|
static Future<({String genre, String albumArtist})> fetchMusicBrainzTags({
|
|
required String isrc,
|
|
String albumName = '',
|
|
}) async {
|
|
final result = await _channel.invokeMethod('fetchMusicBrainzTags', {
|
|
'isrc': isrc,
|
|
'album_name': albumName,
|
|
});
|
|
final decoded = _decodeMapResult(result);
|
|
return (
|
|
genre: decoded['genre']?.toString() ?? '',
|
|
albumArtist: decoded['album_artist']?.toString() ?? '',
|
|
);
|
|
}
|
|
|
|
/// Streaming-platform links for a track, keyed by song.link platform ID.
|
|
/// Served from the Go-side memory cache; throws when the lookup fails.
|
|
static Future<Map<String, String>> getTrackPlatformLinks({
|
|
String spotifyId = '',
|
|
String isrc = '',
|
|
}) async {
|
|
final result = await _channel.invokeMethod('getTrackPlatformLinks', {
|
|
'spotify_id': spotifyId,
|
|
'isrc': isrc,
|
|
});
|
|
final decoded = _decodeMapResult(result);
|
|
final platforms = decoded['platforms'];
|
|
if (platforms is! Map) return const {};
|
|
return platforms.map(
|
|
(key, value) => MapEntry(key.toString(), value.toString()),
|
|
);
|
|
}
|
|
|
|
static Future<Map<String, dynamic>?> getExtensionHomeFeed(
|
|
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 {
|
|
return _copyNullableStringMap(await future);
|
|
} finally {
|
|
if (identical(_homeFeedInFlight[cacheKey], entry)) {
|
|
_homeFeedInFlight.remove(cacheKey);
|
|
}
|
|
}
|
|
}
|
|
|
|
static Future<void> setLibraryCoverCacheDir(String cacheDir) async {
|
|
await _channel.invokeMethod('setLibraryCoverCacheDir', {
|
|
'cache_dir': cacheDir,
|
|
});
|
|
}
|
|
|
|
static Future<List<Map<String, dynamic>>> scanLibraryFolder(
|
|
String folderPath,
|
|
) async {
|
|
final result = await _channel.invokeMethod('scanLibraryFolder', {
|
|
'folder_path': folderPath,
|
|
});
|
|
return _decodeMapListResultAsync(result, 'scanLibraryFolder');
|
|
}
|
|
|
|
static Future<LibraryScanNDJSONFile> scanLibraryFolderToNDJSONFile(
|
|
String folderPath, {
|
|
bool Function()? isCancelled,
|
|
}) {
|
|
return _scanToNDJSONFile(
|
|
method: 'scanLibraryFolderToNDJSONFile',
|
|
arguments: {'folder_path': folderPath},
|
|
isCancelled: isCancelled,
|
|
);
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> scanLibraryFolderIncremental(
|
|
String folderPath,
|
|
Map<String, int> existingFiles,
|
|
) async {
|
|
final result = await _channel.invokeMethod('scanLibraryFolderIncremental', {
|
|
'folder_path': folderPath,
|
|
'existing_files': jsonEncode(existingFiles),
|
|
});
|
|
return _decodeRequiredMapResultAsync(
|
|
result,
|
|
'scanLibraryFolderIncremental',
|
|
);
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> scanLibraryFolderIncrementalFromSnapshot(
|
|
String folderPath,
|
|
String snapshotPath,
|
|
) async {
|
|
final result = await _channel.invokeMethod(
|
|
'scanLibraryFolderIncrementalFromSnapshot',
|
|
{'folder_path': folderPath, 'snapshot_path': snapshotPath},
|
|
);
|
|
return _decodeRequiredMapResultAsync(
|
|
result,
|
|
'scanLibraryFolderIncrementalFromSnapshot',
|
|
);
|
|
}
|
|
|
|
static Future<List<Map<String, dynamic>>> scanSafTree(String treeUri) async {
|
|
final result = await _channel.invokeMethod('scanSafTree', {
|
|
'tree_uri': treeUri,
|
|
});
|
|
return _decodeMapListResultAsync(result, 'scanSafTree');
|
|
}
|
|
|
|
static Future<LibraryScanNDJSONFile> scanSafTreeToNDJSONFile(
|
|
String treeUri, {
|
|
bool Function()? isCancelled,
|
|
}) {
|
|
return _scanToNDJSONFile(
|
|
method: 'scanSafTreeToNDJSONFile',
|
|
arguments: {'tree_uri': treeUri},
|
|
isCancelled: isCancelled,
|
|
);
|
|
}
|
|
|
|
static Future<LibraryScanNDJSONFile> _scanToNDJSONFile({
|
|
required String method,
|
|
required Map<String, dynamic> arguments,
|
|
bool Function()? isCancelled,
|
|
}) async {
|
|
// Stable support-directory path lets native SAF scans resume after process death.
|
|
final scanDir = await getApplicationSupportDirectory();
|
|
await scanDir.create(recursive: true);
|
|
final identity = jsonEncode(<String, dynamic>{
|
|
'method': method,
|
|
'arguments': arguments,
|
|
});
|
|
var hash = 0x811c9dc5;
|
|
for (final byte in utf8.encode(identity)) {
|
|
hash ^= byte;
|
|
hash = (hash * 0x01000193) & 0xffffffff;
|
|
}
|
|
final output = File(
|
|
'${scanDir.path}${Platform.pathSeparator}'
|
|
'library_scan_${hash.toRadixString(16).padLeft(8, '0')}.ndjson',
|
|
);
|
|
try {
|
|
if (isCancelled?.call() == true) {
|
|
throw StateError('Library scan cancelled before native scan');
|
|
}
|
|
final result = await _channel.invokeMethod(method, {
|
|
...arguments,
|
|
'output_path': output.path,
|
|
});
|
|
if (result is! Map) {
|
|
throw FormatException('$method returned ${result.runtimeType}');
|
|
}
|
|
if (result['cancelled'] == true) {
|
|
throw FormatException('$method returned a cancelled partial scan');
|
|
}
|
|
final pathValue = result['path'];
|
|
final countValue = result['count'];
|
|
if (pathValue is! String || pathValue.trim().isEmpty) {
|
|
throw FormatException('$method returned an invalid output path');
|
|
}
|
|
if (countValue is! num ||
|
|
!countValue.isFinite ||
|
|
countValue < 0 ||
|
|
countValue != countValue.toInt()) {
|
|
throw FormatException('$method returned an invalid row count');
|
|
}
|
|
final path = pathValue;
|
|
final count = countValue.toInt();
|
|
final file = File(path);
|
|
if (!await file.exists()) {
|
|
throw FormatException('$method did not create its output file');
|
|
}
|
|
return LibraryScanNDJSONFile(file: file, expectedCount: count);
|
|
} catch (_) {
|
|
// Keep partial output; native SAF scan resumes from its sidecar.
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> scanSafTreeIncremental(
|
|
String treeUri,
|
|
Map<String, int> existingFiles,
|
|
) async {
|
|
final result = await _channel.invokeMethod('scanSafTreeIncremental', {
|
|
'tree_uri': treeUri,
|
|
'existing_files': jsonEncode(existingFiles),
|
|
});
|
|
return _decodeRequiredMapResultAsync(result, 'scanSafTreeIncremental');
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> scanSafTreeIncrementalFromSnapshot(
|
|
String treeUri,
|
|
String snapshotPath,
|
|
) async {
|
|
final result = await _channel.invokeMethod(
|
|
'scanSafTreeIncrementalFromSnapshot',
|
|
{'tree_uri': treeUri, 'snapshot_path': snapshotPath},
|
|
);
|
|
return _decodeRequiredMapResultAsync(
|
|
result,
|
|
'scanSafTreeIncrementalFromSnapshot',
|
|
);
|
|
}
|
|
|
|
static Future<Map<String, int>> getSafFileModTimes(List<String> uris) async {
|
|
final map = await _invokeMap('getSafFileModTimes', {
|
|
'uris': jsonEncode(uris),
|
|
});
|
|
return map.map((key, value) => MapEntry(key, (value as num).toInt()));
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> getLibraryScanProgress() async {
|
|
final result = await _channel.invokeMethod('getLibraryScanProgress');
|
|
return _decodeMapResult(result);
|
|
}
|
|
|
|
static Stream<Map<String, dynamic>> libraryScanProgressStream() {
|
|
return _libraryScanProgressEvents.receiveBroadcastStream().map(
|
|
_decodeMapResult,
|
|
);
|
|
}
|
|
|
|
static Future<void> cancelLibraryScan() async {
|
|
await _channel.invokeMethod('cancelLibraryScan');
|
|
}
|
|
|
|
static Object? _decodeJsonResult(dynamic result) {
|
|
if (result is String) {
|
|
if (result.isEmpty) return null;
|
|
return jsonDecode(result);
|
|
}
|
|
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 await _decodeJsonStringAsync(contents);
|
|
} finally {
|
|
try {
|
|
await file.delete();
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
if (result is String &&
|
|
result.length >= _backgroundJsonDecodeThresholdBytes) {
|
|
return _decodeJsonStringAsync(result);
|
|
}
|
|
return _decodeJsonResult(result);
|
|
}
|
|
|
|
static Future<Object?> _decodeJsonStringAsync(String json) {
|
|
if (json.length < _backgroundJsonDecodeThresholdBytes) {
|
|
return Future<Object?>.value(jsonDecode(json));
|
|
}
|
|
return compute(_decodeJsonInBackground, json);
|
|
}
|
|
|
|
static Map<String, dynamic> _decodeRequiredMapResult(
|
|
dynamic result,
|
|
String method,
|
|
) {
|
|
final decoded = _decodeJsonResult(result);
|
|
if (decoded is Map) {
|
|
return decoded.cast<String, dynamic>();
|
|
}
|
|
throw FormatException(
|
|
'Expected map result from $method, got ${decoded.runtimeType}',
|
|
);
|
|
}
|
|
|
|
static Map<String, dynamic>? _decodeNullableMapResult(
|
|
dynamic result,
|
|
String method,
|
|
) {
|
|
final decoded = _decodeJsonResult(result);
|
|
if (decoded == null) return null;
|
|
if (decoded is Map) {
|
|
return decoded.cast<String, dynamic>();
|
|
}
|
|
throw FormatException(
|
|
'Expected nullable map result from $method, got ${decoded.runtimeType}',
|
|
);
|
|
}
|
|
|
|
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,
|
|
) {
|
|
final decoded = _decodeJsonResult(result);
|
|
if (decoded is List) return decoded;
|
|
throw FormatException(
|
|
'Expected list result from $method, got ${decoded.runtimeType}',
|
|
);
|
|
}
|
|
|
|
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,
|
|
) {
|
|
return _decodeRequiredListResult(result, method).map((entry) {
|
|
if (entry is Map) return entry.cast<String, dynamic>();
|
|
throw FormatException(
|
|
'Expected map entry from $method, got ${entry.runtimeType}',
|
|
);
|
|
}).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;
|
|
throw FormatException(
|
|
'Expected string entry from $method, got ${entry.runtimeType}',
|
|
);
|
|
}).toList();
|
|
}
|
|
|
|
static Map<String, dynamic> _decodeMapResult(dynamic result) {
|
|
if (result is Map) {
|
|
return result.cast<String, dynamic>();
|
|
}
|
|
if (result is String) {
|
|
if (result.isEmpty) return const <String, dynamic>{};
|
|
final decoded = jsonDecode(result);
|
|
if (decoded is Map) {
|
|
return decoded.cast<String, dynamic>();
|
|
}
|
|
}
|
|
return const <String, dynamic>{};
|
|
}
|
|
|
|
/// Present the native iOS folder picker. The security-scoped bookmark is
|
|
/// created inside the picker callback while its access grant is still
|
|
/// active; creating it later from a bare path loses the grant.
|
|
/// Returns null when the user cancels; throws on picker/bookmark failure.
|
|
static Future<IosPickedDirectory?> pickIosDirectory() async {
|
|
final result = await _channel.invokeMethod('pickIosDirectory');
|
|
final map = _decodeNullableMapResult(result, 'pickIosDirectory');
|
|
if (map == null) return null;
|
|
final path = map['path'] as String? ?? '';
|
|
final bookmark = map['bookmark'] as String? ?? '';
|
|
if (path.isEmpty || bookmark.isEmpty) return null;
|
|
return IosPickedDirectory(path: path, bookmark: bookmark);
|
|
}
|
|
|
|
/// Create a security-scoped bookmark from a filesystem path picked by
|
|
/// FilePicker on iOS. Must be called while the picker session is still active.
|
|
/// Returns base64-encoded bookmark data, or null on failure.
|
|
static Future<String?> createIosBookmarkFromPath(String path) async {
|
|
try {
|
|
final result = await _channel.invokeMethod('createIosBookmarkFromPath', {
|
|
'path': path,
|
|
});
|
|
return result as String?;
|
|
} catch (e) {
|
|
_log.w('Failed to create iOS bookmark from path: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Resolve a base64-encoded iOS security-scoped bookmark and start accessing
|
|
/// the resource. The returned lease must be passed to
|
|
/// [stopAccessingIosBookmark] by the operation that acquired it.
|
|
static Future<IosSecurityScopedAccess?> startAccessingIosBookmark(
|
|
String bookmark,
|
|
) async {
|
|
try {
|
|
final result = await _channel.invokeMethod('startAccessingIosBookmark', {
|
|
'bookmark': bookmark,
|
|
});
|
|
if (result is! Map) {
|
|
throw FormatException(
|
|
'startAccessingIosBookmark returned ${result.runtimeType}',
|
|
);
|
|
}
|
|
final path = result['path'];
|
|
final token = result['token'];
|
|
if (path is! String ||
|
|
path.trim().isEmpty ||
|
|
token is! String ||
|
|
token.trim().isEmpty) {
|
|
throw const FormatException('Invalid iOS bookmark access lease');
|
|
}
|
|
return IosSecurityScopedAccess(path: path, token: token);
|
|
} catch (e) {
|
|
_log.w('Failed to start accessing iOS bookmark: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Releases exactly the security-scoped lease acquired by the caller.
|
|
static Future<void> stopAccessingIosBookmark(
|
|
IosSecurityScopedAccess access,
|
|
) async {
|
|
try {
|
|
await _channel.invokeMethod('stopAccessingIosBookmark', {
|
|
'token': access.token,
|
|
});
|
|
} catch (e) {
|
|
_log.w('Failed to stop accessing iOS bookmark: $e');
|
|
}
|
|
}
|
|
|
|
static Future<Map<String, dynamic>?> readAudioMetadata(
|
|
String filePath,
|
|
) async {
|
|
try {
|
|
final result = await _channel.invokeMethod('readAudioMetadata', {
|
|
'file_path': filePath,
|
|
});
|
|
return _decodeNullableMapResult(result, 'readAudioMetadata');
|
|
} catch (e) {
|
|
_log.w('Failed to read audio metadata: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> runPostProcessingV2(
|
|
String filePath, {
|
|
Map<String, dynamic>? metadata,
|
|
}) async {
|
|
final input = <String, dynamic>{};
|
|
if (filePath.startsWith('content://')) {
|
|
input['uri'] = filePath;
|
|
} else {
|
|
input['path'] = filePath;
|
|
}
|
|
return _invokeMap('runPostProcessingV2', {
|
|
'input': jsonEncode(input),
|
|
'metadata': metadata != null ? jsonEncode(metadata) : '',
|
|
});
|
|
}
|
|
|
|
static Future<void> initExtensionRepo(String cacheDir) async {
|
|
_log.d('initExtensionRepo: $cacheDir');
|
|
await _channel.invokeMethod('initExtensionRepo', {'cache_dir': cacheDir});
|
|
}
|
|
|
|
static Future<void> setRepoRegistryUrl(String registryUrl) async {
|
|
_log.d('setRepoRegistryUrl: $registryUrl');
|
|
await _channel.invokeMethod('setRepoRegistryUrl', {
|
|
'registry_url': registryUrl,
|
|
});
|
|
}
|
|
|
|
static Future<String> getRepoRegistryUrl() async {
|
|
_log.d('getRepoRegistryUrl');
|
|
final result = await _channel.invokeMethod('getRepoRegistryUrl');
|
|
return result as String? ?? '';
|
|
}
|
|
|
|
static Future<void> clearRepoRegistryUrl() async {
|
|
_log.d('clearRepoRegistryUrl');
|
|
await _channel.invokeMethod('clearRepoRegistryUrl');
|
|
}
|
|
|
|
static Future<List<Map<String, dynamic>>> getRepoExtensions({
|
|
bool forceRefresh = false,
|
|
}) async {
|
|
_log.d('getRepoExtensions (forceRefresh: $forceRefresh)');
|
|
final result = await _channel.invokeMethod('getRepoExtensions', {
|
|
'force_refresh': forceRefresh,
|
|
});
|
|
return _decodeMapListResult(result, 'getRepoExtensions');
|
|
}
|
|
|
|
static Future<List<Map<String, dynamic>>> searchRepoExtensions(
|
|
String query, {
|
|
String? category,
|
|
}) async {
|
|
_log.d('searchRepoExtensions: "$query" (category: $category)');
|
|
final result = await _channel.invokeMethod('searchRepoExtensions', {
|
|
'query': query,
|
|
'category': category ?? '',
|
|
});
|
|
return _decodeMapListResult(result, 'searchRepoExtensions');
|
|
}
|
|
|
|
static Future<List<String>> getRepoCategories() async {
|
|
final result = await _channel.invokeMethod('getRepoCategories');
|
|
return _decodeStringListResult(result, 'getRepoCategories');
|
|
}
|
|
|
|
static Future<String> downloadRepoExtension(
|
|
String extensionId,
|
|
String destDir,
|
|
) async {
|
|
_log.i('downloadRepoExtension: $extensionId to $destDir');
|
|
final result = await _channel.invokeMethod('downloadRepoExtension', {
|
|
'extension_id': extensionId,
|
|
'dest_dir': destDir,
|
|
});
|
|
return result as String;
|
|
}
|
|
|
|
static Future<void> clearRepoCache() async {
|
|
_log.d('clearRepoCache');
|
|
await _channel.invokeMethod('clearRepoCache');
|
|
}
|
|
|
|
static Future<Map<String, dynamic>> parseCueSheet(
|
|
String cuePath, {
|
|
String audioDir = '',
|
|
}) {
|
|
_log.i('parseCueSheet: $cuePath (audioDir: $audioDir)');
|
|
return _invokeMap('parseCueSheet', {
|
|
'cue_path': cuePath,
|
|
'audio_dir': audioDir,
|
|
});
|
|
}
|
|
}
|