Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
04c688c
Drop useMemo, useCallback in favor of react compiler
tvanlaerhoven Mar 18, 2026
9a528f5
Add mediaControl API
tvanlaerhoven Mar 18, 2026
bac3fdf
Add usePlaylist hook
tvanlaerhoven Mar 18, 2026
a2bf37e
Use MediaControlProxy
tvanlaerhoven Mar 23, 2026
2cd406a
Add MediaControlModule
tvanlaerhoven Mar 23, 2026
45790c2
Drop separate QueueNavigator
tvanlaerhoven Mar 23, 2026
1590e97
Drop custom pip actions
tvanlaerhoven Mar 23, 2026
e3da546
Update pipUtils
tvanlaerhoven Mar 24, 2026
8ca631c
Restructure proxy
tvanlaerhoven Mar 24, 2026
2a34982
Update sources
tvanlaerhoven Mar 25, 2026
3b13562
Add media control API for Web
tvanlaerhoven Mar 25, 2026
e09f3d1
Update changelog
tvanlaerhoven Mar 25, 2026
c21b650
Update adapter
tvanlaerhoven Mar 25, 2026
7246a54
Remove use of deprecated properties
tvanlaerhoven Mar 25, 2026
3b3c55b
Add iOS MediaControl module
wvanhaevre Mar 26, 2026
239bc61
Add MediaControl debug flag
wvanhaevre Mar 26, 2026
2547589
Bridge iOS setHandler method
wvanhaevre Mar 26, 2026
c649dc9
Receive the action
wvanhaevre Mar 26, 2026
eda4d0c
Add string conversion methods for MediaControlAction
wvanhaevre Mar 27, 2026
200e88a
Add MediaControlManager for iOS
wvanhaevre Mar 27, 2026
3467306
instantiate mediaControlManager
wvanhaevre Mar 27, 2026
74d04e4
Store bridged actions as event emitting actionHandlers
wvanhaevre Mar 27, 2026
e53581d
Align pipConfig passing with other managers
wvanhaevre Mar 27, 2026
7c035ea
Setup defaults for MediaControlConfig
wvanhaevre Mar 27, 2026
8d065d5
Align mediaControlConfig passing with other managers + use computed v…
wvanhaevre Mar 27, 2026
d578566
Execute actionHandlers when defined.
wvanhaevre Mar 27, 2026
1c7295e
Merge branch 'develop' into feature/mediacontrol-api
wvanhaevre Mar 27, 2026
27e304c
Track control is enabled when actions handlers have been set, no othe…
wvanhaevre Mar 31, 2026
462d0ea
Stop on the fly check for live or in ad status when processing track …
wvanhaevre Mar 31, 2026
8e50c89
Update docs
wvanhaevre Mar 31, 2026
bb58463
Merge branch 'develop' into feature/mediacontrol-api
wvanhaevre Mar 31, 2026
27ecd32
Describe the correct MediaControlActions in the documentation
wvanhaevre Mar 31, 2026
93ba7c2
Merge branch 'feature/mediacontrol-api' of github.com:THEOplayer/reac…
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

## [Unreleased]

### Added

- Added the `MediaControl` API for controlling the media session and lock screen controls with custom handlers.

### Changed

- Deprecated `MediaControlConfiguration.convertSkipToSeek` in favor of custom `MediaControl` handlers.
- Deprecated `MediaControlConfiguration.seekToLiveOnResume` in favor of custom `MediaControl` handlers.
- Upgraded example app to React-Native v0.84.1.

## [10.13.0] - 26-03-27
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