diff --git a/android/src/main/java/com/brentvatne/common/api/ControlsConfig.kt b/android/src/main/java/com/brentvatne/common/api/ControlsConfig.kt index cdc282f76e..e03e5d4141 100644 --- a/android/src/main/java/com/brentvatne/common/api/ControlsConfig.kt +++ b/android/src/main/java/com/brentvatne/common/api/ControlsConfig.kt @@ -5,6 +5,7 @@ import com.facebook.react.bridge.ReadableMap class ControlsConfig { var hideSeekBar: Boolean = false + var seekIncrementMS: Int = 10000 companion object { @JvmStatic @@ -13,6 +14,7 @@ class ControlsConfig { if (src != null) { config.hideSeekBar = ReactBridgeUtils.safeGetBool(src, "hideSeekBar", false) + config.seekIncrementMS = ReactBridgeUtils.safeGetInt(src, "seekIncrementMS", 10000) } return config diff --git a/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.java b/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.java index d92d27695f..e254975f40 100644 --- a/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.java +++ b/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.java @@ -86,10 +86,10 @@ public ExoPlayerView(Context context, AttributeSet attrs, int defStyleAttr) { adOverlayFrameLayout = new FrameLayout(context); layout.addView(shutterView, 1, layoutParams); - layout.addView(subtitleLayout, 2, layoutParams); - layout.addView(adOverlayFrameLayout, 3, layoutParams); + layout.addView(adOverlayFrameLayout, 2, layoutParams); addViewInLayout(layout, 0, aspectRatioParams); + addViewInLayout(subtitleLayout, 1, layoutParams); } private void clearVideoView() { diff --git a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerSimpleCache.kt b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerSimpleCache.kt index 23d51f7793..c644ccb2ec 100644 --- a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerSimpleCache.kt +++ b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerSimpleCache.kt @@ -12,10 +12,9 @@ import java.io.File object RNVSimpleCache { // TODO: when to release? how to check if cache is released? private var simpleCache: SimpleCache? = null - var cacheDataSourceFactory: DataSource.Factory? = null - fun setSimpleCache(context: Context, cacheSize: Int, factory: HttpDataSource.Factory) { - if (cacheDataSourceFactory != null || cacheSize <= 0) return + fun setSimpleCache(context: Context, cacheSize: Int) { + if (simpleCache != null || cacheSize <= 0) return simpleCache = SimpleCache( File(context.cacheDir, "RNVCache"), LeastRecentlyUsedCacheEvictor( @@ -23,9 +22,12 @@ object RNVSimpleCache { ), StandaloneDatabaseProvider(context) ) - cacheDataSourceFactory = - CacheDataSource.Factory() - .setCache(simpleCache!!) - .setUpstreamDataSourceFactory(factory) + } + + fun getCacheFactory(factory: HttpDataSource.Factory): DataSource.Factory { + if (simpleCache == null) return factory + return CacheDataSource.Factory() + .setCache(simpleCache!!) + .setUpstreamDataSourceFactory(factory) } } diff --git a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java index f44ed839a9..957f7c28ae 100644 --- a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java +++ b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java @@ -124,6 +124,7 @@ import com.brentvatne.receiver.AudioBecomingNoisyReceiver; import com.brentvatne.receiver.BecomingNoisyListener; import com.facebook.react.bridge.LifecycleEventListener; +import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.UiThreadUtil; import com.facebook.react.uimanager.ThemedReactContext; import com.google.ads.interactivemedia.v3.api.AdError; @@ -139,6 +140,7 @@ import java.util.Map; import java.util.ArrayList; import java.util.Locale; +import java.util.Objects; import java.util.UUID; import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; @@ -214,7 +216,7 @@ public class ReactExoplayerView extends FrameLayout implements private boolean selectTrackWhenReady = false; private Handler mainHandler; private Runnable mainRunnable; - private DataSource.Factory cacheDataSourceFactory; + private boolean useCache = false; private ControlsConfig controlsConfig = new ControlsConfig(); // Props from React @@ -443,6 +445,17 @@ public void handleOnBackPressed() { setPausedModifier(false); }); + //Handling the rewind and forward button click events + ImageButton exoRewind = playerControlView.findViewById(R.id.exo_rew); + ImageButton exoForward = playerControlView.findViewById(R.id.exo_ffwd); + exoRewind.setOnClickListener((View v) -> { + seekTo(player.getCurrentPosition() - controlsConfig.getSeekIncrementMS()); + }); + + exoForward.setOnClickListener((View v) -> { + seekTo(player.getCurrentPosition() + controlsConfig.getSeekIncrementMS()); + }); + //Handling the pauseButton click event ImageButton pauseButton = playerControlView.findViewById(R.id.exo_pause); pauseButton.setOnClickListener((View v) -> @@ -685,6 +698,15 @@ private void initializePlayer() { mainHandler.postDelayed(mainRunnable, 1); } + public void getCurrentPosition(Promise promise) { + if (player != null) { + double currentPosition = player.getCurrentPosition() / 1000; + promise.resolve(currentPosition); + } else { + promise.reject("PLAYER_NOT_AVAILABLE", "Player is not initialized."); + } + } + private void initializePlayerCore(ReactExoplayerView self) { ExoTrackSelection.Factory videoTrackSelectionFactory = new AdaptiveTrackSelection.Factory(); self.trackSelector = new DefaultTrackSelector(getContext(), videoTrackSelectionFactory); @@ -708,8 +730,8 @@ private void initializePlayerCore(ReactExoplayerView self) { .setAdErrorListener(this) .build(); DefaultMediaSourceFactory mediaSourceFactory = new DefaultMediaSourceFactory(mediaDataSourceFactory); - if (cacheDataSourceFactory != null) { - mediaSourceFactory.setDataSourceFactory(cacheDataSourceFactory); + if (useCache) { + mediaSourceFactory.setDataSourceFactory(RNVSimpleCache.INSTANCE.getCacheFactory(buildHttpDataSourceFactory(true))); } if (adsLoader != null) { @@ -842,7 +864,8 @@ public void onServiceConnected(ComponentName name, IBinder service) { playbackServiceBinder = (PlaybackServiceBinder) service; try { - playbackServiceBinder.getService().registerPlayer(player); + playbackServiceBinder.getService().registerPlayer(player, + Objects.requireNonNull((Class) (themedReactContext.getCurrentActivity()).getClass())); } catch (Exception e) { DebugLog.e(TAG, "Cloud not register ExoPlayer"); } @@ -1014,13 +1037,13 @@ private MediaSource buildMediaSource(Uri uri, String overrideExtension, DrmSessi throw new IllegalStateException("cannot open input file" + srcUri); } } else if ("file".equals(srcUri.getScheme()) || - cacheDataSourceFactory == null) { + !useCache) { mediaSourceFactory = new ProgressiveMediaSource.Factory( mediaDataSourceFactory ); } else { mediaSourceFactory = new ProgressiveMediaSource.Factory( - cacheDataSourceFactory + RNVSimpleCache.INSTANCE.getCacheFactory(buildHttpDataSourceFactory(true)) ); } @@ -2239,12 +2262,11 @@ public void setBufferConfig(BufferConfig config) { if (bufferConfig.getCacheSize() > 0) { RNVSimpleCache.INSTANCE.setSimpleCache( this.getContext(), - bufferConfig.getCacheSize(), - buildHttpDataSourceFactory(false) + bufferConfig.getCacheSize() ); - cacheDataSourceFactory = RNVSimpleCache.INSTANCE.getCacheDataSourceFactory(); + useCache = true; } else { - cacheDataSourceFactory = null; + useCache = false; } releasePlayer(); initializePlayer(); diff --git a/android/src/main/java/com/brentvatne/exoplayer/VideoPlaybackService.kt b/android/src/main/java/com/brentvatne/exoplayer/VideoPlaybackService.kt index 85cc878065..93da8905d7 100644 --- a/android/src/main/java/com/brentvatne/exoplayer/VideoPlaybackService.kt +++ b/android/src/main/java/com/brentvatne/exoplayer/VideoPlaybackService.kt @@ -1,8 +1,10 @@ package com.brentvatne.exoplayer import android.annotation.SuppressLint +import android.app.Activity import android.app.NotificationChannel import android.app.NotificationManager +import android.app.PendingIntent import android.content.Context import android.content.Intent import android.os.Binder @@ -23,6 +25,7 @@ class PlaybackServiceBinder(val service: VideoPlaybackService) : Binder() class VideoPlaybackService : MediaSessionService() { private var mediaSessionsList = mutableMapOf() private var binder = PlaybackServiceBinder(this) + private var sourceActivity: Class? = null // Controls private val commandSeekForward = SessionCommand(COMMAND_SEEK_FORWARD, Bundle.EMPTY) @@ -44,10 +47,11 @@ class VideoPlaybackService : MediaSessionService() { // Player Registry - fun registerPlayer(player: ExoPlayer) { + fun registerPlayer(player: ExoPlayer, from: Class) { if (mediaSessionsList.containsKey(player)) { return } + sourceActivity = from val mediaSession = MediaSession.Builder(this, player) .setId("RNVideoPlaybackService_" + player.hashCode()) @@ -63,6 +67,7 @@ class VideoPlaybackService : MediaSessionService() { hidePlayerNotification(player) val session = mediaSessionsList.remove(player) session?.release() + sourceActivity = null if (mediaSessionsList.isEmpty()) { cleanup() @@ -110,9 +115,13 @@ class VideoPlaybackService : MediaSessionService() { return } + val returnToPlayer = Intent(this, sourceActivity).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + } val notificationCompact = NotificationCompat.Builder(this, NOTIFICATION_CHANEL_ID) .setSmallIcon(androidx.media3.session.R.drawable.media3_icon_circular_play) .setStyle(MediaStyleNotificationHelper.MediaStyle(session)) + .setContentIntent(PendingIntent.getActivity(this, 0, returnToPlayer, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) .build() notificationManager.notify(session.player.hashCode(), notificationCompact) diff --git a/android/src/main/java/com/brentvatne/react/VideoManagerModule.kt b/android/src/main/java/com/brentvatne/react/VideoManagerModule.kt index 11781aa1a2..c736cf01a5 100644 --- a/android/src/main/java/com/brentvatne/react/VideoManagerModule.kt +++ b/android/src/main/java/com/brentvatne/react/VideoManagerModule.kt @@ -2,6 +2,7 @@ package com.brentvatne.react import com.brentvatne.common.toolbox.ReactBridgeUtils import com.brentvatne.exoplayer.ReactExoplayerView +import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactMethod @@ -61,6 +62,13 @@ class VideoManagerModule(reactContext: ReactApplicationContext?) : ReactContextB } } + @ReactMethod + fun getCurrentPosition(reactTag: Int, promise: Promise) { + performOnPlayerView(reactTag) { + it?.getCurrentPosition(promise) + } + } + companion object { private const val REACT_CLASS = "VideoManager" } diff --git a/android/src/main/res/layout/exo_legacy_player_control_view.xml b/android/src/main/res/layout/exo_legacy_player_control_view.xml index bbe13a80c5..50ef45b269 100644 --- a/android/src/main/res/layout/exo_legacy_player_control_view.xml +++ b/android/src/main/res/layout/exo_legacy_player_control_view.xml @@ -4,14 +4,14 @@ android:layout_height="wrap_content" android:layout_gravity="bottom" android:layoutDirection="ltr" - android:background="#CC000000" + android:background="@color/midnight_black" android:orientation="vertical"> + android:textColor="@color/silver_gray"/> + android:layout_height="@dimen/seekBar_height"/> + android:textColor="@color/silver_gray"/> diff --git a/android/src/main/res/values/colors.xml b/android/src/main/res/values/colors.xml new file mode 100644 index 0000000000..305f07c1a8 --- /dev/null +++ b/android/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + #FFBEBEBE + #CC000000 + \ No newline at end of file diff --git a/android/src/main/res/values/dimens.xml b/android/src/main/res/values/dimens.xml new file mode 100644 index 0000000000..218dca64d6 --- /dev/null +++ b/android/src/main/res/values/dimens.xml @@ -0,0 +1,16 @@ + + + + 4dp + 4dp + 4dp + 4dp + + + 50dp + 26dp + 30dp + + + 14sp + \ No newline at end of file diff --git a/docs/pages/component/methods.mdx b/docs/pages/component/methods.mdx index 755901ebb3..112fccaaf7 100644 --- a/docs/pages/component/methods.mdx +++ b/docs/pages/component/methods.mdx @@ -100,6 +100,15 @@ tolerance is the max distance in milliseconds from the seconds position that's a This function will change the volume exactly like [volume](./props#volume) property. default value and range are the same then. +### `getCurrentPosition` + + + +`getCurrentPosition(): Promise` + +This function retrieves and returns the precise current position of the video playback, measured in seconds. +This function will throw an error if player is not initialized. + ### Example Usage ```tsx diff --git a/docs/pages/component/props.mdx b/docs/pages/component/props.mdx index adc81eb0fd..6a1ff72362 100644 --- a/docs/pages/component/props.mdx +++ b/docs/pages/component/props.mdx @@ -53,15 +53,17 @@ A Boolean value that indicates whether the player should automatically delay pla Adjust the control styles. This prop is need only if `controls={true}` and is an object. See the list of prop supported below. -| Property | Type | Description | -|-------------|---------|--------------------------------------------------------------------------------------| -| hideSeekBar | boolean | The default value is `false`, allowing you to hide the seek bar for live broadcasts. | +| Property | Type | Description | +|-----------------|---------|-----------------------------------------------------------------------------------------| +| hideSeekBar | boolean | The default value is `false`, allowing you to hide the seek bar for live broadcasts. | +| seekIncrementMS | number | The default value is `10000`. You can change the value to increment forward and rewind. | Example with default values: ```javascript controlsStyles={{ hideSeekBar: false, + seekIncrementMS: 10000, }} ``` @@ -836,6 +838,8 @@ textTracks={[ Controls whether to show media controls in the notification area. For Android each Video component will have its own notification controls and for iOS only one notification control will be shown for the last Active Video component. +On android this will also allow for external controls, Google Assistant session and other benefits of MediaSession. + You propably want also set `playInBackground` to `true` to keep the video playing when the app is in the background or `playWhenInactive` to `true` to keep the video playing when notifications or the Control Center are in front of the video. To customize the notification controls you can use `metadata` property in the `source` prop. diff --git a/docs/pages/installation.md b/docs/pages/installation.md index 8ddff963cf..1392d0b3d2 100644 --- a/docs/pages/installation.md +++ b/docs/pages/installation.md @@ -24,6 +24,8 @@ Then follow the instructions for your platform to link react-native-video into y ### Standard Method Run `pod install` in the `ios` directory of your project. +⚠️ from version `6.0.0` the minimum iOS version required is `13.0`. For more information see [updating section](updating.md) + ### Enable custom feature in podfile file Samples available in sample app see [sample pod file](https://github.com/TheWidlarzGroup/react-native-video/blob/9c669a2d8a53df36773fd82ff0917280d0659bc7/examples/basic/ios/Podfile#L34) diff --git a/docs/pages/updating.md b/docs/pages/updating.md index 2f6e3696af..a35e0775cd 100644 --- a/docs/pages/updating.md +++ b/docs/pages/updating.md @@ -4,6 +4,19 @@ #### iOS +##### Min iOS version +From version 6.0.0, the minimum iOS version supported is 13.0. Projects that are using `react-native < 0.73` will need to set the minimum iOS version to 13.0 in the Podfile. + +You can do it by adding the following code to your Podfile: +```diff +- platform :ios, min_ios_version_supported + ++ MIN_IOS_OVERRIDE = '13.0' ++ if Gem::Version.new(MIN_IOS_OVERRIDE) > Gem::Version.new(min_ios_version_supported) ++ min_ios_version_supported = MIN_IOS_OVERRIDE ++ end +``` + ##### linking In your project Podfile add support for static dependency linking. This is required to support the new Promises subdependency in the iOS swift conversion. diff --git a/examples/basic/ios/Podfile.lock b/examples/basic/ios/Podfile.lock index 20bf3a5b6c..a92a0b4867 100644 --- a/examples/basic/ios/Podfile.lock +++ b/examples/basic/ios/Podfile.lock @@ -935,7 +935,7 @@ PODS: - React-Mapbuffer (0.74.1): - glog - React-debug - - react-native-video (6.0.0): + - react-native-video (6.1.2): - DoubleConversion - glog - hermes-engine @@ -949,7 +949,7 @@ PODS: - React-featureflags - React-graphics - React-ImageManager - - react-native-video/Video (= 6.0.0) + - react-native-video/Video (= 6.1.2) - React-NativeModulesApple - React-RCTFabric - React-rendererdebug @@ -957,7 +957,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - react-native-video/Video (6.0.0): + - react-native-video/Video (6.1.2): - DoubleConversion - glog - hermes-engine @@ -1398,7 +1398,7 @@ SPEC CHECKSUMS: fmt: 4c2741a687cc09f0634a2e2c72a838b99f1ff120 glog: c5d68082e772fa1c511173d6b30a9de2c05a69a2 hermes-engine: 16b8530de1b383cdada1476cf52d1b52f0692cbc - RCT-Folly: 045d6ecaa59d826c5736dfba0b2f4083ff8d79df + RCT-Folly: 02617c592a293bd6d418e0a88ff4ee1f88329b47 RCTDeprecation: efb313d8126259e9294dc4ee0002f44a6f676aba RCTRequired: f49ea29cece52aee20db633ae7edc4b271435562 RCTTypeSafety: a11979ff0570d230d74de9f604f7d19692157bc4 @@ -1422,7 +1422,7 @@ SPEC CHECKSUMS: React-jsitracing: 3de522f8f794dccd3c54af9160dc992ee65bd494 React-logger: 7e7403a2b14c97f847d90763af76b84b152b6fce React-Mapbuffer: c5844bf3c2206f5475c0fc2340a89b049ea23c97 - react-native-video: e0c12a51732b3a6c46baf5f0d26986b34ca286b4 + react-native-video: edecf23162492d4f41f96ff167684fc0507ca63a React-nativeconfig: 21d89c65ca39875fad2c5c465e0e013e514eba21 React-NativeModulesApple: e2e180dae4486b2978fcf3564cc4c8de4b453a68 React-perflogger: 3d31e0d1e8ad891e43a09ac70b7b17a79773003a @@ -1452,4 +1452,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: bbe1bd5f521b6b9366288dbaf8af1cec85c0a95a -COCOAPODS: 1.15.2 +COCOAPODS: 1.13.0 diff --git a/examples/basic/src/VideoPlayer.tsx b/examples/basic/src/VideoPlayer.tsx index aafd85427e..1054aa1632 100644 --- a/examples/basic/src/VideoPlayer.tsx +++ b/examples/basic/src/VideoPlayer.tsx @@ -7,10 +7,8 @@ import { TouchableOpacity, View, ActivityIndicator, - PanResponder, ToastAndroid, Platform, - PanResponderInstance, Alert, } from 'react-native'; @@ -53,6 +51,7 @@ import styles from './styles'; import AudioTrackSelector from './components/AudioTracksSelector'; import TextTrackSelector from './components/TextTracksSelector'; import VideoTrackSelector from './components/VideoTracksSelector'; +import Seeker from './components/Seeker'; type AdditionnalSourceInfo = { textTracks: TextTracks; @@ -77,10 +76,6 @@ interface StateType { fullscreen: true; decoration: true; isLoading: boolean; - seekerFillWidth: number; - seekerPosition: number; - seekerOffset: number; - seeking: boolean; audioTracks: Array; textTracks: Array; videoTracks: Array; @@ -93,6 +88,7 @@ interface StateType { useCache: boolean; poster?: string; showNotificationControls: boolean; + isSeeking: boolean; } class VideoPlayer extends Component { @@ -109,10 +105,6 @@ class VideoPlayer extends Component { fullscreen: true, decoration: true, isLoading: false, - seekerFillWidth: 0, - seekerPosition: 0, - seekerOffset: 0, - seeking: false, audioTracks: [], textTracks: [], videoTracks: [], @@ -127,10 +119,9 @@ class VideoPlayer extends Component { useCache: false, poster: undefined, showNotificationControls: false, + isSeeking: false, }; - seekerWidth = 0; - // internal usage change to index if you want to select tracks by index instead of lang textTracksSelectionBy = 'index'; @@ -180,6 +171,7 @@ class VideoPlayer extends Component { { description: 'another bunny (can be saved)', uri: 'https://rawgit.com/mediaelement/mediaelement-files/master/big_buck_bunny.mp4', + headers: {referer: 'www.github.com', 'User-Agent': 'react.native.video'}, }, { description: 'sintel with subtitles', @@ -270,7 +262,6 @@ class VideoPlayer extends Component { ); video?: VideoRef; - seekPanResponder?: PanResponderInstance; popupInfo = () => { VideoDecoderProperties.getWidevineLevel().then((widevineLevel: number) => { @@ -299,24 +290,13 @@ class VideoPlayer extends Component { this.onVideoTracks(data); }; - updateSeeker = () => { - // put this code in timeout as because it may be put just after a setState - setTimeout(() => { - const position = this.calculateSeekerPosition(); - this.setSeekerPosition(position); - }, 1); - }; - onProgress = (data: OnProgressData) => { this.setState({currentTime: data.currentTime}); - if (!this.state.seeking) { - this.updateSeeker(); - } }; onSeek = (data: OnSeekData) => { + this.setState({isSeeking: false}); this.setState({currentTime: data.currentTime}); - this.updateSeeker(); }; onVideoLoadStart = () => { @@ -406,13 +386,6 @@ class VideoPlayer extends Component { this.setState({paused: !event.hasAudioFocus}); }; - getCurrentTimePercentage = () => { - if (this.state.currentTime > 0 && this.state.duration !== 0) { - return this.state.currentTime / this.state.duration; - } - return 0; - }; - toast = (visible: boolean, message: string) => { if (visible) { if (Platform.OS === 'android') { @@ -500,166 +473,20 @@ class VideoPlayer extends Component { ); } - componentDidMount() { - this.initSeekPanResponder(); - } - - /** - * Render the seekbar and attach its handlers - */ - - /** - * Constrain the location of the seeker to the - * min/max value based on how big the - * seeker is. - * - * @param {float} val position of seeker handle in px - * @return {float} constrained position of seeker handle in px - */ - constrainToSeekerMinMax(val = 0) { - if (val <= 0) { - return 0; - } else if (val >= this.seekerWidth) { - return this.seekerWidth; - } - return val; - } - - /** - * Set the position of the seekbar's components - * (both fill and handle) according to the - * position supplied. - * - * @param {float} position position in px of seeker handle} - */ - setSeekerPosition(position = 0) { - const state = this.state; - position = this.constrainToSeekerMinMax(position); - - state.seekerFillWidth = position; - state.seekerPosition = position; - - if (!state.seeking) { - state.seekerOffset = position; - } - - this.setState(state); - } - - /** - * Calculate the position that the seeker should be - * at along its track. - * - * @return {float} position of seeker handle in px based on currentTime - */ - calculateSeekerPosition() { - const percent = this.state.currentTime / this.state.duration; - return this.seekerWidth * percent; - } - - /** - * Return the time that the video should be at - * based on where the seeker handle is. - * - * @return {float} time in ms based on seekerPosition. - */ - calculateTimeFromSeekerPosition() { - const percent = this.state.seekerPosition / this.seekerWidth; - return this.state.duration * percent; - } - - /** - * Get our seekbar responder going - */ - initSeekPanResponder() { - this.seekPanResponder = PanResponder.create({ - // Ask to be the responder. - onStartShouldSetPanResponder: (_evt, _gestureState) => true, - onMoveShouldSetPanResponder: (_evt, _gestureState) => true, - - /** - * When we start the pan tell the machine that we're - * seeking. This stops it from updating the seekbar - * position in the onProgress listener. - */ - onPanResponderGrant: (evt, _gestureState) => { - const state = this.state; - // this.clearControlTimeout() - const position = evt.nativeEvent.locationX; - this.setSeekerPosition(position); - state.seeking = true; - this.setState(state); - }, - - /** - * When panning, update the seekbar position, duh. - */ - onPanResponderMove: (evt, gestureState) => { - const position = this.state.seekerOffset + gestureState.dx; - this.setSeekerPosition(position); - }, - - /** - * On release we update the time and seek to it in the video. - * If you seek to the end of the video we fire the - * onEnd callback - */ - onPanResponderRelease: (_evt, _gestureState) => { - const time = this.calculateTimeFromSeekerPosition(); - const state = this.state; - if (time >= state.duration && !state.isLoading) { - state.paused = true; - this.onEnd(); - } else { - this.video?.seek(time); - state.seeking = false; - } - this.setState(state); - }, - }); + videoSeek(position: number) { + this.setState({isSeeking: true}); + this.video?.seek(position); } renderSeekBar() { - if (!this.seekPanResponder) { - return null; - } - const seekerStyle = [ - styles.seekbarFill, - { - width: this.state.seekerFillWidth > 0 ? this.state.seekerFillWidth : 0, - backgroundColor: '#FFF', - }, - ]; - - const seekerPositionStyle = [ - styles.seekbarHandle, - { - left: this.state.seekerPosition > 0 ? this.state.seekerPosition : 0, - }, - ]; - - const seekerPointerStyle = [ - styles.seekbarCircle, - {backgroundColor: '#FFF'}, - ]; - return ( - - - (this.seekerWidth = event.nativeEvent.layout.width) - } - pointerEvents={'none'}> - - - - - - + this.videoSeek(prop)} + isUISeeking={this.state.isSeeking} + /> ); } diff --git a/examples/basic/src/components/Seeker.tsx b/examples/basic/src/components/Seeker.tsx new file mode 100644 index 0000000000..2ca7a28055 --- /dev/null +++ b/examples/basic/src/components/Seeker.tsx @@ -0,0 +1,155 @@ +import React, {useCallback, useEffect, useState} from 'react'; +import {PanResponder, View} from 'react-native'; +import styles from '../styles'; + +interface SeekerProps { + currentTime: number; + duration: number; + isLoading: boolean; + isUISeeking: boolean; + videoSeek: (arg0: number) => void; +} + +const Seeker = ({ + currentTime, + duration, + isLoading, + isUISeeking, + videoSeek, +}: SeekerProps) => { + const [seeking, setSeeking] = useState(false); + const [seekerPosition, setSeekerPosition] = useState(0); + const [seekerWidth, setSeekerWidth] = useState(0); + + /** + * Set the position of the seekbar's components + * (both fill and handle) according to the + * position supplied. + * + * @param {float} position position in px of seeker handle} + */ + const updateSeekerPosition = useCallback( + (position = 0) => { + if (position <= 0) { + position = 0; + } else if (position >= seekerWidth) { + position = seekerWidth; + } + setSeekerPosition(position); + }, + [seekerWidth], + ); + + /** + * Return the time that the video should be at + * based on where the seeker handle is. + * + * @return {float} time in ms based on seekerPosition. + */ + const calculateTimeFromSeekerPosition = () => { + const percent = seekerPosition / seekerWidth; + return duration * percent; + }; + + /** + * Get our seekbar responder going + */ + + const seekPanResponder = PanResponder.create({ + // Ask to be the responder. + onStartShouldSetPanResponder: (_evt, _gestureState) => true, + onMoveShouldSetPanResponder: (_evt, _gestureState) => true, + + /** + * When we start the pan tell the machine that we're + * seeking. This stops it from updating the seekbar + * position in the onProgress listener. + */ + onPanResponderGrant: (evt, _gestureState) => { + const position = evt.nativeEvent.locationX; + updateSeekerPosition(position); + setSeeking(true); + }, + + /** + * When panning, update the seekbar position, duh. + */ + onPanResponderMove: (evt, _gestureState) => { + const position = evt.nativeEvent.locationX; + updateSeekerPosition(position); + }, + + /** + * On release we update the time and seek to it in the video. + * If you seek to the end of the video we fire the + * onEnd callback + */ + onPanResponderRelease: (_evt, _gestureState) => { + const time = calculateTimeFromSeekerPosition(); + if (time >= duration && !isLoading) { + // FIXME ... + // state.paused = true; + // this.onEnd(); + } else { + videoSeek(time); + setSeeking(false); + } + }, + }); + + useEffect(() => { + if (!isLoading && !seeking && !isUISeeking) { + console.log('update position from currentTime'); + const percent = currentTime / duration; + const position = seekerWidth * percent; + updateSeekerPosition(position); + } + }, [ + currentTime, + duration, + isLoading, + seekerWidth, + seeking, + isUISeeking, + updateSeekerPosition, + ]); + + if (!seekPanResponder) { + return null; + } + const seekerStyle = [ + styles.seekbarFill, + { + width: seekerPosition > 0 ? seekerPosition : 0, + backgroundColor: '#FFF', + }, + ]; + + const seekerPositionStyle = [ + styles.seekbarHandle, + { + left: seekerPosition > 0 ? seekerPosition : 0, + }, + ]; + + const seekerPointerStyle = [styles.seekbarCircle, {backgroundColor: '#FFF'}]; + + return ( + + setSeekerWidth(event.nativeEvent.layout.width)} + pointerEvents={'none'}> + + + + + + + ); +}; + +export default Seeker; diff --git a/ios/Video/RCTVideo.swift b/ios/Video/RCTVideo.swift index 486524d947..72439bb6c6 100644 --- a/ios/Video/RCTVideo.swift +++ b/ios/Video/RCTVideo.swift @@ -1622,6 +1622,16 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH } } + @objc + func getCurrentPlaybackTime(_ resolve: @escaping RCTPromiseResolveBlock, _ reject: @escaping RCTPromiseRejectBlock) { + if let player = _playerItem { + let currentTime = RCTVideoUtils.getCurrentTime(playerItem: player) + resolve(currentTime) + } else { + reject("PLAYER_NOT_AVAILABLE", "Player is not initialized.", nil) + } + } + // Workaround for #3418 - https://github.com/TheWidlarzGroup/react-native-video/issues/3418#issuecomment-2043508862 @objc func setOnClick(_: Any) {} diff --git a/ios/Video/RCTVideoManager.m b/ios/Video/RCTVideoManager.m index d0ead34a9c..d65901bb4f 100644 --- a/ios/Video/RCTVideoManager.m +++ b/ios/Video/RCTVideoManager.m @@ -88,4 +88,8 @@ @interface RCT_EXTERN_MODULE (RCTVideoManager, RCTViewManager) RCT_EXTERN_METHOD(setVolume : (nonnull float*)volume reactTag : (nonnull NSNumber*)reactTag) +RCT_EXTERN_METHOD(getCurrentPosition + : (nonnull NSNumber*)reactTag resolver + : (RCTPromiseResolveBlock)resolve rejecter + : (RCTPromiseRejectBlock)reject) @end diff --git a/ios/Video/RCTVideoManager.swift b/ios/Video/RCTVideoManager.swift index d8053f37ea..b4e3144af7 100644 --- a/ios/Video/RCTVideoManager.swift +++ b/ios/Video/RCTVideoManager.swift @@ -18,13 +18,15 @@ class RCTVideoManager: RCTViewManager { return } - guard let view = self.bridge.uiManager.view(forReactTag: reactTag) as? RCTVideo else { - RCTLogError("Invalid view returned from registry, expecting RCTVideo, got: \(String(describing: view))") + let view = self.bridge.uiManager.view(forReactTag: reactTag) + + guard let videoView = view as? RCTVideo else { + DebugLog("Invalid view returned from registry, expecting RCTVideo, got: \(String(describing: view))") callback(nil) return } - callback(view) + callback(videoView) } } @@ -84,6 +86,13 @@ class RCTVideoManager: RCTViewManager { }) } + @objc(getCurrentPosition:resolver:rejecter:) + func getCurrentPosition(reactTag: NSNumber, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + performOnVideoView(withReactTag: reactTag, callback: { videoView in + videoView?.getCurrentPlaybackTime(resolve, reject) + }) + } + override class func requiresMainQueueSetup() -> Bool { return true } diff --git a/src/Video.tsx b/src/Video.tsx index 55b9bd0194..c2dba261c4 100644 --- a/src/Video.tsx +++ b/src/Video.tsx @@ -65,6 +65,7 @@ export interface VideoRef { ) => void; save: (options: object) => Promise; setVolume: (volume: number) => void; + getCurrentPosition: () => Promise; } const Video = forwardRef( @@ -295,6 +296,10 @@ const Video = forwardRef( return VideoManager.setVolume(volume, getReactTag(nativeRef)); }, []); + const getCurrentPosition = useCallback(() => { + return VideoManager.getCurrentPosition(getReactTag(nativeRef)); + }, []); + const onVideoLoadStart = useCallback( (e: NativeSyntheticEvent) => { hasPoster && setShowPoster(true); @@ -512,6 +517,7 @@ const Video = forwardRef( resume, restoreUserInterfaceForPictureInPictureStopCompleted, setVolume, + getCurrentPosition, }), [ seek, @@ -522,6 +528,7 @@ const Video = forwardRef( resume, restoreUserInterfaceForPictureInPictureStopCompleted, setVolume, + getCurrentPosition, ], ); diff --git a/src/specs/VideoNativeComponent.ts b/src/specs/VideoNativeComponent.ts index 146553e617..d776956c03 100644 --- a/src/specs/VideoNativeComponent.ts +++ b/src/specs/VideoNativeComponent.ts @@ -285,6 +285,7 @@ export type OnAudioFocusChangedData = Readonly<{ type ControlsStyles = Readonly<{ hideSeekBar?: boolean; + seekIncrementMS?: number; }>; export interface VideoNativeProps extends ViewProps { @@ -388,6 +389,7 @@ export interface VideoManagerType { reactTag: number, ) => Promise; setVolume: (volume: number, reactTag: number) => Promise; + getCurrentPosition: (reactTag: number) => Promise; } export interface VideoDecoderPropertiesType { diff --git a/src/types/video.ts b/src/types/video.ts index 6aa93c5260..7c6001c5de 100644 --- a/src/types/video.ts +++ b/src/types/video.ts @@ -204,6 +204,7 @@ export type AudioOutput = 'speaker' | 'earpiece'; export type ControlsStyles = { hideSeekBar?: boolean; + seekIncrementMS?: number; }; export interface ReactVideoProps extends ReactVideoEvents, ViewProps {