diff --git a/amazon-ivs-react-native-player.podspec b/amazon-ivs-react-native-player.podspec index 136fb7c..0ed8d9e 100644 --- a/amazon-ivs-react-native-player.podspec +++ b/amazon-ivs-react-native-player.podspec @@ -27,5 +27,5 @@ Pod::Spec.new do |s| s.dependency "React-Core" - s.dependency "AmazonIVSPlayer", "~> 1.23.0" + s.dependency "AmazonIVSPlayer", "~> 1.25.0-rc.2.1" end diff --git a/android/gradle.properties b/android/gradle.properties index c225a4a..519fdb3 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -2,4 +2,4 @@ AmazonIvs_kotlinVersion=1.6.10 AmazonIvs_compileSdkVersion=31 AmazonIvs_buildToolsVersion=31.0.0 AmazonIvs_targetSdkVersion=31 -AmazonIvs_ivsVersion=1.23.0 +AmazonIvs_ivsVersion=1.25.0-rc.2 diff --git a/android/src/main/java/com/amazonaws/ivs/reactnative/player/AmazonIvsView.kt b/android/src/main/java/com/amazonaws/ivs/reactnative/player/AmazonIvsView.kt index 60988bd..d5ee42b 100644 --- a/android/src/main/java/com/amazonaws/ivs/reactnative/player/AmazonIvsView.kt +++ b/android/src/main/java/com/amazonaws/ivs/reactnative/player/AmazonIvsView.kt @@ -7,6 +7,7 @@ import android.net.Uri import android.widget.FrameLayout import com.amazonaws.ivs.player.* import android.os.Build +import android.util.Log import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.LifecycleEventListener import com.facebook.react.bridge.ReactContext @@ -31,6 +32,7 @@ class AmazonIvsView(private val context: ThemedReactContext) : FrameLayout(conte private var finishedLoading: Boolean = false private var pipEnabled: Boolean = false private var isInBackground: Boolean = false + private var preloadSourceMap: HashMap = hashMapOf() enum class Events(private val mName: String) { @@ -320,6 +322,31 @@ class AmazonIvsView(private val context: ThemedReactContext) : FrameLayout(conte player?.setOrigin(origin) } + fun preload(id: Int, url: String) { + // Beta API + val mplayer = player as? MediaPlayer + val source = mplayer?.preload(Uri.parse(url)) + source?.let { + preloadSourceMap.put(id, source) + } + } + + fun loadSource(id: Int) { + // Beta API + val source = preloadSourceMap.get(id) + source?.let { + val mplayer = player as? MediaPlayer + mplayer?.loadSource(source) + } + } + + fun releaseSource(id: Int) { + // Beta API + val source = preloadSourceMap.remove(id) + source?.let { + source.release() + } + } fun onPlayerStateChange(state: Player.State) { val reactContext = context as ReactContext @@ -503,6 +530,12 @@ class AmazonIvsView(private val context: ThemedReactContext) : FrameLayout(conte } fun cleanup() { + // Cleanup any remaining sources + for (source in preloadSourceMap.values) { + source.release() + } + preloadSourceMap.clear() + player?.removeListener(playerListener!!) player?.release() player = null diff --git a/android/src/main/java/com/amazonaws/ivs/reactnative/player/AmazonIvsViewManager.kt b/android/src/main/java/com/amazonaws/ivs/reactnative/player/AmazonIvsViewManager.kt index 50bc95a..4203aec 100644 --- a/android/src/main/java/com/amazonaws/ivs/reactnative/player/AmazonIvsViewManager.kt +++ b/android/src/main/java/com/amazonaws/ivs/reactnative/player/AmazonIvsViewManager.kt @@ -1,5 +1,6 @@ package com.amazonaws.ivs.reactnative.player +import android.util.Log import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap import com.facebook.react.common.MapBuilder @@ -9,6 +10,9 @@ import com.facebook.react.uimanager.annotations.ReactProp class AmazonIvsViewManager : SimpleViewManager() { private enum class Commands { + PRELOAD, + LOAD_SOURCE, + RELEASE_SOURCE, PLAY, PAUSE, SEEK_TO, @@ -27,22 +31,41 @@ class AmazonIvsViewManager : SimpleViewManager() { } override fun getCommandsMap(): Map? { - return MapBuilder.of( - "play", - Commands.PLAY.ordinal, - "pause", - Commands.PAUSE.ordinal, - "setOrigin", - Commands.SET_ORIGIN.ordinal, - "seekTo", - Commands.SEEK_TO.ordinal, - "togglePip", - Commands.TOGGLE_PIP.ordinal + return mapOf( + "preload" to Commands.PRELOAD.ordinal, + "loadSource" to Commands.LOAD_SOURCE.ordinal, + "releaseSource" to Commands.RELEASE_SOURCE.ordinal, + "play" to Commands.PLAY.ordinal, + "pause" to Commands.PAUSE.ordinal, + "setOrigin" to Commands.SET_ORIGIN.ordinal, + "seekTo" to Commands.SEEK_TO.ordinal, + "togglePip" to Commands.TOGGLE_PIP.ordinal ) } override fun receiveCommand(view: AmazonIvsView, commandType: Int, args: ReadableArray?) { when (commandType) { + Commands.PRELOAD.ordinal -> { + val id = args?.getInt(0) + val url = args?.getString(1) + id?.let { + url?.let { + view.preload(id, url) + } + } + } + Commands.LOAD_SOURCE.ordinal -> { + val id = args?.getInt(0) + id?.let { + view.loadSource(id) + } + } + Commands.RELEASE_SOURCE.ordinal -> { + val id = args?.getInt(0) + id?.let { + view.releaseSource(id) + } + } Commands.PLAY.ordinal -> view.play() Commands.PAUSE.ordinal -> view.pause() Commands.TOGGLE_PIP.ordinal -> view.togglePip() diff --git a/example/android/.project b/example/android/.project index 3964dd3..a43adbe 100644 --- a/example/android/.project +++ b/example/android/.project @@ -1,6 +1,6 @@ - android + AmazonIvsExample Project android created by Buildship. @@ -14,4 +14,15 @@ org.eclipse.buildship.core.gradleprojectnature + + + 1701901663543 + + 30 + + org.eclipse.core.resources.regexFilterMatcher + node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__ + + + diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 59d5138..71f320b 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,8 +1,8 @@ PODS: - amazon-ivs-react-native-player (1.4.1): - - AmazonIVSPlayer (~> 1.23.0) + - AmazonIVSPlayer (~> 1.25.0-rc.2.1) - React-Core - - AmazonIVSPlayer (1.23.0) + - AmazonIVSPlayer (1.25.0-rc.2.1) - boost (1.76.0) - CocoaAsyncSocket (7.6.5) - DoubleConversion (1.1.6) @@ -579,8 +579,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - amazon-ivs-react-native-player: d61c43140bd2a006f6181c3d18fbba3e24f388b1 - AmazonIVSPlayer: 056aa543826328b578f51d5f3a1ac47b227d813d + amazon-ivs-react-native-player: 8d77be2a79382c8a23bf0650fff90bf2f1597600 + AmazonIVSPlayer: eac627800d289abd3d004bc34223137047ec8b5e boost: a7c83b31436843459a1961bfd74b96033dc77234 CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54 @@ -640,4 +640,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 10b965f81e7d51bcd1f3d840c000ea2ebdaf2ec6 -COCOAPODS: 1.11.3 +COCOAPODS: 1.14.3 diff --git a/example/src/App.tsx b/example/src/App.tsx index 5922a40..ca04e1d 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -13,6 +13,7 @@ import PlaygroundExample from './screens/PlaygroundExample'; import Home from './screens/Home'; import SimpleExample from './screens/SimpleExample'; import AdvancedExample from './screens/AdvancedExample'; +import SwipeableExample from './screens/SwipeableExample'; import { TestPlan } from './screens/TestPlan'; export const theme = { @@ -30,6 +31,7 @@ export type RootStackParamList = { TestPlan: undefined; SimpleExample: undefined; AdvancedExample: undefined; + SwipeableExample: undefined; PlaygroundExample: undefined; }; @@ -64,6 +66,10 @@ export default function App() { + + { + navigate('SwipeableExample'); + }} + > + + + + A simple implementation of a swipeable video flow. + + + = [ + 'https://fcc3ddae59ed.us-west-2.playback.live-video.net/api/video/v1/us-west-2.893648527354.channel.XFAcAcypUxQm.m3u8', + 'https://46074450f652.us-west-2.playback.live-video.net/api/video/v1/us-west-2.385480771703.channel.ajs2EabyQ9fO.m3u8', + 'https://fcc3ddae59ed.us-west-2.playback.live-video.net/api/video/v1/us-west-2.893648527354.channel.XFAcAcypUxQm.m3u8', + 'https://46074450f652.us-west-2.playback.live-video.net/api/video/v1/us-west-2.385480771703.channel.ajs2EabyQ9fO.m3u8', + 'https://fcc3ddae59ed.us-west-2.playback.live-video.net/api/video/v1/us-west-2.893648527354.channel.XFAcAcypUxQm.m3u8', +]; + +export default function SwipeableExample() { + return ; +} + +type SwipeableVideoProps = { + videos: Array; +}; + +const SwipeableVideo = (props: SwipeableVideoProps) => { + const [prevIndex, setPrevIndex] = useState(-1); + const [currentIndex, setCurrentIndex] = useState(-1); + const [loadingIndex, setLoadingIndex] = useState(-1); + + const playerRef = useRef(null); + const prevSource = useRef(); + const currentSource = useRef(); + const nextSource = useRef(); + + const calcPrevIndex = (index: number) => { + return index === 0 ? props.videos.length - 1 : index - 1; + }; + const calcNextIndex = (index: number) => { + return index === props.videos.length - 1 ? 0 : index + 1; + }; + + const onSwipe = ( + event: HandlerStateChangeEvent + ) => { + const { translationX, state } = event.nativeEvent; + + if (state === State.END) { + // Swiped from the right + if (translationX < 0) { + loadSource(calcNextIndex(currentIndex)); + + // Swiped from the left + } else { + // For demo app purposes, only allow swiping right + console.warn( + "[onSwipe] the demo doesn't allow navigating to previous videos" + ); + } + } + }; + + // For test purposes, always cache bust + const cacheBust = (url: string): string => { + const delim = url.indexOf('?') === -1 ? '?' : '&'; + + return `${url}${delim}cb=${Date.now()}`; + }; + + const loadSource = (indexToLoad: number) => { + if (playerRef.current === null) { + return; + } + const player = playerRef.current; + + console.log('\n[loadSource] playing index:', indexToLoad); + + let lastIndex = -1; + if (currentSource.current !== undefined) { + // previousSource?.release(); + console.log('\treusing currentSource, assigning to prevSource'); + + // Example of how to discard a source we no longer intend to use + const prevSourceInstance = prevSource.current; + if (prevSourceInstance !== undefined) { + console.log( + "\t\treleasing old prevSource, as we don't intend to use it again" + ); + player.releaseSource(prevSourceInstance); + } + + prevSource.current = currentSource.current; + lastIndex = calcPrevIndex(indexToLoad); + } + + if (nextSource.current !== undefined) { + console.log('\treusing nextSource, assigning to currentSource'); + currentSource.current = nextSource.current; + } else { + const url = cacheBust(props.videos[indexToLoad]); + console.log('\tloading new currentSource', url); + currentSource.current = player.preload(url); + } + + player.loadSource(currentSource.current); + + const nextIndex = calcNextIndex(indexToLoad); + const nextUrl = cacheBust(props.videos[nextIndex]); + console.log('\tloading new nextSource', nextUrl); + // Preload the next source + nextSource.current = player.preload(nextUrl); + + console.log('\tcurrent indexes:'); + console.log('\t\tprevIndex:', lastIndex); + console.log('\t\tcurrentIndex:', indexToLoad); + console.log('\t\tnextIndex:', nextIndex); + + setPrevIndex(lastIndex); + setCurrentIndex(indexToLoad); + setLoadingIndex(nextIndex); + }; + + useEffect(() => { + // On mount, load the first source + loadSource(0); + + // On unmount, release any remaining sources + () => { + if (playerRef.current === null) { + return; + } + const player = playerRef.current; + + [prevSource.current, currentSource.current, nextSource.current].forEach( + (source: Source | undefined) => { + if (source !== undefined) { + player.releaseSource(source); + } + } + ); + }; + }, []); + + return ( + + + + + + + + + + + Loaded video: + + #{prevIndex + 1} + + + + Playing video: + #{currentIndex + 1} + + + Loading video: + #{loadingIndex + 1} + + + + + + + Swipe from right-to-left to go to the next video. The next video + will automatically preload when the first starts playing. + + + + + + + ); +}; + +type IVSPlayerContainerProps = { + playerRef: RefObject; +}; + +const IVSPlayerContainer = (props: IVSPlayerContainerProps) => { + return ; +}; + +const styles = StyleSheet.create({ + card: { + marginBottom: 10, + }, + + controlsRow: { + flexDirection: 'row', + flexWrap: 'wrap', + }, + textShadow: { + fontSize: 14, + color: ' #FFFFFF', + paddingLeft: 20, + paddingRight: 20, + textShadowColor: '#585858', + textShadowOffset: { + width: 10, + height: 10, + }, + textShadowRadius: 20, + + marginBottom: 2, + }, +}); diff --git a/ios/AmazonIvsManager.m b/ios/AmazonIvsManager.m index 5a0a6cf..88e017d 100644 --- a/ios/AmazonIvsManager.m +++ b/ios/AmazonIvsManager.m @@ -17,6 +17,9 @@ @interface RCT_EXTERN_MODULE(AmazonIvsManager, RCTViewManager) RCT_EXPORT_VIEW_PROPERTY(progressInterval, NSNumber) RCT_EXPORT_VIEW_PROPERTY(volume, double) RCT_EXPORT_VIEW_PROPERTY(breakpoints, NSArray) +RCT_EXTERN_METHOD(preload:(nonnull NSNumber *)node id:(nonnull NSNumber *)id url:(NSString *)url) +RCT_EXTERN_METHOD(loadSource:(nonnull NSNumber *)node id:(nonnull NSNumber *)id) +RCT_EXTERN_METHOD(releaseSource:(nonnull NSNumber *)node id:(nonnull NSNumber *)id) RCT_EXTERN_METHOD(play:(nonnull NSNumber *)node) RCT_EXTERN_METHOD(pause:(nonnull NSNumber *)node) RCT_EXPORT_VIEW_PROPERTY(streamUrl, NSString) diff --git a/ios/AmazonIvsManager.swift b/ios/AmazonIvsManager.swift index 6e71631..3a260a9 100644 --- a/ios/AmazonIvsManager.swift +++ b/ios/AmazonIvsManager.swift @@ -40,4 +40,25 @@ class AmazonIvsManager: RCTViewManager { component.togglePip() } } + + @objc func preload(_ node: NSNumber, id: NSNumber, url: NSString) { + DispatchQueue.main.async { + let component = self.bridge.uiManager.view(forReactTag: node) as! AmazonIvsView + component.preload(id: id.intValue, url: url) + } + } + + @objc func loadSource(_ node: NSNumber, id: NSNumber) { + DispatchQueue.main.async { + let component = self.bridge.uiManager.view(forReactTag: node) as! AmazonIvsView + component.loadSource(id: id.intValue) + } + } + + @objc func releaseSource(_ node: NSNumber, id: NSNumber) { + DispatchQueue.main.async { + let component = self.bridge.uiManager.view(forReactTag: node) as! AmazonIvsView + component.releaseSource(id: id.intValue) + } + } } diff --git a/ios/AmazonIvsView.swift b/ios/AmazonIvsView.swift index 48d3ede..580d767 100644 --- a/ios/AmazonIvsView.swift +++ b/ios/AmazonIvsView.swift @@ -1,6 +1,7 @@ import Foundation import UIKit import AmazonIVSPlayer +import AmazonIVSPlayer_Private // Beta API access @objc(AmazonIvsView) class AmazonIvsView: UIView, IVSPlayer.Delegate { @@ -35,6 +36,7 @@ class AmazonIvsView: UIView, IVSPlayer.Delegate { private var lastDuration: CMTime?; private var lastFramesDropped: Int?; private var lastFramesDecoded: Int?; + private var preloadSourceMap: [Int: IVSSource] = [:] private var _pipController: Any? = nil @@ -82,6 +84,7 @@ class AmazonIvsView: UIView, IVSPlayer.Delegate { } deinit { + self.preloadSourceMap.removeAll() self.removeProgressObserver() self.removePlayerObserver() self.removeTimePointObserver() @@ -280,7 +283,26 @@ class AmazonIvsView: UIView, IVSPlayer.Delegate { player.setOrigin(url) } + @objc func preload(id: Int, url: NSString) { + // Beta API + let url = URL(string: url as String) + if let url = url { + let source = player.preload(url) + preloadSourceMap[id] = source; + } + } + + @objc func loadSource(id: Int) { + // Beta API + if let source = preloadSourceMap[id] { + player.load(source) + } + } + @objc func releaseSource(id: Int) { + // Beta API + preloadSourceMap.removeValue(forKey: id) + } @objc func togglePip() { guard #available(iOS 15, *), let pipController = pipController else { diff --git a/package.json b/package.json index 91c10a8..10ff526 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "react-native-builder-bob": "^0.18.1", "react-native-paper": "^5.12.3", "react-test-renderer": "18.0.0", + "react-native-gesture-handler": "2.14.1", "semver": "^7.5.4", "simple-git": "^3.19.1", "ts-jest": "^29.1.2", diff --git a/src/IVSPlayer.tsx b/src/IVSPlayer.tsx index 591ca0d..54e5e7e 100644 --- a/src/IVSPlayer.tsx +++ b/src/IVSPlayer.tsx @@ -23,7 +23,9 @@ import type { VideoData, IVSPlayerRef, ResizeMode, + Source, } from './types'; +import { createSourceWrapper } from './source'; type IVSPlayerProps = { style?: ViewStyle; @@ -172,6 +174,37 @@ const IVSPlayerContainer = React.forwardRef( const mediaPlayerRef = useRef(null); const initialized = useRef(false); + const preload = useCallback((url: string) => { + const sourceWrapper = createSourceWrapper(url); + + UIManager.dispatchViewManagerCommand( + findNodeHandle(mediaPlayerRef.current), + + UIManager.getViewManagerConfig(VIEW_NAME).Commands.preload, + [sourceWrapper.getId(), sourceWrapper.getUri()] + ); + + return sourceWrapper; + }, []); + + const loadSource = useCallback((source: Source) => { + UIManager.dispatchViewManagerCommand( + findNodeHandle(mediaPlayerRef.current), + + UIManager.getViewManagerConfig(VIEW_NAME).Commands.loadSource, + [source.getId()] + ); + }, []); + + const releaseSource = useCallback((source: Source) => { + UIManager.dispatchViewManagerCommand( + findNodeHandle(mediaPlayerRef.current), + + UIManager.getViewManagerConfig(VIEW_NAME).Commands.releaseSource, + [source.getId()] + ); + }, []); + const play = useCallback(() => { UIManager.dispatchViewManagerCommand( findNodeHandle(mediaPlayerRef.current), @@ -229,13 +262,25 @@ const IVSPlayerContainer = React.forwardRef( useImperativeHandle( ref, () => ({ + preload, + loadSource, + releaseSource, play, pause, seekTo, setOrigin, togglePip, }), - [play, pause, seekTo, setOrigin, togglePip] + [ + preload, + loadSource, + releaseSource, + play, + pause, + seekTo, + setOrigin, + togglePip, + ] ); const onSeekHandler = ( diff --git a/src/source.ts b/src/source.ts new file mode 100644 index 0000000..d77826e --- /dev/null +++ b/src/source.ts @@ -0,0 +1,25 @@ +import type { Source } from './types'; + +let sourceId = 0; + +export const createSourceWrapper = (url: string): Source => { + return new SourceWrapper(sourceId++, url); +}; + +class SourceWrapper implements Source { + private _id: number; + private _url: string; + + constructor(id: number, url: string) { + this._id = id; + this._url = url; + } + + public getId() { + return this._id; + } + + public getUri() { + return this._url; + } +} diff --git a/src/types.ts b/src/types.ts index d69df8f..4e13f14 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,7 +35,15 @@ export type TextMetadataCue = { textDescription: string; }; +export type Source = { + getId: () => void; + getUri: () => void; +}; + export type IVSPlayerRef = { + preload: (url: string) => Source; + loadSource: (source: Source) => void; + releaseSource: (source: Source) => void; play: () => void; pause: () => void; seekTo: (position: number) => void; diff --git a/yarn.lock b/yarn.lock index a64022c..dc3cf7b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1855,6 +1855,13 @@ dependencies: "@jridgewell/trace-mapping" "0.3.9" +"@egjs/hammerjs@^2.0.17": + version "2.0.17" + resolved "https://registry.yarnpkg.com/@egjs/hammerjs/-/hammerjs-2.0.17.tgz#5dc02af75a6a06e4c2db0202cae38c9263895124" + integrity sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A== + dependencies: + "@types/hammerjs" "^2.0.36" + "@endemolshinegroup/cosmiconfig-typescript-loader@^3.0.2": version "3.0.2" resolved "https://registry.yarnpkg.com/@endemolshinegroup/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-3.0.2.tgz#eea4635828dde372838b0909693ebd9aafeec22d" @@ -2800,6 +2807,11 @@ dependencies: "@types/node" "*" +"@types/hammerjs@^2.0.36": + version "2.0.45" + resolved "https://registry.yarnpkg.com/@types/hammerjs/-/hammerjs-2.0.45.tgz#ffa764bb68a66c08db6efb9c816eb7be850577b1" + integrity sha512-qkcUlZmX6c4J8q45taBKTL3p+LbITgyx7qhlPYOdOHZB7B31K0mXbP5YA7i7SgDeEGuI9MnumiKPEMrxg8j3KQ== + "@types/inquirer@^9.0.3": version "9.0.3" resolved "https://registry.yarnpkg.com/@types/inquirer/-/inquirer-9.0.3.tgz#dc99da4f2f6de9d26c284b4f6aaab4d98c456db1" @@ -8384,6 +8396,17 @@ react-native-codegen@^0.69.2: jscodeshift "^0.13.1" nullthrows "^1.1.1" +react-native-gesture-handler@2.14.1: + version "2.14.1" + resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.14.1.tgz#930640231024b7921435ab476aa501dd4a6b2e01" + integrity sha512-YiM1BApV4aKeuwsM6O4C2ufwewYEKk6VMXOt0YqEZFMwABBFWhXLySFZYjBSNRU2USGppJbfHP1q1DfFQpKhdA== + dependencies: + "@egjs/hammerjs" "^2.0.17" + hoist-non-react-statics "^3.3.0" + invariant "^2.2.4" + lodash "^4.17.21" + prop-types "^15.7.2" + react-native-gradle-plugin@^0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/react-native-gradle-plugin/-/react-native-gradle-plugin-0.0.7.tgz#96602f909745239deab7b589443f14fce5da2056"