perf(playback): stream SAF tracks through descriptor leases

This commit is contained in:
zarzet
2026-08-30 23:18:03 +07:00
parent c056bd60a6
commit 91b2de8175
3 changed files with 219 additions and 20 deletions
@@ -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<String, ParcelFileDescriptor>()
@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<String, String>? {
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<String>("uri") ?: ""
val lease = withContext(Dispatchers.IO) {
openSafPlaybackLease(uriStr)
}
result.success(lease)
}
"safClosePlaybackLease" -> {
val token = call.argument<String>("token") ?: ""
closeSafPlaybackLease(token)
result.success(null)
}
"safCreateFromPath" -> {
val treeUriStr = call.argument<String>("tree_uri") ?: ""
val relativeDir = call.argument<String>("relative_dir") ?: ""
+115 -20
View File
@@ -266,6 +266,7 @@ class MusicPlayerHandler extends BaseAudioHandler
final List<PlayableMedia> _media = [];
final List<MediaItem> _queueItems = [];
final Map<String, String> _resolvedPathCache = {};
final Map<String, int> _resolvedPathSizes = {};
final Map<String, Future<String?>> _pendingSourceResolutions = {};
final List<String> _resolvedPathOrder = [];
final Set<String> _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<double> _normalizationVolumeFor(String path) async {
Future<double> _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<int>(
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) {
+31
View File
@@ -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<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,