From 91b2de8175c10967966106e08eb5ddb809cce6c0 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 30 Aug 2026 23:18:03 +0700 Subject: [PATCH] perf(playback): stream SAF tracks through descriptor leases --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 73 ++++++++++ lib/services/music_player_service.dart | 135 +++++++++++++++--- lib/services/platform_bridge.dart | 31 ++++ 3 files changed, 219 insertions(+), 20 deletions(-) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 4b99634c..c68c9e98 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -8,6 +8,7 @@ import android.content.IntentFilter import android.net.Uri import android.os.Build import android.os.Bundle +import android.os.ParcelFileDescriptor import android.os.storage.StorageManager import android.provider.DocumentsContract import androidx.activity.OnBackPressedCallback @@ -40,7 +41,9 @@ import java.io.FileInputStream import java.io.FileOutputStream import java.security.MessageDigest import java.security.SecureRandom +import java.util.LinkedHashMap import java.util.Locale +import java.util.UUID class MainActivity: FlutterFragmentActivity() { // Mirrors audio_service's AudioServiceFragmentActivity: the shared engine @@ -86,6 +89,8 @@ class MainActivity: FlutterFragmentActivity() { private var libraryScanProgressEventSink: EventChannel.EventSink? = null private var lastLibraryScanProgressPayload: String? = null private var flutterBackCallback: OnBackPressedCallback? = null + private val playbackLeaseLock = Any() + private val playbackLeases = LinkedHashMap() @Volatile internal var safScanCancel = false @Volatile internal var safScanActive = false /** Tri-state: null = untested, true = works, false = fails (Samsung SELinux). */ @@ -804,6 +809,61 @@ class MainActivity: FlutterFragmentActivity() { channel.invokeMethod("extensionSessionGrantCompleted", payload) } + /** + * Opens a short-lived descriptor lease for zero-copy SAF playback. The + * returned proc path has no URI scheme, so MediaPlayer opens it in this + * process and duplicates the descriptor before the Dart side closes the + * lease. A small hard cap protects against abandoned method calls. + */ + private fun openSafPlaybackLease(uriStr: String): Map? { + if (!uriStr.startsWith("content://")) return null + val descriptor = try { + contentResolver.openFileDescriptor(Uri.parse(uriStr), "r") + } catch (e: Exception) { + android.util.Log.w("SpotiFLAC", "Failed to open SAF playback descriptor: ${e.message}") + null + } ?: return null + + val token = UUID.randomUUID().toString() + synchronized(playbackLeaseLock) { + while (playbackLeases.size >= 4) { + val oldest = playbackLeases.entries.firstOrNull() ?: break + playbackLeases.remove(oldest.key) + try { + oldest.value.close() + } catch (_: Exception) {} + } + playbackLeases[token] = descriptor + } + return mapOf( + "token" to token, + "path" to "/proc/self/fd/${descriptor.fd}", + ) + } + + private fun closeSafPlaybackLease(token: String) { + if (token.isBlank()) return + val descriptor = synchronized(playbackLeaseLock) { + playbackLeases.remove(token) + } ?: return + try { + descriptor.close() + } catch (_: Exception) {} + } + + private fun closeAllSafPlaybackLeases() { + val descriptors = synchronized(playbackLeaseLock) { + val openDescriptors = playbackLeases.values.toList() + playbackLeases.clear() + openDescriptors + } + descriptors.forEach { descriptor -> + try { + descriptor.close() + } catch (_: Exception) {} + } + } + override fun onDestroy() { libraryStorageReceiver?.let { try { @@ -818,6 +878,7 @@ class MainActivity: FlutterFragmentActivity() { } stopDownloadProgressStream() stopLibraryScanProgressStream() + closeAllSafPlaybackLeases() super.onDestroy() } @@ -1123,6 +1184,18 @@ class MainActivity: FlutterFragmentActivity() { } result.success(tempPath) } + "safOpenPlaybackLease" -> { + val uriStr = call.argument("uri") ?: "" + val lease = withContext(Dispatchers.IO) { + openSafPlaybackLease(uriStr) + } + result.success(lease) + } + "safClosePlaybackLease" -> { + val token = call.argument("token") ?: "" + closeSafPlaybackLease(token) + result.success(null) + } "safCreateFromPath" -> { val treeUriStr = call.argument("tree_uri") ?: "" val relativeDir = call.argument("relative_dir") ?: "" diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart index 2e835346..ea09aea2 100644 --- a/lib/services/music_player_service.dart +++ b/lib/services/music_player_service.dart @@ -266,6 +266,7 @@ class MusicPlayerHandler extends BaseAudioHandler final List _media = []; final List _queueItems = []; final Map _resolvedPathCache = {}; + final Map _resolvedPathSizes = {}; final Map> _pendingSourceResolutions = {}; final List _resolvedPathOrder = []; final Set _pendingResolvedPathDeletes = {}; @@ -305,7 +306,8 @@ class MusicPlayerHandler extends BaseAudioHandler milliseconds: 500, ); static const Duration _positionPersistInterval = Duration(seconds: 10); - static const int _maxResolvedPathCacheEntries = 64; + static const int _maxResolvedPathCacheEntries = 3; + static const int _maxResolvedPathCacheBytes = 256 * 1024 * 1024; DateTime? get sleepTimerEndsAt => _sleepTimerEndsAt; @@ -556,9 +558,13 @@ class MusicPlayerHandler extends BaseAudioHandler /// album gain fallback; Opus R128 tags are converted to ReplayGain dB by /// the Go reader). 1.0 when disabled, untagged, or unreadable. Positive /// gains clamp at 1.0 — setVolume can only attenuate. - Future _normalizationVolumeFor(String path) async { + Future _normalizationVolumeFor( + String path, { + String? cacheKey, + }) async { if (!_playbackNormalizationEnabled) return 1.0; - final cached = _normalizationVolumeCache[path]; + final effectiveCacheKey = cacheKey ?? path; + final cached = _normalizationVolumeCache[effectiveCacheKey]; if (cached != null) return cached; var volume = 1.0; @@ -576,7 +582,7 @@ class MusicPlayerHandler extends BaseAudioHandler if (_normalizationVolumeCache.length > 128) { _normalizationVolumeCache.clear(); } - _normalizationVolumeCache[path] = volume; + _normalizationVolumeCache[effectiveCacheKey] = volume; return volume; } @@ -594,11 +600,27 @@ class MusicPlayerHandler extends BaseAudioHandler if (index < 0 || index >= _media.length) return; unawaited(() async { final media = _media[index]; - final resolved = media.isContentUri + var resolved = media.isContentUri ? _resolvedPathCache[media.source] : media.source; + ContentUriPlaybackLease? playbackLease; + if (resolved == null && media.isContentUri) { + try { + playbackLease = await PlatformBridge.openContentUriPlaybackLease( + media.source, + ); + resolved = playbackLease?.path; + } catch (_) {} + resolved ??= await _resolveSource(media); + } if (resolved == null) return; - final volume = await _normalizationVolumeFor(resolved); + final volume = await _normalizationVolumeFor( + resolved, + cacheKey: media.isContentUri ? media.source : null, + ); + if (playbackLease != null) { + await PlatformBridge.closeContentUriPlaybackLease(playbackLease.token); + } if (_index != index || generation != _playRequestGeneration) return; try { await _player.setVolume(volume); @@ -612,7 +634,12 @@ class MusicPlayerHandler extends BaseAudioHandler if (!media.isContentUri) return media.source; final cached = _resolvedPathCache[media.source]; - if (cached != null) return cached; + if (cached != null) { + if (await File(cached).exists()) return cached; + _resolvedPathCache.remove(media.source); + _resolvedPathSizes.remove(media.source); + _resolvedPathOrder.remove(media.source); + } final inFlight = _pendingSourceResolutions[media.source]; if (inFlight != null) return inFlight; @@ -628,12 +655,20 @@ class MusicPlayerHandler extends BaseAudioHandler } _resolvedPathCache[media.source] = tempPath; + _resolvedPathSizes[media.source] = await File(tempPath).length(); _resolvedPathOrder ..remove(media.source) ..add(media.source); - while (_resolvedPathOrder.length > _maxResolvedPathCacheEntries) { + while (_resolvedPathOrder.length > _maxResolvedPathCacheEntries || + (_resolvedPathOrder.length > 1 && + _resolvedPathSizes.values.fold( + 0, + (sum, size) => sum + size, + ) > + _maxResolvedPathCacheBytes)) { final evictedSource = _resolvedPathOrder.removeAt(0); final evictedPath = _resolvedPathCache.remove(evictedSource); + _resolvedPathSizes.remove(evictedSource); if (evictedPath != null) { unawaited(_discardResolvedPath(evictedPath)); } @@ -993,8 +1028,27 @@ class MusicPlayerHandler extends BaseAudioHandler await _claimHardwareMediaButtons(); if (!_isCurrentPlayRequest(generation, media)) return; - final resolved = await _resolveSource(media); - if (!_isCurrentPlayRequest(generation, media)) return; + // Android opens SAF files through a short-lived file-descriptor lease. + // MediaPlayer duplicates that descriptor, so the original can be closed + // immediately after play() without retaining a full-file cache copy. + ContentUriPlaybackLease? playbackLease; + if (Platform.isAndroid && media.isContentUri) { + try { + playbackLease = await PlatformBridge.openContentUriPlaybackLease( + media.source, + ); + } catch (e) { + _log.w('Failed to open direct SAF playback lease: $e'); + } + } + var resolved = playbackLease?.path ?? await _resolveSource(media); + var usingLocalSafCopy = media.isContentUri && playbackLease == null; + if (!_isCurrentPlayRequest(generation, media)) { + if (playbackLease != null) { + await PlatformBridge.closeContentUriPlaybackLease(playbackLease.token); + } + return; + } if (resolved == null) { _log.e('No playable source for ${media.title}'); _broadcastState(playerState: PlayerState.stopped); @@ -1004,14 +1058,22 @@ class MusicPlayerHandler extends BaseAudioHandler try { await musicPlayerExclusiveAudioHook?.call(); } catch (_) {} - if (!_isCurrentPlayRequest(generation, media)) return; + if (!_isCurrentPlayRequest(generation, media)) { + if (playbackLease != null) { + await PlatformBridge.closeContentUriPlaybackLease(playbackLease.token); + } + return; + } _switchingGeneration = generation; try { // Set before play() so the track never starts at the wrong loudness; // always set (1.0 when disabled/untagged) so a previous track's // attenuation can't leak into the next one. - final normalizationVolume = await _normalizationVolumeFor(resolved); + var normalizationVolume = await _normalizationVolumeFor( + resolved, + cacheKey: media.isContentUri ? media.source : null, + ); if (!_isCurrentPlayRequest(generation, media)) return; await _player.setAudioContext(_musicAudioContext); if (!_isCurrentPlayRequest(generation, media)) return; @@ -1022,22 +1084,51 @@ class MusicPlayerHandler extends BaseAudioHandler if (!_isCurrentPlayRequest(generation, media)) return; await _player.setVolume(normalizationVolume); if (!_isCurrentPlayRequest(generation, media)) return; - await _player.play( - DeviceFileSource(resolved), - position: effectiveStartPosition > Duration.zero - ? effectiveStartPosition - : null, - ); + final startAt = effectiveStartPosition > Duration.zero + ? effectiveStartPosition + : null; + try { + await _player.play(DeviceFileSource(resolved), position: startAt); + if (playbackLease != null) { + await PlatformBridge.closeContentUriPlaybackLease( + playbackLease.token, + ); + playbackLease = null; + } + } catch (directError) { + if (playbackLease == null || + !_isCurrentPlayRequest(generation, media)) { + rethrow; + } + await PlatformBridge.closeContentUriPlaybackLease(playbackLease.token); + playbackLease = null; + _log.w( + 'Direct SAF playback unavailable; using bounded local fallback: ' + '$directError', + ); + final fallback = await _resolveSource(media); + if (fallback == null || !_isCurrentPlayRequest(generation, media)) { + rethrow; + } + resolved = fallback; + usingLocalSafCopy = true; + normalizationVolume = await _normalizationVolumeFor( + fallback, + cacheKey: media.source, + ); + await _player.setVolume(normalizationVolume); + await _player.play(DeviceFileSource(fallback), position: startAt); + } if (!_isCurrentPlayRequest(generation, media)) return; _sourceReady = true; _pendingRestorePosition = null; - _activeResolvedPath = media.isContentUri ? resolved : null; + _activeResolvedPath = usingLocalSafCopy ? resolved : null; await _cleanupPendingResolvedPaths(); // Plain file paths were already published before loading. Re-publishing // them with an identical resolved path made Now Playing clear and probe // the same metadata twice on every Next. SAF needs this second event so // the UI can inspect its temporary local copy. - if (media.isContentUri) { + if (usingLocalSafCopy) { mediaItem.add(media.toMediaItem(resolvedSource: resolved)); } _broadcastPosition(effectiveStartPosition, force: true); @@ -1054,6 +1145,9 @@ class MusicPlayerHandler extends BaseAudioHandler _log.e('Playback failed for ${media.title}: $e'); _broadcastState(playerState: PlayerState.stopped); } finally { + if (playbackLease != null) { + await PlatformBridge.closeContentUriPlaybackLease(playbackLease.token); + } if (_switchingGeneration == generation) { _switchingGeneration = 0; } @@ -1400,6 +1494,7 @@ class MusicPlayerHandler extends BaseAudioHandler ..._pendingResolvedPathDeletes, }; _resolvedPathCache.clear(); + _resolvedPathSizes.clear(); _resolvedPathOrder.clear(); _pendingResolvedPathDeletes.clear(); for (final path in tempPaths) { diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index c2cc6faf..1f4eebb1 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -79,6 +79,13 @@ class IosSecurityScopedAccess { 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; @@ -814,6 +821,30 @@ class PlatformBridge { return result as String?; } + static Future 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.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 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 createSafFileFromPath({ required String treeUri, required String relativeDir,