Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package com.doublesymmetry.kotlinaudio.event

class EventHolder internal constructor(private val playerEventHolder: PlayerEventHolder) {
val customSchemeRequest
get() = playerEventHolder.customSchemeRequest

val audioItemTransition
get() = playerEventHolder.audioItemTransition

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import kotlinx.coroutines.launch
class PlayerEventHolder {
private val coroutineScope = MainScope()

private var _customSchemeRequest = MutableSharedFlow<CustomSchemeRequest>(1)
var customSchemeRequest = _customSchemeRequest.asSharedFlow()

private var _stateChange = MutableSharedFlow<AudioPlayerState>(1)
var stateChange = _stateChange.asSharedFlow()

Expand Down Expand Up @@ -61,6 +64,12 @@ class PlayerEventHolder {
*/
var onPlayerActionTriggeredExternally = _onPlayerActionTriggeredExternally.asSharedFlow()

internal fun updateCustomSchemeRequest(id: String, uri: String) {
coroutineScope.launch {
_customSchemeRequest.emit(CustomSchemeRequest(id, uri))
}
}

internal fun updateAudioPlayerState(state: AudioPlayerState) {
coroutineScope.launch {
_stateChange.emit(state)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.doublesymmetry.kotlinaudio.models

data class CustomSchemeRequest (
val id: String,
val uri: String
)

data class CustomSchemeResponse (
val id: String,
val newUri: String?,
val headerprops: MutableMap<String, String>?
)
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.ExoPlaybackException
import androidx.media3.session.legacy.RatingCompat
import com.doublesymmetry.kotlinaudio.event.PlayerEventHolder
import com.doublesymmetry.kotlinaudio.models.AudioItem
import com.doublesymmetry.kotlinaudio.models.AudioItemTransitionReason
import com.doublesymmetry.kotlinaudio.models.AudioPlayerState
import com.doublesymmetry.kotlinaudio.models.CustomSchemeResponse
import com.doublesymmetry.kotlinaudio.models.MediaSessionCallback
import com.doublesymmetry.kotlinaudio.models.PlayWhenReadyChangeData
import com.doublesymmetry.kotlinaudio.models.PlaybackError
Expand All @@ -46,6 +48,7 @@ abstract class BaseAudioPlayer internal constructor(
) {

val exoPlayer: ExoPlayer
val mediaFactory: MediaFactory
val forwardingPlayer: InnerForwardingPlayer
val player: Player
get() {
Expand All @@ -69,6 +72,17 @@ abstract class BaseAudioPlayer internal constructor(
open val currentItem: AudioItem?
get() = exoPlayer.currentMediaItem?.let { AudioItem.fromMediaItem(it) }

internal val customSchemeResponses = mutableListOf<CustomSchemeResponse> ()

// If this is defined AND matches the beginning of an AudioItem's audioUrl, each new connection
// request from exoplayer will emit a CustomSchemeRequest event and then block the connection
// until respondToCustomSchemeRequest() has been called with a matching id (or we time out).
// This mechanism enables users to rewrite the url and provide headers on a per-connection
// basis, for instance for auth purposes.
var customUrlPrefix: String?
get() = mediaFactory.customUrlPrefix
set(v) { mediaFactory.customUrlPrefix = v }

var playbackError: PlaybackError? = null
var playerState: AudioPlayerState = AudioPlayerState.IDLE
private set(value) {
Expand Down Expand Up @@ -163,11 +177,13 @@ abstract class BaseAudioPlayer internal constructor(

val renderer = DefaultRenderersFactory(context)
renderer.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER)
mediaFactory = MediaFactory(context, cache, playerEventHolder, customSchemeResponses)

exoPlayer = ExoPlayer
.Builder(context)
.setRenderersFactory(renderer)
.setHandleAudioBecomingNoisy(options.handleAudioBecomingNoisy)
.setMediaSourceFactory(MediaFactory(context, cache))
.setMediaSourceFactory(mediaFactory)
.setWakeMode(setWakeMode(options.wakeMode))
.apply {
setLoadControl(setupBuffer(options.bufferOptions))
Expand Down Expand Up @@ -282,6 +298,10 @@ abstract class BaseAudioPlayer internal constructor(
exoPlayer.seekTo(positionMs)
}

fun respondToCustomSchemeRequest(id: String, newUri: String?, headerprops: MutableMap<String, String>?) {
customSchemeResponses.add(CustomSchemeResponse(id, newUri, headerprops))
}

@UnstableApi
inner class InnerPlayerListener : Listener {

Expand Down Expand Up @@ -430,12 +450,20 @@ abstract class BaseAudioPlayer internal constructor(
}

override fun onPlayerError(error: PlaybackException) {
val detailMessage: String? = if(error is ExoPlaybackException) {
when (error.type){
ExoPlaybackException.TYPE_SOURCE -> error.sourceException.message
ExoPlaybackException.TYPE_RENDERER -> error.rendererException.message
ExoPlaybackException.TYPE_UNEXPECTED -> error.unexpectedException.message
else -> null
}
} else { null }
val _playbackError = PlaybackError(
error.errorCodeName
.replace("ERROR_CODE_", "")
.lowercase(Locale.getDefault())
.replace("_", "-"),
error.message
error.message + " - " + (detailMessage?:"")
)
playerEventHolder.updatePlaybackError(_playbackError)
playbackError = _playbackError
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.datasource.RawResourceDataSource
import androidx.media3.datasource.ResolvingDataSource
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.exoplayer.dash.DashMediaSource
Expand All @@ -25,19 +26,27 @@ import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
import androidx.media3.extractor.DefaultExtractorsFactory
import com.doublesymmetry.kotlinaudio.event.PlayerEventHolder
import com.doublesymmetry.kotlinaudio.utils.isUriLocalFile
import com.doublesymmetry.kotlinaudio.models.CustomSchemeResponse
import okio.IOException
import java.util.UUID


@OptIn(UnstableApi::class)
class MediaFactory (
private val context: Context,
private val cache: SimpleCache?
private val cache: SimpleCache?,
private val playerEventHolder: PlayerEventHolder,
private val customSchemeResponses: MutableList<CustomSchemeResponse>
) : MediaSource.Factory {

companion object {
private const val DEFAULT_USER_AGENT = "react-native-track-player"
}

var customUrlPrefix: String? = null

private val mediaFactory = DefaultMediaSourceFactory(context)

override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory {
Expand All @@ -64,13 +73,14 @@ class MediaFactory (
// HACK: why are these capitalized?
val resourceType = mediaItem.mediaMetadata.extras?.getString("type")?.lowercase()
val uri = Uri.parse(mediaItem.mediaMetadata.extras?.getString("uri")!!)
val customPrefix = customUrlPrefix
val factory: DataSource.Factory = when {
resourceId != 0 && resourceId != null -> {
val raw = RawResourceDataSource(context)
raw.open(DataSpec(uri))
DataSource.Factory { raw }
}
isUriLocalFile(uri) -> {
((customPrefix == null) || !uri.toString().startsWith(customPrefix)) && isUriLocalFile(uri) -> {
DefaultDataSource.Factory(context)
}
else -> {
Expand All @@ -83,7 +93,42 @@ class MediaFactory (
}
}

enableCaching(tempFactory)
val httpFactory = enableCaching(tempFactory)

// If the uri matches our current custom scheme prefix we'll use ResolvingDataSource so that we can override
// each new connection to the file. Otherwise we'll just use DefaultHttpDataSource directly.
val returnFactory = if ((customPrefix == null) || !uri.toString().startsWith(customPrefix)) httpFactory else {
val resolver = ResolvingDataSource.Resolver { dataSpec ->
val reqId = UUID.randomUUID().toString()
// Emit event about the new request
playerEventHolder.updateCustomSchemeRequest(reqId, dataSpec.uri.toString())

var loopCount = 0
var response: CustomSchemeResponse? = null

// We'll time out and throw an exception after (waitMs * waitIterations) milliseconds.
val waitMs: Long = 100
val waitIterations = 600

while(response == null && loopCount < waitIterations){
Thread.sleep(waitMs)
try {
response = customSchemeResponses.first{ it.id == reqId }
customSchemeResponses.remove(response)
} catch (e: NoSuchElementException) {
loopCount++
}
}
if(response == null) throw IOException("Custom scheme request timed out while waiting for response")

val headers = if (response.headerprops != null) response.headerprops!! else mapOf<String,String>()
val url = if (response.newUri != null) response.newUri!! else dataSpec.uri.toString()
dataSpec.withAdditionalHeaders((headers)).withUri(Uri.parse(url))
}
ResolvingDataSource.Factory(httpFactory, resolver)
}

returnFactory
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ class MusicEvents(private val reactContext: ReactContext) : BroadcastReceiver()

// Other
const val PLAYER_ERROR = "player-error"
const val CUSTOM_SCHEME_REQUEST_RECEVIED = "custom-scheme-request-received"
const val CONNECTOR_CONNECTED = "android-controller-connected"
const val CONNECTOR_DISCONNECTED = "android-controller-disconnected"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,20 @@ class MusicModule(reactContext: ReactApplicationContext) : NativeTrackPlayerSpec
callback.resolve(null)
}

override fun respondToCustomSchemeRequest(data: ReadableMap?, callback: Promise) = launchInScope {
if (verifyServiceBoundOrReject(callback)) return@launchInScope

val bundle = Arguments.toBundle(data)
val id = bundle?.getString("id")
if(id is String){
musicService.respondToCustomSchemeRequest(bundle)
callback.resolve(null)
} else {
callback.reject("missing_argument", "Id is required when responding to custom scheme requests")
}

}

override fun setVolume(volume: Double, callback: Promise) = launchInScope {
if (verifyServiceBoundOrReject(callback)) return@launchInScope

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ class MusicService : HeadlessJsMediaService() {
private lateinit var fakePlayer: ExoPlayer
private lateinit var mediaSession: MediaLibrarySession
private var progressUpdateJob: Job? = null
private var previousProgressBundle: Bundle? = null
private var sessionCommands: SessionCommands? = null
private var playerCommands: Player.Commands? = null
private var customLayout: List<CommandButton> = listOf()
Expand Down Expand Up @@ -206,7 +207,10 @@ class MusicService : HeadlessJsMediaService() {
handleAudioFocus = playerOptions?.getBoolean(AUTO_HANDLE_INTERRUPTIONS) ?: true,
interceptPlayerActionsTriggeredExternally = true,
skipSilence = playerOptions?.getBoolean(SKIP_SILENCE) ?: false,
wakeMode = playerOptions?.getInt(WAKE_MODE, 0) ?: 0
// Hardcode wake mode "Network", just like we did in the KotlinAudio dependency for RNTP 4.x
// TODO: Switch to doing this from the model code once the option is exposed all the way up to TypeScript.
wakeMode = 2
// wakeMode = playerOptions?.getInt(WAKE_MODE, 0) ?: 0
)
player = QueuedAudioPlayer(this@MusicService, options)
fakePlayer.release()
Expand Down Expand Up @@ -240,6 +244,11 @@ class MusicService : HeadlessJsMediaService() {
androidOptions?.getBoolean(PAUSE_ON_INTERRUPTION_KEY) ?: false
player.shuffleMode = androidOptions?.getBoolean(SHUFFLE_KEY) ?: false

options.getString("customUrlPrefix")?.let {
// Explicitly setting an empty prefix string disables it
player.customUrlPrefix = if (it.isEmpty()) null else it
}

// setup progress update events if configured
progressUpdateJob?.cancel()
val updateInterval =
Expand Down Expand Up @@ -322,8 +331,17 @@ class MusicService : HeadlessJsMediaService() {
@MainThread
private fun progressUpdateEventFlow(interval: Double) = flow {
while (true) {
if (player.isPlaying) {
val bundle = progressUpdateEvent()
val bundle = progressUpdateEvent()
val prevBundle = previousProgressBundle
// In some cases we want progress updates even when audio is not playing.
// Ref: https://gitlab.fusetools.com/suide/suide/-/issues/9201
val bundleHasChanged = (prevBundle !== null) && (
// We only care about position and duration.
!(prevBundle.get(POSITION_KEY)?.equals(bundle.get(POSITION_KEY)) ?: false ) ||
!(prevBundle.get(DURATION_KEY)?.equals(bundle.get(DURATION_KEY)) ?: false ))

previousProgressBundle = bundle
if (player.isPlaying || bundleHasChanged) {
emit(bundle)
}

Expand Down Expand Up @@ -500,6 +518,24 @@ class MusicService : HeadlessJsMediaService() {
updateMetadataForTrack(player.currentIndex, bundle)
}

@MainThread
fun respondToCustomSchemeRequest(bundle: Bundle) {
val id = bundle.getString("id")
if(id == null) return
val newUri = bundle.getString("newUri")
val headers = bundle.getBundle("headerprops")
var headerprops: MutableMap<String, String>? = null

if(headers != null) {
headerprops = HashMap()
for (h in headers.keySet()) {
headerprops!![h] = headers.getString(h)!!
}
}

player.respondToCustomSchemeRequest(id, newUri, headerprops)
}

private fun emitPlaybackTrackChangedEvents(
previousIndex: Int?,
oldPosition: Double
Expand Down Expand Up @@ -657,6 +693,17 @@ class MusicService : HeadlessJsMediaService() {
emit(MusicEvents.PLAYBACK_ERROR, getPlaybackErrorBundle())
}
}

scope.launch {
event.customSchemeRequest.collect {
val bundle = Bundle().apply {
putString("id", it.id)
putString("uri", it.uri)
}

emit(MusicEvents.CUSTOM_SCHEME_REQUEST_RECEVIED, bundle)
}
}
}

private fun getPlaybackErrorBundle(): Bundle {
Expand Down
4 changes: 4 additions & 0 deletions ios/TrackPlayer.mm
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,10 @@ - (void)updateOptions:(nonnull NSDictionary *)options resolve:(nonnull RCTPromis
[nativeTrackPlayer updateOptions:options resolver:resolve rejecter:reject];
}

- (void)respondToCustomSchemeRequest:(nonnull NSDictionary *)response resolve:(nonnull RCTPromiseResolveBlock)resolve reject:(nonnull RCTPromiseRejectBlock)reject {
[nativeTrackPlayer respondToCustomSchemeRequest:response resolver:resolve rejecter:reject];
}

// event listeners
- (void)sendEvent:(NSString *)name body:(id)body {
[super sendEventWithName:name body:body];
Expand Down
Loading