mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-02 16:20:57 +02:00
perf(playback): stream SAF tracks through descriptor leases
This commit is contained in:
@@ -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") ?: ""
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user