From 04c688c08696aa10fe5b02687c81864c49ad3cf0 Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Wed, 18 Mar 2026 14:39:06 +0100 Subject: [PATCH 01/30] Drop useMemo, useCallback in favor of react compiler --- example/src/App.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/example/src/App.tsx b/example/src/App.tsx index f7d84ab91..fef1fb2da 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -99,7 +99,7 @@ export default function App() { // In PiP presentation mode on NewArch Android, there is an issue where SafeAreayView does not update the edges in time, // so explicitly disable them here. - const edges: Edges = useMemo(() => (presentationMode === PresentationMode.pip ? [] : ['left', 'top', 'right', 'bottom']), [presentationMode]); + const edges: Edges = presentationMode === PresentationMode.pip ? [] : ['left', 'top', 'right', 'bottom']; const onTheoAdsEvent = (event: TheoAdsEvent) => { console.log(event); @@ -108,7 +108,7 @@ export default function App() { } }; - const onPlayerReady = useCallback((player: THEOplayer) => { + const onPlayerReady = (player: THEOplayer) => { setPlayer(player); // optional debug logs player.addEventListener(PlayerEventType.SOURCE_CHANGE, console.log); @@ -144,7 +144,7 @@ export default function App() { }; console.log('THEOplayer is ready'); - }, []); + }; return ( /** From 9a528f5037a48054dc455be2f09e025f8f8cc6ee Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Wed, 18 Mar 2026 14:40:15 +0100 Subject: [PATCH 02/30] Add mediaControl API --- src/api/media/MediaControlAPI.ts | 20 ++++++++++++++++++ src/api/media/MediaControlConfiguration.ts | 4 ++++ src/api/media/barrel.ts | 1 + src/api/player/THEOplayer.ts | 6 ++++++ src/internal/adapter/THEOplayerAdapter.ts | 7 +++++++ .../media/MediaControlNativeAdapter.ts | 21 +++++++++++++++++++ 6 files changed, 59 insertions(+) create mode 100644 src/api/media/MediaControlAPI.ts create mode 100644 src/internal/adapter/media/MediaControlNativeAdapter.ts diff --git a/src/api/media/MediaControlAPI.ts b/src/api/media/MediaControlAPI.ts new file mode 100644 index 000000000..635c39ee3 --- /dev/null +++ b/src/api/media/MediaControlAPI.ts @@ -0,0 +1,20 @@ +import { THEOplayer } from 'react-native-theoplayer'; + +export type MediaControlHandler = (player: THEOplayer) => void; + +export enum MediaControlAction { + PLAY = 'play', + PAUSE = 'pause', + SKIP_TO_PREVIOUS = 'skipToPrevious', + SKIP_TO_NEXT = 'skipToNext', +} + +export interface MediaControlAPI { + /** + * Sets a handler for a media control action. + * + * @param action The media control action to set the handler for. + * @param handler The handler function that will be called when the specified media control action is triggered. The handler receives the THEOplayer instance as an argument. + */ + setHandler(action: MediaControlAction, handler: MediaControlHandler): void; +} diff --git a/src/api/media/MediaControlConfiguration.ts b/src/api/media/MediaControlConfiguration.ts index 6230de208..983545322 100644 --- a/src/api/media/MediaControlConfiguration.ts +++ b/src/api/media/MediaControlConfiguration.ts @@ -44,6 +44,8 @@ export interface MediaControlConfiguration { * @defaultValue `false` * * @platform ios,android + * + * @deprecated Use {@link MediaControlAPI} to handle `skipToPrevious` and `skipToNext` actions instead. */ readonly convertSkipToSeek?: boolean; @@ -63,6 +65,8 @@ export interface MediaControlConfiguration { * @defaultValue `false` * * @platform ios,android + * + * @deprecated Use {@link MediaControlAPI} to handle `play` action and seek to the live edge instead. */ readonly seekToLiveOnResume?: boolean; } diff --git a/src/api/media/barrel.ts b/src/api/media/barrel.ts index 99acda576..37f303eb9 100644 --- a/src/api/media/barrel.ts +++ b/src/api/media/barrel.ts @@ -1 +1,2 @@ export * from './MediaControlConfiguration'; +export * from './MediaControlAPI'; diff --git a/src/api/player/THEOplayer.ts b/src/api/player/THEOplayer.ts index a66289724..501e87fd7 100644 --- a/src/api/player/THEOplayer.ts +++ b/src/api/player/THEOplayer.ts @@ -15,6 +15,7 @@ import type { PlayerVersion } from './PlayerVersion'; import type { EventBroadcastAPI } from '../broadcast/EventBroadcastAPI'; import { TheoAdsAPI } from '../theoads/TheoAdsAPI'; import { TheoLiveAPI } from '../theolive/TheoLiveAPI'; +import { MediaControlAPI } from '../media/MediaControlAPI'; export type PreloadType = 'none' | 'metadata' | 'auto' | ''; @@ -294,4 +295,9 @@ export interface THEOplayer extends EventDispatcher { * @deprecated use {@link THEOplayer.theoLive} instead. */ readonly theolive: TheoLiveAPI; + + /** + * The API for media controls. + */ + readonly mediaControl?: MediaControlAPI; } diff --git a/src/internal/adapter/THEOplayerAdapter.ts b/src/internal/adapter/THEOplayerAdapter.ts index c746ad7bd..5f3b005b3 100644 --- a/src/internal/adapter/THEOplayerAdapter.ts +++ b/src/internal/adapter/THEOplayerAdapter.ts @@ -52,6 +52,7 @@ import { EventBroadcastAdapter } from './broadcast/EventBroadcastAdapter'; import { DefaultNativePlayerState } from './DefaultNativePlayerState'; import { THEOAdsNativeAdapter } from './theoads/THEOAdsNativeAdapter'; import { TheoLiveNativeAdapter } from './theolive/TheoLiveNativeAdapter'; +import { MediaControlNativeAdapter } from './media/MediaControlNativeAdapter'; const NativePlayerModule = NativeModules.THEORCTPlayerModule; @@ -64,6 +65,7 @@ export class THEOplayerAdapter extends DefaultEventDispatcher im private readonly _abrAdapter: AbrAdapter; private readonly _textTrackStyleAdapter: TextTrackStyleAdapter; private readonly _theoliveAdapter: TheoLiveNativeAdapter; + private readonly _mediaControlAdapter: MediaControlNativeAdapter; private _externalEventRouter: EventBroadcastAPI | undefined = undefined; private _playerVersion!: PlayerVersion; @@ -77,6 +79,7 @@ export class THEOplayerAdapter extends DefaultEventDispatcher im this._abrAdapter = new AbrAdapter(this._view); this._textTrackStyleAdapter = new TextTrackStyleAdapter(this._view); this._theoliveAdapter = new TheoLiveNativeAdapter(this._view); + this._mediaControlAdapter = new MediaControlNativeAdapter(this); this.addEventListeners(); } @@ -247,6 +250,10 @@ export class THEOplayerAdapter extends DefaultEventDispatcher im return this._theoliveAdapter; } + get mediaControl(): MediaControlNativeAdapter { + return this._mediaControlAdapter; + } + set autoplay(autoplay: boolean) { this._state.autoplay = autoplay; NativePlayerModule.setAutoplay(this._view.nativeHandle, autoplay); diff --git a/src/internal/adapter/media/MediaControlNativeAdapter.ts b/src/internal/adapter/media/MediaControlNativeAdapter.ts new file mode 100644 index 000000000..b0548c450 --- /dev/null +++ b/src/internal/adapter/media/MediaControlNativeAdapter.ts @@ -0,0 +1,21 @@ +import { THEOplayer, MediaControlAction, MediaControlAPI, MediaControlHandler } from 'react-native-theoplayer'; +import { NativeEventEmitter, NativeModules } from 'react-native'; + +export class MediaControlNativeAdapter implements MediaControlAPI { + private mediaControlEmitter = new NativeEventEmitter(NativeModules.THEORCTMediaControlModule); + private handlers: Map = new Map(); + + constructor(private readonly _player: THEOplayer) { + this.mediaControlEmitter.addListener('MediaControlEvent', (event) => { + const { tag, action } = event; + if (tag === this._player.nativeHandle) { + this.handlers.get(action)?.(this._player); + } + }); + } + + setHandler(action: MediaControlAction, handler: MediaControlHandler): void { + this.handlers.set(action, handler); + NativeModules.THEORCTMediaControlModule.setHandler(this._player.nativeHandle || -1, action); + } +} From bac3fdfe17c7f4e27f52f6dbf0e0e1cefb019e66 Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Wed, 18 Mar 2026 14:41:36 +0100 Subject: [PATCH 03/30] Add usePlaylist hook --- example/package-lock.json | 4 +- example/src/App.tsx | 16 +- example/src/{custom => assets}/sources.json | 251 ++++++++++++++------ example/src/custom/Source.ts | 1 + example/src/custom/SourceMenuButton.tsx | 35 ++- example/src/hooks/usePlaylist.ts | 100 ++++++++ 6 files changed, 310 insertions(+), 97 deletions(-) rename example/src/{custom => assets}/sources.json (67%) create mode 100644 example/src/hooks/usePlaylist.ts diff --git a/example/package-lock.json b/example/package-lock.json index 592e6bd47..8acee1534 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -51,7 +51,7 @@ } }, "..": { - "version": "10.9.0", + "version": "10.12.0", "license": "BSD-3-Clause-Clear", "dependencies": { "@theoplayer/cmcd-connector-web": "^1.4.0", @@ -13338,7 +13338,7 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/example/src/App.tsx b/example/src/App.tsx index fef1fb2da..d4386e629 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { useCallback, useMemo, useState } from 'react'; +import { useState } from 'react'; import { AirplayButton, AutoFocusGuide, @@ -36,7 +36,7 @@ import { THEOplayerView, } from 'react-native-theoplayer'; import { Platform, StatusBar, StyleSheet, useColorScheme, View } from 'react-native'; -import { SourceMenuButton, SOURCES } from './custom/SourceMenuButton'; +import { SourceMenuButton } from './custom/SourceMenuButton'; import { BackgroundAudioSubMenu } from './custom/BackgroundAudioSubMenu'; import { PiPSubMenu } from './custom/PipSubMenu'; import { MediaCacheDownloadButton } from './custom/MediaCacheDownloadButton'; @@ -44,7 +44,7 @@ import { MediaCacheMenuButton } from './custom/MediaCacheMenuButton'; import { MediaCachingTaskListSubMenu } from './custom/MediaCachingTaskListSubMenu'; import { RenderingTargetSubMenu } from './custom/RenderingTargetSubMenu'; import { AutoPlaySubMenu } from './custom/AutoPlaySubMenu'; -import { SafeAreaProvider, SafeAreaView, Edges } from 'react-native-safe-area-context'; +import { Edges, SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; import { usePresentationMode } from './hooks/usePresentationMode'; import { EzdrmFairplayContentProtectionIntegrationFactory, @@ -52,6 +52,9 @@ import { KeyOSDrmWidevineContentProtectionIntegrationFactory, } from '@theoplayer/react-native-drm'; import { ExtensionMenuButton } from './custom/ExtensionMenuButton'; +import { usePlaylist } from './hooks/usePlaylist'; +import SOURCES from './assets/sources.json'; +import { Source } from './custom/Source'; // Register Ezdrm Fairplay integration ContentProtectionRegistry.registerContentProtectionIntegration('customEzdrm', 'fairplay', new EzdrmFairplayContentProtectionIntegrationFactory()); @@ -96,6 +99,7 @@ export default function App() { const [player, setPlayer] = useState(undefined); const presentationMode = usePresentationMode(player); const isDarkMode = useColorScheme() === 'dark'; + const { currentSource } = usePlaylist(player, SOURCES as Source[]); // In PiP presentation mode on NewArch Android, there is an issue where SafeAreayView does not update the edges in time, // so explicitly disable them here. @@ -130,7 +134,7 @@ export default function App() { sdkVersions().then((versions) => console.log(`[theoplayer] ${JSON.stringify(versions, null, 4)}`)); player.autoplay = true; - player.source = SOURCES[0].source; + player.source = currentSource.source; player.backgroundAudioConfiguration = { enabled: true, @@ -173,7 +177,7 @@ export default function App() { {/*This is a custom menu for source selection.*/} - + {!Platform.isTV && ( <> @@ -238,7 +242,7 @@ export default function App() { const styles = StyleSheet.create({ container: { flex: 1, - // on iOS we cannot stretch an inline playerView to cover the whole screen, otherwise it assumes fullscreen presentationMode. + // on iOS, we cannot stretch an inline playerView to cover the whole screen, otherwise it assumes fullscreen presentationMode. marginHorizontal: Platform.select({ ios: 2, default: 0 }), alignItems: 'center', justifyContent: 'center', diff --git a/example/src/custom/sources.json b/example/src/assets/sources.json similarity index 67% rename from example/src/custom/sources.json rename to example/src/assets/sources.json index c0448af1d..5fbcb8d24 100644 --- a/example/src/custom/sources.json +++ b/example/src/assets/sources.json @@ -1,7 +1,8 @@ [ { - "name": "HLS - Sideloaded Chapters", + "name": "Sintel • HLS • Sideloaded Chapters", "os": ["ios", "android", "web"], + "needsLicense": false, "source": { "sources": [ { @@ -9,27 +10,31 @@ "type": "application/x-mpegurl" } ], + "poster": "https://cdn.theoplayer.com/video/sintel/poster.jpg", "metadata": { "title": "Sintel", - "subtitle": "HLS - Sideloaded Chapters", - "album": "React-Native THEOplayer demos", - "mediaUri": "https://theoplayer.com", - "displayIconUri": "https://cdn.theoplayer.com/video/sintel_old/poster.jpg", - "artist": "THEOplayer" + "subtitle": "HLS • Sideloaded Chapters", + "album": "OptiView React-Native demos", + "mediaUri": "https://optiview.dolby.com", + "displayIconUri": "https://cdn.theoplayer.com/video/sintel/poster.jpg", + "artist": "Dolby" }, - "textTracks": [{ - "kind": "chapters", - "src": "https://cdn.theoplayer.com/video/sintel/chapters.vtt", - "format": "webvtt", - "srclang": "en", - "label": "Chapters", - "default": true - }] + "textTracks": [ + { + "kind": "chapters", + "src": "https://cdn.theoplayer.com/video/sintel/chapters.vtt", + "format": "webvtt", + "srclang": "en", + "label": "Chapters", + "default": true + } + ] } }, { - "name": "DASH - Thumbnails in manifest", + "name": "Big Buck Bunny • DASH • Thumbnails", "os": ["android", "web"], + "needsLicense": false, "source": { "sources": [ { @@ -40,17 +45,18 @@ "poster": "https://cdn.theoplayer.com/video/big_buck_bunny/poster.jpg", "metadata": { "title": "Big Buck Bunny", - "subtitle": "DASH - Thumbnails in manifest", - "album": "React-Native THEOplayer demos", - "mediaUri": "https://theoplayer.com", + "subtitle": "DASH • Thumbnails", + "album": "OptiView React Native demos", + "mediaUri": "https://optiview.dolby.com", "displayIconUri": "https://cdn.theoplayer.com/video/big_buck_bunny/poster.jpg", - "artist": "THEOplayer" + "artist": "Dolby" } } }, { - "name": "DASH - Thumbnails Side-loaded", + "name": "Big Buck Bunny • DASH • Side-loaded Thumbnails", "os": ["android", "web"], + "needsLicense": false, "source": { "sources": [ { @@ -58,6 +64,15 @@ "type": "application/dash+xml" } ], + "poster": "https://cdn.theoplayer.com/video/big_buck_bunny/poster.jpg", + "metadata": { + "title": "Big Buck Bunny", + "subtitle": "DASH • Side-loaded Thumbnails", + "album": "OptiView React Native demos", + "mediaUri": "https://optiview.dolby.com", + "displayIconUri": "https://cdn.theoplayer.com/video/big_buck_bunny/poster.jpg", + "artist": "Dolby" + }, "textTracks": [ { "default": true, @@ -69,20 +84,31 @@ } }, { - "name": "DASH - VOD - Clear", + "name": "Sintel • DASH • Clear", "os": ["android", "web"], + "needsLicense": false, "source": { "sources": [ { "src": "https://cdn.theoplayer.com/video/dash/webvtt-embedded-in-isobmff/Manifest.mpd", "type": "application/dash+xml" } - ] + ], + "poster": "https://cdn.theoplayer.com/video/sintel/poster.jpg", + "metadata": { + "title": "Sintel", + "subtitle": "DASH • Clear", + "album": "OptiView React-Native demos", + "mediaUri": "https://optiview.dolby.com", + "displayIconUri": "https://cdn.theoplayer.com/video/sintel/poster.jpg", + "artist": "Dolby" + } } }, { - "name": "DASH - VOD - ezDRM (Widevine)", + "name": "Tears of Steel • DASH • ezDRM (Widevine)", "os": ["android", "web"], + "needsLicense": false, "source": { "sources": { "src": "https://cdn.theoplayer.com/video/dash/tos-dash-widevine/tos_h264_main.mpd", @@ -92,12 +118,22 @@ "licenseAcquisitionURL": "https://widevine-dash.ezdrm.com/proxy?pX=62448C" } } + }, + "poster": "https://cdn.theoplayer.com/video/dash/tos-dash-widevine/poster.png", + "metadata": { + "title": "Tears of Steel", + "subtitle": "DASH • ezDRM (Widevine)", + "album": "OptiView React-Native demos", + "mediaUri": "https://optiview.dolby.com", + "displayIconUri": "https://cdn.theoplayer.com/video/dash/tos-dash-widevine/poster.png", + "artist": "Dolby" } } }, { - "name": "DASH - VOD - keyOS (Widevine)", + "name": "Meridian • DASH • KeyOS (Widevine)", "os": ["android", "web"], + "needsLicense": true, "source": { "sources": { "src": "https://d2jl6e4h8300i8.cloudfront.net/netflix_meridian/4k-18.5!9/keyos-logo/g180-avc_a2.0-vbr-aac-128k/r30/dash-wv-pr/stream.mpd", @@ -111,21 +147,41 @@ "licenseAcquisitionURL": "https://widevine.keyos.com/api/v4/getLicense" } } + }, + "poster": "https://cdn.theoplayer.com/video/meridian_poster.jpg", + "metadata": { + "title": "Meridian", + "subtitle": "DASH • KeyOS (Widevine)", + "album": "OptiView React-Native demos", + "mediaUri": "https://optiview.dolby.com", + "displayIconUri": "https://cdn.theoplayer.com/video/meridian_poster.jpg", + "artist": "Dolby" } } }, { - "name": "DASH - Live - Clear", + "name": "LiveSim • DASH", "os": ["android", "web"], + "needsLicense": true, "source": { "sources": { "src": "https://livesim.dashif.org/livesim/chunkdur_1/ato_7/testpic4_8s/Manifest.mpd" + }, + "poster": "https://cdn.theoplayer.com/video/dash/test_video/poster.png", + "metadata": { + "title": "LiveSim", + "subtitle": "DASH", + "album": "OptiView React-Native demos", + "mediaUri": "https://optiview.dolby.com", + "displayIconUri": "https://cdn.theoplayer.com/video/dash/test_video/poster.png", + "artist": "Dolby" } } }, { - "name": "DASH - CSAI - Google IMA pre-roll", + "name": "Sintel • CSAI • Google IMA pre-roll", "os": ["android", "web"], + "needsLicense": false, "source": { "sources": [ { @@ -140,16 +196,26 @@ "src": "https://cdn.theoplayer.com/demos/ads/vast/dfp-preroll-no-skip.xml" } } - ] + ], + "poster": "https://cdn.theoplayer.com/video/sintel/poster.jpg", + "metadata": { + "title": "Sintel", + "subtitle": "CSAI • Google IMA pre-roll", + "album": "OptiView React-Native demos", + "mediaUri": "https://optiview.dolby.com", + "displayIconUri": "https://cdn.theoplayer.com/video/sintel/poster.jpg", + "artist": "Dolby" + } } }, { - "name": "DASH - CSAI - Google IMA mid-roll VAST", + "name": "Sintel • CSAI • Google IMA mid-roll VAST", "os": ["android", "web"], + "needsLicense": false, "source": { "sources": [ { - "src": "https://contentserver.prudentgiraffe.com/videos/dash/webvtt-embedded-in-isobmff/Manifest.mpd", + "src": "https://cdn.theoplayer.com/video/dash/webvtt-embedded-in-isobmff/Manifest.mpd", "type": "application/dash+xml" } ], @@ -161,16 +227,26 @@ }, "timeOffset": 10 } - ] + ], + "poster": "https://cdn.theoplayer.com/video/sintel/poster.jpg", + "metadata": { + "title": "Sintel", + "subtitle": "CSAI • Google IMA mid-roll VAST", + "album": "OptiView React-Native demos", + "mediaUri": "https://optiview.dolby.com", + "displayIconUri": "https://cdn.theoplayer.com/video/sintel/poster.jpg", + "artist": "Dolby" + } } }, { - "name": "DASH - CSAI - Google IMA mid-roll VMAP", + "name": "Sintel • CSAI • Google IMA mid-roll VMAP", "os": ["android", "web"], + "needsLicense": false, "source": { "sources": [ { - "src": "https://contentserver.prudentgiraffe.com/videos/dash/webvtt-embedded-in-isobmff/Manifest.mpd", + "src": "https://cdn.theoplayer.com/video/dash/webvtt-embedded-in-isobmff/Manifest.mpd", "type": "application/dash+xml" } ], @@ -181,12 +257,22 @@ "src": "https://contentserver.prudentgiraffe.com/ads/double-midroll-mergeable-adbreak.xml" } } - ] + ], + "poster": "https://cdn.theoplayer.com/video/sintel/poster.jpg", + "metadata": { + "title": "Sintel", + "subtitle": "CSAI • Google IMA mid-roll VMAP", + "album": "OptiView React-Native demos", + "mediaUri": "https://optiview.dolby.com", + "displayIconUri": "https://cdn.theoplayer.com/video/sintel/poster.jpg", + "artist": "Dolby" + } } }, { - "name": "HLS - VOD - Clear - The Venture Bros", + "name": "The Venture Bros • HLS • Clear", "os": ["ios", "android", "web"], + "needsLicense": false, "source": { "sources": { "src": "https://cdn.theoplayer.com/video/adultswim/clip.m3u8", @@ -196,9 +282,9 @@ "metadata": { "title": "The Venture Bros", "subtitle": "Adult Swim", - "album": "React-Native THEOplayer demos", - "displayIconUri": "https://cdn.theoplayer.com/react-native-theoplayer/temp/THEOPlayer-200x200.png", - "artist": "THEOplayer", + "album": "OptiView React Native demos", + "displayIconUri": "https://cdn.theoplayer.com/video/dolby_optiview.jpg", + "artist": "Dolby", "type": "tv-show", "releaseDate": "november 29th", "releaseYear": 1997 @@ -206,8 +292,9 @@ } }, { - "name": "THEOlive test", + "name": "THEOlive • demo", "os": ["web", "ios", "android"], + "needsLicense": true, "source": { "sources": [ { @@ -221,41 +308,47 @@ } ], "metadata": { - "title": "THEOlive NFL demo - test", - "mediaUri": "https://theoplayer.com", - "artist": "THEOplayer" + "title": "THEOlive • demo", + "mediaUri": "https://optiview.dolby.com", + "artist": "Dolby" } } }, { - "name": "HLS - VOD - Clear - DateRange Metadata", + "name": "Star Wars Episode VII HLS • Clear • DateRange Metadata", "os": ["ios", "web", "android"], + "needsLicense": false, "source": { "sources": { "src": "https://cdn.theoplayer.com/video/star_wars_episode_vii-the_force_awakens_official_comic-con_2015_reel_(2015)/index-daterange.m3u8", "type": "application/x-mpegurl" }, + "poster": "https://cdn.theoplayer.com/video/dolby_optiview.jpg", "metadata": { - "title": "Star Wars Episode VII - The Force Awakens Official Comic-Con 2015 Reel", - "displayIconUri": "https://cdn.theoplayer.com/react-native-theoplayer/temp/THEOPlayer-200x200.png" + "title": "Star Wars Episode VII • The Force Awakens Official Comic-Con 2015 Reel", + "subtitle": "Clear • DateRange Metadata", + "displayIconUri": "https://cdn.theoplayer.com/video/dolby_optiview.jpg", + "album": "OptiView React Native demos", + "artist": "Dolby" } } }, { - "name": "HLS - LIVE - Clear", + "name": "Tears of Steel • HLS • Live", "os": ["ios", "android", "web"], + "needsLicense": true, "source": { "sources": { "src": "https://ireplay.tv/test/blender.m3u8", "type": "application/x-mpegurl" }, - "poster": "https://cdn.theoplayer.com/react-native-theoplayer/temp/THEOPlayer-640x640.png", + "poster": "https://cdn.theoplayer.com/video/dolby_optiview.jpg", "metadata": { - "title": "Blender", - "subtitle": "Test stream", + "title": "Tears of Steel", + "subtitle": "HLS • Live", "album": "React-Native demos", - "displayIconUri": "https://cdn.theoplayer.com/react-native-theoplayer/temp/THEOPlayer-200x200.png", - "artist": "THEOplayer", + "displayIconUri": "https://cdn.theoplayer.com/video/dolby_optiview.jpg", + "artist": "Dolby", "type": "tv-show", "releaseDate": "november 30th", "releaseYear": 1997 @@ -263,44 +356,46 @@ } }, { - "name": "HLS - VOD - Clear - bipbop (id3 meta)", + "name": "BipBop • HLS • Live", "os": ["ios", "web"], + "needsLicense": true, "source": { "sources": { "src": "https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_16x9/bipbop_16x9_variant.m3u8", "type": "application/x-mpegurl" }, - "poster": "https://cdn.theoplayer.com/react-native-theoplayer/temp/THEOPlayer-1200x675.png", + "poster": "https://cdn.theoplayer.com/video/dolby_optiview.jpg", "metadata": { "title": "Bipbop", - "subtitle": "Bipbop Subtitle", "album": "Bipbop Album", - "displayIconUri": "https://cdn.theoplayer.com/react-native-theoplayer/temp/THEOPlayer-200x200.png", + "displayIconUri": "https://cdn.theoplayer.com/video/dolby_optiview.jpg", "artist": "Apple" } } }, { - "name": "HLS - VOD - Clear - elephants-dream (id3 meta)", + "name": "Elephants Dream • HLS • Clear", "os": ["ios", "web"], + "needsLicense": false, "source": { "sources": { "src": "https://cdn.theoplayer.com/video/elephants-dream/playlist-single-audio.m3u8", "type": "application/x-mpegurl" }, - "poster": "https://cdn.theoplayer.com/react-native-theoplayer/temp/THEOPlayer-1200x675.png", + "poster": "https://cdn.theoplayer.com/video/elephants-dream.png", "metadata": { "title": "Elephants Dream", "subtitle": "Elephants Dream Subtitle", "album": "Elephants Dream Album", - "displayIconUri": "https://cdn.theoplayer.com/react-native-theoplayer/temp/THEOPlayer-200x200.png", + "displayIconUri": "https://cdn.theoplayer.com/video/elephants-dream.png", "artist": "The elephant" } } }, { - "name": "HLS - CSAI - Google IMA pre-roll", + "name": "Elephants Dream • HLS • CSAI • Google IMA pre-roll", "os": ["ios", "web"], + "needsLicense": false, "source": { "sources": [ { @@ -314,12 +409,12 @@ "sources": "https://cdn.theoplayer.com/demos/ads/vast/dfp-preroll-no-skip.xml" } ], - "poster": "https://cdn.theoplayer.com/react-native-theoplayer/temp/THEOPlayer-1200x675.png", + "poster": "https://cdn.theoplayer.com/video/elephants-dream.png", "metadata": { "title": "Elephants Dream with Preroll", "subtitle": "Elephants Dream with Preroll Subtitle", "album": "Elephants Album", - "displayIconUri": "https://cdn.theoplayer.com/react-native-theoplayer/temp/THEOPlayer-200x200.png", + "displayIconUri": "https://cdn.theoplayer.com/video/elephants-dream.png", "artist": "The Dream" } } @@ -327,6 +422,7 @@ { "name": "HLS - SGAI (THEOads)", "os": ["web"], + "needsLicense": true, "source": { "sources": { "src": "https://cluster.dev.theostream.live/nfl-unified-channel/hls/k8s/live/scte35.isml/.m3u8", @@ -347,6 +443,7 @@ { "name": "HLS - CSAI - Google IMA mid-roll VAST", "os": ["ios", "web"], + "needsLicense": false, "source": { "sources": [ { @@ -363,18 +460,19 @@ "timeOffset": 10 } ], - "poster": "https://cdn.theoplayer.com/react-native-theoplayer/temp/THEOPlayer-1200x675.png", + "poster": "https://cdn.theoplayer.com/video/dolby_optiview.jpg", "metadata": { "title": "Elephants Dream with Midroll", "subtitle": "Elephants Dream with Midroll Subtitle", "album": "Ellies Album", - "displayIconUri": "https://cdn.theoplayer.com/react-native-theoplayer/temp/THEOPlayer-200x200.png", + "displayIconUri": "https://cdn.theoplayer.com/video/dolby_optiview.jpg", "artist": "Ellie" } } }, { "name": "HLS - CSAI - Google IMA VMAP", + "needsLicense": false, "os": ["ios", "web"], "source": { "sources": [ @@ -391,12 +489,12 @@ } } ], - "poster": "https://cdn.theoplayer.com/react-native-theoplayer/temp/THEOPlayer-1200x675.png", + "poster": "https://cdn.theoplayer.com/video/dolby_optiview.jpg", "metadata": { "title": "Elephants Dream with VMAP", "subtitle": "Elephants Dream with VMAP Subtitle", "album": "Ellies Album", - "displayIconUri": "https://cdn.theoplayer.com/react-native-theoplayer/temp/THEOPlayer-200x200.png", + "displayIconUri": "https://cdn.theoplayer.com/video/dolby_optiview.jpg", "artist": "Ellie" } } @@ -404,6 +502,7 @@ { "name": "DASH - SSAI - Google DAI", "os": ["android", "web"], + "needsLicense": true, "source": { "sources": { "type": "application/dash+xml", @@ -419,6 +518,7 @@ { "name": "HLS - SSAI - Google DAI - VOD", "os": ["ios", "web"], + "needsLicense": true, "source": { "sources": { "ssai": { @@ -433,6 +533,7 @@ { "name": "HLS - SSAI - Google DAI - LIVE", "os": ["ios", "web"], + "needsLicense": true, "source": { "sources": { "ssai": { @@ -446,6 +547,7 @@ { "name": "HLS - VOD/Thumbnails - Clear - Big Buck Bunny", "os": ["ios", "web"], + "needsLicense": false, "source": { "sources": { "src": "https://cdn.theoplayer.com/video/big_buck_bunny/big_buck_bunny.m3u8", @@ -474,6 +576,7 @@ { "name": "HLS - VOD - ezDRM (FairPlay)", "os": ["ios", "web"], + "needsLicense": true, "source": { "sources": { "src": "https://fps.ezdrm.com/demo/video/ezdrm.m3u8", @@ -491,6 +594,7 @@ { "name": "HLS - VOD - keyOS (FairPlay)", "os": ["ios", "web"], + "needsLicense": true, "source": { "sources": { "src": "https://d2jl6e4h8300i8.cloudfront.net/netflix_meridian/4k-19.5!9/keyos-logo/g180-avc_a2.0-vbr-aac-128k/r30/hls-fp/master.m3u8", @@ -511,6 +615,7 @@ { "name": "MP4 - Elephants Dream", "os": ["ios", "web", "android"], + "needsLicense": false, "source": { "sources": { "src": "https://cdn.theoplayer.com/video/elephants-dream.mp4" @@ -519,7 +624,8 @@ }, { "name": "MP3 - SoundHelix Song1", - "os": ["ios", "web"], + "os": ["ios", "web", "android"], + "needsLicense": true, "source": { "sources": { "src": "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3" @@ -529,6 +635,7 @@ { "name": "HLS - THEOads", "os": ["ios", "web", "android"], + "needsLicense": true, "source": { "sources": { "src": "https://cluster.dev.theostream.live/nfl-unified-channel/hls/k8s/live/scte35.isml/.m3u8", @@ -549,6 +656,7 @@ { "name": "Millicast", "os": ["ios", "web"], + "needsLicense": false, "source": { "sources": [ { @@ -560,15 +668,16 @@ "metadata": { "title": "Millicast", "subtitle": "Millicast demo", - "album": "React-Native THEOplayer demos", - "mediaUri": "https://theoplayer.com", - "artist": "THEOplayer" + "album": "OptiView React Native demos", + "mediaUri": "https://optiview.dolby.com", + "artist": "Dolby" } } }, { "name": "Millicast", "os": ["android"], + "needsLicense": false, "source": { "sources": [ { @@ -581,9 +690,9 @@ "metadata": { "title": "Millicast", "subtitle": "Millicast demo", - "album": "React-Native THEOplayer demos", - "mediaUri": "https://theoplayer.com", - "artist": "THEOplayer" + "album": "OptiView React Native demos", + "mediaUri": "https://optiview.dolby.com", + "artist": "Dolby" } } } diff --git a/example/src/custom/Source.ts b/example/src/custom/Source.ts index 19ddd16eb..7638bc102 100644 --- a/example/src/custom/Source.ts +++ b/example/src/custom/Source.ts @@ -3,5 +3,6 @@ import type { SourceDescription } from 'react-native-theoplayer'; export interface Source { name: string; os: string[]; + needsLicense: boolean; source: SourceDescription; } diff --git a/example/src/custom/SourceMenuButton.tsx b/example/src/custom/SourceMenuButton.tsx index 0900347e4..6d46b16d0 100644 --- a/example/src/custom/SourceMenuButton.tsx +++ b/example/src/custom/SourceMenuButton.tsx @@ -1,38 +1,37 @@ -import React, { useContext, useState } from 'react'; -import { Platform } from 'react-native'; +import React, { useContext } from 'react'; import { ListSvg, MenuButton, MenuRadioButton, MenuView, PlayerContext, ScrollableMenu } from '@theoplayer/react-native-ui'; import type { Source } from './Source'; -import ALL_SOURCES from './sources.json'; +import { usePlaylist } from '../hooks/usePlaylist'; -export const SOURCES = ALL_SOURCES.filter((source) => source.os.indexOf(Platform.OS) >= 0) as Source[]; +export interface SourceMenuButtonProps { + sources: Source[]; -export const SourceMenuButton = () => { - if (!(SOURCES && SOURCES.length > 0)) { - return <>; - } + includeWithLicense?: boolean | undefined; +} + +export const SourceMenuButton = (props: SourceMenuButtonProps) => { const createMenu = () => { - return ; + return ; }; return } menuConstructor={createMenu} />; }; -export const SourceMenuView = () => { - const { player } = useContext(PlayerContext); - const selectedSource = SOURCES.find((source) => source.source === player.source); - const [localSourceId, setLocalSourceId] = useState(selectedSource ? SOURCES.indexOf(selectedSource) : undefined); +export const SourceMenuView = ({ sources, includeWithLicense }: { sources: Source[]; includeWithLicense: boolean | undefined }) => { + const { player, ui } = useContext(PlayerContext); + const initialIndex = sources.findIndex((source) => source.source === player?.source); + const { sources: filteredSources, currentIndex, setSourceByIndex } = usePlaylist(player, sources, initialIndex, includeWithLicense); const selectSource = (id: number | undefined) => { - setLocalSourceId(id); - // eslint-disable-next-line react-hooks/immutability - player.source = id !== undefined ? SOURCES[id].source : undefined; + setSourceByIndex(id); + ui.closeCurrentMenu_(); }; return ( ( - + items={filteredSources.map((source, id) => ( + ))} /> } diff --git a/example/src/hooks/usePlaylist.ts b/example/src/hooks/usePlaylist.ts new file mode 100644 index 000000000..d1446358f --- /dev/null +++ b/example/src/hooks/usePlaylist.ts @@ -0,0 +1,100 @@ +import type { Source } from '../custom/Source'; +import { useEffect, useMemo, useState } from 'react'; +import { MediaControlAction, THEOplayer } from 'react-native-theoplayer'; +import { Platform } from 'react-native'; + +export interface PlaylistResult { + /** + * The list of sources in the playlist, filtered based on the `includeWithLicense` flag and platform support. + */ + sources: Source[]; + + /** + * The currently selected source in the playlist. + */ + currentSource: Source; + + /** + * The index of the currently selected source in the playlist. + */ + currentIndex: number; + + /** + * Function to set the current source in the playlist by its index. It updates the THEOplayer source and the + * current index state. + */ + setSourceByIndex: (index: number | undefined) => void; +} + +/** + * Custom hook to manage a playlist of sources for THEOplayer. + * It filters the provided sources based on the `includeWithLicense` flag and sets up handlers for media control + * actions to enable playlist navigation using the media session API (e.g. lock screen controls, bluetooth controls, etc.). + * + * @param player The THEOplayer instance to control. + * @param sources The list of sources to include in the playlist. + * @param initialIndex The index of the initially selected source in the playlist. If undefined, the first source will be selected by default. + * @param includeWithLicense Whether to include sources that require a license in the playlist. Defaults to false. + * @return An array containing the current source and the list of filtered sources in the playlist. + */ +export const usePlaylist = ( + player: THEOplayer | undefined, + sources: Source[], + initialIndex: number | undefined = undefined, + includeWithLicense: boolean = false, +): PlaylistResult => { + const filteredSources = useMemo( + () => + sources.filter((source) => { + // Only keep sources that don't require a license or if the includeWithLicense flag is set. + const filteredOutOnLicense = source.needsLicense && !includeWithLicense; + // Only keep sources that are supported on the current platform. + const filteredOutOnPlatform = source.os.indexOf(Platform.OS) < 0; + return !filteredOutOnLicense && !filteredOutOnPlatform; + }), + [sources, includeWithLicense], + ); + const initialValidIndex = initialIndex !== undefined && initialIndex < filteredSources.length ? initialIndex : 0; + const [currentIndex, setCurrentIndex] = useState(initialValidIndex); + + useEffect(() => { + if (!player) return; + + const handleNext = () => { + setCurrentIndex((index) => { + const newIndex = (index + 1) % filteredSources.length; + player.source = filteredSources[newIndex].source; + return newIndex; + }); + }; + + const handlePrevious = () => { + setCurrentIndex((index) => { + const newIndex = (index - 1 + filteredSources.length) % filteredSources.length; + player.source = filteredSources[newIndex].source; + return newIndex; + }); + }; + + // Install handlers for media control actions to enable playlist navigation using the media session API (e.g. lock + // screen controls, bluetooth controls, etc.) + player.mediaControl?.setHandler(MediaControlAction.SKIP_TO_NEXT, handleNext); + player.mediaControl?.setHandler(MediaControlAction.SKIP_TO_PREVIOUS, handlePrevious); + }, [player, filteredSources]); + + const setSourceByIndex = (index: number | undefined) => { + if (index !== undefined && index >= 0 && index < filteredSources.length) { + if (!player) return; + // eslint-disable-next-line react-hooks/immutability + player.source = filteredSources[index].source; + setCurrentIndex(index); + } + }; + + return { + sources: filteredSources, + currentSource: filteredSources[currentIndex], + currentIndex, + setSourceByIndex, + }; +}; From a2bf37e17f508bda6e2c868b874342721488e5be Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Mon, 23 Mar 2026 13:45:41 +0100 Subject: [PATCH 04/30] Use MediaControlProxy --- .../com/theoplayer/ReactTHEOplayerContext.kt | 82 ++------ .../com/theoplayer/media/MediaControlProxy.kt | 197 ++++++++++++++++++ .../com/theoplayer/player/PlayerModule.kt | 1 - 3 files changed, 210 insertions(+), 70 deletions(-) create mode 100644 android/src/main/java/com/theoplayer/media/MediaControlProxy.kt diff --git a/android/src/main/java/com/theoplayer/ReactTHEOplayerContext.kt b/android/src/main/java/com/theoplayer/ReactTHEOplayerContext.kt index 31e78e1ab..f85c0d756 100644 --- a/android/src/main/java/com/theoplayer/ReactTHEOplayerContext.kt +++ b/android/src/main/java/com/theoplayer/ReactTHEOplayerContext.kt @@ -34,32 +34,16 @@ 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 @@ -67,7 +51,7 @@ class ReactTHEOplayerContext private constructor( 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() @@ -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 @@ -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? @@ -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. @@ -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) } } @@ -297,8 +249,9 @@ class ReactTHEOplayerContext private constructor( config: MediaSessionConfig ) { connector?.apply { + mediaControlProxy.detach() + debug = BuildConfig.LOG_MEDIASESSION_EVENTS - removeListener(mediaSessionListener) player = this@ReactTHEOplayerContext.player @@ -306,21 +259,16 @@ class ReactTHEOplayerContext private constructor( // 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) } } @@ -390,12 +338,10 @@ class ReactTHEOplayerContext private constructor( private val onSourceChange = EventListener { mediaSessionConnector?.setMediaSessionMetadata(player.source) binder?.updateNotification() - applyAllowedMediaControls() } private val onLoadedMetadata = EventListener { binder?.updateNotification() - applyAllowedMediaControls() } private val onPlay = EventListener { @@ -403,14 +349,12 @@ class ReactTHEOplayerContext private constructor( bindMediaPlaybackService() } binder?.updateNotification(PlaybackStateCompat.STATE_PLAYING) - applyAllowedMediaControls() audioBecomingNoisyManager.setEnabled(true) audioFocusManager?.requestAudioFocus() } private val onPause = EventListener { binder?.updateNotification(PlaybackStateCompat.STATE_PAUSED) - applyAllowedMediaControls() audioBecomingNoisyManager.setEnabled(false) } diff --git a/android/src/main/java/com/theoplayer/media/MediaControlProxy.kt b/android/src/main/java/com/theoplayer/media/MediaControlProxy.kt new file mode 100644 index 000000000..658d5b931 --- /dev/null +++ b/android/src/main/java/com/theoplayer/media/MediaControlProxy.kt @@ -0,0 +1,197 @@ +package com.theoplayer.media + +import android.support.v4.media.session.PlaybackStateCompat +import com.facebook.react.module.annotations.ReactModule +import com.theoplayer.android.api.player.Player +import com.theoplayer.android.api.timerange.TimeRanges +import com.theoplayer.android.connector.mediasession.MediaSessionConnector +import com.theoplayer.android.connector.mediasession.PlaybackCallback +import com.theoplayer.android.connector.mediasession.QueueNavigator + +typealias MediaControlHandler = () -> Unit + +private const val DEFAULT_ACTIVE_ITEM_ID = 0L + +const val AVAILABLE_QUEUE_ACTIONS = (PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM or + PlaybackStateCompat.ACTION_SKIP_TO_NEXT or + PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS) + +@Suppress("unused") +@ReactModule(name = MediaControlModule.NAME) +class MediaControlProxy : PlaybackCallback, QueueNavigator { + + private var player: Player? = null + private var connector: MediaSessionConnector? = null + private var serviceBinder: MediaPlaybackService.MediaPlaybackBinder? = null + private var config: MediaSessionConfig? = null + private val handlers: MutableMap = mutableMapOf() + + fun attach( + player: Player?, + connector: MediaSessionConnector, + serviceBinder: MediaPlaybackService.MediaPlaybackBinder?, + config: MediaSessionConfig + ) { + this.player = player + this.connector = connector + this.serviceBinder = serviceBinder + this.config = config + + connector.apply { + skipForwardInterval = config.skipForwardInterval + skipBackwardsInterval = config.skipBackwardInterval + queueNavigator = this@MediaControlProxy + // All playback actions are routed through this proxy + playbackCallback = this@MediaControlProxy + invalidatePlaybackState() + } + } + + fun detach() { + connector?.apply { + queueNavigator = null + playbackCallback = null + } + } + + fun setHandler(action: MediaControlAction, handler: MediaControlHandler) { + handlers[action] = handler + + // Make sure the MediaSession known about the updated set of supported actions. + connector?.invalidatePlaybackState() + } + + fun invokeHandler(action: MediaControlAction): Boolean { + if (!handlers.containsKey(action)) { + return false + } + handlers[action]?.invoke() + return true + } + + fun hasHandler(action: MediaControlAction): Boolean { + return handlers.containsKey(action) + } + + override fun onPlay() { + // Make sure the session is currently active and ready to receive commands. + connector?.setActive(true) + + // Don't allow play actions during ads, or on live streams if not configured to allow it. + if (isInAd() || (isLive() && config?.allowLivePlayPause != true)) return + + // Check if an external handler is registered for the PLAY keycode, and invoke it if so + if (invokeHandler(MediaControlAction.PLAY)) return + + player?.play() + + // Optionally seek to live, if configured. + if (config?.seekToLiveOnResume == true && isLive()) { + player?.currentTime = Double.POSITIVE_INFINITY + } + } + + override fun onPause() { + // Don't allow pause actions during ads, or on live streams if not configured to allow it. + if (isInAd() || (isLive() && config?.allowLivePlayPause != true)) return + + // Check if an external handler is registered for the PAUSE keycode, and invoke it if so + if (invokeHandler(MediaControlAction.PAUSE)) return + + player?.pause() + } + + override fun onStop() { + serviceBinder?.stopForegroundService() + } + + override fun onFastForward() { + // Don't allow skip actions during ads, or on live streams. + if (isInAd() || isLive()) return + + skip(connector?.skipForwardInterval ?: 0.0) + } + + override fun onRewind() { + // Don't allow skip actions during ads, or on live streams. + if (isInAd() || isLive()) return + + skip(-(connector?.skipBackwardsInterval ?: 0.0)) + } + + override fun onSetPlaybackSpeed(speed: Float) { + player?.playbackRate = speed.toDouble() + } + + override fun onSeekTo(positionMs: Long) { + // Don't allow seek actions during ads, or on live streams. + if (isInAd() || isLive()) return + + player?.currentTime = 1e-03 * positionMs + } + + private fun skip(skipTime: Double) { + val player = connector?.player ?: return + + val currentTime: Double = player.currentTime + val seekable: TimeRanges = player.seekable + if (java.lang.Double.isNaN(currentTime) || seekable.length() == 0) { + return + } + for (i in 0 until seekable.length()) { + if (seekable.getStart(i) <= currentTime && seekable.getEnd(i) >= currentTime) { + player.currentTime = seekable.getEnd(i) + .coerceAtMost(seekable.getStart(i).coerceAtLeast(currentTime + skipTime)) + } + } + } + + private fun isLive(): Boolean { + return player?.duration?.isInfinite() == true + } + + private fun isInAd(): Boolean { + return player?.ads?.isPlaying == true + } + + override fun getSupportedQueueNavigatorActions(player: Player): Long { + val canQueue = hasHandler(MediaControlAction.SKIP_TO_NEXT) || + hasHandler(MediaControlAction.SKIP_TO_PREVIOUS) || config?.convertSkipToSeek == true + return if (canQueue) { + AVAILABLE_QUEUE_ACTIONS + } else { + 0L + } + } + + override fun getActiveQueueItemId(player: Player): Long { + return DEFAULT_ACTIVE_ITEM_ID + } + + override fun onSkipToPrevious(player: Player) { + // Check if an external handler is registered for the MEDIA_NEXT keycode, and invoke it if so + if (invokeHandler(MediaControlAction.SKIP_TO_PREVIOUS)) return + + // Check if we need to treat a MEDIA_PREVIOUS keycode as a MEDIA_REWIND + if (config?.convertSkipToSeek == true) { + player.currentTime -= config?.skipBackwardInterval ?: 0.0 + } + } + + override fun onSkipToQueueItem( + player: Player, + id: Long + ) { + // Unsupported action + } + + override fun onSkipToNext(player: Player) { + // Check if an external handler is registered for the MEDIA_NEXT keycode, and invoke it if so + if (invokeHandler(MediaControlAction.SKIP_TO_NEXT)) return + + // Otherwise default logic: Check if we need to treat a MEDIA_NEXT keycode as a MEDIA_FAST_FORWARD + if (config?.convertSkipToSeek == true) { + player.currentTime += config?.skipForwardInterval ?: 0.0 + } + } +} diff --git a/android/src/main/java/com/theoplayer/player/PlayerModule.kt b/android/src/main/java/com/theoplayer/player/PlayerModule.kt index d65107023..b5620e6e5 100644 --- a/android/src/main/java/com/theoplayer/player/PlayerModule.kt +++ b/android/src/main/java/com/theoplayer/player/PlayerModule.kt @@ -20,7 +20,6 @@ import com.theoplayer.presentation.PipConfigAdapter import com.theoplayer.track.TextTrackStyleAdapter import com.theoplayer.util.ViewResolver import com.theoplayer.util.findReactRootView -import com.theoplayer.util.getClosestParentOfType @Suppress("unused") @ReactModule(name = PlayerModule.NAME) From 2cd406ac4df398d2eb1627664f6b25e6c3fe1156 Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Mon, 23 Mar 2026 13:46:03 +0100 Subject: [PATCH 05/30] Add MediaControlModule --- .../com/theoplayer/ReactTHEOplayerPackage.kt | 3 + .../theoplayer/media/MediaControlModule.kt | 79 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 android/src/main/java/com/theoplayer/media/MediaControlModule.kt diff --git a/android/src/main/java/com/theoplayer/ReactTHEOplayerPackage.kt b/android/src/main/java/com/theoplayer/ReactTHEOplayerPackage.kt index f5ee8d023..c7e9f9191 100644 --- a/android/src/main/java/com/theoplayer/ReactTHEOplayerPackage.kt +++ b/android/src/main/java/com/theoplayer/ReactTHEOplayerPackage.kt @@ -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 @@ -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 } } @@ -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, ) } } diff --git a/android/src/main/java/com/theoplayer/media/MediaControlModule.kt b/android/src/main/java/com/theoplayer/media/MediaControlModule.kt new file mode 100644 index 000000000..c5d04dfba --- /dev/null +++ b/android/src/main/java/com/theoplayer/media/MediaControlModule.kt @@ -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 { + 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) + } +} From 45790c2ea20917947764c8a1a90ec951a948016e Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Mon, 23 Mar 2026 13:46:17 +0100 Subject: [PATCH 06/30] Drop separate QueueNavigator --- .../theoplayer/media/MediaQueueNavigator.kt | 33 ------------------- 1 file changed, 33 deletions(-) delete mode 100644 android/src/main/java/com/theoplayer/media/MediaQueueNavigator.kt diff --git a/android/src/main/java/com/theoplayer/media/MediaQueueNavigator.kt b/android/src/main/java/com/theoplayer/media/MediaQueueNavigator.kt deleted file mode 100644 index 2750686e9..000000000 --- a/android/src/main/java/com/theoplayer/media/MediaQueueNavigator.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.theoplayer.media - -import com.theoplayer.android.api.player.Player -import com.theoplayer.android.connector.mediasession.QueueNavigator - -private const val DEFAULT_ACTIVE_ITEM_ID = 0L - -class MediaQueueNavigator(private var mediaSessionConfig: MediaSessionConfig): QueueNavigator { - override fun getActiveQueueItemId(player: Player): Long { - return DEFAULT_ACTIVE_ITEM_ID - } - - override fun getSupportedQueueNavigatorActions(player: Player): Long { - return QueueNavigator.AVAILABLE_ACTIONS - } - - override fun onSkipToNext(player: Player) { - // Check if we need to treat a MEDIA_NEXT keycode as a MEDIA_FAST_FORWARD - if (mediaSessionConfig.convertSkipToSeek) { - player.currentTime += mediaSessionConfig.skipForwardInterval - } - } - - override fun onSkipToPrevious(player: Player) { - // Check if we need to treat a MEDIA_PREVIOUS keycode as a MEDIA_REWIND - if (mediaSessionConfig.convertSkipToSeek) { - player.currentTime -= mediaSessionConfig.skipBackwardInterval - } - } - - override fun onSkipToQueueItem(player: Player, id: Long) { - } -} From 1590e9768a0e6e88d9669e0f6ac7dad61d9e39b4 Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Mon, 23 Mar 2026 13:49:27 +0100 Subject: [PATCH 07/30] Drop custom pip actions --- .../com/theoplayer/presentation/PipUtils.kt | 100 ------------------ 1 file changed, 100 deletions(-) diff --git a/android/src/main/java/com/theoplayer/presentation/PipUtils.kt b/android/src/main/java/com/theoplayer/presentation/PipUtils.kt index 457423972..b37bc1864 100644 --- a/android/src/main/java/com/theoplayer/presentation/PipUtils.kt +++ b/android/src/main/java/com/theoplayer/presentation/PipUtils.kt @@ -1,22 +1,18 @@ package com.theoplayer.presentation import android.annotation.SuppressLint -import android.app.PendingIntent import android.app.PictureInPictureParams -import android.app.RemoteAction import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.graphics.Rect -import android.graphics.drawable.Icon import android.os.Build import android.util.Rational import android.view.ViewGroup import androidx.annotation.RequiresApi import com.facebook.react.uimanager.ThemedReactContext import com.theoplayer.BuildConfig -import com.theoplayer.R import com.theoplayer.ReactTHEOplayerContext import com.theoplayer.android.api.ads.ima.GoogleImaAdEvent import com.theoplayer.android.api.ads.ima.GoogleImaAdEventType @@ -31,9 +27,7 @@ private const val ACTION_PLAY = 0 private const val ACTION_PAUSE = ACTION_PLAY + 1 private const val ACTION_RWD = ACTION_PLAY + 2 private const val ACTION_FFD = ACTION_PLAY + 3 -private const val ACTION_IGNORE = ACTION_PLAY + 999 private const val SKIP_TIME = 15 -private const val NO_ICON = -1 private val PIP_ASPECT_RATIO_DEFAULT = Rational(16, 9) private val PIP_ASPECT_RATIO_MIN = Rational(100, 239) @@ -114,63 +108,6 @@ class PipUtils( disable() } - @RequiresApi(Build.VERSION_CODES.O) - fun buildPipActions( - paused: Boolean, - enablePlayPause: Boolean, - enableTrickPlay: Boolean - ): List { - return mutableListOf().apply { - - // Trick-play: Rewind - if (enableTrickPlay) { - add( - buildRemoteAction( - ACTION_RWD, - R.drawable.ic_rewind, - R.string.rewind, - R.string.rewind_description - ) - ) - } - - // Play/pause - // Always add this button, but send an ACTION_IGNORE and make invisible if disabled. - // If no RemoteActions are added, MediaSession takes over the UI. - add( - if (paused) { - buildRemoteAction( - ACTION_PLAY, - R.drawable.ic_play, - R.string.play, - R.string.play_description, - enablePlayPause - ) - } else { - buildRemoteAction( - ACTION_PAUSE, - R.drawable.ic_pause, - R.string.play, - R.string.pause_description, - enablePlayPause - ) - } - ) - - // Trick-play: Fast Forward - if (enableTrickPlay) { - add( - buildRemoteAction( - ACTION_FFD, - R.drawable.ic_fast_forward, - R.string.fast_forward, - R.string.fast_forward_description - ) - ) - } - } - } - private fun getSafeAspectRatio(width: Int, height: Int): Rational { val aspectRatio = Rational(width, height) if (aspectRatio.isNaN || aspectRatio.isInfinite || aspectRatio.isZero) { @@ -199,20 +136,12 @@ class PipUtils( @RequiresApi(Build.VERSION_CODES.O) fun getPipParams(): PictureInPictureParams { val view = viewCtx.playerView - val player = view.player val visibleRect = getContentViewRect(view) - val isAd = player.ads.isPlaying - val isLive = player.duration.isInfinite() - val enablePlayPause = !isAd - val enableTrickPlay = !isAd && !isLive return PictureInPictureParams.Builder() .setSourceRectHint(visibleRect) // Must be between 2.39:1 and 1:2.39 (inclusive) .setAspectRatio(getSafeAspectRatio(view.player.videoWidth, view.player.videoHeight)) - .setActions( - buildPipActions(player.isPaused, enablePlayPause, enableTrickPlay) - ) .build() } @@ -238,33 +167,4 @@ class PipUtils( } } } - - @RequiresApi(Build.VERSION_CODES.O) - private fun buildRemoteAction( - requestId: Int, - iconId: Int, - titleId: Int, - descId: Int, - enabled: Boolean = true - ): RemoteAction { - // Ignore the action if it is disabled - val requestCode = if (enabled) requestId else ACTION_IGNORE - val intent = Intent(ACTION_MEDIA_CONTROL).putExtra(EXTRA_ACTION, requestCode) - val pendingIntent = - PendingIntent.getBroadcast(reactContext, requestCode, intent, PendingIntent.FLAG_IMMUTABLE) - val icon: Icon = Icon.createWithResource( - reactContext, - when { - enabled -> iconId - // On Android 8-11 devices, if you go to PiP during an IMA ad on Android, - // the NO_ICON causes a System UI crash. - Build.VERSION.SDK_INT in Build.VERSION_CODES.O..Build.VERSION_CODES.R -> - android.R.drawable.screen_background_light_transparent - else -> NO_ICON - } - ) - val title = reactContext.getString(titleId) - val desc = reactContext.getString(descId) - return RemoteAction(icon, title, desc, pendingIntent) - } } From e3da5466d66436009e9b3b64601b2efa79c013ad Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Tue, 24 Mar 2026 16:06:28 +0100 Subject: [PATCH 08/30] Update pipUtils --- .../com/theoplayer/presentation/PipUtils.kt | 144 ++++++++++++++++-- android/src/main/res/drawable/ic_next.xml | 9 ++ android/src/main/res/drawable/ic_prev.xml | 9 ++ android/src/main/res/values/strings.xml | 4 + 4 files changed, 157 insertions(+), 9 deletions(-) create mode 100644 android/src/main/res/drawable/ic_next.xml create mode 100644 android/src/main/res/drawable/ic_prev.xml diff --git a/android/src/main/java/com/theoplayer/presentation/PipUtils.kt b/android/src/main/java/com/theoplayer/presentation/PipUtils.kt index b37bc1864..4026b732e 100644 --- a/android/src/main/java/com/theoplayer/presentation/PipUtils.kt +++ b/android/src/main/java/com/theoplayer/presentation/PipUtils.kt @@ -1,18 +1,22 @@ package com.theoplayer.presentation import android.annotation.SuppressLint +import android.app.PendingIntent import android.app.PictureInPictureParams +import android.app.RemoteAction import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.graphics.Rect +import android.graphics.drawable.Icon import android.os.Build import android.util.Rational import android.view.ViewGroup import androidx.annotation.RequiresApi import com.facebook.react.uimanager.ThemedReactContext import com.theoplayer.BuildConfig +import com.theoplayer.R import com.theoplayer.ReactTHEOplayerContext import com.theoplayer.android.api.ads.ima.GoogleImaAdEvent import com.theoplayer.android.api.ads.ima.GoogleImaAdEventType @@ -27,7 +31,10 @@ private const val ACTION_PLAY = 0 private const val ACTION_PAUSE = ACTION_PLAY + 1 private const val ACTION_RWD = ACTION_PLAY + 2 private const val ACTION_FFD = ACTION_PLAY + 3 -private const val SKIP_TIME = 15 +private const val ACTION_SKIP_TO_PREV = ACTION_PLAY + 4 +private const val ACTION_SKIP_TO_NEXT = ACTION_PLAY + 5 +private const val ACTION_IGNORE = ACTION_PLAY + 999 +private const val NO_ICON = -1 private val PIP_ASPECT_RATIO_DEFAULT = Rational(16, 9) private val PIP_ASPECT_RATIO_MIN = Rational(100, 239) @@ -108,6 +115,84 @@ class PipUtils( disable() } + @RequiresApi(Build.VERSION_CODES.O) + fun buildPipActions( + paused: Boolean, + enablePlayPause: Boolean, + enableQueueActions: Boolean, + enableTrickPlay: Boolean + ): List { + return mutableListOf().apply { + + if (enableQueueActions) { + // Queue controls: Skip to Previous + add( + buildRemoteAction( + ACTION_SKIP_TO_PREV, + R.drawable.ic_prev, + R.string.skip_to_previous, + R.string.skip_to_previous_description + ) + ) + } else if (enableTrickPlay) { + // Trick-play: Rewind + add( + buildRemoteAction( + ACTION_RWD, + R.drawable.ic_rewind, + R.string.rewind, + R.string.rewind_description + ) + ) + } + + // Play/pause + // Always add this button, but send an ACTION_IGNORE and make invisible if disabled. + // If no RemoteActions are added, MediaSession takes over the UI. + add( + if (paused) { + buildRemoteAction( + ACTION_PLAY, + R.drawable.ic_play, + R.string.play, + R.string.play_description, + enablePlayPause + ) + } else { + buildRemoteAction( + ACTION_PAUSE, + R.drawable.ic_pause, + R.string.play, + R.string.pause_description, + enablePlayPause + ) + } + ) + + if (enableQueueActions) { + // Queue controls: Skip to Next + add( + buildRemoteAction( + ACTION_SKIP_TO_NEXT, + R.drawable.ic_next, + R.string.skip_to_next, + R.string.skip_to_previous_description + ) + ) + } else if (enableTrickPlay) { + // Trick-play: Fast Forward + add( + buildRemoteAction( + ACTION_FFD, + R.drawable.ic_fast_forward, + R.string.fast_forward, + R.string.fast_forward_description + ) + ) + } + } + } + private fun getSafeAspectRatio(width: Int, height: Int): Rational { val aspectRatio = Rational(width, height) if (aspectRatio.isNaN || aspectRatio.isInfinite || aspectRatio.isZero) { @@ -135,13 +220,20 @@ class PipUtils( @RequiresApi(Build.VERSION_CODES.O) fun getPipParams(): PictureInPictureParams { - val view = viewCtx.playerView - val visibleRect = getContentViewRect(view) + val mediaControlProxy = viewCtx.mediaControlProxy + val visibleRect = getContentViewRect(viewCtx.playerView) return PictureInPictureParams.Builder() .setSourceRectHint(visibleRect) // Must be between 2.39:1 and 1:2.39 (inclusive) - .setAspectRatio(getSafeAspectRatio(view.player.videoWidth, view.player.videoHeight)) + .setAspectRatio(getSafeAspectRatio(player.videoWidth, player.videoHeight)) + .setActions( + buildPipActions( + player.isPaused, + mediaControlProxy.playPauseEnabled, + mediaControlProxy.queueActionsEnabled, + mediaControlProxy.trickPlayEnabled) + ) .build() } @@ -156,15 +248,49 @@ class PipUtils( @RequiresApi(Build.VERSION_CODES.O) override fun onReceive(context: Context?, intent: Intent?) { intent?.getIntExtra(EXTRA_ACTION, -1)?.let { action -> - when (action) { - ACTION_PLAY -> player.play() - ACTION_PAUSE -> player.pause() - ACTION_FFD -> player.currentTime += SKIP_TIME - ACTION_RWD -> player.currentTime -= SKIP_TIME + viewCtx.mediaControlProxy.apply { + when (action) { + ACTION_PLAY -> onPlay() + ACTION_PAUSE -> onPause() + ACTION_FFD -> onFastForward() + ACTION_RWD -> onRewind() + ACTION_SKIP_TO_NEXT -> onSkipToNext(player) + ACTION_SKIP_TO_PREV -> onSkipToPrevious(player) + } } reactContext.currentActivity?.setPictureInPictureParams(getPipParams()) } } } } + + @RequiresApi(Build.VERSION_CODES.O) + private fun buildRemoteAction( + requestId: Int, + iconId: Int, + titleId: Int, + descId: Int, + enabled: Boolean = true + ): RemoteAction { + // Ignore the action if it is disabled + val requestCode = if (enabled) requestId else ACTION_IGNORE + val intent = Intent(ACTION_MEDIA_CONTROL).putExtra(EXTRA_ACTION, requestCode) + val pendingIntent = + PendingIntent.getBroadcast(reactContext, requestCode, intent, PendingIntent.FLAG_IMMUTABLE) + val icon: Icon = Icon.createWithResource( + reactContext, + when { + enabled -> iconId + // On Android 8-11 devices, if you go to PiP during an IMA ad on Android, + // the NO_ICON causes a System UI crash. + Build.VERSION.SDK_INT in Build.VERSION_CODES.O..Build.VERSION_CODES.R -> + android.R.drawable.screen_background_light_transparent + + else -> NO_ICON + } + ) + val title = reactContext.getString(titleId) + val desc = reactContext.getString(descId) + return RemoteAction(icon, title, desc, pendingIntent) + } } diff --git a/android/src/main/res/drawable/ic_next.xml b/android/src/main/res/drawable/ic_next.xml new file mode 100644 index 000000000..76e5ed724 --- /dev/null +++ b/android/src/main/res/drawable/ic_next.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_prev.xml b/android/src/main/res/drawable/ic_prev.xml new file mode 100644 index 000000000..040f96f44 --- /dev/null +++ b/android/src/main/res/drawable/ic_prev.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index b9ae95009..c35c3aef0 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -8,6 +8,10 @@ Fast Forward video Rewind Rewind video + Skip to Previous + Skip to previous playlist item + Skip to Next + Skip to next playlist item THEOplayer service providing background playback. theoplayer_default_channel From 8ca631c5ac97e2e8676599c2304ec2dbb1f591da Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Tue, 24 Mar 2026 16:06:50 +0100 Subject: [PATCH 09/30] Restructure proxy --- .../com/theoplayer/media/MediaControlProxy.kt | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/android/src/main/java/com/theoplayer/media/MediaControlProxy.kt b/android/src/main/java/com/theoplayer/media/MediaControlProxy.kt index 658d5b931..fab2a460c 100644 --- a/android/src/main/java/com/theoplayer/media/MediaControlProxy.kt +++ b/android/src/main/java/com/theoplayer/media/MediaControlProxy.kt @@ -16,6 +16,12 @@ const val AVAILABLE_QUEUE_ACTIONS = (PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_IT PlaybackStateCompat.ACTION_SKIP_TO_NEXT or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS) +/** + * MediaControlProxy serves as a proxy for media control actions sent by the MediaSession. It allows + * external code to register handlers for specific media control actions, which will be invoked when the + * corresponding action is received from the MediaSession. If no handler is registered for a given action, + * the proxy will fall back to default behavior (e.g. calling player.play() for a PLAY action). + */ @Suppress("unused") @ReactModule(name = MediaControlModule.NAME) class MediaControlProxy : PlaybackCallback, QueueNavigator { @@ -73,12 +79,22 @@ class MediaControlProxy : PlaybackCallback, QueueNavigator { return handlers.containsKey(action) } + val queueActionsEnabled: Boolean + get() = hasHandler(MediaControlAction.SKIP_TO_NEXT) || + hasHandler(MediaControlAction.SKIP_TO_PREVIOUS) || config?.convertSkipToSeek == true + + val trickPlayEnabled: Boolean + get() = !isInAd() && !isLive() + + val playPauseEnabled: Boolean + get() = !isInAd() && (!isLive() || config?.allowLivePlayPause == true) + override fun onPlay() { // Make sure the session is currently active and ready to receive commands. connector?.setActive(true) // Don't allow play actions during ads, or on live streams if not configured to allow it. - if (isInAd() || (isLive() && config?.allowLivePlayPause != true)) return + if (!playPauseEnabled) return // Check if an external handler is registered for the PLAY keycode, and invoke it if so if (invokeHandler(MediaControlAction.PLAY)) return @@ -93,7 +109,7 @@ class MediaControlProxy : PlaybackCallback, QueueNavigator { override fun onPause() { // Don't allow pause actions during ads, or on live streams if not configured to allow it. - if (isInAd() || (isLive() && config?.allowLivePlayPause != true)) return + if (!playPauseEnabled) return // Check if an external handler is registered for the PAUSE keycode, and invoke it if so if (invokeHandler(MediaControlAction.PAUSE)) return @@ -107,14 +123,14 @@ class MediaControlProxy : PlaybackCallback, QueueNavigator { override fun onFastForward() { // Don't allow skip actions during ads, or on live streams. - if (isInAd() || isLive()) return + if (!trickPlayEnabled) return skip(connector?.skipForwardInterval ?: 0.0) } override fun onRewind() { // Don't allow skip actions during ads, or on live streams. - if (isInAd() || isLive()) return + if (!trickPlayEnabled) return skip(-(connector?.skipBackwardsInterval ?: 0.0)) } @@ -125,7 +141,7 @@ class MediaControlProxy : PlaybackCallback, QueueNavigator { override fun onSeekTo(positionMs: Long) { // Don't allow seek actions during ads, or on live streams. - if (isInAd() || isLive()) return + if (!trickPlayEnabled) return player?.currentTime = 1e-03 * positionMs } @@ -155,9 +171,7 @@ class MediaControlProxy : PlaybackCallback, QueueNavigator { } override fun getSupportedQueueNavigatorActions(player: Player): Long { - val canQueue = hasHandler(MediaControlAction.SKIP_TO_NEXT) || - hasHandler(MediaControlAction.SKIP_TO_PREVIOUS) || config?.convertSkipToSeek == true - return if (canQueue) { + return if (queueActionsEnabled) { AVAILABLE_QUEUE_ACTIONS } else { 0L From 2a3498221ccd0bf8a91766f76c7025738aa80b0b Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Wed, 25 Mar 2026 09:29:13 +0100 Subject: [PATCH 10/30] Update sources --- example/src/assets/sources.json | 281 ++++++++++++++++---------------- 1 file changed, 140 insertions(+), 141 deletions(-) diff --git a/example/src/assets/sources.json b/example/src/assets/sources.json index 5fbcb8d24..e230bf10a 100644 --- a/example/src/assets/sources.json +++ b/example/src/assets/sources.json @@ -1,6 +1,6 @@ [ { - "name": "Sintel • HLS • Sideloaded Chapters", + "name": "Sintel • HLS • Side-loaded Chapters", "os": ["ios", "android", "web"], "needsLicense": false, "source": { @@ -10,13 +10,13 @@ "type": "application/x-mpegurl" } ], - "poster": "https://cdn.theoplayer.com/video/sintel/poster.jpg", + "poster": "https://cdn.theoplayer.com/video/posters/sintel.jpg", "metadata": { - "title": "Sintel", - "subtitle": "HLS • Sideloaded Chapters", + "title": "Sintel with Side-loaded Chapters", + "subtitle": "HLS • Side-loaded Chapters", "album": "OptiView React-Native demos", "mediaUri": "https://optiview.dolby.com", - "displayIconUri": "https://cdn.theoplayer.com/video/sintel/poster.jpg", + "displayIconUri": "https://cdn.theoplayer.com/video/posters/sintel.jpg", "artist": "Dolby" }, "textTracks": [ @@ -42,13 +42,13 @@ "type": "application/dash+xml" } ], - "poster": "https://cdn.theoplayer.com/video/big_buck_bunny/poster.jpg", + "poster": "https://cdn.theoplayer.com/video/posters/big_buck_bunny.jpg", "metadata": { - "title": "Big Buck Bunny", + "title": "Big Buck Bunny with Thumbnails", "subtitle": "DASH • Thumbnails", "album": "OptiView React Native demos", "mediaUri": "https://optiview.dolby.com", - "displayIconUri": "https://cdn.theoplayer.com/video/big_buck_bunny/poster.jpg", + "displayIconUri": "https://cdn.theoplayer.com/video/posters/big_buck_bunny.jpg", "artist": "Dolby" } } @@ -64,13 +64,13 @@ "type": "application/dash+xml" } ], - "poster": "https://cdn.theoplayer.com/video/big_buck_bunny/poster.jpg", + "poster": "https://cdn.theoplayer.com/video/posters/big_buck_bunny.jpg", "metadata": { - "title": "Big Buck Bunny", + "title": "Big Buck Bunny with Side-loaded Thumbnails", "subtitle": "DASH • Side-loaded Thumbnails", "album": "OptiView React Native demos", "mediaUri": "https://optiview.dolby.com", - "displayIconUri": "https://cdn.theoplayer.com/video/big_buck_bunny/poster.jpg", + "displayIconUri": "https://cdn.theoplayer.com/video/posters/big_buck_bunny.jpg", "artist": "Dolby" }, "textTracks": [ @@ -94,13 +94,13 @@ "type": "application/dash+xml" } ], - "poster": "https://cdn.theoplayer.com/video/sintel/poster.jpg", + "poster": "https://cdn.theoplayer.com/video/posters/sintel.jpg", "metadata": { - "title": "Sintel", + "title": "Sintel DASH", "subtitle": "DASH • Clear", "album": "OptiView React-Native demos", "mediaUri": "https://optiview.dolby.com", - "displayIconUri": "https://cdn.theoplayer.com/video/sintel/poster.jpg", + "displayIconUri": "https://cdn.theoplayer.com/video/posters/sintel.jpg", "artist": "Dolby" } } @@ -119,13 +119,13 @@ } } }, - "poster": "https://cdn.theoplayer.com/video/dash/tos-dash-widevine/poster.png", + "poster": "https://cdn.theoplayer.com/video/posters/tears_of_steel.jpg", "metadata": { - "title": "Tears of Steel", + "title": "Tears of Steel ezDRM", "subtitle": "DASH • ezDRM (Widevine)", "album": "OptiView React-Native demos", "mediaUri": "https://optiview.dolby.com", - "displayIconUri": "https://cdn.theoplayer.com/video/dash/tos-dash-widevine/poster.png", + "displayIconUri": "https://cdn.theoplayer.com/video/posters/tears_of_steel.jpg", "artist": "Dolby" } } @@ -148,13 +148,13 @@ } } }, - "poster": "https://cdn.theoplayer.com/video/meridian_poster.jpg", + "poster": "https://cdn.theoplayer.com/video/posters/meridian.jpg", "metadata": { "title": "Meridian", "subtitle": "DASH • KeyOS (Widevine)", "album": "OptiView React-Native demos", "mediaUri": "https://optiview.dolby.com", - "displayIconUri": "https://cdn.theoplayer.com/video/meridian_poster.jpg", + "displayIconUri": "https://cdn.theoplayer.com/video/posters/meridian.jpg", "artist": "Dolby" } } @@ -167,13 +167,13 @@ "sources": { "src": "https://livesim.dashif.org/livesim/chunkdur_1/ato_7/testpic4_8s/Manifest.mpd" }, - "poster": "https://cdn.theoplayer.com/video/dash/test_video/poster.png", + "poster": "https://cdn.theoplayer.com/video/posters/livesim.jpg", "metadata": { "title": "LiveSim", "subtitle": "DASH", "album": "OptiView React-Native demos", "mediaUri": "https://optiview.dolby.com", - "displayIconUri": "https://cdn.theoplayer.com/video/dash/test_video/poster.png", + "displayIconUri": "https://cdn.theoplayer.com/video/posters/livesim.jpg", "artist": "Dolby" } } @@ -197,13 +197,13 @@ } } ], - "poster": "https://cdn.theoplayer.com/video/sintel/poster.jpg", + "poster": "https://cdn.theoplayer.com/video/posters/sintel.jpg", "metadata": { - "title": "Sintel", + "title": "Sintel with pre-roll", "subtitle": "CSAI • Google IMA pre-roll", "album": "OptiView React-Native demos", "mediaUri": "https://optiview.dolby.com", - "displayIconUri": "https://cdn.theoplayer.com/video/sintel/poster.jpg", + "displayIconUri": "https://cdn.theoplayer.com/video/posters/sintel.jpg", "artist": "Dolby" } } @@ -228,13 +228,13 @@ "timeOffset": 10 } ], - "poster": "https://cdn.theoplayer.com/video/sintel/poster.jpg", + "poster": "https://cdn.theoplayer.com/video/posters/sintel.jpg", "metadata": { - "title": "Sintel", + "title": "Sintel with mid-roll VAST", "subtitle": "CSAI • Google IMA mid-roll VAST", "album": "OptiView React-Native demos", "mediaUri": "https://optiview.dolby.com", - "displayIconUri": "https://cdn.theoplayer.com/video/sintel/poster.jpg", + "displayIconUri": "https://cdn.theoplayer.com/video/posters/sintel.jpg", "artist": "Dolby" } } @@ -258,13 +258,13 @@ } } ], - "poster": "https://cdn.theoplayer.com/video/sintel/poster.jpg", + "poster": "https://cdn.theoplayer.com/video/posters/sintel.jpg", "metadata": { - "title": "Sintel", + "title": "Sintel with mid-roll VMAP", "subtitle": "CSAI • Google IMA mid-roll VMAP", "album": "OptiView React-Native demos", "mediaUri": "https://optiview.dolby.com", - "displayIconUri": "https://cdn.theoplayer.com/video/sintel/poster.jpg", + "displayIconUri": "https://cdn.theoplayer.com/video/posters/sintel.jpg", "artist": "Dolby" } } @@ -278,12 +278,12 @@ "src": "https://cdn.theoplayer.com/video/adultswim/clip.m3u8", "type": "application/x-mpegurl" }, - "poster": "https://cdn.theoplayer.com/video/adultswim/poster.png", + "poster": "https://cdn.theoplayer.com/video/posters/adultswim.jpg", "metadata": { "title": "The Venture Bros", "subtitle": "Adult Swim", "album": "OptiView React Native demos", - "displayIconUri": "https://cdn.theoplayer.com/video/dolby_optiview.jpg", + "displayIconUri": "https://cdn.theoplayer.com/video/posters/dolby_optiview.jpg", "artist": "Dolby", "type": "tv-show", "releaseDate": "november 29th", @@ -307,9 +307,12 @@ } } ], + "poster": "https://cdn.theoplayer.com/video/posters/dolby_optiview.jpg", "metadata": { "title": "THEOlive • demo", + "displayIconUri": "https://cdn.theoplayer.com/video/posters/dolby_optiview.jpg", "mediaUri": "https://optiview.dolby.com", + "album": "OptiView React Native demos", "artist": "Dolby" } } @@ -323,11 +326,11 @@ "src": "https://cdn.theoplayer.com/video/star_wars_episode_vii-the_force_awakens_official_comic-con_2015_reel_(2015)/index-daterange.m3u8", "type": "application/x-mpegurl" }, - "poster": "https://cdn.theoplayer.com/video/dolby_optiview.jpg", + "poster": "https://cdn.theoplayer.com/video/posters/dolby_optiview.jpg", "metadata": { "title": "Star Wars Episode VII • The Force Awakens Official Comic-Con 2015 Reel", "subtitle": "Clear • DateRange Metadata", - "displayIconUri": "https://cdn.theoplayer.com/video/dolby_optiview.jpg", + "displayIconUri": "https://cdn.theoplayer.com/video/posters/dolby_optiview.jpg", "album": "OptiView React Native demos", "artist": "Dolby" } @@ -342,12 +345,12 @@ "src": "https://ireplay.tv/test/blender.m3u8", "type": "application/x-mpegurl" }, - "poster": "https://cdn.theoplayer.com/video/dolby_optiview.jpg", + "poster": "https://cdn.theoplayer.com/video/posters/dolby_optiview.jpg", "metadata": { - "title": "Tears of Steel", + "title": "Tears of Steel Live", "subtitle": "HLS • Live", - "album": "React-Native demos", - "displayIconUri": "https://cdn.theoplayer.com/video/dolby_optiview.jpg", + "album": "OptiView React Native demos", + "displayIconUri": "https://cdn.theoplayer.com/video/posters/dolby_optiview.jpg", "artist": "Dolby", "type": "tv-show", "releaseDate": "november 30th", @@ -364,12 +367,13 @@ "src": "https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_16x9/bipbop_16x9_variant.m3u8", "type": "application/x-mpegurl" }, - "poster": "https://cdn.theoplayer.com/video/dolby_optiview.jpg", + "poster": "https://cdn.theoplayer.com/video/posters/dolby_optiview.jpg", "metadata": { "title": "Bipbop", - "album": "Bipbop Album", - "displayIconUri": "https://cdn.theoplayer.com/video/dolby_optiview.jpg", - "artist": "Apple" + "subtitle": "HLS • Live", + "album": "OptiView React Native demos", + "displayIconUri": "https://cdn.theoplayer.com/video/posters/dolby_optiview.jpg", + "artist": "Dolby" } } }, @@ -382,13 +386,13 @@ "src": "https://cdn.theoplayer.com/video/elephants-dream/playlist-single-audio.m3u8", "type": "application/x-mpegurl" }, - "poster": "https://cdn.theoplayer.com/video/elephants-dream.png", + "poster": "https://cdn.theoplayer.com/video/posters/elephants_dream.jpg", "metadata": { - "title": "Elephants Dream", - "subtitle": "Elephants Dream Subtitle", - "album": "Elephants Dream Album", - "displayIconUri": "https://cdn.theoplayer.com/video/elephants-dream.png", - "artist": "The elephant" + "title": "Elephants Dream HLS", + "subtitle": "HLS • Clear", + "album": "OptiView React Native demos", + "displayIconUri": "https://cdn.theoplayer.com/video/posters/elephants_dream.jpg", + "artist": "Dolby" } } }, @@ -409,39 +413,18 @@ "sources": "https://cdn.theoplayer.com/demos/ads/vast/dfp-preroll-no-skip.xml" } ], - "poster": "https://cdn.theoplayer.com/video/elephants-dream.png", + "poster": "https://cdn.theoplayer.com/video/posters/elephants_dream.jpg", "metadata": { - "title": "Elephants Dream with Preroll", - "subtitle": "Elephants Dream with Preroll Subtitle", - "album": "Elephants Album", - "displayIconUri": "https://cdn.theoplayer.com/video/elephants-dream.png", - "artist": "The Dream" + "title": "Elephants Dream with pre-roll", + "subtitle": "HLS • CSAI • Google IMA pre-roll", + "album": "OptiView React Native demos", + "displayIconUri": "https://cdn.theoplayer.com/video/posters/elephants_dream.jpg", + "artist": "Dolby" } } }, { - "name": "HLS - SGAI (THEOads)", - "os": ["web"], - "needsLicense": true, - "source": { - "sources": { - "src": "https://cluster.dev.theostream.live/nfl-unified-channel/hls/k8s/live/scte35.isml/.m3u8", - "type": "application/x-mpegurl", - "hlsDateRange": true - }, - "ads": [ - { - "integration": "theoads", - "networkCode": "51636543", - "customAssetKey": "nfl-sgai-demo", - "backdropDoubleBox": "https://demo.theoads.live/img/THEOads_double_box.svg", - "backdropLShape": "https://demo.theoads.live/img/THEOads_L_Shape.svg" - } - ] - } - }, - { - "name": "HLS - CSAI - Google IMA mid-roll VAST", + "name": "Elephants Dream • HLS • CSAI • Google IMA mid-roll", "os": ["ios", "web"], "needsLicense": false, "source": { @@ -460,18 +443,18 @@ "timeOffset": 10 } ], - "poster": "https://cdn.theoplayer.com/video/dolby_optiview.jpg", + "poster": "https://cdn.theoplayer.com/video/posters/elephants_dream.jpg", "metadata": { - "title": "Elephants Dream with Midroll", - "subtitle": "Elephants Dream with Midroll Subtitle", - "album": "Ellies Album", - "displayIconUri": "https://cdn.theoplayer.com/video/dolby_optiview.jpg", - "artist": "Ellie" + "title": "Elephants Dream with mid-roll", + "subtitle": "HLS • CSAI • Google IMA mid-roll", + "album": "OptiView React Native demos", + "displayIconUri": "https://cdn.theoplayer.com/video/posters/elephants_dream.jpg", + "artist": "Dolby" } } }, { - "name": "HLS - CSAI - Google IMA VMAP", + "name": "Elephants Dream • HLS • CSAI • Google IMA VMAP", "needsLicense": false, "os": ["ios", "web"], "source": { @@ -489,35 +472,19 @@ } } ], - "poster": "https://cdn.theoplayer.com/video/dolby_optiview.jpg", + "poster": "https://cdn.theoplayer.com/video/posters/elephants_dream.jpg", "metadata": { "title": "Elephants Dream with VMAP", "subtitle": "Elephants Dream with VMAP Subtitle", - "album": "Ellies Album", - "displayIconUri": "https://cdn.theoplayer.com/video/dolby_optiview.jpg", - "artist": "Ellie" - } - } - }, - { - "name": "DASH - SSAI - Google DAI", - "os": ["android", "web"], - "needsLicense": true, - "source": { - "sources": { - "type": "application/dash+xml", - "ssai": { - "integration": "google-dai", - "availabilityType": "vod", - "contentSourceID": "2474148", - "videoID": "bbb-clear" - } + "album": "OptiView React Native demos", + "displayIconUri": "https://cdn.theoplayer.com/video/posters/elephants_dream.jpg", + "artist": "Dolby" } } }, { - "name": "HLS - SSAI - Google DAI - VOD", - "os": ["ios", "web"], + "name": "Tears of Steel • HLS • Google DAI", + "os": ["ios", "web", "android"], "needsLicense": true, "source": { "sources": { @@ -527,12 +494,21 @@ "contentSourceID": "2548831", "videoID": "tears-of-steel" } + }, + "poster": "https://cdn.theoplayer.com/video/posters/tears_of_steel.jpg", + "metadata": { + "title": "Tears of Steel with DAI", + "subtitle": "HLS • Google DAI", + "album": "OptiView React Native demos", + "mediaUri": "https://optiview.dolby.com", + "displayIconUri": "https://cdn.theoplayer.com/video/posters/tears_of_steel.jpg", + "artist": "Dolby" } } }, { - "name": "HLS - SSAI - Google DAI - LIVE", - "os": ["ios", "web"], + "name": "Big Buck Bunny • HLS • Google DAI • LIVE", + "os": ["ios", "web", "android"], "needsLicense": true, "source": { "sources": { @@ -541,11 +517,20 @@ "availabilityType": "live", "assetKey": "sN_IYUG8STe1ZzhIIE_ksA" } + }, + "poster": "https://cdn.theoplayer.com/video/posters/big_buck_bunny.jpg", + "metadata": { + "title": "Big Buck Bunny with DAI", + "subtitle": "HLS • Google DAI • LIVE", + "album": "OptiView React Native demos", + "mediaUri": "https://optiview.dolby.com", + "displayIconUri": "https://cdn.theoplayer.com/video/posters/big_buck_bunny.jpg", + "artist": "Dolby" } } }, { - "name": "HLS - VOD/Thumbnails - Clear - Big Buck Bunny", + "name": "Big Buck Bunny • HLS • Thumbnails • Clear", "os": ["ios", "web"], "needsLicense": false, "source": { @@ -553,13 +538,13 @@ "src": "https://cdn.theoplayer.com/video/big_buck_bunny/big_buck_bunny.m3u8", "type": "application/x-mpegurl" }, - "poster": "https://cdn.theoplayer.com/video/big_buck_bunny/poster.jpg", + "poster": "https://cdn.theoplayer.com/video/posters/big_buck_bunny.jpg", "metadata": { - "title": "The Title", - "subtitle": "The Subtitle", - "album": "Album", - "displayIconUri": "https://cdn.theoplayer.com/video/big_buck_bunny/poster.jpg", - "artist": "Artist" + "title": "Big Buck Bunny with Thumbnails", + "subtitle": "HLS • Thumbnails • Clear", + "album": "OptiView React Native demos", + "displayIconUri": "https://cdn.theoplayer.com/video/posters/big_buck_bunny.jpg", + "artist": "Dolby" }, "textTracks": [ { @@ -574,7 +559,7 @@ } }, { - "name": "HLS - VOD - ezDRM (FairPlay)", + "name": "HLS • VOD • ezDRM (FairPlay)", "os": ["ios", "web"], "needsLicense": true, "source": { @@ -588,11 +573,19 @@ "licenseAcquisitionURL": "https://fps.ezdrm.com/api/licenses/09cc0377-6dd4-40cb-b09d-b582236e70fe" } } + }, + "poster": "https://cdn.theoplayer.com/video/posters/dolby_optiview.jpg", + "metadata": { + "title": "ezDRM (FairPlay)", + "subtitle": "HLS • VOD • ezDRM (FairPlay)", + "album": "OptiView React Native demos", + "displayIconUri": "https://cdn.theoplayer.com/video/posters/dolby_optiview.jpg", + "artist": "Dolby" } } }, { - "name": "HLS - VOD - keyOS (FairPlay)", + "name": "HLS • VOD • keyOS (FairPlay)", "os": ["ios", "web"], "needsLicense": true, "source": { @@ -609,52 +602,54 @@ "certificateURL": "https://fp-keyos.licensekeyserver.com/cert/7e11400c7dccd29d0174c674397d99dd.der" } } + }, + "poster": "https://cdn.theoplayer.com/video/posters/dolby_optiview.jpg", + "metadata": { + "title": "keyOS (FairPlay)", + "subtitle": "HLS • VOD • keyOS (FairPlay)", + "album": "OptiView React Native demos", + "displayIconUri": "https://cdn.theoplayer.com/video/posters/dolby_optiview.jpg", + "artist": "Dolby" } } }, { - "name": "MP4 - Elephants Dream", + "name": "Elephants Dream • MP4", "os": ["ios", "web", "android"], "needsLicense": false, "source": { "sources": { "src": "https://cdn.theoplayer.com/video/elephants-dream.mp4" + }, + "poster": "https://cdn.theoplayer.com/video/posters/elephants_dream.jpg", + "metadata": { + "title": "Elephants Dream MP4", + "album": "OptiView React Native demos", + "displayIconUri": "https://cdn.theoplayer.com/video/posters/elephants_dream.jpg", + "artist": "Dolby" } } }, { - "name": "MP3 - SoundHelix Song1", + "name": "SoundHelix Song1 • MP3", "os": ["ios", "web", "android"], "needsLicense": true, "source": { "sources": { "src": "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3" - } - } - }, - { - "name": "HLS - THEOads", - "os": ["ios", "web", "android"], - "needsLicense": true, - "source": { - "sources": { - "src": "https://cluster.dev.theostream.live/nfl-unified-channel/hls/k8s/live/scte35.isml/.m3u8", - "type": "application/x-mpegurl", - "hlsDateRange": true }, - "ads": [ - { - "integration": "theoads", - "networkCode": "51636543", - "customAssetKey": "nfl-sgai-demo", - "backdropDoubleBox": "https://demo.theoads.live/img/THEOads_double_box.svg", - "backdropLShape": "https://demo.theoads.live/img/THEOads_L_Shape.svg" - } - ] + "poster": "https://cdn.theoplayer.com/video/posters/dolby_optiview.jpg", + "metadata": { + "title": "SoundHelix Song 1", + "album": "OptiView React Native demos", + "mediaUri": "https://optiview.dolby.com", + "displayIconUri": "https://cdn.theoplayer.com/video/posters/dolby_optiview.jpg", + "artist": "Dolby" + } } }, { - "name": "Millicast", + "name": "Millicast Demo", "os": ["ios", "web"], "needsLicense": false, "source": { @@ -665,17 +660,19 @@ "streamAccountId": "k9Mwad" } ], + "poster": "https://cdn.theoplayer.com/video/posters/dolby_optiview.jpg", "metadata": { "title": "Millicast", "subtitle": "Millicast demo", "album": "OptiView React Native demos", "mediaUri": "https://optiview.dolby.com", + "displayIconUri": "https://cdn.theoplayer.com/video/posters/big_buck_bunny.jpg", "artist": "Dolby" } } }, { - "name": "Millicast", + "name": "Millicast Demo", "os": ["android"], "needsLicense": false, "source": { @@ -687,11 +684,13 @@ "apiUrl": "https://director.millicast.com/api/director/subscribe" } ], + "poster": "https://cdn.theoplayer.com/video/posters/dolby_optiview.jpg", "metadata": { "title": "Millicast", "subtitle": "Millicast demo", "album": "OptiView React Native demos", "mediaUri": "https://optiview.dolby.com", + "displayIconUri": "https://cdn.theoplayer.com/video/posters/big_buck_bunny.jpg", "artist": "Dolby" } } From 3b13562729eda6a61cc2a476f0154d64dd817adc Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Wed, 25 Mar 2026 09:51:24 +0100 Subject: [PATCH 11/30] Add media control API for Web --- src/internal/adapter/THEOplayerWebAdapter.ts | 4 ++ .../adapter/media/MediaControlWebAdapter.ts | 21 ++++++++++ src/internal/adapter/web/WebMediaSession.ts | 39 ++++++++++++------- 3 files changed, 51 insertions(+), 13 deletions(-) create mode 100644 src/internal/adapter/media/MediaControlWebAdapter.ts diff --git a/src/internal/adapter/THEOplayerWebAdapter.ts b/src/internal/adapter/THEOplayerWebAdapter.ts index eee810e4f..3e0ca7859 100644 --- a/src/internal/adapter/THEOplayerWebAdapter.ts +++ b/src/internal/adapter/THEOplayerWebAdapter.ts @@ -84,6 +84,10 @@ export class THEOplayerWebAdapter extends DefaultEventDispatcher } } + get mediaControl() { + return this._mediaSession?.mediaControlAdapter; + } + get abr(): ABRConfiguration | undefined { return this._player?.abr as ABRConfiguration | undefined; } diff --git a/src/internal/adapter/media/MediaControlWebAdapter.ts b/src/internal/adapter/media/MediaControlWebAdapter.ts new file mode 100644 index 000000000..4e1115121 --- /dev/null +++ b/src/internal/adapter/media/MediaControlWebAdapter.ts @@ -0,0 +1,21 @@ +import { MediaControlAction, MediaControlAPI, MediaControlHandler } from 'react-native-theoplayer'; +import { WebMediaSession } from '../web/WebMediaSession'; + +export class MediaControlWebAdapter implements MediaControlAPI { + private handlers: Map = new Map(); + + constructor(private readonly mediaSession: WebMediaSession) {} + + setHandler(action: MediaControlAction, handler: MediaControlHandler): void { + this.handlers.set(action, handler); + this.mediaSession.updateMediaSession(); + } + + hasHandler(action: MediaControlAction): boolean { + return this.handlers.has(action); + } + + getHandler(action: MediaControlAction): MediaControlHandler | undefined { + return this.handlers.get(action); + } +} diff --git a/src/internal/adapter/web/WebMediaSession.ts b/src/internal/adapter/web/WebMediaSession.ts index 6ab750f8f..c0359c639 100644 --- a/src/internal/adapter/web/WebMediaSession.ts +++ b/src/internal/adapter/web/WebMediaSession.ts @@ -1,7 +1,8 @@ /* eslint-disable @typescript-eslint/no-empty-function */ import type { ChromelessPlayer } from 'theoplayer'; import type { THEOplayerWebAdapter } from '../THEOplayerWebAdapter'; -import { MediaControlConfiguration } from 'react-native-theoplayer'; +import { MediaControlAction, MediaControlConfiguration } from 'react-native-theoplayer'; +import { MediaControlWebAdapter } from '../media/MediaControlWebAdapter'; const DEFAULT_SKIP_FORWARD_INTERVAL = 5; const DEFAULT_SKIP_BACKWARD_INTERVAL = 5; @@ -30,19 +31,23 @@ const mediaSession = (function () { * @link https://w3c.github.io/mediasession */ export class WebMediaSession { - private readonly _config: MediaControlConfiguration; - private readonly _player: ChromelessPlayer; - private readonly _webAdapter: THEOplayerWebAdapter; - - constructor(adapter: THEOplayerWebAdapter, player: ChromelessPlayer, config: MediaControlConfiguration = defaultMediaControlConfiguration) { - this._player = player; - this._webAdapter = adapter; - this._config = config; + private readonly _mediaControlAdapter: MediaControlWebAdapter; + + constructor( + private readonly _webAdapter: THEOplayerWebAdapter, + private readonly _player: ChromelessPlayer, + private readonly _config: MediaControlConfiguration = defaultMediaControlConfiguration, + ) { this._player.addEventListener('sourcechange', this.onSourceChange); + this._mediaControlAdapter = new MediaControlWebAdapter(this); + } + + get mediaControlAdapter() { + return this._mediaControlAdapter; } updateMediaSession() { - // update trickplay capabilities + // Update trick-play capabilities if (this.isTrickPlayEnabled()) { mediaSession.setActionHandler('seekbackward', (event) => { const skipTime = event.seekOffset || this._config.skipBackwardInterval || DEFAULT_SKIP_BACKWARD_INTERVAL; @@ -67,7 +72,7 @@ export class WebMediaSession { mediaSession.setActionHandler('seekto', NoOp); } - // update play/pause capabilities + // Update play/pause capabilities if (this.isPlayPauseEnabled()) { mediaSession.setActionHandler('play', () => { this._player?.play(); @@ -80,10 +85,18 @@ export class WebMediaSession { mediaSession.setActionHandler('pause', NoOp); } - // update playbackState + // Update queue actions + if (this.mediaControlAdapter.hasHandler(MediaControlAction.SKIP_TO_PREVIOUS)) { + mediaSession.setActionHandler('previoustrack', this.mediaControlAdapter.getHandler(MediaControlAction.SKIP_TO_PREVIOUS) as () => void); + } + if (this.mediaControlAdapter.hasHandler(MediaControlAction.SKIP_TO_NEXT)) { + mediaSession.setActionHandler('nexttrack', this.mediaControlAdapter.getHandler(MediaControlAction.SKIP_TO_NEXT) as () => void); + } + + // Update playbackState mediaSession.playbackState = this._player.paused ? 'paused' : 'playing'; - // update position + // Update position this.updatePositionState(); } From e09f3d1344d05ed64287e342229a27b749ee12d8 Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Wed, 25 Mar 2026 09:55:33 +0100 Subject: [PATCH 12/30] Update changelog --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3067b367a..908d8e580 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [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. + ## [10.12.1] - 26-03-19 ### Fixed From c21b6505040ee58ce00a98998d6ca024297bad5d Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Wed, 25 Mar 2026 09:57:48 +0100 Subject: [PATCH 13/30] Update adapter --- src/internal/adapter/media/MediaControlNativeAdapter.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/internal/adapter/media/MediaControlNativeAdapter.ts b/src/internal/adapter/media/MediaControlNativeAdapter.ts index b0548c450..c61327734 100644 --- a/src/internal/adapter/media/MediaControlNativeAdapter.ts +++ b/src/internal/adapter/media/MediaControlNativeAdapter.ts @@ -1,8 +1,10 @@ import { THEOplayer, MediaControlAction, MediaControlAPI, MediaControlHandler } from 'react-native-theoplayer'; import { NativeEventEmitter, NativeModules } from 'react-native'; +const NativeMediaControlModule = NativeModules.THEORCTMediaControlModule; + export class MediaControlNativeAdapter implements MediaControlAPI { - private mediaControlEmitter = new NativeEventEmitter(NativeModules.THEORCTMediaControlModule); + private mediaControlEmitter = new NativeEventEmitter(NativeMediaControlModule); private handlers: Map = new Map(); constructor(private readonly _player: THEOplayer) { @@ -16,6 +18,6 @@ export class MediaControlNativeAdapter implements MediaControlAPI { setHandler(action: MediaControlAction, handler: MediaControlHandler): void { this.handlers.set(action, handler); - NativeModules.THEORCTMediaControlModule.setHandler(this._player.nativeHandle || -1, action); + NativeMediaControlModule.setHandler(this._player.nativeHandle || -1, action); } } From 7246a54acc11834941769bf748368dd503ec03e1 Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Wed, 25 Mar 2026 09:58:21 +0100 Subject: [PATCH 14/30] Remove use of deprecated properties --- example/src/App.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/example/src/App.tsx b/example/src/App.tsx index d4386e629..3d72f128a 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -82,9 +82,7 @@ const playerConfig: PlayerConfiguration = { mediaSessionEnabled: true, skipForwardInterval: 30, skipBackwardInterval: 10, - convertSkipToSeek: true, - allowLivePlayPause: true, - seekToLiveOnResume: true, + allowLivePlayPause: false, }, ads: { theoads: true, From 3b3c55b3ebe17c9374ec7c07e12412cb2fe16610 Mon Sep 17 00:00:00 2001 From: William Van Haevre Date: Thu, 26 Mar 2026 12:12:30 +0100 Subject: [PATCH 15/30] Add iOS MediaControl module --- ios/THEOplayerRCTBridge.m | 7 ++++++ .../THEOplayerRCTMediaControlAPI.swift | 25 +++++++++++++++++++ react-native-theoplayer.podspec | 2 +- 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 ios/mediaControl/THEOplayerRCTMediaControlAPI.swift diff --git a/ios/THEOplayerRCTBridge.m b/ios/THEOplayerRCTBridge.m index 6ffa20537..d6430dc63 100644 --- a/ios/THEOplayerRCTBridge.m +++ b/ios/THEOplayerRCTBridge.m @@ -315,3 +315,10 @@ @interface RCT_EXTERN_REMAP_MODULE(THEORCTTHEOAdsModule, THEOplayerRCTTHEOAdsAPI @end +// ---------------------------------------------------------------------------- +// MediaControl Module +// ---------------------------------------------------------------------------- +@interface RCT_EXTERN_REMAP_MODULE(THEORCTMediaControlModule, THEOplayerRCTMediaControlAPI, RCTEventEmitter) + + +@end diff --git a/ios/mediaControl/THEOplayerRCTMediaControlAPI.swift b/ios/mediaControl/THEOplayerRCTMediaControlAPI.swift new file mode 100644 index 000000000..c6cae7224 --- /dev/null +++ b/ios/mediaControl/THEOplayerRCTMediaControlAPI.swift @@ -0,0 +1,25 @@ +// +// THEOplayerRCTMediaControlAPI.swift +// + +import Foundation +import UIKit +import THEOplayerSDK + +let MC_TAG: String = "[ContentProtectionMediaControlAPI]" + +@objc(THEOplayerRCTMediaControlAPI) +class THEOplayerRCTMediaControlAPI: RCTEventEmitter { + + override static func moduleName() -> String! { + return "THEOplayerRCTMediaControlAPI" + } + + override static func requiresMainQueueSetup() -> Bool { + return false + } + + override func supportedEvents() -> [String]! { + return ["MediaControlEvent"] + } +} diff --git a/react-native-theoplayer.podspec b/react-native-theoplayer.podspec index fe642bd34..02ee032f0 100644 --- a/react-native-theoplayer.podspec +++ b/react-native-theoplayer.podspec @@ -35,7 +35,7 @@ Pod::Spec.new do |s| s.platforms = { :ios => "13.4", :tvos => "13.4" } s.source = { :git => "https://www.theoplayer.com/.git", :tag => "#{s.version}" } - s.source_files = 'ios/*.{h,m,swift}', 'ios/ads/*.swift', 'ios/casting/*.swift', 'ios/contentprotection/*.swift', 'ios/pip/*.swift', 'ios/backgroundAudio/*.swift', 'ios/cache/*.swift', 'ios/sideloadedMetadata/*.swift', 'ios/eventBroadcasting/*.swift' , 'ios/ui/*.swift', 'ios/presentationMode/*.swift', 'ios/viewController/*.swift', 'ios/THEOlive/*.swift', 'ios/THEOads/*.swift', 'ios/millicast/*.swift' + s.source_files = 'ios/*.{h,m,swift}', 'ios/ads/*.swift', 'ios/casting/*.swift', 'ios/contentprotection/*.swift', 'ios/pip/*.swift', 'ios/backgroundAudio/*.swift', 'ios/cache/*.swift', 'ios/sideloadedMetadata/*.swift', 'ios/eventBroadcasting/*.swift' , 'ios/ui/*.swift', 'ios/presentationMode/*.swift', 'ios/mediaControl/*.swift', 'ios/viewController/*.swift', 'ios/THEOlive/*.swift', 'ios/THEOads/*.swift', 'ios/millicast/*.swift' s.resources = ['ios/*.css'] # ReactNative Dependency From 239bc6196726cb82b8f6b27008d6022a83a90aa3 Mon Sep 17 00:00:00 2001 From: William Van Haevre Date: Thu, 26 Mar 2026 12:12:46 +0100 Subject: [PATCH 16/30] Add MediaControl debug flag --- ios/THEOplayerRCTDebug.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ios/THEOplayerRCTDebug.swift b/ios/THEOplayerRCTDebug.swift index 9db3b6d30..39750e939 100644 --- a/ios/THEOplayerRCTDebug.swift +++ b/ios/THEOplayerRCTDebug.swift @@ -60,3 +60,6 @@ let DEBUG_PRESENTATIONMODES = DEBUG && false // Debug flag to monitor App state changes let DEBUG_APPSTATE = DEBUG && false +// Debug flag to monitor mediacontrol API usage +let DEBUG_MEDIA_CONTROL_API = DEBUG && false + From 25475897e3d9779f1ca567a60f7069fe2cca64f6 Mon Sep 17 00:00:00 2001 From: William Van Haevre Date: Thu, 26 Mar 2026 12:13:12 +0100 Subject: [PATCH 17/30] Bridge iOS setHandler method --- ios/THEOplayerRCTBridge.m | 2 ++ ios/mediaControl/THEOplayerRCTMediaControlAPI.swift | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/ios/THEOplayerRCTBridge.m b/ios/THEOplayerRCTBridge.m index d6430dc63..39bd12ec8 100644 --- a/ios/THEOplayerRCTBridge.m +++ b/ios/THEOplayerRCTBridge.m @@ -320,5 +320,7 @@ @interface RCT_EXTERN_REMAP_MODULE(THEORCTTHEOAdsModule, THEOplayerRCTTHEOAdsAPI // ---------------------------------------------------------------------------- @interface RCT_EXTERN_REMAP_MODULE(THEORCTMediaControlModule, THEOplayerRCTMediaControlAPI, RCTEventEmitter) +RCT_EXTERN_METHOD(setHandler:(nonnull NSNumber *)node + action:(nonnull NSString *)action) @end diff --git a/ios/mediaControl/THEOplayerRCTMediaControlAPI.swift b/ios/mediaControl/THEOplayerRCTMediaControlAPI.swift index c6cae7224..4470ef594 100644 --- a/ios/mediaControl/THEOplayerRCTMediaControlAPI.swift +++ b/ios/mediaControl/THEOplayerRCTMediaControlAPI.swift @@ -22,4 +22,8 @@ class THEOplayerRCTMediaControlAPI: RCTEventEmitter { override func supportedEvents() -> [String]! { return ["MediaControlEvent"] } + + @objc(setHandler:action:) + func setHandler(_ node: NSNumber, action: String) -> Void { + } } From c649dc99dc84b6afaf6f5ecb6687e284aa883819 Mon Sep 17 00:00:00 2001 From: William Van Haevre Date: Thu, 26 Mar 2026 12:13:35 +0100 Subject: [PATCH 18/30] Receive the action --- ios/mediaControl/THEOplayerRCTMediaControlAPI.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ios/mediaControl/THEOplayerRCTMediaControlAPI.swift b/ios/mediaControl/THEOplayerRCTMediaControlAPI.swift index 4470ef594..9f6c68954 100644 --- a/ios/mediaControl/THEOplayerRCTMediaControlAPI.swift +++ b/ios/mediaControl/THEOplayerRCTMediaControlAPI.swift @@ -25,5 +25,11 @@ class THEOplayerRCTMediaControlAPI: RCTEventEmitter { @objc(setHandler:action:) func setHandler(_ node: NSNumber, action: String) -> Void { + DispatchQueue.main.async { + if let theView = self.bridge.uiManager.view(forReactTag: node) as? THEOplayerRCTView, + let player = theView.player { + if DEBUG_MEDIA_CONTROL_API || true { PrintUtils.printLog(logText: "[NATIVE] Handler set for \(action) action") } + } + } } } From eda4d0cc5ee28c7d7f1c4f898f2149f57e63df0e Mon Sep 17 00:00:00 2001 From: William Van Haevre Date: Fri, 27 Mar 2026 12:06:23 +0100 Subject: [PATCH 19/30] Add string conversion methods for MediaControlAction --- ios/THEOplayerRCTTypeUtils.swift | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/ios/THEOplayerRCTTypeUtils.swift b/ios/THEOplayerRCTTypeUtils.swift index 7c5a210ae..77a2b1847 100644 --- a/ios/THEOplayerRCTTypeUtils.swift +++ b/ios/THEOplayerRCTTypeUtils.swift @@ -98,6 +98,36 @@ class THEOplayerRCTTypeUtils { } } + class func mediaControlActionFromString(_ action: String) -> MediaControlAction { + switch action { + case "play": + return MediaControlAction.PLAY + case "pause": + return MediaControlAction.PAUSE + case "skipToPrevious": + return MediaControlAction.SKIP_TO_PREVIOUS + case "skipToNext": + return MediaControlAction.SKIP_TO_NEXT + default: + return MediaControlAction.PLAY + } + } + + class func mediaControlActionToString(_ action: MediaControlAction) -> String { + switch action { + case MediaControlAction.PLAY: + return "play" + case MediaControlAction.PAUSE: + return "pause" + case MediaControlAction.SKIP_TO_PREVIOUS: + return "skipToPrevious" + case MediaControlAction.SKIP_TO_NEXT: + return "skipToNext" + default: + return "play" + } + } + class func textTrackEdgeStyleStringFromString(_ style: String) -> String { switch style { case "dropshadow": From 200e88a63859de0eadbda60f1c2a6fbd553f4a61 Mon Sep 17 00:00:00 2001 From: William Van Haevre Date: Fri, 27 Mar 2026 12:06:47 +0100 Subject: [PATCH 20/30] Add MediaControlManager for iOS --- .../THEOplayerRCTMediaControlManager.swift | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 ios/mediaControl/THEOplayerRCTMediaControlManager.swift diff --git a/ios/mediaControl/THEOplayerRCTMediaControlManager.swift b/ios/mediaControl/THEOplayerRCTMediaControlManager.swift new file mode 100644 index 000000000..7efa9917b --- /dev/null +++ b/ios/mediaControl/THEOplayerRCTMediaControlManager.swift @@ -0,0 +1,34 @@ +// THEOplayerRCTMediaControlManager.swift + +import Foundation + +enum MediaControlAction: String { + case PLAY = "closed" + case PAUSE = "restored" + case SKIP_TO_PREVIOUS = "skipToPrevious" + case SKIP_TO_NEXT = "skipToNext" +} + +public class THEOplayerRCTMediaControlManager { + private var actionHandlers: [MediaControlAction: (() -> Void)] = [:] + + func setMediaControlActionHandler(action: MediaControlAction, handler: @escaping (() -> Void)) { + self.actionHandlers[action] = handler + } + + func hasMediaControlActionHandler(for action: MediaControlAction) -> Bool { + return self.actionHandlers[action] != nil + } + + func executeMediaControlAction(action: MediaControlAction) -> Bool { + if let handler = self.actionHandlers[action] { + handler() + return true + } + return false + } + + func destroy() { + self.actionHandlers.removeAll() + } +} From 34673069f1fdc4c90529b24875a6996846e7b103 Mon Sep 17 00:00:00 2001 From: William Van Haevre Date: Fri, 27 Mar 2026 12:07:05 +0100 Subject: [PATCH 21/30] instantiate mediaControlManager --- ios/THEOplayerRCTView.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ios/THEOplayerRCTView.swift b/ios/THEOplayerRCTView.swift index e58c275e2..bb3e397c0 100644 --- a/ios/THEOplayerRCTView.swift +++ b/ios/THEOplayerRCTView.swift @@ -42,6 +42,7 @@ public class THEOplayerRCTView: UIView { var remoteCommandsManager: THEOplayerRCTRemoteCommandsManager var pipManager: THEOplayerRCTPipManager var pipControlsManager: THEOplayerRCTPipControlsManager + var mediaControlManager: THEOplayerRCTMediaControlManager var isApplicationInBackground: Bool = (UIApplication.shared.applicationState == .background) var adsConfig = AdsConfig() @@ -114,6 +115,7 @@ public class THEOplayerRCTView: UIView { self.remoteCommandsManager = THEOplayerRCTRemoteCommandsManager() self.pipManager = THEOplayerRCTPipManager() self.pipControlsManager = THEOplayerRCTPipControlsManager() + self.mediaControlManager = THEOplayerRCTMediaControlManager() super.init(frame: .zero) self.setupAppStateObservers() @@ -150,6 +152,7 @@ public class THEOplayerRCTView: UIView { self.pipControlsManager.destroy() self.presentationModeManager.destroy() self.backgroundAudioManager.destroy() + self.mediaControlManager.destroy() self.destroyBackgroundAudio() self.player?.removeAllIntegrations() From 74d04e45690fbaf1b6210cef330954100a406a90 Mon Sep 17 00:00:00 2001 From: William Van Haevre Date: Fri, 27 Mar 2026 12:08:09 +0100 Subject: [PATCH 22/30] Store bridged actions as event emitting actionHandlers --- .../THEOplayerRCTMediaControlAPI.swift | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/ios/mediaControl/THEOplayerRCTMediaControlAPI.swift b/ios/mediaControl/THEOplayerRCTMediaControlAPI.swift index 9f6c68954..296df5218 100644 --- a/ios/mediaControl/THEOplayerRCTMediaControlAPI.swift +++ b/ios/mediaControl/THEOplayerRCTMediaControlAPI.swift @@ -6,8 +6,6 @@ import Foundation import UIKit import THEOplayerSDK -let MC_TAG: String = "[ContentProtectionMediaControlAPI]" - @objc(THEOplayerRCTMediaControlAPI) class THEOplayerRCTMediaControlAPI: RCTEventEmitter { @@ -26,10 +24,28 @@ class THEOplayerRCTMediaControlAPI: RCTEventEmitter { @objc(setHandler:action:) func setHandler(_ node: NSNumber, action: String) -> Void { DispatchQueue.main.async { - if let theView = self.bridge.uiManager.view(forReactTag: node) as? THEOplayerRCTView, - let player = theView.player { - if DEBUG_MEDIA_CONTROL_API || true { PrintUtils.printLog(logText: "[NATIVE] Handler set for \(action) action") } + if let theView = self.bridge.uiManager.view(forReactTag: node) as? THEOplayerRCTView { + let mediaControlManager = theView.mediaControlManager + let mediaControlAction = THEOplayerRCTTypeUtils.mediaControlActionFromString(action) + mediaControlManager.setMediaControlActionHandler(action: mediaControlAction, handler: self.handlerForAction(action, node: node)) + if DEBUG_MEDIA_CONTROL_API { PrintUtils.printLog(logText: "[NATIVE] Handler set for \(action) action") } + } + } + } + + private func handlerForAction(_ action: String, node: NSNumber) -> (() -> Void) { + return { [weak self] in + if let self = self { + self.sendEvent( + withName: "MediaControlEvent", + body: [ + "action": action, + "tag": node + ] + ) } + if DEBUG_MEDIA_CONTROL_API { PrintUtils.printLog(logText: "[NATIVE] Handler triggered for \(action) action") } } } + } From e53581d4a5e98b6f885080ac467b58e32db35a6a Mon Sep 17 00:00:00 2001 From: William Van Haevre Date: Fri, 27 Mar 2026 12:15:45 +0100 Subject: [PATCH 23/30] Align pipConfig passing with other managers --- ios/THEOplayerRCTView.swift | 6 +++--- ios/pip/THEOplayerRCTPipControlsManager.swift | 21 ++++++++----------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/ios/THEOplayerRCTView.swift b/ios/THEOplayerRCTView.swift index bb3e397c0..2b5f90be5 100644 --- a/ios/THEOplayerRCTView.swift +++ b/ios/THEOplayerRCTView.swift @@ -76,7 +76,7 @@ public class THEOplayerRCTView: UIView { } var pipConfig = PipConfig() { didSet { - self.pipControlsManager.setPipConfig(pipConfig) + self.pipControlsManager.updatePipControls() } } var backgroundAudioConfig = BackgroundAudioConfig() { @@ -219,8 +219,8 @@ public class THEOplayerRCTView: UIView { self.theoadsEventHandler.setPlayer(player) self.castEventHandler.setPlayer(player) self.nowPlayingManager.setPlayer(player) - self.remoteCommandsManager.setPlayer(player) - self.pipControlsManager.setPlayer(player) + self.remoteCommandsManager.setPlayer(player, view: self) + self.pipControlsManager.setPlayer(player, view: self) self.presentationModeManager.setPlayer(player, view: self) self.backgroundAudioManager.setPlayer(player, view: self) self.pipManager.setView(view: self) diff --git a/ios/pip/THEOplayerRCTPipControlsManager.swift b/ios/pip/THEOplayerRCTPipControlsManager.swift index 64667be31..b76949b47 100644 --- a/ios/pip/THEOplayerRCTPipControlsManager.swift +++ b/ios/pip/THEOplayerRCTPipControlsManager.swift @@ -8,9 +8,9 @@ import MediaPlayer class THEOplayerRCTPipControlsManager: NSObject { // MARK: Members private weak var player: THEOplayer? + private weak var view: THEOplayerRCTView? private var isLive: Bool = false private var inAd: Bool = false - private var pipConfig = PipConfig() // MARK: player Listeners private var durationChangeListener: EventListener? @@ -25,8 +25,9 @@ class THEOplayerRCTPipControlsManager: NSObject { } // MARK: - player en controller setup / breakdown - func setPlayer(_ player: THEOplayer) { + func setPlayer(_ player: THEOplayer, view: THEOplayerRCTView?) { self.player = player; + self.view = view; self.isLive = false self.inAd = false @@ -34,11 +35,6 @@ class THEOplayerRCTPipControlsManager: NSObject { self.attachListeners() } - func setPipConfig(_ newPipConfig: PipConfig) { - self.pipConfig = newPipConfig - self.updatePipControls() - } - func willStartPip() { if let player = self.player, let duration = player.duration { @@ -54,12 +50,13 @@ class THEOplayerRCTPipControlsManager: NSObject { func updatePipControls() { if let player = self.player, - let pip = player.pip { + let pip = player.pip, + let pipConfig = self.view?.pipConfig { pip.configure(configuration: self.newPipConfiguration()) if DEBUG_PIPCONTROLS { PrintUtils.printLog(logText: "[NATIVE] Pip controls updated for \(self.isLive ? "LIVE" : "VOD"), \(self.inAd ? "AD IS PLAYING" : "NO AD PLAYING")") } if DEBUG_PIPCONTROLS { PrintUtils.printLog(logText: "requiresLinearPlayback = \(self.isLive || self.inAd)") } - if DEBUG_PIPCONTROLS { PrintUtils.printLog(logText: "canStartPictureInPictureAutomaticallyFromInline = \(self.pipConfig.canStartPictureInPictureAutomaticallyFromInline)") } - if DEBUG_PIPCONTROLS { PrintUtils.printLog(logText: "retainPresentationModeOnSourceChange = \(self.pipConfig.retainPresentationModeOnSourceChange)") } + if DEBUG_PIPCONTROLS { PrintUtils.printLog(logText: "canStartPictureInPictureAutomaticallyFromInline = \(pipConfig.canStartPictureInPictureAutomaticallyFromInline)") } + if DEBUG_PIPCONTROLS { PrintUtils.printLog(logText: "retainPresentationModeOnSourceChange = \(pipConfig.retainPresentationModeOnSourceChange)") } } } @@ -69,8 +66,8 @@ class THEOplayerRCTPipControlsManager: NSObject { builder.requiresLinearPlayback = self.isLive || self.inAd #if os(iOS) builder.nativePictureInPicture = true - builder.canStartPictureInPictureAutomaticallyFromInline = self.pipConfig.canStartPictureInPictureAutomaticallyFromInline - builder.retainPresentationModeOnSourceChange = self.pipConfig.retainPresentationModeOnSourceChange + builder.canStartPictureInPictureAutomaticallyFromInline = self.view?.pipConfig.canStartPictureInPictureAutomaticallyFromInline ?? false + builder.retainPresentationModeOnSourceChange = self.view?.pipConfig.retainPresentationModeOnSourceChange ?? false #endif return builder.build() } From 7c035ea13c4022eda09bceb2319a9711467eaedb Mon Sep 17 00:00:00 2001 From: William Van Haevre Date: Fri, 27 Mar 2026 12:35:10 +0100 Subject: [PATCH 24/30] Setup defaults for MediaControlConfig --- .../THEOplayerRCTView+MediaControlConfig.swift | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/ios/backgroundAudio/THEOplayerRCTView+MediaControlConfig.swift b/ios/backgroundAudio/THEOplayerRCTView+MediaControlConfig.swift index 3cbdd227b..405f23585 100644 --- a/ios/backgroundAudio/THEOplayerRCTView+MediaControlConfig.swift +++ b/ios/backgroundAudio/THEOplayerRCTView+MediaControlConfig.swift @@ -1,14 +1,19 @@ -// THEOplayerRCTView+UIConfig.swift +// THEOplayerRCTView+MediaControlConfig.swift import Foundation import THEOplayerSDK +let DEFAULT_SKIP_INTERVAL = 15 +let DEFAULT_CONVERT_SKIP_TO_SEEK = false +let DEFAULT_ALLOW_LIVE_PLAY_PAUSE = true +let DEFAULT_SEEK_TO_LIVE_ON_RESUME = false + struct MediaControlConfig { - var skipForwardInterval: Int = 15 - var skipBackwardInterval: Int = 15 - var convertSkipToSeek: Bool = false - var allowLivePlayPause: Bool = true - var seekToLiveOnResume: Bool = false + var skipForwardInterval: Int = DEFAULT_SKIP_INTERVAL + var skipBackwardInterval: Int = DEFAULT_SKIP_INTERVAL + var convertSkipToSeek: Bool = DEFAULT_CONVERT_SKIP_TO_SEEK + var allowLivePlayPause: Bool = DEFAULT_ALLOW_LIVE_PLAY_PAUSE + var seekToLiveOnResume: Bool = DEFAULT_SEEK_TO_LIVE_ON_RESUME } extension THEOplayerRCTView { From 8d065d570ee003e4df8029eee4b20d43fcab91ea Mon Sep 17 00:00:00 2001 From: William Van Haevre Date: Fri, 27 Mar 2026 12:36:24 +0100 Subject: [PATCH 25/30] Align mediaControlConfig passing with other managers + use computed values for config values --- ios/THEOplayerRCTView.swift | 2 +- .../THEOplayerRCTRemoteCommandsManager.swift | 51 ++++++++++++------- 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/ios/THEOplayerRCTView.swift b/ios/THEOplayerRCTView.swift index 2b5f90be5..2f9b12d2d 100644 --- a/ios/THEOplayerRCTView.swift +++ b/ios/THEOplayerRCTView.swift @@ -71,7 +71,7 @@ public class THEOplayerRCTView: UIView { var mediaControlConfig = MediaControlConfig() { didSet { - self.remoteCommandsManager.setMediaControlConfig(mediaControlConfig) + self.remoteCommandsManager.updateRemoteCommands() } } var pipConfig = PipConfig() { diff --git a/ios/backgroundAudio/THEOplayerRCTRemoteCommandsManager.swift b/ios/backgroundAudio/THEOplayerRCTRemoteCommandsManager.swift index b908ee43f..0f4b8cae8 100644 --- a/ios/backgroundAudio/THEOplayerRCTRemoteCommandsManager.swift +++ b/ios/backgroundAudio/THEOplayerRCTRemoteCommandsManager.swift @@ -7,10 +7,10 @@ import MediaPlayer class THEOplayerRCTRemoteCommandsManager: NSObject { // MARK: Members private weak var player: THEOplayer? + private weak var view: THEOplayerRCTView? private var isLive: Bool = false private var inAd: Bool = false private var hasSource: Bool = false - private var mediaControlConfig = MediaControlConfig() // MARK: player Listeners private var durationChangeListener: EventListener? @@ -18,6 +18,23 @@ class THEOplayerRCTRemoteCommandsManager: NSObject { private var adBreakBeginListener: EventListener? private var adBreakEndListener: EventListener? + // MARK: computed + private var seekToLiveOnResume: Bool { + self.view?.mediaControlConfig.seekToLiveOnResume ?? DEFAULT_SEEK_TO_LIVE_ON_RESUME + } + private var skipForwardInterval: NSNumber { + NSNumber(value: self.view?.mediaControlConfig.skipForwardInterval ?? DEFAULT_SKIP_INTERVAL) + } + private var skipBackwardInterval: NSNumber { + NSNumber(value: self.view?.mediaControlConfig.skipBackwardInterval ?? DEFAULT_SKIP_INTERVAL) + } + private var allowLivePlayPause: Bool { + self.view?.mediaControlConfig.allowLivePlayPause ?? DEFAULT_ALLOW_LIVE_PLAY_PAUSE + } + private var convertSkipToSeek: Bool { + self.view?.mediaControlConfig.convertSkipToSeek ?? DEFAULT_CONVERT_SKIP_TO_SEEK + } + // MARK: - destruction func destroy() { // dettach listeners @@ -25,19 +42,15 @@ class THEOplayerRCTRemoteCommandsManager: NSObject { } // MARK: - player setup / breakdown - func setPlayer(_ player: THEOplayer) { + func setPlayer(_ player: THEOplayer, view: THEOplayerRCTView?) { self.player = player; + self.view = view; self.initRemoteCommands() // attach listeners self.attachListeners() } - func setMediaControlConfig(_ newMediaControlConfig: MediaControlConfig) { - self.mediaControlConfig = newMediaControlConfig - self.updateRemoteCommands() - } - private func initRemoteCommands() { self.isLive = false self.inAd = false @@ -65,10 +78,10 @@ class THEOplayerRCTRemoteCommandsManager: NSObject { // SCRUBBER commandCenter.changePlaybackPositionCommand.addTarget(self, action: #selector(onScrubCommand(_:))) // ADD SEEK FORWARD - commandCenter.skipForwardCommand.preferredIntervals = [NSNumber(value: self.mediaControlConfig.skipForwardInterval)] + commandCenter.skipForwardCommand.preferredIntervals = [self.skipForwardInterval] commandCenter.skipForwardCommand.addTarget(self, action: #selector(onSkipForwardCommand(_:))) // ADD SEEK BACKWARD - commandCenter.skipBackwardCommand.preferredIntervals = [NSNumber(value: self.mediaControlConfig.skipBackwardInterval)] + commandCenter.skipBackwardCommand.preferredIntervals = [self.skipBackwardInterval] commandCenter.skipBackwardCommand.addTarget(self, action: #selector(onSkipBackwardCommand(_:))) // ADD NEXT TRACK commandCenter.nextTrackCommand.addTarget(self, action: #selector(onNextTrackCommand(_:))) @@ -82,7 +95,7 @@ class THEOplayerRCTRemoteCommandsManager: NSObject { let commandCenter = MPRemoteCommandCenter.shared() let skipControlsEnabled = self.hasSource && !self.inAd && !self.isLive - let playPauseControlsEnabled = self.hasSource && !self.inAd && (!self.isLive || mediaControlConfig.allowLivePlayPause) + let playPauseControlsEnabled = self.hasSource && !self.inAd && (!self.isLive || self.allowLivePlayPause) // update the enabled state to have correct visual representation in the lockscreen commandCenter.pauseCommand.isEnabled = playPauseControlsEnabled @@ -96,16 +109,16 @@ class THEOplayerRCTRemoteCommandsManager: NSObject { commandCenter.previousTrackCommand.isEnabled = skipControlsEnabled // set configured skip forward/backward intervals - commandCenter.skipForwardCommand.preferredIntervals = [NSNumber(value: self.mediaControlConfig.skipForwardInterval)] - commandCenter.skipBackwardCommand.preferredIntervals = [NSNumber(value: self.mediaControlConfig.skipBackwardInterval)] + commandCenter.skipForwardCommand.preferredIntervals = [self.skipForwardInterval] + commandCenter.skipBackwardCommand.preferredIntervals = [self.skipBackwardInterval] - if DEBUG_REMOTECOMMANDS { PrintUtils.printLog(logText: "[NATIVE] Remote commands updated for \(self.isLive ? "LIVE" : "VOD") (ALLOWLIVEPLAYPAUSE: \(mediaControlConfig.allowLivePlayPause)) (\(self.inAd ? "AD IS PLAYING" : "NO AD PLAYING")).") } + if DEBUG_REMOTECOMMANDS { PrintUtils.printLog(logText: "[NATIVE] Remote commands updated for \(self.isLive ? "LIVE" : "VOD") (ALLOWLIVEPLAYPAUSE: \(self.view?.mediaControlConfig.allowLivePlayPause ?? false)) (\(self.inAd ? "AD IS PLAYING" : "NO AD PLAYING")).") } } @objc private func onPlayCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { if let player = self.player, !self.inAd { - if self.isLive && self.mediaControlConfig.seekToLiveOnResume { + if self.isLive && self.seekToLiveOnResume { if DEBUG_REMOTECOMMANDS { PrintUtils.printLog(logText: "[NATIVE] Seek to live.") } player.currentTime = .infinity } @@ -132,7 +145,7 @@ class THEOplayerRCTRemoteCommandsManager: NSObject { if let player = self.player, !self.inAd { if player.paused { - if self.isLive && self.mediaControlConfig.seekToLiveOnResume { + if self.isLive && self.seekToLiveOnResume { if DEBUG_REMOTECOMMANDS { PrintUtils.printLog(logText: "[NATIVE] Seek to live.") } player.currentTime = .infinity } @@ -200,10 +213,10 @@ class THEOplayerRCTRemoteCommandsManager: NSObject { @objc private func onPreviousTrackCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { if let player = self.player, - self.mediaControlConfig.convertSkipToSeek, + self.convertSkipToSeek, !self.isLive, !self.inAd { - player.currentTime = player.currentTime - Double(self.mediaControlConfig.skipBackwardInterval) + player.currentTime = player.currentTime - Double(truncating: self.skipBackwardInterval) if DEBUG_REMOTECOMMANDS { PrintUtils.printLog(logText: "[NATIVE] previous track command handled as skip backward command.") } } else { if DEBUG_REMOTECOMMANDS { PrintUtils.printLog(logText: "[NATIVE] previous track command not handled.") } @@ -213,10 +226,10 @@ class THEOplayerRCTRemoteCommandsManager: NSObject { @objc private func onNextTrackCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { if let player = self.player, - self.mediaControlConfig.convertSkipToSeek, + self.convertSkipToSeek, !self.isLive, !self.inAd { - player.currentTime = player.currentTime + Double(self.mediaControlConfig.skipForwardInterval) + player.currentTime = player.currentTime + Double(truncating: self.skipForwardInterval) if DEBUG_REMOTECOMMANDS { PrintUtils.printLog(logText: "[NATIVE] next track command handled as skip forward command.") } } else { if DEBUG_REMOTECOMMANDS { PrintUtils.printLog(logText: "[NATIVE] next track command not handled.") } From d5785665e047db7d38434ba57cf70d6474bd72a7 Mon Sep 17 00:00:00 2001 From: William Van Haevre Date: Fri, 27 Mar 2026 13:17:54 +0100 Subject: [PATCH 26/30] Execute actionHandlers when defined. --- .../THEOplayerRCTRemoteCommandsManager.swift | 79 +++++++++++++------ 1 file changed, 56 insertions(+), 23 deletions(-) diff --git a/ios/backgroundAudio/THEOplayerRCTRemoteCommandsManager.swift b/ios/backgroundAudio/THEOplayerRCTRemoteCommandsManager.swift index 0f4b8cae8..7301a9317 100644 --- a/ios/backgroundAudio/THEOplayerRCTRemoteCommandsManager.swift +++ b/ios/backgroundAudio/THEOplayerRCTRemoteCommandsManager.swift @@ -91,22 +91,32 @@ class THEOplayerRCTRemoteCommandsManager: NSObject { if DEBUG_REMOTECOMMANDS { PrintUtils.printLog(logText: "[NATIVE] Remote commands initialised.") } } + private func hasActionHandler(for action: MediaControlAction) -> Bool { + return self.view?.mediaControlManager.hasMediaControlActionHandler(for: action) ?? false + } + + private func executeAction(for action: MediaControlAction) -> Bool { + return self.view?.mediaControlManager.executeMediaControlAction(action: action) ?? false + } + func updateRemoteCommands() { let commandCenter = MPRemoteCommandCenter.shared() - let skipControlsEnabled = self.hasSource && !self.inAd && !self.isLive let playPauseControlsEnabled = self.hasSource && !self.inAd && (!self.isLive || self.allowLivePlayPause) + let positionControlEnabled = self.hasSource && !self.inAd && !self.isLive + let seekControlEnabled = self.hasSource && !self.inAd && !self.isLive && !self.hasActionHandler(for: .SKIP_TO_NEXT) && !self.hasActionHandler(for: .SKIP_TO_PREVIOUS) + let trackControlEnabled = self.hasSource && !self.inAd && !self.isLive && self.hasActionHandler(for: .SKIP_TO_NEXT) && self.hasActionHandler(for: .SKIP_TO_PREVIOUS) // update the enabled state to have correct visual representation in the lockscreen commandCenter.pauseCommand.isEnabled = playPauseControlsEnabled commandCenter.playCommand.isEnabled = playPauseControlsEnabled commandCenter.togglePlayPauseCommand.isEnabled = playPauseControlsEnabled commandCenter.stopCommand.isEnabled = playPauseControlsEnabled - commandCenter.changePlaybackPositionCommand.isEnabled = skipControlsEnabled - commandCenter.skipForwardCommand.isEnabled = skipControlsEnabled - commandCenter.skipBackwardCommand.isEnabled = skipControlsEnabled - commandCenter.nextTrackCommand.isEnabled = skipControlsEnabled - commandCenter.previousTrackCommand.isEnabled = skipControlsEnabled + commandCenter.changePlaybackPositionCommand.isEnabled = positionControlEnabled + commandCenter.skipForwardCommand.isEnabled = seekControlEnabled + commandCenter.skipBackwardCommand.isEnabled = seekControlEnabled + commandCenter.nextTrackCommand.isEnabled = trackControlEnabled + commandCenter.previousTrackCommand.isEnabled = trackControlEnabled // set configured skip forward/backward intervals commandCenter.skipForwardCommand.preferredIntervals = [self.skipForwardInterval] @@ -122,7 +132,10 @@ class THEOplayerRCTRemoteCommandsManager: NSObject { if DEBUG_REMOTECOMMANDS { PrintUtils.printLog(logText: "[NATIVE] Seek to live.") } player.currentTime = .infinity } - player.play() + if !self.executeAction(for: .PLAY) { + if DEBUG_MEDIA_CONTROL_API { PrintUtils.printLog(logText: "[NATIVE] Executing default Play action.") } + player.play() + } if DEBUG_REMOTECOMMANDS { PrintUtils.printLog(logText: "[NATIVE] Play command handled.") } } else { if DEBUG_REMOTECOMMANDS { PrintUtils.printLog(logText: "[NATIVE] Play command not handled.") } @@ -133,7 +146,10 @@ class THEOplayerRCTRemoteCommandsManager: NSObject { @objc private func onPauseCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { if let player = self.player, !self.inAd { - player.pause() + if !self.executeAction(for: .PAUSE) { + if DEBUG_MEDIA_CONTROL_API { PrintUtils.printLog(logText: "[NATIVE] Executing default Pause action.") } + player.pause() + } if DEBUG_REMOTECOMMANDS { PrintUtils.printLog(logText: "[NATIVE] Pause command handled.") } } else { if DEBUG_REMOTECOMMANDS { PrintUtils.printLog(logText: "[NATIVE] Pause command not handled.") } @@ -144,17 +160,23 @@ class THEOplayerRCTRemoteCommandsManager: NSObject { @objc private func onTogglePlayPauseCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { if let player = self.player, !self.inAd { - if player.paused { - if self.isLive && self.seekToLiveOnResume { - if DEBUG_REMOTECOMMANDS { PrintUtils.printLog(logText: "[NATIVE] Seek to live.") } - player.currentTime = .infinity + if player.paused { + if !self.executeAction(for: .PLAY) { + if DEBUG_MEDIA_CONTROL_API { PrintUtils.printLog(logText: "[NATIVE] Executing default Toogle play action.") } + if self.isLive && self.seekToLiveOnResume { + if DEBUG_REMOTECOMMANDS { PrintUtils.printLog(logText: "[NATIVE] Seek to live.") } + player.currentTime = .infinity + } + player.play() + } + if DEBUG_REMOTECOMMANDS { PrintUtils.printLog(logText: "[NATIVE] Toggled to playing.") } + } else { + if !self.executeAction(for: .PAUSE) { + if DEBUG_MEDIA_CONTROL_API { PrintUtils.printLog(logText: "[NATIVE] Executing default Toogle pause action.") } + player.pause() + } + if DEBUG_REMOTECOMMANDS { PrintUtils.printLog(logText: "[NATIVE] Toggled to paused.") } } - if DEBUG_REMOTECOMMANDS { PrintUtils.printLog(logText: "[NATIVE] Toggled to playing.") } - player.play() - } else { - if DEBUG_REMOTECOMMANDS { PrintUtils.printLog(logText: "[NATIVE] Toggled to paused.") } - player.pause() - } if DEBUG_REMOTECOMMANDS { PrintUtils.printLog(logText: "[NATIVE] Toggle play/pause command handled.") } } else { if DEBUG_REMOTECOMMANDS { PrintUtils.printLog(logText: "[NATIVE] Toggle play/pause command not handled.") } @@ -166,7 +188,10 @@ class THEOplayerRCTRemoteCommandsManager: NSObject { if let player = self.player, !self.inAd { if !player.paused { - player.pause() + if !self.executeAction(for: .PAUSE) { + if DEBUG_MEDIA_CONTROL_API { PrintUtils.printLog(logText: "[NATIVE] Executing default Pause action.") } + player.pause() + } } if DEBUG_REMOTECOMMANDS { PrintUtils.printLog(logText: "[NATIVE] Stop command handled.") } } else { @@ -213,10 +238,14 @@ class THEOplayerRCTRemoteCommandsManager: NSObject { @objc private func onPreviousTrackCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { if let player = self.player, - self.convertSkipToSeek, !self.isLive, !self.inAd { - player.currentTime = player.currentTime - Double(truncating: self.skipBackwardInterval) + if !self.executeAction(for: .SKIP_TO_PREVIOUS) { + if DEBUG_MEDIA_CONTROL_API { PrintUtils.printLog(logText: "[NATIVE] Executing default Skip to previous action.") } + if self.convertSkipToSeek { + player.currentTime = player.currentTime - Double(truncating: self.skipBackwardInterval) + } + } if DEBUG_REMOTECOMMANDS { PrintUtils.printLog(logText: "[NATIVE] previous track command handled as skip backward command.") } } else { if DEBUG_REMOTECOMMANDS { PrintUtils.printLog(logText: "[NATIVE] previous track command not handled.") } @@ -226,10 +255,14 @@ class THEOplayerRCTRemoteCommandsManager: NSObject { @objc private func onNextTrackCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { if let player = self.player, - self.convertSkipToSeek, !self.isLive, !self.inAd { - player.currentTime = player.currentTime + Double(truncating: self.skipForwardInterval) + if !self.executeAction(for: .SKIP_TO_NEXT) { + if DEBUG_MEDIA_CONTROL_API { PrintUtils.printLog(logText: "[NATIVE] Executing default Skip to next action.") } + if self.convertSkipToSeek { + player.currentTime = player.currentTime + Double(truncating: self.skipForwardInterval) + } + } if DEBUG_REMOTECOMMANDS { PrintUtils.printLog(logText: "[NATIVE] next track command handled as skip forward command.") } } else { if DEBUG_REMOTECOMMANDS { PrintUtils.printLog(logText: "[NATIVE] next track command not handled.") } From 27e304c64889ce2bb9acd2b71d74bca7c64a0720 Mon Sep 17 00:00:00 2001 From: William Van Haevre Date: Tue, 31 Mar 2026 13:34:37 +0200 Subject: [PATCH 27/30] Track control is enabled when actions handlers have been set, no other rules required --- ios/backgroundAudio/THEOplayerRCTRemoteCommandsManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/backgroundAudio/THEOplayerRCTRemoteCommandsManager.swift b/ios/backgroundAudio/THEOplayerRCTRemoteCommandsManager.swift index 7301a9317..25c035ba3 100644 --- a/ios/backgroundAudio/THEOplayerRCTRemoteCommandsManager.swift +++ b/ios/backgroundAudio/THEOplayerRCTRemoteCommandsManager.swift @@ -105,7 +105,7 @@ class THEOplayerRCTRemoteCommandsManager: NSObject { let playPauseControlsEnabled = self.hasSource && !self.inAd && (!self.isLive || self.allowLivePlayPause) let positionControlEnabled = self.hasSource && !self.inAd && !self.isLive let seekControlEnabled = self.hasSource && !self.inAd && !self.isLive && !self.hasActionHandler(for: .SKIP_TO_NEXT) && !self.hasActionHandler(for: .SKIP_TO_PREVIOUS) - let trackControlEnabled = self.hasSource && !self.inAd && !self.isLive && self.hasActionHandler(for: .SKIP_TO_NEXT) && self.hasActionHandler(for: .SKIP_TO_PREVIOUS) + let trackControlEnabled = self.hasActionHandler(for: .SKIP_TO_NEXT) && self.hasActionHandler(for: .SKIP_TO_PREVIOUS) // update the enabled state to have correct visual representation in the lockscreen commandCenter.pauseCommand.isEnabled = playPauseControlsEnabled From 462d0eac1b1cd2979dcde2d719861a524c46007a Mon Sep 17 00:00:00 2001 From: William Van Haevre Date: Tue, 31 Mar 2026 15:21:10 +0200 Subject: [PATCH 28/30] Stop on the fly check for live or in ad status when processing track control actions --- .../THEOplayerRCTRemoteCommandsManager.swift | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/ios/backgroundAudio/THEOplayerRCTRemoteCommandsManager.swift b/ios/backgroundAudio/THEOplayerRCTRemoteCommandsManager.swift index 25c035ba3..b4553c709 100644 --- a/ios/backgroundAudio/THEOplayerRCTRemoteCommandsManager.swift +++ b/ios/backgroundAudio/THEOplayerRCTRemoteCommandsManager.swift @@ -237,9 +237,7 @@ class THEOplayerRCTRemoteCommandsManager: NSObject { } @objc private func onPreviousTrackCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { - if let player = self.player, - !self.isLive, - !self.inAd { + if let player = self.player { if !self.executeAction(for: .SKIP_TO_PREVIOUS) { if DEBUG_MEDIA_CONTROL_API { PrintUtils.printLog(logText: "[NATIVE] Executing default Skip to previous action.") } if self.convertSkipToSeek { @@ -254,9 +252,7 @@ class THEOplayerRCTRemoteCommandsManager: NSObject { } @objc private func onNextTrackCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { - if let player = self.player, - !self.isLive, - !self.inAd { + if let player = self.player { if !self.executeAction(for: .SKIP_TO_NEXT) { if DEBUG_MEDIA_CONTROL_API { PrintUtils.printLog(logText: "[NATIVE] Executing default Skip to next action.") } if self.convertSkipToSeek { From 8e50c898ea25435b29460e40e8d0c8867c081643 Mon Sep 17 00:00:00 2001 From: William Van Haevre Date: Tue, 31 Mar 2026 17:39:37 +0200 Subject: [PATCH 29/30] Update docs --- README.md | 1 + doc/mediacontrol.md | 87 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 doc/mediacontrol.md diff --git a/README.md b/README.md index 59828656f..0c2105a9d 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/doc/mediacontrol.md b/doc/mediacontrol.md new file mode 100644 index 000000000..55bc33d2a --- /dev/null +++ b/doc/mediacontrol.md @@ -0,0 +1,87 @@ +# Media Control API + +Our [Media Control API](../src/api/media/MediaControlAPI.ts) provides a unified way to customise the behaviour of the different media playback controls and media sessions across platforms (iOS, Android, and Web). It enables integration with platform-level media controls such as lock screen controls, notification controls, media session, ... + +## What is it used for? + +- **Remote Control Actions:** Handle play, pause, seek, skip, and track switching from system UI (lock screen, notifications, etc.). +- **Media Session Integration:** Display media state and respond to hardware/media key events. +- **Custom Playlist Navigation:** Enable playlist navigation using system controls. + +## Platform Support + +- **Android:** Integrates with [Media Session](https://developer.android.com/guide/topics/media-apps/working-with-a-media-session) and media notifications. +- **iOS:** Integrates with [Now Playing](https://developer.apple.com/documentation/mediaplayer/mpnowplayinginfocenter) and [Remote Command Center](https://developer.apple.com/documentation/mediaplayer/mpremotecommandcenter). +- **Web:** Integrates with the [Media Session API](https://www.w3.org/TR/mediasession/). + +## MediaControl API and MediaControl Action Reference + +The [Media Control API](../src/api/media/MediaControlAPI.ts) allows you to override the default player's behaviour, by defining a handler for one of the MediaControl Actions: +```typescript +setHandler(action: MediaControlAction, handler: MediaControlHandler): void; +``` + +The [MediaControlAction](../src/api/media/MediaControlAPI.ts) enum defines all actions that can be controlled by the Media Control API: + +- `PLAY`: Triggered when the user presses play. +- `PAUSE`: Triggered when the user presses pause. +- `SEEK_FORWARD`: Triggered when the user requests to seek forward by a preset interval. +- `SEEK_BACKWARD`: Triggered when the user requests to seek backward by a preset interval. +- `SKIP_TO_NEXT`: Triggered when the user requests to go to the next track or playlist item. +- `SKIP_TO_PREVIOUS`: Triggered when the user requests to go to the previous track or playlist item. + +Play and pause are only enabled for VOD and when the stream is not displaying an ad. For LIVE streams this can be configured through `allowLivePlayPause` in the player's [MediaControlConfiguration](../src/api/media/MediaControlConfiguration.ts). + +If no handler is defined for an action, the player's default behaviour is applied. + +### iOS: Track Control vs. Seek Behavior + +On iOS, when you set handlers for `SKIP_TO_NEXT` or `SKIP_TO_PREVIOUS`, these handlers will take precedence over the seek behavior. This means: + +- **If you provide next/previous track handlers:** + - System controls (e.g., lock screen) will display and trigger your handlers for track navigation. + - Seeking forward/backward via next/previous is not shown as seperate controls. (Platform limitation) +- **If you do not provide next/previous handlers:** + - The system will display and use the default or configured seek forward/backward functionality. + +This allows you to customize whether system controls are used for playlist navigation or for seeking within the current track. + +Note: In both cases, you can always use the system's time slider to adjust the playhead to seek to a location in the stream. + +## Configuration + +You can add additional media control configuration using the [MediaControlConfiguration](../src/api/media/MediaControlConfiguration.ts) interface: + +```typescript +export interface MediaControlConfiguration { + mediaSessionEnabled?: boolean; // (Web/Android) Enable/disable media session (default: true) + skipForwardInterval?: number; // (Web/Android/iOS) Skip forward interval (defaults: 5s on Web, Android / 15s on iOS) + skipBackwardInterval?: number; // (Web/Android/iOS) Skip backward interval (defaults: 5s on Web, Android / 15s on iOS) + allowLivePlayPause?: boolean; // (Android/iOS) Enable play/pause for live (defaults: false on Android / true on iOS) +} +``` + +## Example Usage: Playlist Navigation + +The Media Control API can be used to handle playlist navigation via system controls. For example, in a custom React hook: + +```typescript +import { MediaControlAction } from 'react-native-theoplayer'; + +// ... +useEffect(() => { + if (!player) return; + + const handleNext = () => { /* update player source ... */ }; + const handlePrevious = () => { /* update player source ... */ }; + + player.mediaControl?.setHandler(MediaControlAction.SKIP_TO_NEXT, handleNext); + player.mediaControl?.setHandler(MediaControlAction.SKIP_TO_PREVIOUS, handlePrevious); +}, [player, filteredSources]); +``` + +This enables users to skip tracks using lock screen or Bluetooth controls. If you do not set these handlers, the controls will perform seek actions instead. + +## Demo + +As a demonstration, see the the `usePlaylist` hook in our [example app](../example/). From 27ecd32cabcb8d21c6376403cea25563cc472181 Mon Sep 17 00:00:00 2001 From: William Van Haevre Date: Tue, 31 Mar 2026 17:48:21 +0200 Subject: [PATCH 30/30] Describe the correct MediaControlActions in the documentation --- doc/mediacontrol.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/doc/mediacontrol.md b/doc/mediacontrol.md index 55bc33d2a..8af800ecb 100644 --- a/doc/mediacontrol.md +++ b/doc/mediacontrol.md @@ -25,8 +25,6 @@ The [MediaControlAction](../src/api/media/MediaControlAPI.ts) enum defines all a - `PLAY`: Triggered when the user presses play. - `PAUSE`: Triggered when the user presses pause. -- `SEEK_FORWARD`: Triggered when the user requests to seek forward by a preset interval. -- `SEEK_BACKWARD`: Triggered when the user requests to seek backward by a preset interval. - `SKIP_TO_NEXT`: Triggered when the user requests to go to the next track or playlist item. - `SKIP_TO_PREVIOUS`: Triggered when the user requests to go to the previous track or playlist item. @@ -42,7 +40,7 @@ On iOS, when you set handlers for `SKIP_TO_NEXT` or `SKIP_TO_PREVIOUS`, these ha - System controls (e.g., lock screen) will display and trigger your handlers for track navigation. - Seeking forward/backward via next/previous is not shown as seperate controls. (Platform limitation) - **If you do not provide next/previous handlers:** - - The system will display and use the default or configured seek forward/backward functionality. + - The system will display and use the seek forward/backward functionality with the configured or default intervals. This allows you to customize whether system controls are used for playlist navigation or for seeking within the current track.