import 'dart:convert'; import 'dart:io'; import 'package:flutter/services.dart'; import 'package:spotiflac_android/services/download_request_payload.dart'; import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('PlatformBridge'); class PlatformBridge { static const _channel = MethodChannel('com.zarz.spotiflac/backend'); static const _downloadProgressEvents = EventChannel( 'com.zarz.spotiflac/download_progress_stream', ); static const _libraryScanProgressEvents = EventChannel( 'com.zarz.spotiflac/library_scan_progress_stream', ); static bool get supportsCoreBackend => Platform.isAndroid || Platform.isIOS; static bool get supportsExtensionSystem => Platform.isAndroid || Platform.isIOS; static Future> checkAvailability( String spotifyId, String isrc, ) async { _log.d('checkAvailability: $spotifyId (ISRC: $isrc)'); final result = await _channel.invokeMethod('checkAvailability', { 'spotify_id': spotifyId, 'isrc': isrc, }); return jsonDecode(result as String) as Map; } static Future> _invokeDownloadMethod( String method, DownloadRequestPayload payload, ) async { final request = jsonEncode(payload.toJson()); final result = await _channel.invokeMethod(method, request); return jsonDecode(result as String) as Map; } static Future> downloadByStrategy({ required DownloadRequestPayload payload, bool? useExtensions, bool? useFallback, }) async { final routedPayload = payload.withStrategy( useExtensions: useExtensions, useFallback: useFallback, ); _log.i( 'downloadByStrategy: "${payload.trackName}" by ${payload.artistName} ' '(service: ${payload.service}, ext: ${routedPayload.useExtensions}, fallback: ${routedPayload.useFallback})', ); final response = await _invokeDownloadMethod( 'downloadByStrategy', routedPayload, ); if (response['success'] == true) { final service = response['service'] ?? payload.service; final filePath = response['file_path'] ?? ''; final bitDepth = response['actual_bit_depth'] as num?; final sampleRate = response['actual_sample_rate'] as num?; final qualityStr = bitDepth != null && sampleRate != null ? ' ($bitDepth-bit/${(sampleRate / 1000).toStringAsFixed(1)}kHz)' : ''; _log.i('Download success via $service$qualityStr: $filePath'); } else { final error = response['error'] ?? 'Unknown error'; final errorType = response['error_type'] ?? ''; _log.e('Download failed: $error (type: $errorType)'); } return response; } static Future> getDownloadProgress() async { final result = await _channel.invokeMethod('getDownloadProgress'); return _decodeMapResult(result); } static Future> getAllDownloadProgress() async { final result = await _channel.invokeMethod('getAllDownloadProgress'); return _decodeMapResult(result); } static Stream> downloadProgressStream() { return _downloadProgressEvents.receiveBroadcastStream().map( _decodeMapResult, ); } static Future exitApp() async { await _channel.invokeMethod('exitApp'); } static Future initItemProgress(String itemId) async { await _channel.invokeMethod('initItemProgress', {'item_id': itemId}); } static Future finishItemProgress(String itemId) async { await _channel.invokeMethod('finishItemProgress', {'item_id': itemId}); } static Future clearItemProgress(String itemId) async { await _channel.invokeMethod('clearItemProgress', {'item_id': itemId}); } static Future cancelDownload(String itemId) async { await _channel.invokeMethod('cancelDownload', {'item_id': itemId}); } static Future setDownloadDirectory(String path) async { await _channel.invokeMethod('setDownloadDirectory', {'path': path}); } static Future setNetworkCompatibilityOptions({ required bool allowHttp, required bool insecureTls, }) async { await _channel.invokeMethod('setNetworkCompatibilityOptions', { 'allow_http': allowHttp, 'insecure_tls': insecureTls, }); } static Future> checkDuplicate( String outputDir, String isrc, ) async { final result = await _channel.invokeMethod('checkDuplicate', { 'output_dir': outputDir, 'isrc': isrc, }); return jsonDecode(result as String) as Map; } static Future buildFilename( String template, Map metadata, ) async { final result = await _channel.invokeMethod('buildFilename', { 'template': template, 'metadata': jsonEncode(metadata), }); return result as String; } static Future sanitizeFilename(String filename) async { final result = await _channel.invokeMethod('sanitizeFilename', { 'filename': filename, }); return result as String; } static Future?> pickSafTree() async { final result = await _channel.invokeMethod('pickSafTree'); if (result == null) return null; return jsonDecode(result as String) as Map; } static Future safExists(String uri) async { final result = await _channel.invokeMethod('safExists', {'uri': uri}); return result as bool; } static Future safDelete(String uri) async { final result = await _channel.invokeMethod('safDelete', {'uri': uri}); return result as bool; } static Future> safStat(String uri) async { final result = await _channel.invokeMethod('safStat', {'uri': uri}); return jsonDecode(result as String) as Map; } static Future> resolveSafFile({ required String treeUri, required String fileName, String relativeDir = '', }) async { final result = await _channel.invokeMethod('resolveSafFile', { 'tree_uri': treeUri, 'relative_dir': relativeDir, 'file_name': fileName, }); return jsonDecode(result as String) as Map; } static Future copyContentUriToTemp(String uri) async { final result = await _channel.invokeMethod('safCopyToTemp', {'uri': uri}); return result as String?; } static Future replaceContentUriFromPath( String uri, String srcPath, ) async { final result = await _channel.invokeMethod('safReplaceFromPath', { 'uri': uri, 'src_path': srcPath, }); return result as bool; } static Future 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 openContentUri(String uri, {String mimeType = ''}) async { await _channel.invokeMethod('openContentUri', { 'uri': uri, 'mime_type': mimeType, }); } static Future shareContentUri(String uri, {String title = ''}) async { final result = await _channel.invokeMethod('shareContentUri', { 'uri': uri, 'title': title, }); return result as bool? ?? false; } static Future shareMultipleContentUris( List uris, { String title = '', }) async { final result = await _channel.invokeMethod('shareMultipleContentUris', { 'uris': uris, 'title': title, }); return result as bool? ?? false; } static Future> fetchLyrics( String spotifyId, String trackName, String artistName, { int durationMs = 0, }) async { final result = await _channel.invokeMethod('fetchLyrics', { 'spotify_id': spotifyId, 'track_name': trackName, 'artist_name': artistName, 'duration_ms': durationMs, }); return jsonDecode(result as String) as Map; } static Future 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> getLyricsLRCWithSource( String spotifyId, String trackName, String artistName, { String? filePath, int durationMs = 0, }) async { final result = await _channel.invokeMethod('getLyricsLRCWithSource', { 'spotify_id': spotifyId, 'track_name': trackName, 'artist_name': artistName, 'file_path': filePath ?? '', 'duration_ms': durationMs, }); return jsonDecode(result as String) as Map; } static Future> embedLyricsToFile( String filePath, String lyrics, ) async { final result = await _channel.invokeMethod('embedLyricsToFile', { 'file_path': filePath, 'lyrics': lyrics, }); return jsonDecode(result as String) as Map; } static Future cleanupConnections() async { await _channel.invokeMethod('cleanupConnections'); } static Future> downloadCoverToFile( String coverUrl, String outputPath, { bool maxQuality = true, }) async { final result = await _channel.invokeMethod('downloadCoverToFile', { 'cover_url': coverUrl, 'output_path': outputPath, 'max_quality': maxQuality, }); return jsonDecode(result as String) as Map; } static Future> extractCoverToFile( String audioPath, String outputPath, ) async { final result = await _channel.invokeMethod('extractCoverToFile', { 'audio_path': audioPath, 'output_path': outputPath, }); return jsonDecode(result as String) as Map; } static Future> fetchAndSaveLyrics({ required String trackName, required String artistName, required String spotifyId, required int durationMs, required String outputPath, String audioFilePath = '', }) async { final result = await _channel.invokeMethod('fetchAndSaveLyrics', { 'track_name': trackName, 'artist_name': artistName, 'spotify_id': spotifyId, 'duration_ms': durationMs, 'output_path': outputPath, 'audio_file_path': audioFilePath, }); return jsonDecode(result as String) as Map; } /// Providers not in the list are disabled. static Future setLyricsProviders(List providers) async { final providersJSON = jsonEncode(providers); await _channel.invokeMethod('setLyricsProviders', { 'providers_json': providersJSON, }); } static Future> getLyricsProviders() async { final result = await _channel.invokeMethod('getLyricsProviders'); final List decoded = jsonDecode(result as String) as List; return decoded.cast(); } static Future>> getAvailableLyricsProviders() async { final result = await _channel.invokeMethod('getAvailableLyricsProviders'); final List decoded = jsonDecode(result as String) as List; return decoded.cast>(); } /// Sets advanced lyrics fetch options used by provider-specific integrations. static Future setLyricsFetchOptions( Map options, ) async { final optionsJSON = jsonEncode(options); await _channel.invokeMethod('setLyricsFetchOptions', { 'options_json': optionsJSON, }); } static Future> getLyricsFetchOptions() async { final result = await _channel.invokeMethod('getLyricsFetchOptions'); return jsonDecode(result as String) as Map; } static Future> reEnrichFile( Map request, ) async { final requestJSON = jsonEncode(request); final result = await _channel.invokeMethod('reEnrichFile', { 'request_json': requestJSON, }); return jsonDecode(result as String) as Map; } static Future> readFileMetadata(String filePath) async { final result = await _channel.invokeMethod('readFileMetadata', { 'file_path': filePath, }); return jsonDecode(result as String) as Map; } static Future> editFileMetadata( String filePath, Map metadata, ) async { final metadataJSON = jsonEncode(metadata); final result = await _channel.invokeMethod('editFileMetadata', { 'file_path': filePath, 'metadata_json': metadataJSON, }); return jsonDecode(result as String) as Map; } /// Rewrites ARTIST/ALBUMARTIST Vorbis comments as multiple split entries /// using the native Go FLAC writer, fixing FFmpeg's tag deduplication. static Future> rewriteSplitArtistTags( String filePath, String artist, String albumArtist, ) async { final result = await _channel.invokeMethod('rewriteSplitArtistTags', { 'file_path': filePath, 'artist': artist, 'album_artist': albumArtist, }); return jsonDecode(result as String) as Map; } static Future writeTempToSaf(String tempPath, String safUri) async { final result = await _channel.invokeMethod('writeTempToSaf', { 'temp_path': tempPath, 'saf_uri': safUri, }); final map = jsonDecode(result as String) as Map; return map['success'] == true; } static Future startDownloadService({ String trackName = '', String artistName = '', int queueCount = 0, }) async { await _channel.invokeMethod('startDownloadService', { 'track_name': trackName, 'artist_name': artistName, 'queue_count': queueCount, }); } static Future stopDownloadService() async { await _channel.invokeMethod('stopDownloadService'); } static Future updateDownloadServiceProgress({ required String trackName, required String artistName, required int progress, required int total, required int queueCount, }) async { await _channel.invokeMethod('updateDownloadServiceProgress', { 'track_name': trackName, 'artist_name': artistName, 'progress': progress, 'total': total, 'queue_count': queueCount, }); } static Future isDownloadServiceRunning() async { final result = await _channel.invokeMethod('isDownloadServiceRunning'); return result as bool; } static Future preWarmTrackCache( List> tracks, ) async { final tracksJson = jsonEncode(tracks); await _channel.invokeMethod('preWarmTrackCache', {'tracks': tracksJson}); } static Future getTrackCacheSize() async { final result = await _channel.invokeMethod('getTrackCacheSize'); return result as int; } static Future clearTrackCache() async { await _channel.invokeMethod('clearTrackCache'); } static Future> searchDeezerAll( String query, { int trackLimit = 15, int artistLimit = 2, String? filter, }) async { final result = await _channel.invokeMethod('searchDeezerAll', { 'query': query, 'track_limit': trackLimit, 'artist_limit': artistLimit, 'filter': filter ?? '', }); return jsonDecode(result as String) as Map; } static Future> searchTidalAll( String query, { int trackLimit = 15, int artistLimit = 2, String? filter, }) async { final result = await _channel.invokeMethod('searchTidalAll', { 'query': query, 'track_limit': trackLimit, 'artist_limit': artistLimit, 'filter': filter ?? '', }); return jsonDecode(result as String) as Map; } static Future> searchQobuzAll( String query, { int trackLimit = 15, int artistLimit = 2, String? filter, }) async { final result = await _channel.invokeMethod('searchQobuzAll', { 'query': query, 'track_limit': trackLimit, 'artist_limit': artistLimit, 'filter': filter ?? '', }); return jsonDecode(result as String) as Map; } static Future> getDeezerRelatedArtists( String artistId, { int limit = 12, }) async { final result = await _channel.invokeMethod('getDeezerRelatedArtists', { 'artist_id': artistId, 'limit': limit, }); return jsonDecode(result as String) as Map; } static Future> getDeezerMetadata( String resourceType, String resourceId, ) async { final result = await _channel.invokeMethod('getDeezerMetadata', { 'resource_type': resourceType, 'resource_id': resourceId, }); if (result == null) { throw Exception( 'getDeezerMetadata returned null for $resourceType:$resourceId', ); } return jsonDecode(result as String) as Map; } static Future> parseDeezerUrl(String url) async { final result = await _channel.invokeMethod('parseDeezerUrl', {'url': url}); return jsonDecode(result as String) as Map; } static Future> getQobuzMetadata( String resourceType, String resourceId, ) async { final result = await _channel.invokeMethod('getQobuzMetadata', { 'resource_type': resourceType, 'resource_id': resourceId, }); if (result == null) { throw Exception( 'getQobuzMetadata returned null for $resourceType:$resourceId', ); } return jsonDecode(result as String) as Map; } static Future> parseQobuzUrl(String url) async { final result = await _channel.invokeMethod('parseQobuzUrl', {'url': url}); return jsonDecode(result as String) as Map; } static Future> parseTidalUrl(String url) async { final result = await _channel.invokeMethod('parseTidalUrl', {'url': url}); return jsonDecode(result as String) as Map; } static Future> getTidalMetadata( String resourceType, String resourceId, ) async { final result = await _channel.invokeMethod('getTidalMetadata', { 'resource_type': resourceType, 'resource_id': resourceId, }); if (result == null) { throw Exception( 'getTidalMetadata returned null for $resourceType:$resourceId', ); } return jsonDecode(result as String) as Map; } static Future> convertTidalToSpotifyDeezer( String tidalUrl, ) async { final result = await _channel.invokeMethod('convertTidalToSpotifyDeezer', { 'url': tidalUrl, }); return jsonDecode(result as String) as Map; } static Future> searchDeezerByISRC(String isrc) async { final result = await _channel.invokeMethod('searchDeezerByISRC', { 'isrc': isrc, }); return jsonDecode(result as String) as Map; } static Future?> getDeezerExtendedMetadata( String trackId, ) async { try { final result = await _channel.invokeMethod('getDeezerExtendedMetadata', { 'track_id': trackId, }); if (result == null) return null; final data = jsonDecode(result as String) as Map; 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> convertSpotifyToDeezer( String resourceType, String spotifyId, ) async { final result = await _channel.invokeMethod('convertSpotifyToDeezer', { 'resource_type': resourceType, 'spotify_id': spotifyId, }); return jsonDecode(result as String) as Map; } static Future>> getGoLogs() async { final result = await _channel.invokeMethod('getLogs'); final logs = jsonDecode(result as String) as List; return logs.map((e) => e as Map).toList(); } static Future> getGoLogsSince(int index) async { final result = await _channel.invokeMethod('getLogsSince', { 'index': index, }); return jsonDecode(result as String) as Map; } static Future clearGoLogs() async { await _channel.invokeMethod('clearLogs'); } static Future getGoLogCount() async { final result = await _channel.invokeMethod('getLogCount'); return result as int; } static Future setGoLoggingEnabled(bool enabled) async { await _channel.invokeMethod('setLoggingEnabled', {'enabled': enabled}); } static Future initExtensionSystem( String extensionsDir, String dataDir, ) async { _log.d('initExtensionSystem: $extensionsDir, $dataDir'); await _channel.invokeMethod('initExtensionSystem', { 'extensions_dir': extensionsDir, 'data_dir': dataDir, }); } static Future> loadExtensionsFromDir( String dirPath, ) async { _log.d('loadExtensionsFromDir: $dirPath'); final result = await _channel.invokeMethod('loadExtensionsFromDir', { 'dir_path': dirPath, }); return jsonDecode(result as String) as Map; } static Future> loadExtensionFromPath( String filePath, ) async { _log.d('loadExtensionFromPath: $filePath'); final result = await _channel.invokeMethod('loadExtensionFromPath', { 'file_path': filePath, }); return jsonDecode(result as String) as Map; } static Future unloadExtension(String extensionId) async { _log.d('unloadExtension: $extensionId'); await _channel.invokeMethod('unloadExtension', { 'extension_id': extensionId, }); } static Future removeExtension(String extensionId) async { _log.d('removeExtension: $extensionId'); await _channel.invokeMethod('removeExtension', { 'extension_id': extensionId, }); } static Future> upgradeExtension(String filePath) async { _log.d('upgradeExtension: $filePath'); final result = await _channel.invokeMethod('upgradeExtension', { 'file_path': filePath, }); return jsonDecode(result as String) as Map; } static Future> checkExtensionUpgrade( String filePath, ) async { _log.d('checkExtensionUpgrade: $filePath'); final result = await _channel.invokeMethod('checkExtensionUpgrade', { 'file_path': filePath, }); return jsonDecode(result as String) as Map; } static Future>> getInstalledExtensions() async { final result = await _channel.invokeMethod('getInstalledExtensions'); final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } static Future setExtensionEnabled( String extensionId, bool enabled, ) async { _log.d('setExtensionEnabled: $extensionId = $enabled'); await _channel.invokeMethod('setExtensionEnabled', { 'extension_id': extensionId, 'enabled': enabled, }); } static Future setProviderPriority(List providerIds) async { _log.d('setProviderPriority: $providerIds'); await _channel.invokeMethod('setProviderPriority', { 'priority': jsonEncode(providerIds), }); } static Future> getProviderPriority() async { final result = await _channel.invokeMethod('getProviderPriority'); final list = jsonDecode(result as String) as List; return list.map((e) => e as String).toList(); } static Future setDownloadFallbackExtensionIds( List? extensionIds, ) async { _log.d('setDownloadFallbackExtensionIds: $extensionIds'); await _channel.invokeMethod('setDownloadFallbackExtensionIds', { 'extension_ids': extensionIds == null ? '' : jsonEncode(extensionIds), }); } static Future setMetadataProviderPriority( List providerIds, ) async { _log.d('setMetadataProviderPriority: $providerIds'); await _channel.invokeMethod('setMetadataProviderPriority', { 'priority': jsonEncode(providerIds), }); } static Future> getMetadataProviderPriority() async { final result = await _channel.invokeMethod('getMetadataProviderPriority'); final list = jsonDecode(result as String) as List; return list.map((e) => e as String).toList(); } static Future> getExtensionSettings( String extensionId, ) async { final result = await _channel.invokeMethod('getExtensionSettings', { 'extension_id': extensionId, }); return jsonDecode(result as String) as Map; } static Future setExtensionSettings( String extensionId, Map settings, ) async { _log.d('setExtensionSettings: $extensionId'); await _channel.invokeMethod('setExtensionSettings', { 'extension_id': extensionId, 'settings': jsonEncode(settings), }); } static Future> 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 jsonDecode(result) as Map; } static Future>> searchTracksWithExtensions( String query, { int limit = 20, }) async { _log.d('searchTracksWithExtensions: "$query"'); final result = await _channel.invokeMethod('searchTracksWithExtensions', { 'query': query, 'limit': limit, }); final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } static Future>> 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}, ); final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } static Future cleanupExtensions() async { _log.d('cleanupExtensions'); await _channel.invokeMethod('cleanupExtensions'); } static Future?> getExtensionPendingAuth( String extensionId, ) async { final result = await _channel.invokeMethod('getExtensionPendingAuth', { 'extension_id': extensionId, }); if (result == null) return null; return jsonDecode(result as String) as Map; } static Future setExtensionAuthCode( String extensionId, String authCode, ) async { _log.d('setExtensionAuthCode: $extensionId'); await _channel.invokeMethod('setExtensionAuthCode', { 'extension_id': extensionId, 'auth_code': authCode, }); } static Future 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 clearExtensionPendingAuth(String extensionId) async { await _channel.invokeMethod('clearExtensionPendingAuth', { 'extension_id': extensionId, }); } static Future isExtensionAuthenticated(String extensionId) async { final result = await _channel.invokeMethod('isExtensionAuthenticated', { 'extension_id': extensionId, }); return result as bool; } static Future>> getAllPendingAuthRequests() async { final result = await _channel.invokeMethod('getAllPendingAuthRequests'); final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } static Future?> getPendingFFmpegCommand( String commandId, ) async { final result = await _channel.invokeMethod('getPendingFFmpegCommand', { 'command_id': commandId, }); if (result == null) return null; return jsonDecode(result as String) as Map; } static Future 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>> getAllPendingFFmpegCommands() async { final result = await _channel.invokeMethod('getAllPendingFFmpegCommands'); final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } static Future>> customSearchWithExtension( String extensionId, String query, { Map? options, }) async { final result = await _channel.invokeMethod('customSearchWithExtension', { 'extension_id': extensionId, 'query': query, 'options': options != null ? jsonEncode(options) : '', }); final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } static Future>> getSearchProviders() async { final result = await _channel.invokeMethod('getSearchProviders'); final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } static Future?> handleURLWithExtension( String url, ) async { try { final result = await _channel.invokeMethod('handleURLWithExtension', { 'url': url, }); if (result == null || result == '') return null; return jsonDecode(result as String) as Map; } catch (e) { return null; } } static Future findURLHandler(String url) async { final result = await _channel.invokeMethod('findURLHandler', {'url': url}); if (result == null || result == '') return null; return result as String; } static Future>> getURLHandlers() async { final result = await _channel.invokeMethod('getURLHandlers'); final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } static Future?> getAlbumWithExtension( String extensionId, String albumId, ) async { try { final result = await _channel.invokeMethod('getAlbumWithExtension', { 'extension_id': extensionId, 'album_id': albumId, }); if (result == null || result == '') return null; return jsonDecode(result as String) as Map; } catch (e) { _log.e('getAlbumWithExtension failed: $e'); return null; } } static Future?> getPlaylistWithExtension( String extensionId, String playlistId, ) async { try { final result = await _channel.invokeMethod('getPlaylistWithExtension', { 'extension_id': extensionId, 'playlist_id': playlistId, }); if (result == null || result == '') return null; return jsonDecode(result as String) as Map; } catch (e) { _log.e('getPlaylistWithExtension failed: $e'); return null; } } static Future?> getArtistWithExtension( String extensionId, String artistId, ) async { try { final result = await _channel.invokeMethod('getArtistWithExtension', { 'extension_id': extensionId, 'artist_id': artistId, }); if (result == null || result == '') return null; return jsonDecode(result as String) as Map; } catch (e) { _log.e('getArtistWithExtension failed: $e'); return null; } } static Future?> getExtensionHomeFeed( String extensionId, ) async { try { final result = await _channel.invokeMethod('getExtensionHomeFeed', { 'extension_id': extensionId, }); if (result == null || result == '') return null; return jsonDecode(result as String) as Map; } catch (e) { _log.e('getExtensionHomeFeed failed: $e'); return null; } } static Future?> getExtensionBrowseCategories( String extensionId, ) async { try { final result = await _channel.invokeMethod( 'getExtensionBrowseCategories', {'extension_id': extensionId}, ); if (result == null || result == '') return null; return jsonDecode(result as String) as Map; } catch (e) { _log.e('getExtensionBrowseCategories failed: $e'); return null; } } static Future setLibraryCoverCacheDir(String cacheDir) async { _log.i('setLibraryCoverCacheDir: $cacheDir'); await _channel.invokeMethod('setLibraryCoverCacheDir', { 'cache_dir': cacheDir, }); } static Future>> scanLibraryFolder( String folderPath, ) async { _log.i('scanLibraryFolder: $folderPath'); final result = await _channel.invokeMethod('scanLibraryFolder', { 'folder_path': folderPath, }); final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } static Future> scanLibraryFolderIncremental( String folderPath, Map existingFiles, ) async { _log.i( 'scanLibraryFolderIncremental: $folderPath (${existingFiles.length} existing files)', ); final result = await _channel.invokeMethod('scanLibraryFolderIncremental', { 'folder_path': folderPath, 'existing_files': jsonEncode(existingFiles), }); return jsonDecode(result as String) as Map; } static Future> scanLibraryFolderIncrementalFromSnapshot( String folderPath, String snapshotPath, ) async { final result = await _channel.invokeMethod( 'scanLibraryFolderIncrementalFromSnapshot', {'folder_path': folderPath, 'snapshot_path': snapshotPath}, ); return jsonDecode(result as String) as Map; } static Future>> scanSafTree(String treeUri) async { _log.i('scanSafTree: $treeUri'); final result = await _channel.invokeMethod('scanSafTree', { 'tree_uri': treeUri, }); final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } static Future> scanSafTreeIncremental( String treeUri, Map existingFiles, ) async { _log.i( 'scanSafTreeIncremental: $treeUri (${existingFiles.length} existing files)', ); final result = await _channel.invokeMethod('scanSafTreeIncremental', { 'tree_uri': treeUri, 'existing_files': jsonEncode(existingFiles), }); return jsonDecode(result as String) as Map; } static Future> scanSafTreeIncrementalFromSnapshot( String treeUri, String snapshotPath, ) async { final result = await _channel.invokeMethod( 'scanSafTreeIncrementalFromSnapshot', {'tree_uri': treeUri, 'snapshot_path': snapshotPath}, ); return jsonDecode(result as String) as Map; } static Future> getSafFileModTimes(List uris) async { final result = await _channel.invokeMethod('getSafFileModTimes', { 'uris': jsonEncode(uris), }); final map = jsonDecode(result as String) as Map; return map.map((key, value) => MapEntry(key, (value as num).toInt())); } static Future> getLibraryScanProgress() async { final result = await _channel.invokeMethod('getLibraryScanProgress'); return _decodeMapResult(result); } static Stream> libraryScanProgressStream() { return _libraryScanProgressEvents.receiveBroadcastStream().map( _decodeMapResult, ); } static Future cancelLibraryScan() async { await _channel.invokeMethod('cancelLibraryScan'); } static Map _decodeMapResult(dynamic result) { if (result is Map) { return result.cast(); } if (result is String) { if (result.isEmpty) return const {}; final decoded = jsonDecode(result); if (decoded is Map) { return decoded.cast(); } } return const {}; } // MARK: - iOS Security-Scoped 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 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. Returns the resolved filesystem path. /// The resource stays accessed until [stopAccessingIosBookmark] is called. static Future startAccessingIosBookmark(String bookmark) async { try { final result = await _channel.invokeMethod('startAccessingIosBookmark', { 'bookmark': bookmark, }); return result as String?; } catch (e) { _log.w('Failed to start accessing iOS bookmark: $e'); return null; } } /// Stop accessing the currently active iOS security-scoped resource. static Future stopAccessingIosBookmark() async { try { await _channel.invokeMethod('stopAccessingIosBookmark'); } catch (e) { _log.w('Failed to stop accessing iOS bookmark: $e'); } } static Future?> readAudioMetadata( String filePath, ) async { try { final result = await _channel.invokeMethod('readAudioMetadata', { 'file_path': filePath, }); if (result == null || result == '') return null; return jsonDecode(result as String) as Map; } catch (e) { _log.w('Failed to read audio metadata: $e'); return null; } } static Future> runPostProcessing( String filePath, { Map? metadata, }) async { final result = await _channel.invokeMethod('runPostProcessing', { 'file_path': filePath, 'metadata': metadata != null ? jsonEncode(metadata) : '', }); return jsonDecode(result as String) as Map; } static Future> runPostProcessingV2( String filePath, { Map? metadata, }) async { final input = {}; if (filePath.startsWith('content://')) { input['uri'] = filePath; } else { input['path'] = filePath; } final result = await _channel.invokeMethod('runPostProcessingV2', { 'input': jsonEncode(input), 'metadata': metadata != null ? jsonEncode(metadata) : '', }); return jsonDecode(result as String) as Map; } static Future>> getPostProcessingProviders() async { final result = await _channel.invokeMethod('getPostProcessingProviders'); final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } static Future initExtensionStore(String cacheDir) async { _log.d('initExtensionStore: $cacheDir'); await _channel.invokeMethod('initExtensionStore', {'cache_dir': cacheDir}); } static Future setStoreRegistryUrl(String registryUrl) async { _log.d('setStoreRegistryUrl: $registryUrl'); await _channel.invokeMethod('setStoreRegistryUrl', { 'registry_url': registryUrl, }); } static Future getStoreRegistryUrl() async { _log.d('getStoreRegistryUrl'); final result = await _channel.invokeMethod('getStoreRegistryUrl'); return result as String? ?? ''; } static Future clearStoreRegistryUrl() async { _log.d('clearStoreRegistryUrl'); await _channel.invokeMethod('clearStoreRegistryUrl'); } static Future>> getStoreExtensions({ bool forceRefresh = false, }) async { _log.d('getStoreExtensions (forceRefresh: $forceRefresh)'); final result = await _channel.invokeMethod('getStoreExtensions', { 'force_refresh': forceRefresh, }); final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } static Future>> searchStoreExtensions( String query, { String? category, }) async { _log.d('searchStoreExtensions: "$query" (category: $category)'); final result = await _channel.invokeMethod('searchStoreExtensions', { 'query': query, 'category': category ?? '', }); final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } static Future> getStoreCategories() async { final result = await _channel.invokeMethod('getStoreCategories'); final list = jsonDecode(result as String) as List; return list.cast(); } static Future downloadStoreExtension( String extensionId, String destDir, ) async { _log.i('downloadStoreExtension: $extensionId to $destDir'); final result = await _channel.invokeMethod('downloadStoreExtension', { 'extension_id': extensionId, 'dest_dir': destDir, }); return result as String; } static Future clearStoreCache() async { _log.d('clearStoreCache'); await _channel.invokeMethod('clearStoreCache'); } static Future> parseCueSheet( String cuePath, { String audioDir = '', }) async { _log.i('parseCueSheet: $cuePath (audioDir: $audioDir)'); final result = await _channel.invokeMethod('parseCueSheet', { 'cue_path': cuePath, 'audio_dir': audioDir, }); return jsonDecode(result as String) as Map; } }