diff --git a/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt b/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt index b2409a0..119caaf 100644 --- a/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt +++ b/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt @@ -18,9 +18,11 @@ import com.doublesymmetry.trackplayer.utils.RejectionException import com.facebook.react.bridge.* import com.google.android.exoplayer2.DefaultLoadControl.* import com.google.android.exoplayer2.Player +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import timber.log.Timber import java.util.* import javax.annotation.Nonnull @@ -169,8 +171,12 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM return } + val bundledData = Arguments.toBundle(data) + val androidOptions = bundledData?.getBundle(MusicService.ANDROID_OPTIONS_KEY) + val allowBackgroundSetup = androidOptions?.getBoolean("allowBackgroundSetup") ?: false + // prevent crash Fatal Exception: android.app.RemoteServiceException$ForegroundServiceDidNotStartInTimeException - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && AppForegroundTracker.backgrounded) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && AppForegroundTracker.backgrounded && !allowBackgroundSetup) { promise.reject( "android_cannot_setup_player_in_background", "On Android the app must be in the foreground when setting up the player." @@ -179,7 +185,6 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM } // Validate buffer keys. - val bundledData = Arguments.toBundle(data) val minBuffer = bundledData?.getDouble(MusicService.MIN_BUFFER_KEY)?.toMilliseconds()?.toInt() ?: DEFAULT_MIN_BUFFER_MS @@ -251,7 +256,7 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM } @ReactMethod - fun updateOptions(data: ReadableMap?, callback: Promise) = scope.launch { + fun updateOptions(data: ReadableMap?, callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch val options = Arguments.toBundle(data) @@ -262,19 +267,24 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM callback.resolve(null) } + } @ReactMethod - fun add(data: ReadableArray?, insertBeforeIndex: Int, callback: Promise) = scope.launch { + fun add(data: ReadableArray?, insertBeforeIndex: Int, callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch try { - val tracks = readableArrayToTrackList(data); + // Track conversion is O(queue) work (Bundle parsing, Uri resolution) — + // keep it off the main thread so long queues don't freeze the UI/ANR. + val tracks = withContext(Dispatchers.Default) { + readableArrayToTrackList(data).map { it.toAudioItem() } + }; if (insertBeforeIndex < -1 || insertBeforeIndex > musicService.tracks.size) { callback.reject("index_out_of_bounds", "The track index is out of bounds") return@launch } val index = if (insertBeforeIndex == -1) musicService.tracks.size else insertBeforeIndex - musicService.add( + musicService.addPrepared( tracks, index ) @@ -283,9 +293,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM rejectWithException(callback, exception) } } + } @ReactMethod - fun load(data: ReadableMap?, callback: Promise) = scope.launch { + fun load(data: ReadableMap?, callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch if (data == null) { callback.resolve(null) @@ -299,16 +310,18 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM callback.reject("invalid_track_object", "Track was not a dictionary type") } } + } @ReactMethod - fun move(fromIndex: Int, toIndex: Int, callback: Promise) = scope.launch { + fun move(fromIndex: Int, toIndex: Int, callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch musicService.move(fromIndex, toIndex) callback.resolve(null) } + } @ReactMethod - fun remove(data: ReadableArray?, callback: Promise) = scope.launch { + fun remove(data: ReadableArray?, callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch val inputIndexes = Arguments.toList(data) if (inputIndexes != null) { @@ -329,9 +342,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM } callback.resolve(null) } + } @ReactMethod - fun updateMetadataForTrack(index: Int, map: ReadableMap?, callback: Promise) = + fun updateMetadataForTrack(index: Int, map: ReadableMap?, callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch @@ -346,9 +360,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM callback.resolve(null) } } + } @ReactMethod - fun updateNowPlayingMetadata(map: ReadableMap?, callback: Promise) = scope.launch { + fun updateNowPlayingMetadata(map: ReadableMap?, callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch if (musicService.tracks.isEmpty()) @@ -362,9 +377,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM callback.resolve(null) } + } @ReactMethod - fun clearNowPlayingMetadata(callback: Promise) = scope.launch { + fun clearNowPlayingMetadata(callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch if (musicService.tracks.isEmpty()) @@ -373,17 +389,19 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM musicService.clearNotificationMetadata() callback.resolve(null) } + } @ReactMethod - fun removeUpcomingTracks(callback: Promise) = scope.launch { + fun removeUpcomingTracks(callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch musicService.removeUpcomingTracks() callback.resolve(null) } + } @ReactMethod - fun skip(index: Int, initialTime: Float, callback: Promise) = scope.launch { + fun skip(index: Int, initialTime: Float, callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch musicService.skip(index) @@ -394,9 +412,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM callback.resolve(null) } + } @ReactMethod - fun skipToNext(initialTime: Float, callback: Promise) = scope.launch { + fun skipToNext(initialTime: Float, callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch musicService.skipToNext() @@ -407,9 +426,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM callback.resolve(null) } + } @ReactMethod - fun skipToPrevious(initialTime: Float, callback: Promise) = scope.launch { + fun skipToPrevious(initialTime: Float, callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch musicService.skipToPrevious() @@ -420,9 +440,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM callback.resolve(null) } + } @ReactMethod - fun reset(callback: Promise) = scope.launch { + fun reset(callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch musicService.stop() @@ -431,188 +452,224 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM callback.resolve(null) } + } @ReactMethod - fun play(callback: Promise) = scope.launch { + fun play(callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch musicService.play() callback.resolve(null) } + } @ReactMethod - fun pause(callback: Promise) = scope.launch { + fun pause(callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch musicService.pause() callback.resolve(null) } + } + + @ReactMethod + fun setPauseAtEndOfItem(enabled: Boolean, callback: Promise) { scope.launch { + if (verifyServiceBoundOrReject(callback)) return@launch + + musicService.setPauseAtEndOfItem(enabled) + callback.resolve(null) + } + } @ReactMethod - fun stop(callback: Promise) = scope.launch { + fun stop(callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch musicService.stop() callback.resolve(null) } + } @ReactMethod - fun seekTo(seconds: Float, callback: Promise) = scope.launch { + fun seekTo(seconds: Float, callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch musicService.seekTo(seconds) callback.resolve(null) } + } @ReactMethod - fun seekBy(offset: Float, callback: Promise) = scope.launch { + fun seekBy(offset: Float, callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch musicService.seekBy(offset) callback.resolve(null) } + } @ReactMethod - fun retry(callback: Promise) = scope.launch { + fun retry(callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch musicService.retry() callback.resolve(null) } + } @ReactMethod - fun setVolume(volume: Float, callback: Promise) = scope.launch { + fun setVolume(volume: Float, callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch musicService.setVolume(volume) callback.resolve(null) } + } @ReactMethod - fun getVolume(callback: Promise) = scope.launch { + fun getVolume(callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch callback.resolve(musicService.getVolume()) } + } @ReactMethod - fun setRate(rate: Float, callback: Promise) = scope.launch { + fun setRate(rate: Float, callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch musicService.setRate(rate) callback.resolve(null) } + } @ReactMethod - fun getRate(callback: Promise) = scope.launch { + fun getRate(callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch callback.resolve(musicService.getRate()) } + } @ReactMethod - fun setRepeatMode(mode: Int, callback: Promise) = scope.launch { + fun setRepeatMode(mode: Int, callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch musicService.setRepeatMode(RepeatMode.fromOrdinal(mode)) callback.resolve(null) } + } @ReactMethod - fun getRepeatMode(callback: Promise) = scope.launch { + fun getRepeatMode(callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch callback.resolve(musicService.getRepeatMode().ordinal) } + } @ReactMethod - fun setPlayWhenReady(playWhenReady: Boolean, callback: Promise) = scope.launch { + fun setPlayWhenReady(playWhenReady: Boolean, callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch musicService.playWhenReady = playWhenReady callback.resolve(null) } + } @ReactMethod - fun getPlayWhenReady(callback: Promise) = scope.launch { + fun getPlayWhenReady(callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch callback.resolve(musicService.playWhenReady) } + } @ReactMethod - fun getTrack(index: Int, callback: Promise) = scope.launch { + fun getTrack(index: Int, callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch if (index >= 0 && index < musicService.tracks.size) { - callback.resolve(Arguments.fromBundle(musicService.tracks[index].originalItem)) + callback.resolve(musicService.tracks[index].originalItem?.let { Arguments.fromBundle(it) }) } else { callback.resolve(null) } } + } @ReactMethod - fun getQueue(callback: Promise) = scope.launch { + fun getQueue(callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch - callback.resolve(Arguments.fromList(musicService.tracks.map { it.originalItem })) + // Clone on main (the service mutates originalItem there), marshal off-main. + val bundles = musicService.tracks.map { track -> track.originalItem?.let(::Bundle) } + callback.resolve(withContext(Dispatchers.Default) { Arguments.fromList(bundles) }) + } } @ReactMethod - fun setQueue(data: ReadableArray?, callback: Promise) = scope.launch { + fun setQueue(data: ReadableArray?, callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch try { + val tracks = withContext(Dispatchers.Default) { + readableArrayToTrackList(data).map { it.toAudioItem() } + } musicService.clear() - musicService.add(readableArrayToTrackList(data)) + musicService.addPrepared(tracks, 0) callback.resolve(null) } catch (exception: Exception) { rejectWithException(callback, exception) } } + } @ReactMethod - fun getActiveTrackIndex(callback: Promise) = scope.launch { + fun getActiveTrackIndex(callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch callback.resolve( if (musicService.tracks.isEmpty()) null else musicService.getCurrentTrackIndex() ) } + } @ReactMethod - fun getActiveTrack(callback: Promise) = scope.launch { + fun getActiveTrack(callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch callback.resolve( if (musicService.tracks.isEmpty()) null - else Arguments.fromBundle( - musicService.tracks[musicService.getCurrentTrackIndex()].originalItem - ) + else musicService.tracks[musicService.getCurrentTrackIndex()].originalItem + ?.let { Arguments.fromBundle(it) } ) } + } @ReactMethod - fun getDuration(callback: Promise) = scope.launch { + fun getDuration(callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch callback.resolve(musicService.getDurationInSeconds()) } + } @ReactMethod - fun getBufferedPosition(callback: Promise) = scope.launch { + fun getBufferedPosition(callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch callback.resolve(musicService.getBufferedPositionInSeconds()) } + } @ReactMethod - fun getPosition(callback: Promise) = scope.launch { + fun getPosition(callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch callback.resolve(musicService.getPositionInSeconds()) } + } @ReactMethod - fun getProgress(callback: Promise) = scope.launch { + fun getProgress(callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch var bundle = Bundle() bundle.putDouble("duration", musicService.getDurationInSeconds()); @@ -620,10 +677,12 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM bundle.putDouble("buffered", musicService.getBufferedPositionInSeconds()); callback.resolve(Arguments.fromBundle(bundle)) } + } @ReactMethod - fun getPlaybackState(callback: Promise) = scope.launch { + fun getPlaybackState(callback: Promise) { scope.launch { if (verifyServiceBoundOrReject(callback)) return@launch callback.resolve(Arguments.fromBundle(musicService.getPlayerStateBundle(musicService.state))) } + } } diff --git a/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt b/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt index afa6b0f..87af67c 100644 --- a/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt +++ b/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt @@ -9,6 +9,7 @@ import android.os.Binder import android.os.Build import android.os.Bundle import android.os.IBinder +import android.os.Trace import android.support.v4.media.RatingCompat import androidx.annotation.MainThread import androidx.core.app.NotificationCompat @@ -300,6 +301,18 @@ class MusicService : HeadlessJsTaskService() { player.add(items, atIndex) } + /** + * Track -> AudioItem construction is safe off the player looper and can be + * noticeable even for a bounded window. MusicModule prepares these on + * Dispatchers.Default; only the actual Media3 playlist mutation stays here. + */ + @MainThread + fun addPrepared(items: List, atIndex: Int) { + tracePlaylistMutation("AstraQueue.rntpAdd") { + player.add(items, atIndex) + } + } + @MainThread fun load(track: Track) { player.load(track.toAudioItem()) @@ -307,7 +320,9 @@ class MusicService : HeadlessJsTaskService() { @MainThread fun move(fromIndex: Int, toIndex: Int) { - player.move(fromIndex, toIndex); + tracePlaylistMutation("AstraQueue.rntpMove") { + player.move(fromIndex, toIndex); + } } @MainThread @@ -317,12 +332,25 @@ class MusicService : HeadlessJsTaskService() { @MainThread fun remove(indexes: List) { - player.remove(indexes) + tracePlaylistMutation("AstraQueue.rntpRemove") { + player.remove(indexes) + } } @MainThread fun clear() { - player.clear() + tracePlaylistMutation("AstraQueue.rntpClear") { + player.clear() + } + } + + private inline fun tracePlaylistMutation(name: String, block: () -> T): T { + Trace.beginSection(name) + return try { + block() + } finally { + Trace.endSection() + } } @MainThread @@ -335,6 +363,10 @@ class MusicService : HeadlessJsTaskService() { player.pause() } + fun setPauseAtEndOfItem(enabled: Boolean) { + player.setPauseAtEndOfItem(enabled) + } + @MainThread fun stop() { player.stop() @@ -741,7 +773,7 @@ class MusicService : HeadlessJsTaskService() { @MainThread private fun emit(event: String, data: Bundle? = null) { - reactNativeHost.reactInstanceManager.currentReactContext + (applicationContext as? com.facebook.react.ReactApplication)?.reactHost?.currentReactContext ?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) ?.emit(event, data?.let { Arguments.fromBundle(it) }) } @@ -751,7 +783,7 @@ class MusicService : HeadlessJsTaskService() { val payload = Arguments.createArray() data.forEach { payload.pushMap(Arguments.fromBundle(it)) } - reactNativeHost.reactInstanceManager.currentReactContext + (applicationContext as? com.facebook.react.ReactApplication)?.reactHost?.currentReactContext ?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) ?.emit(event, payload) }