diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 54296c23..223be5ae 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -10,10 +10,12 @@ assignees: '' # Bug ## Platform diff --git a/API.md b/API.md index b735a026..a5e9e089 100644 --- a/API.md +++ b/API.md @@ -304,6 +304,7 @@ var styles = StyleSheet.create({ |[selectedTextTrack](#selectedtexttrack)|Android, iOS| |[selectedVideoTrack](#selectedvideotrack)|Android| |[source](#source)|All| +|[subtitleStyle](#subtitleStyle)|Android| |[textTracks](#texttracks)|Android, iOS| |[trackId](#trackId)|Android| |[useTextureView](#usetextureview)|Android| @@ -443,7 +444,7 @@ Determines if the player needs to throw an error when connection is lost or not Platforms: Android ### DRM -To setup DRM please follow [this guide](./DRM.md) +To setup DRM please follow [this guide](./docs/DRM.md) Platforms: Android, iOS @@ -842,6 +843,23 @@ The following other types are supported on some platforms, but aren't fully docu `content://, ms-appx://, ms-appdata://, assets-library://` +#### subtitleStyle + +Property | Description | Platforms +--- | --- | --- +fontSizeTrack | Adjust the font size of the subtitles. Default: font size of the device | Android +paddingTop | Adjust the top padding of the subtitles. Default: 0| Android +paddingBottom | Adjust the bottom padding of the subtitles. Default: 0| Android +paddingLeft | Adjust the left padding of the subtitles. Default: 0| Android +paddingRight | Adjust the right padding of the subtitles. Default: 0| Android + + +Example: + +``` +subtitleStyle={{ paddingBottom: 50, fontSize: 20 }} +``` + #### textTracks Load one or more "sidecar" text tracks. This takes an array of objects representing each track. Each object should have the format: diff --git a/CHANGELOG.md b/CHANGELOG.md index a97ae6fc..95b2a97e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,20 @@ ### Version 6.0.0-alpha.2 -- +- Feature add support of subtitle styling on android [#2759](https://github.com/react-native-video/react-native-video/pull/2759) +- Fix Android #2690 ensure onEnd is not sent twice [#2690](https://github.com/react-native-video/react-native-video/issues/2690) +- Fix Exoplayer progress not reported when paused [#2664](https://github.com/react-native-video/react-native-video/pull/2664) +- Call playbackRateChange onPlay and onPause [#1493](https://github.com/react-native-video/react-native-video/pull/1493) +- Fix being unable to disable sideloaded texttracks in the AVPlayer [#2679](https://github.com/react-native-video/react-native-video/pull/2679) +- Fixed crash when iOS seek method called reject on the promise [#2743](https://github.com/react-native-video/react-native-video/pull/2743) +- Fix maxBitRate property being ignored on Android [#2670](https://github.com/react-native-video/react-native-video/pull/2670) +- Fix crash when the source is a cameraroll [#2639] (https://github.com/react-native-video/react-native-video/pull/2639) ### Version 6.0.0-alpha.1 - Remove Android MediaPlayer support [#2724](https://github.com/react-native-video/react-native-video/pull/2724) + **WARNING**: when switching from older version to V6, you need to remove all refrerences of android-exoplayer. This android-exoplayer folder has been renamed to android. Exoplayer is now the only player implementation supported. + - Replace Image.propTypes with ImagePropTypes. [#2718](https://github.com/react-native-video/react-native-video/pull/2718) - Fix iOS build caused by type mismatch [#2720](https://github.com/react-native-video/react-native-video/pull/2720) - ERROR TypeError: undefined is not an object (evaluating '_reactNative.Image.propTypes.resizeMode') [#2714](https://github.com/react-native-video/react-native-video/pull/2714) diff --git a/Video.js b/Video.js index 7fd563bb..287573a1 100644 --- a/Video.js +++ b/Video.js @@ -285,7 +285,7 @@ export default class Video extends Component { } const isNetwork = !!(uri && uri.match(/^https?:/)); - const isAsset = !!(uri && uri.match(/^(assets-library|ipod-library|file|content|ms-appx|ms-appdata):/)); + const isAsset = !!(uri && uri.match(/^(assets-library|ph|ipod-library|file|content|ms-appx|ms-appdata):/)); let nativeResizeMode; const RCTVideoInstance = this.getViewManagerConfig('RCTVideo'); @@ -490,6 +490,13 @@ Video.propTypes = { fullscreenAutorotate: PropTypes.bool, fullscreenOrientation: PropTypes.oneOf(['all', 'landscape', 'portrait']), progressUpdateInterval: PropTypes.number, + subtitleStyle: PropTypes.shape({ + paddingTop: PropTypes.number, + paddingBottom: PropTypes.number, + paddingLeft: PropTypes.number, + paddingRight: PropTypes.number, + fontSize: PropTypes.number, + }), useTextureView: PropTypes.bool, useSecureView: PropTypes.bool, hideShutterView: PropTypes.bool, diff --git a/android/src/main/java/com/brentvatne/ReactBridgeUtils.java b/android/src/main/java/com/brentvatne/ReactBridgeUtils.java new file mode 100644 index 00000000..c3391283 --- /dev/null +++ b/android/src/main/java/com/brentvatne/ReactBridgeUtils.java @@ -0,0 +1,22 @@ +package com.brentvatne; + +import com.facebook.react.bridge.ReadableMap; + +/* +* This file define static helpers to parse in an easier way input props + */ +public class ReactBridgeUtils { + /* + retrieve key from map as int. fallback is returned if not available + */ + static public int safeGetInt(ReadableMap map, String key, int fallback) { + return map != null && map.hasKey(key) && !map.isNull(key) ? map.getInt(key) : fallback; + } + + /* + retrieve key from map as double. fallback is returned if not available + */ + static public double safeGetDouble(ReadableMap map, String key, double fallback) { + return map != null && map.hasKey(key) && !map.isNull(key) ? map.getDouble(key) : fallback; + } +} diff --git a/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.java b/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.java index a6cd1c4f..8c97298a 100644 --- a/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.java +++ b/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.java @@ -5,6 +5,7 @@ import android.content.Context; import androidx.core.content.ContextCompat; import android.util.AttributeSet; import android.util.Log; +import android.util.TypedValue; import android.view.Gravity; import android.view.SurfaceView; import android.view.TextureView; @@ -100,6 +101,16 @@ public final class ExoPlayerView extends FrameLayout { player.setVideoSurfaceView((SurfaceView) surfaceView); } } + public void setSubtitleStyle(SubtitleStyle style) { + // ensure we reset subtile style before reapplying it + subtitleLayout.setUserDefaultStyle(); + subtitleLayout.setUserDefaultTextSize(); + + if (style.getFontSize() > 0) { + subtitleLayout.setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, style.getFontSize()); + } + subtitleLayout.setPadding(style.getPaddingLeft(), style.getPaddingTop(), style.getPaddingRight(), style.getPaddingBottom()); + } private void updateSurfaceView() { View view; diff --git a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java index 9a790fdc..9a334132 100644 --- a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java +++ b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java @@ -205,18 +205,29 @@ class ReactExoplayerView extends FrameLayout implements private final AudioManager audioManager; private final AudioBecomingNoisyReceiver audioBecomingNoisyReceiver; + // store last progress event values to avoid sending unnecessary messages + private long lastPos = -1; + private long lastBufferDuration = -1; + private long lastDuration = -1; + private final Handler progressHandler = new Handler(Looper.getMainLooper()) { @Override public void handleMessage(Message msg) { switch (msg.what) { case SHOW_PROGRESS: - if (player != null - && player.getPlaybackState() == Player.STATE_READY - && player.getPlayWhenReady() - ) { + if (player != null) { long pos = player.getCurrentPosition(); long bufferedDuration = player.getBufferedPercentage() * player.getDuration() / 100; - eventEmitter.progressChanged(pos, bufferedDuration, player.getDuration(), getPositionInFirstPeriodMsForCurrentWindow(pos)); + long duration = player.getDuration(); + + if (lastPos != pos + || lastBufferDuration != bufferedDuration + || lastDuration != duration) { + lastPos = pos; + lastBufferDuration = bufferedDuration; + lastDuration = duration; + eventEmitter.progressChanged(pos, bufferedDuration, player.getDuration(), getPositionInFirstPeriodMsForCurrentWindow(pos)); + } msg = obtainMessage(SHOW_PROGRESS); sendMessageDelayed(msg, Math.round(mProgressUpdateInterval)); } @@ -392,6 +403,15 @@ class ReactExoplayerView extends FrameLayout implements eventListener = new Player.Listener() { @Override public void onPlaybackStateChanged(int playbackState) { + View playButton = playerControlView.findViewById(R.id.exo_play); + View pauseButton = playerControlView.findViewById(R.id.exo_pause); + if (playButton != null && playButton.getVisibility() == GONE) { + playButton.setVisibility(INVISIBLE); + } + if (pauseButton != null && pauseButton.getVisibility() == GONE) { + pauseButton.setVisibility(INVISIBLE); + } + reLayout(playPauseControlContainer); //Remove this eventListener once its executed. since UI will work fine once after the reLayout is done player.removeListener(eventListener); @@ -804,7 +824,10 @@ class ReactExoplayerView extends FrameLayout implements player.setPlayWhenReady(true); } } else { - player.setPlayWhenReady(false); + // ensure playback is not ENDED, else it will trigger another ended event + if (player.getPlaybackState() != Player.STATE_ENDED) { + player.setPlayWhenReady(false); + } } } @@ -945,6 +968,7 @@ class ReactExoplayerView extends FrameLayout implements int playbackState = player.getPlaybackState(); boolean playWhenReady = player.getPlayWhenReady(); String text = "onStateChanged: playWhenReady=" + playWhenReady + ", playbackState="; + eventEmitter.playbackRateChange(playWhenReady && playbackState == ExoPlayer.STATE_READY ? 1.0f : 0.0f); switch (playbackState) { case Player.STATE_IDLE: text += "idle"; @@ -1005,9 +1029,15 @@ class ReactExoplayerView extends FrameLayout implements private void videoLoaded() { if (loadVideoStarted) { loadVideoStarted = false; - setSelectedAudioTrack(audioTrackType, audioTrackValue); - setSelectedVideoTrack(videoTrackType, videoTrackValue); - setSelectedTextTrack(textTrackType, textTrackValue); + if (audioTrackType != null) { + setSelectedAudioTrack(audioTrackType, audioTrackValue); + } + if (videoTrackType != null) { + setSelectedVideoTrack(videoTrackType, videoTrackValue); + } + if (textTrackType != null) { + setSelectedTextTrack(textTrackType, textTrackValue); + } Format videoFormat = player.getVideoFormat(); int width = videoFormat != null ? videoFormat.width : 0; int height = videoFormat != null ? videoFormat.height : 0; @@ -1826,4 +1856,8 @@ class ReactExoplayerView extends FrameLayout implements } } } + + public void setSubtitleStyle(SubtitleStyle style) { + exoPlayerView.setSubtitleStyle(style); + } } diff --git a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerViewManager.java b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerViewManager.java index 3744fcae..ce1d6a8c 100644 --- a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerViewManager.java +++ b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerViewManager.java @@ -79,6 +79,8 @@ public class ReactExoplayerViewManager extends ViewGroupManager(on: .global()) { fulfill, reject in guard CMTimeCompare(current, cmSeekTime) != 0 else { - reject(NSError()) + reject(NSError(domain: "", code: 0, userInfo: nil)) return } if !paused { player.pause() } diff --git a/ios/Video/Features/RCTVideoUtils.swift b/ios/Video/Features/RCTVideoUtils.swift index 69c127af..50df8e3f 100644 --- a/ios/Video/Features/RCTVideoUtils.swift +++ b/ios/Video/Features/RCTVideoUtils.swift @@ -1,5 +1,6 @@ import AVFoundation import Promises +import Photos /*! * Collection of pure functions @@ -37,16 +38,17 @@ enum RCTVideoUtils { return 0 } - static func urlFilePath(filepath:NSString!) -> NSURL! { + static func urlFilePath(filepath:NSString!, searchPath:FileManager.SearchPathDirectory) -> NSURL! { if filepath.contains("file://") { return NSURL(string: filepath as String) } // if no file found, check if the file exists in the Document directory - let paths:[String]! = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true) + let paths:[String]! = NSSearchPathForDirectoriesInDomains(searchPath, .userDomainMask, true) var relativeFilePath:String! = filepath.lastPathComponent // the file may be multiple levels below the documents directory - let fileComponents:[String]! = filepath.components(separatedBy: "Documents/") + let directoryString:String! = searchPath == .cachesDirectory ? "Library/Caches/" : "Documents"; + let fileComponents:[String]! = filepath.components(separatedBy: directoryString) if fileComponents.count > 1 { relativeFilePath = fileComponents[1] } @@ -192,6 +194,7 @@ enum RCTVideoUtils { static func getValidTextTracks(asset:AVAsset, assetOptions:NSDictionary?, mixComposition:AVMutableComposition, textTracks:[TextTrack]?) -> [TextTrack] { let videoAsset:AVAssetTrack! = asset.tracks(withMediaType: AVMediaType.video).first var validTextTracks:[TextTrack] = [] + if let textTracks = textTracks, textTracks.count > 0 { for i in 0.. TextTrack? { + let fileManager = FileManager.default + let cachesDirectoryUrl = fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0] + let filePath = cachesDirectoryUrl.appendingPathComponent("empty.vtt").path + + if !fileManager.fileExists(atPath: filePath) { + let stringToWrite = "WEBVTT\n\n1\n99:59:59.000 --> 99:59:59.001\n." + + do { + try stringToWrite.write(to: URL(fileURLWithPath: filePath), atomically: true, encoding: String.Encoding.utf8) + } catch { + return nil + } + } + + return TextTrack([ + "language": "disabled", + "title": "EmptyVttFile", + "type": "text/vtt", + "uri": filePath, + ]) + } + static func delay(seconds: Int = 0) -> Promise { return Promise(on: .global()) { fulfill, reject in DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Double(Int64(seconds)) / Double(NSEC_PER_SEC), execute: { @@ -226,8 +265,23 @@ enum RCTVideoUtils { } } + static func preparePHAsset(uri: String) -> Promise { + return Promise(on: .global()) { fulfill, reject in + let assetId = String(uri[uri.index(uri.startIndex, offsetBy: "ph://".count)...]) + guard let phAsset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil).firstObject else { + reject(NSError(domain: "", code: 0, userInfo: nil)) + return + } + let options = PHVideoRequestOptions() + options.isNetworkAccessAllowed = true + PHCachingImageManager().requestAVAsset(forVideo: phAsset, options: options) { data, _, _ in + fulfill(data) + } + } + } + static func prepareAsset(source:VideoSource) -> (asset:AVURLAsset?, assetOptions:NSMutableDictionary?)? { - guard source.uri != nil && source.uri != "" else { return nil } + guard let sourceUri = source.uri, sourceUri != "" else { return nil } var asset:AVURLAsset! let bundlePath = Bundle.main.path(forResource: source.uri, ofType: source.type) ?? "" let url = source.isNetwork || source.isAsset diff --git a/ios/Video/RCTVideo.swift b/ios/Video/RCTVideo.swift index 59973a36..a339aff4 100644 --- a/ios/Video/RCTVideo.swift +++ b/ios/Video/RCTVideo.swift @@ -227,10 +227,20 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH RCTVideoUtils.delay() .then{ [weak self] in guard let self = self else {throw NSError(domain: "", code: 0, userInfo: nil)} - guard let source = self._source, - let assetResult = RCTVideoUtils.prepareAsset(source: source), - let asset = assetResult.asset, - let assetOptions = assetResult.assetOptions else { + guard let source = self._source else { + DebugLog("The source not exist") + throw NSError(domain: "", code: 0, userInfo: nil) + } + if let uri = source.uri, uri.starts(with: "ph://") { + return Promise { + RCTVideoUtils.preparePHAsset(uri: uri).then { asset in + return self.playerItemPrepareText(asset:asset, assetOptions:nil) + } + } + } + guard let assetResult = RCTVideoUtils.prepareAsset(source: source), + let asset = assetResult.asset, + let assetOptions = assetResult.assetOptions else { DebugLog("Could not find video URL in source '\(self._source)'") throw NSError(domain: "", code: 0, userInfo: nil) } diff --git a/ios/Video/RCTVideoManager.swift b/ios/Video/RCTVideoManager.swift index bf42f707..d1c73de8 100644 --- a/ios/Video/RCTVideoManager.swift +++ b/ios/Video/RCTVideoManager.swift @@ -5,7 +5,7 @@ import React class RCTVideoManager: RCTViewManager { override func view() -> UIView { - return RCTVideo(eventDispatcher: bridge.eventDispatcher()) + return RCTVideo(eventDispatcher: bridge.eventDispatcher() as! RCTEventDispatcher) } func methodQueue() -> DispatchQueue { diff --git a/ios/Video/RCTVideoPlayerViewController.swift b/ios/Video/RCTVideoPlayerViewController.swift index 95c926af..e398e62f 100644 --- a/ios/Video/RCTVideoPlayerViewController.swift +++ b/ios/Video/RCTVideoPlayerViewController.swift @@ -19,8 +19,11 @@ class RCTVideoPlayerViewController: AVPlayerViewController { override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - rctDelegate.videoPlayerViewControllerWillDismiss(playerViewController: self) - rctDelegate.videoPlayerViewControllerDidDismiss(playerViewController: self) + + if rctDelegate != nil { + rctDelegate.videoPlayerViewControllerWillDismiss(playerViewController: self) + rctDelegate.videoPlayerViewControllerDidDismiss(playerViewController: self) + } } #if !TARGET_OS_TV