diff --git a/Video.js b/Video.js index 86b75dae..f8967344 100644 --- a/Video.js +++ b/Video.js @@ -343,7 +343,11 @@ export default class Video extends Component { patchVer: source.patchVer || 0, requestHeaders: source.headers ? this.stringsOnlyObject(source.headers) : {}, startTime: source.startTime || 0, - endTime: source.endTime + endTime: source.endTime, + // Custom Metadata + title: source.title, + subtitle: source.subtitle, + description: source.description, }, onVideoLoadStart: this._onLoadStart, onVideoPlaybackStateChanged: this._onPlaybackStateChanged, @@ -492,6 +496,13 @@ Video.propTypes = { language: PropTypes.string.isRequired, }) ), + chapters: PropTypes.arrayOf( + PropTypes.shape({ + title: PropTypes.string, + startTime: PropTypes.number.isRequired, + endTime: PropTypes.number.isRequired, + }) + ), paused: PropTypes.bool, muted: PropTypes.bool, volume: PropTypes.number, diff --git a/examples/exampletvOS/App.tsx b/examples/exampletvOS/App.tsx index d151527b..f7ef3f7c 100644 --- a/examples/exampletvOS/App.tsx +++ b/examples/exampletvOS/App.tsx @@ -13,11 +13,26 @@ export default function App() { uri: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', // uri: 'https://sample.vodobox.net/skate_phantom_flex_4k/skate_phantom_flex_4k.m3u8', // type: 'm3u8', + title: 'Custom Title', + subtitle: 'Custom Subtitle', + description: 'Custom Description', }} style={[styles.fullScreen, StyleSheet.absoluteFillObject]} controls fullscreen resizeMode={'contain'} + chapters={[ + { + title: 'Chapter 1', + startTime: 0.0, + endTime: 20.0, + }, + { + title: 'Chapter 2', + startTime: 20.0, + endTime: 40.0, + }, + ]} /> ); diff --git a/ios/Video/DataStructures/Chapter.swift b/ios/Video/DataStructures/Chapter.swift new file mode 100644 index 00000000..8d0d6fdd --- /dev/null +++ b/ios/Video/DataStructures/Chapter.swift @@ -0,0 +1,25 @@ + +struct Chapter { + let title: String + let uri: String + let startTime: Double + let endTime: Double + + let json: NSDictionary? + + init(_ json: NSDictionary!) { + guard json != nil else { + self.json = nil + self.title = "" + self.uri = "" + self.startTime = 0 + self.endTime = 0 + return + } + self.json = json + self.title = json["title"] as? String ?? "" + self.uri = json["uri"] as? String ?? "" + self.startTime = json["startTime"] as? Double ?? 0 + self.endTime = json["endTime"] as? Double ?? 0 + } +} diff --git a/ios/Video/DataStructures/VideoSource.swift b/ios/Video/DataStructures/VideoSource.swift index 7cb7c38b..d3f16dbf 100644 --- a/ios/Video/DataStructures/VideoSource.swift +++ b/ios/Video/DataStructures/VideoSource.swift @@ -8,6 +8,10 @@ struct VideoSource { let requestHeaders: Dictionary? let startTime: Int64? let endTime: Int64? + // Custom Metadata + let title: String? + let subtitle: String? + let description: String? let json: NSDictionary? @@ -22,6 +26,9 @@ struct VideoSource { self.requestHeaders = nil self.startTime = nil self.endTime = nil + self.title = nil + self.subtitle = nil + self.description = nil return } self.json = json @@ -33,5 +40,8 @@ struct VideoSource { self.requestHeaders = json["requestHeaders"] as? Dictionary self.startTime = json["startTime"] as? Int64 self.endTime = json["endTime"] as? Int64 + self.title = json["title"] as? String + self.subtitle = json["subtitle"] as? String + self.description = json["description"] as? String } } diff --git a/ios/Video/RCTVideo.swift b/ios/Video/RCTVideo.swift index 055a1915..6ad51613 100644 --- a/ios/Video/RCTVideo.swift +++ b/ios/Video/RCTVideo.swift @@ -14,6 +14,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH private var _source:VideoSource? private var _playerBufferEmpty:Bool = true private var _playerLayer:AVPlayerLayer? + private var _chapters:[Chapter]? private var _playerViewController:RCTVideoPlayerViewController? private var _videoURL:NSURL? @@ -356,7 +357,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH func playerItemPrepareText(asset:AVAsset!, assetOptions:NSDictionary?) -> AVPlayerItem { if (_textTracks == nil) || _textTracks?.count==0 { - return AVPlayerItem(asset: asset) + return self.playerItemPropegateMetadata(AVPlayerItem(asset: asset)) } // AVPlayer can't airplay AVMutableCompositions @@ -371,7 +372,74 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH setTextTracks(validTextTracks) } - return AVPlayerItem(asset: mixComposition) + return self.playerItemPropegateMetadata(AVPlayerItem(asset: mixComposition)) + } + + func playerItemPropegateMetadata(_ playerItem: AVPlayerItem!) -> AVPlayerItem { + var mapping: [AVMetadataIdentifier: Any] = [:] + + if let title = _source?.title { + mapping[.commonIdentifierTitle] = title + } + + if let subtitle = _source?.subtitle { + mapping[.iTunesMetadataTrackSubTitle] = subtitle + } + + if let description = _source?.description { + mapping[.commonIdentifierDescription] = description + } + + if !mapping.isEmpty { + playerItem.externalMetadata = createMetadataItems(for: mapping) + } + + if let chapters = _chapters { + playerItem.navigationMarkerGroups = makeNavigationMarkerGroups(chapters) + } + + return playerItem + } + + func createMetadataItems(for mapping: [AVMetadataIdentifier: Any]) -> [AVMetadataItem] { + return mapping.compactMap { createMetadataItem(for:$0, value:$1) } + } + + private func makeNavigationMarkerGroups(_ chapters: [Chapter]) -> [AVNavigationMarkersGroup] { + var metadataGroups = [AVTimedMetadataGroup]() + + // Iterate over the defined chapters and build a timed metadata group object for each. + chapters.forEach { chapter in + metadataGroups.append(makeTimedMetadataGroup(for: chapter)) + } + + return [AVNavigationMarkersGroup(title: nil, timedNavigationMarkers: metadataGroups)] + } + + private func makeTimedMetadataGroup(for chapter: Chapter) -> AVTimedMetadataGroup { + var metadata = [AVMetadataItem]() + + // Create a metadata item that contains the chapter title. + let titleItem = createMetadataItem(for: .commonIdentifierTitle, value: chapter.title) + metadata.append(titleItem) + + // Create a time range for the metadata group. + let timescale: Int32 = 600 + let startTime = CMTime(seconds: chapter.startTime, preferredTimescale: timescale) + let endTime = CMTime(seconds: chapter.endTime, preferredTimescale: timescale) + let timeRange = CMTimeRangeFromTimeToTime(start: startTime, end: endTime) + + return AVTimedMetadataGroup(items: metadata, timeRange: timeRange) + } + + private func createMetadataItem(for identifier: AVMetadataIdentifier, + value: Any) -> AVMetadataItem { + let item = AVMutableMetadataItem() + item.identifier = identifier + item.value = value as? NSCopying & NSObjectProtocol + // Specify "und" to indicate an undefined language. + item.extendedLanguageTag = "und" + return item.copy() as! AVMetadataItem } // MARK: - Prop setters @@ -640,6 +708,15 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH // in case textTracks was set after selectedTextTrack if (_selectedTextTrackCriteria != nil) {setSelectedTextTrack(_selectedTextTrackCriteria)} } + + @objc + func setChapters(_ chapters:[NSDictionary]?) { + setChapters(chapters?.map { Chapter($0) }) + } + + func setChapters(_ chapters:[Chapter]?) { + _chapters = chapters + } @objc func setFullscreen(_ fullscreen:Bool) { diff --git a/ios/Video/RCTVideoManager.m b/ios/Video/RCTVideoManager.m index 0d503d14..544c89fa 100644 --- a/ios/Video/RCTVideoManager.m +++ b/ios/Video/RCTVideoManager.m @@ -14,6 +14,7 @@ RCT_EXPORT_VIEW_PROPERTY(allowsExternalPlayback, BOOL); RCT_EXPORT_VIEW_PROPERTY(textTracks, NSArray); RCT_EXPORT_VIEW_PROPERTY(selectedTextTrack, NSDictionary); RCT_EXPORT_VIEW_PROPERTY(selectedAudioTrack, NSDictionary); +RCT_EXPORT_VIEW_PROPERTY(chapters, NSArray); RCT_EXPORT_VIEW_PROPERTY(paused, BOOL); RCT_EXPORT_VIEW_PROPERTY(muted, BOOL); RCT_EXPORT_VIEW_PROPERTY(controls, BOOL);