Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
b1406e9
Drop useMemo, useCallback in favor of react compiler
tvanlaerhoven Mar 18, 2026
5e4516f
Add mediaControl API
tvanlaerhoven Mar 18, 2026
e09eb61
Add usePlaylist hook
tvanlaerhoven Mar 18, 2026
7a2cdb4
Use MediaControlProxy
tvanlaerhoven Mar 23, 2026
e4930fd
Add MediaControlModule
tvanlaerhoven Mar 23, 2026
4fa365b
Drop separate QueueNavigator
tvanlaerhoven Mar 23, 2026
b6242eb
Drop custom pip actions
tvanlaerhoven Mar 23, 2026
67b50da
Update pipUtils
tvanlaerhoven Mar 24, 2026
915e557
Restructure proxy
tvanlaerhoven Mar 24, 2026
c10cf37
Update sources
tvanlaerhoven Mar 25, 2026
07e6a20
Add media control API for Web
tvanlaerhoven Mar 25, 2026
913322c
Update changelog
tvanlaerhoven Mar 25, 2026
910ff9d
Update adapter
tvanlaerhoven Mar 25, 2026
0376781
Remove use of deprecated properties
tvanlaerhoven Mar 25, 2026
3a8a530
Add iOS MediaControl module
wvanhaevre Mar 26, 2026
8316a0c
Add MediaControl debug flag
wvanhaevre Mar 26, 2026
aa8ec32
Bridge iOS setHandler method
wvanhaevre Mar 26, 2026
ea22df5
Receive the action
wvanhaevre Mar 26, 2026
88adad7
Add string conversion methods for MediaControlAction
wvanhaevre Mar 27, 2026
1954c71
Add MediaControlManager for iOS
wvanhaevre Mar 27, 2026
a21ed6e
instantiate mediaControlManager
wvanhaevre Mar 27, 2026
d2ae103
Store bridged actions as event emitting actionHandlers
wvanhaevre Mar 27, 2026
9655b03
Align pipConfig passing with other managers
wvanhaevre Mar 27, 2026
6bcb81b
Setup defaults for MediaControlConfig
wvanhaevre Mar 27, 2026
7063865
Align mediaControlConfig passing with other managers + use computed v…
wvanhaevre Mar 27, 2026
fe44d5a
Execute actionHandlers when defined.
wvanhaevre Mar 27, 2026
72050ee
Track control is enabled when actions handlers have been set, no othe…
wvanhaevre Mar 31, 2026
2890a2a
Stop on the fly check for live or in ad status when processing track …
wvanhaevre Mar 31, 2026
1d8df45
Update docs
wvanhaevre Mar 31, 2026
8381de3
Describe the correct MediaControlActions in the documentation
wvanhaevre Mar 31, 2026
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
### Added

- Added `useSystemCaptionStyle` flag to `PlayerConfiguration` on Android. When set to `true`, the player will apply the caption styles as configured in the system settings.
- Added the `MediaControl` API for controlling the media session and lock screen controls with custom handlers.

### Fixed

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ This section gives an overview of features, limitations and known issues:
- [Digital Rights Management (DRM)](./doc/drm.md)
- [Expo](./doc/expo.md)
- [Fullscreen presentation](./doc/fullscreen.md)
- [Media Control](./doc/mediacontrol.md)
- [Media Caching](./doc/media-caching.md)
- [Migrating to THEOplayer 9.x](./doc/migrating-to-react-native-theoplayer-9.md)
- [Migrating to THEOplayer 10.x🔥](./doc/migrating-to-react-native-theoplayer-10.md)
Expand Down
82 changes: 13 additions & 69 deletions android/src/main/java/com/theoplayer/ReactTHEOplayerContext.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,40 +34,24 @@ import com.theoplayer.android.api.millicast.MillicastIntegrationFactory
import com.theoplayer.android.api.player.Player
import com.theoplayer.android.api.player.RenderingTarget
import com.theoplayer.android.connector.mediasession.MediaSessionConnector
import com.theoplayer.android.connector.mediasession.MediaSessionListener
import com.theoplayer.audio.AudioBecomingNoisyManager
import com.theoplayer.audio.AudioFocusManager
import com.theoplayer.audio.BackgroundAudioConfig
import com.theoplayer.media.MediaControlProxy
import com.theoplayer.media.MediaPlaybackService
import com.theoplayer.media.MediaQueueNavigator
import com.theoplayer.media.MediaSessionConfig
import java.util.concurrent.atomic.AtomicBoolean

private const val TAG = "ReactTHEOplayerContext"

private const val ALLOWED_PLAYBACK_ACTIONS = (
PlaybackStateCompat.ACTION_PLAY_PAUSE or
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_SEEK_TO or
PlaybackStateCompat.ACTION_FAST_FORWARD or
PlaybackStateCompat.ACTION_REWIND or
PlaybackStateCompat.ACTION_SET_PLAYBACK_SPEED)

private const val ALLOWED_PLAY_PAUSE_ACTIONS = (
PlaybackStateCompat.ACTION_PLAY_PAUSE or
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE)

@Suppress("SimplifyBooleanWithConstants", "KotlinConstantConditions")
class ReactTHEOplayerContext private constructor(
private val reactContext: ThemedReactContext,
private val configAdapter: PlayerConfigAdapter
) {
private val mainHandler = Handler(Looper.getMainLooper())
private var isBound = AtomicBoolean()
private var binder: MediaPlaybackService.MediaPlaybackBinder? = null
private var mediaSessionConnector: MediaSessionConnector? = null

private var audioBecomingNoisyManager = AudioBecomingNoisyManager(reactContext) {
// Audio is about to become 'noisy' due to a change in audio outputs: pause the player
player.pause()
Expand All @@ -80,11 +64,14 @@ class ReactTHEOplayerContext private constructor(
field = value
}

var mediaSessionConfig: MediaSessionConfig = configAdapter.mediaSessionConfig()
private var mediaSessionConnector: MediaSessionConnector? = null

private var mediaSessionConfig: MediaSessionConfig = configAdapter.mediaSessionConfig()
set(value) {
applyMediaSessionConfig(mediaSessionConnector, value)
field = value
}
var mediaControlProxy: MediaControlProxy = MediaControlProxy()

lateinit var playerView: THEOplayerView

Expand Down Expand Up @@ -140,19 +127,6 @@ class ReactTHEOplayerContext private constructor(
}
}

private val mediaSessionListener = object : MediaSessionListener() {
override fun onStop() {
binder?.stopForegroundService()
}

override fun onPlay() {
// Optionally seek to live, if configured.
if (mediaSessionConfig.seekToLiveOnResume && player.duration.isInfinite()) {
player.currentTime = Double.POSITIVE_INFINITY
}
}
}

private fun applyBackgroundPlaybackConfig(
config: BackgroundAudioConfig,
prevConfig: BackgroundAudioConfig?
Expand Down Expand Up @@ -184,21 +158,6 @@ class ReactTHEOplayerContext private constructor(
}
}

private fun applyAllowedMediaControls() {
// Reduce allowed set of remote control playback actions for ads & live streams.
val isLive = player.duration.isInfinite()
val isInAd = player.ads.isPlaying
mediaSessionConnector?.enabledPlaybackActions = when {
// Allow trick-play for live events if configured
isLive && mediaSessionConfig.allowLivePlayPause -> ALLOWED_PLAY_PAUSE_ACTIONS
isLive && !mediaSessionConfig.allowLivePlayPause -> 0
// Do not allow playback actions during ad play-out
isInAd -> 0

else -> ALLOWED_PLAYBACK_ACTIONS
}
}

private fun bindMediaPlaybackService() {
// Bind to an existing service, if available
// A bound service runs only as long as another application component is bound to it.
Expand Down Expand Up @@ -279,15 +238,8 @@ class ReactTHEOplayerContext private constructor(
// Destroy any existent media session
mediaSessionConnector?.destroy()

// Create and initialize the media session
val mediaSession = MediaSessionCompat(reactContext, TAG)

// Do not let MediaButtons restart the player when media session is not active.
// https://developer.android.com/media/legacy/media-buttons#restarting-inactive-mediasessions
mediaSession.setMediaButtonReceiver(null)

// Create a MediaSessionConnector and attach the THEOplayer instance.
mediaSessionConnector = MediaSessionConnector(mediaSession).also {
mediaSessionConnector = MediaSessionConnector(MediaSessionCompat(reactContext, TAG)).also {
applyMediaSessionConfig(it, mediaSessionConfig)
}
}
Expand All @@ -297,30 +249,26 @@ class ReactTHEOplayerContext private constructor(
config: MediaSessionConfig
) {
connector?.apply {
mediaControlProxy.detach()

debug = BuildConfig.LOG_MEDIASESSION_EVENTS
removeListener(mediaSessionListener)

player = this@ReactTHEOplayerContext.player

// Set mediaSession active and ready to receive media button events, but not if the player
// is backgrounded.
setActive(!isHostPaused && BuildConfig.EXTENSION_MEDIASESSION && config.mediaSessionEnabled)

skipForwardInterval = config.skipForwardInterval
skipBackwardsInterval = config.skipBackwardInterval

// Pass metadata from source description
setMediaSessionMetadata(player?.source)

// Do not let MediaButtons restart the player when media session is not active.
// https://developer.android.com/media/legacy/media-buttons#restarting-inactive-mediasessions
this.mediaSession.setMediaButtonReceiver(null)
mediaSession.setMediaButtonReceiver(null)

// Install a queue navigator, but only if we want to handle skip buttons.
if (mediaSessionConfig.convertSkipToSeek) {
queueNavigator = MediaQueueNavigator(mediaSessionConfig)
}
addListener(mediaSessionListener)
// Route all media control actions through the MediaControlProxy, which will decide whether to
// invoke the action or not.
mediaControlProxy.attach(player, this, binder, config)
}
}

Expand Down Expand Up @@ -390,27 +338,23 @@ class ReactTHEOplayerContext private constructor(
private val onSourceChange = EventListener<SourceChangeEvent> {
mediaSessionConnector?.setMediaSessionMetadata(player.source)
binder?.updateNotification()
applyAllowedMediaControls()
}

private val onLoadedMetadata = EventListener<LoadedMetadataEvent> {
binder?.updateNotification()
applyAllowedMediaControls()
}

private val onPlay = EventListener<PlayEvent> {
if (BuildConfig.USE_PLAYBACK_SERVICE && isBackgroundAudioEnabled) {
bindMediaPlaybackService()
}
binder?.updateNotification(PlaybackStateCompat.STATE_PLAYING)
applyAllowedMediaControls()
audioBecomingNoisyManager.setEnabled(true)
audioFocusManager?.requestAudioFocus()
}

private val onPause = EventListener<PauseEvent> {
binder?.updateNotification(PlaybackStateCompat.STATE_PAUSED)
applyAllowedMediaControls()
audioBecomingNoisyManager.setEnabled(false)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import com.theoplayer.cache.CacheModule
import com.theoplayer.drm.ContentProtectionModule
import com.theoplayer.cast.CastModule
import com.theoplayer.broadcast.EventBroadcastModule
import com.theoplayer.media.MediaControlModule
import com.theoplayer.player.PlayerModule
import com.theoplayer.theolive.THEOliveModule
import com.theoplayer.theoads.THEOadsModule
Expand All @@ -26,6 +27,7 @@ class ReactTHEOplayerPackage : BaseReactPackage() {
EventBroadcastModule.NAME -> EventBroadcastModule(reactContext)
THEOliveModule.NAME -> THEOliveModule(reactContext)
THEOadsModule.NAME -> THEOadsModule(reactContext)
MediaControlModule.NAME -> MediaControlModule(reactContext)
else -> null
}
}
Expand All @@ -45,6 +47,7 @@ class ReactTHEOplayerPackage : BaseReactPackage() {
EventBroadcastModule.NAME to EventBroadcastModule.INFO,
THEOliveModule.NAME to THEOliveModule.INFO,
THEOadsModule.NAME to THEOadsModule.INFO,
MediaControlModule.NAME to MediaControlModule.INFO,
)
}
}
Expand Down
79 changes: 79 additions & 0 deletions android/src/main/java/com/theoplayer/media/MediaControlModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package com.theoplayer.media

import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.module.model.ReactModuleInfo
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.modules.core.DeviceEventManagerModule
import com.theoplayer.ReactTHEOplayerView
import com.theoplayer.util.ViewResolver

private const val PROP_TAG = "tag"
private const val PROP_ACTION = "action"

enum class MediaControlAction(val propName: String) {
PLAY("play"),
PAUSE("pause"),
SKIP_TO_NEXT("skipToNext"),
SKIP_TO_PREVIOUS("skipToPrevious");

companion object {
private val map = entries.associateBy(MediaControlAction::propName)
fun fromPropName(propName: String): MediaControlAction? = map[propName]
}
}

@Suppress("unused")
@ReactModule(name = MediaControlModule.NAME)
class MediaControlModule(context: ReactApplicationContext) : ReactContextBaseJavaModule(context) {
companion object {
const val NAME = "THEORCTMediaControlModule"
val INFO = ReactModuleInfo(
name = NAME,
className = NAME,
canOverrideExistingModule = false,
needsEagerInit = false,
isCxxModule = false,
isTurboModule = false,
)
const val MEDIA_CONTROL_EVENT = "MediaControlEvent"
}

private val viewResolver: ViewResolver = ViewResolver(context)

override fun getName(): String {
return NAME
}

override fun getConstants(): Map<String, Any> {
return mapOf("MEDIA_CONTROL_EVENT" to MEDIA_CONTROL_EVENT)
}

/**
* Register a handler for a media control action. Instead of storing the Callback, use event emitter for multiple notifications.
* When the action occurs, call sendEvent to notify JS listeners.
*/
@ReactMethod
fun setHandler(tag: Int, action: String) {
val mediaControlAction = MediaControlAction.fromPropName(action)
if (mediaControlAction != null) {
viewResolver.resolveViewByTag(tag) { view: ReactTHEOplayerView? ->
view?.playerContext?.mediaControlProxy?.setHandler(mediaControlAction, {
sendEvent(MEDIA_CONTROL_EVENT, Arguments.createMap().apply {
putInt(PROP_TAG, tag)
putString(PROP_ACTION, action)
})
})
}
}
}

private fun sendEvent(eventName: String, params: WritableMap?) {
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit(eventName, params)
}
}
Loading
Loading