From d398e1493cb9e26916e917ca34818adf5f1b51e2 Mon Sep 17 00:00:00 2001 From: Eduard Mazur Date: Tue, 14 Jan 2025 02:33:48 +0200 Subject: [PATCH] [Feat] Added additional buttons for contextual actions --- .../DataStructures/ContextualActionData.swift | 20 +++++ ios/Video/RCTVideo+ContextualActions.swift | 88 +++++++++++++++++++ ios/Video/RCTVideo-Bridging-Header.h | 2 +- ios/Video/RCTVideo.swift | 54 +++++++++++- ios/Video/RCTVideoManager.m | 4 + ios/Video/RCTVideoManager.swift | 1 + ios/Video/ReactNativeVideoManager.swift | 22 ++--- src/types/events.ts | 2 + src/types/video.ts | 11 +++ 9 files changed, 191 insertions(+), 13 deletions(-) create mode 100644 ios/Video/DataStructures/ContextualActionData.swift create mode 100644 ios/Video/RCTVideo+ContextualActions.swift diff --git a/ios/Video/DataStructures/ContextualActionData.swift b/ios/Video/DataStructures/ContextualActionData.swift new file mode 100644 index 0000000000..d706ad882d --- /dev/null +++ b/ios/Video/DataStructures/ContextualActionData.swift @@ -0,0 +1,20 @@ +// +// ContextualActionData.swift +// react-native-video +// +// Created by Eduard Mazur on 14.01.2025. +// + +import Foundation + +struct ContextualActionData { + let action: String + let startAt: Double + let endAt: Double? +} + +public enum ContextualButtonState { + case none + case skipIntro + case nextEpisode +} diff --git a/ios/Video/RCTVideo+ContextualActions.swift b/ios/Video/RCTVideo+ContextualActions.swift new file mode 100644 index 0000000000..6417bca06e --- /dev/null +++ b/ios/Video/RCTVideo+ContextualActions.swift @@ -0,0 +1,88 @@ +// +// RCTVideo+ContextualActions.swift +// react-native-video +// +// Created by Eduard Mazur on 14.01.2025. +// + +import AVKit + +@available(tvOS 15.0, *) +extension RCTVideo { + func configureContextualActions() { + guard let player = playerForContextualActions else { + print("Player is not initialized.") + return + } + + let interval = CMTime(seconds: 0.5, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) + + timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] currentTime in + self?.updateContextualActions(currentTime: currentTime) + } + } + + func removeContextualActionsTimeObserver() { + guard let player = playerForContextualActions, let token = timeObserverToken else { return } + player.removeTimeObserver(token) + timeObserverToken = nil + } + + func updateContextualActions(currentTime: CMTime) { + let currentSeconds = CMTimeGetSeconds(currentTime) + + guard let playerViewController = playerViewControllerForContextualActions else { + return + } + + for actionData in contextualActionData { + if currentSeconds >= actionData.startAt, + let endAt = actionData.endAt, currentSeconds < endAt { + handleAction(actionData, playerViewController: playerViewController) + return + } else if currentSeconds >= actionData.startAt, actionData.endAt == nil { + handleAction(actionData, playerViewController: playerViewController) + return + } + } + + if currentContextualState != .none { + playerViewController.contextualActions = [] + currentContextualState = .none + } + } + + func handleAction(_ actionData: ContextualActionData, playerViewController: AVPlayerViewController) { + switch actionData.action { + case "SkipIntro": + if currentContextualState != .skipIntro { + let skipIntroAction = UIAction(title: "Skip Intro") { [weak self] _ in + self?.handleSkipIntro() + } + playerViewController.contextualActions = [skipIntroAction] + currentContextualState = .skipIntro + } + case "NextEpisode": + if currentContextualState != .nextEpisode { + let nextEpisodeAction = UIAction(title: "Next Episode") { [weak self] _ in + self?.handleNextEpisode() + } + playerViewController.contextualActions = [nextEpisodeAction] + currentContextualState = .nextEpisode + } + default: + break + } + } + + func handleSkipIntro() { + onSkipIntro?(["message": "Skip Intro triggered"]) + let skipTime = CMTime(seconds: 120, preferredTimescale: 1) + playerForContextualActions?.seek(to: skipTime, toleranceBefore: .zero, toleranceAfter: .zero) + } + + func handleNextEpisode() { + onNextEpisode?(["message": "Next Episode triggered"]) + print("Next episode triggered") + } +} diff --git a/ios/Video/RCTVideo-Bridging-Header.h b/ios/Video/RCTVideo-Bridging-Header.h index 6522d5ae53..967c6692ee 100644 --- a/ios/Video/RCTVideo-Bridging-Header.h +++ b/ios/Video/RCTVideo-Bridging-Header.h @@ -1,6 +1,6 @@ #import "RCTVideoSwiftLog.h" #import - +#import #if __has_include() #import "RCTVideoCache.h" #endif diff --git a/ios/Video/RCTVideo.swift b/ios/Video/RCTVideo.swift index 7915e82dbf..133b961109 100644 --- a/ios/Video/RCTVideo.swift +++ b/ios/Video/RCTVideo.swift @@ -135,7 +135,42 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH @objc var onTextTracks: RCTDirectEventBlock? @objc var onAudioTracks: RCTDirectEventBlock? @objc var onTextTrackDataChanged: RCTDirectEventBlock? + @objc var onSkipIntro: RCTDirectEventBlock? + @objc var onNextEpisode: RCTDirectEventBlock? + + // Contextual Actions + public var playerViewControllerForContextualActions: AVPlayerViewController? { + return _playerViewController + } + public var playerForContextualActions: AVPlayer? { + return _player + } + + public var currentContextualState: ContextualButtonState = .none + public var timeObserverToken: Any? + + var contextualActionData: [ContextualActionData] = [] + + private func parseContextualActions() { + contextualActionData = contextualActions.compactMap { actionDict in + guard let action = actionDict["action"] as? String, + let startAt = actionDict["startAt"] as? Double else { + return nil + } + let endAt = actionDict["endAt"] as? Double + return ContextualActionData(action: action, startAt: startAt, endAt: endAt) + } + } + + @objc var contextualActions: [[String: Any]] = [] { + didSet { + parseContextualActions() + } + } + + + @objc func _onPictureInPictureEnter() { onPictureInPictureStatusChanged?(["isActive": NSNumber(value: true)]) @@ -289,6 +324,12 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH _pip = nil #endif ReactNativeVideoManager.shared.unregisterView(newInstance: self) + + #if os(tvOS) + if #available(tvOS 15.0, *) { + removeContextualActionsTimeObserver() + } + #endif } // MARK: - App lifecycle handlers @@ -1104,11 +1145,17 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH if _controls { let viewController: UIViewController! = self.reactViewController() viewController?.addChild(_playerViewController) + self.addSubview(_playerViewController.view) } - + _playerObserver.playerViewController = _playerViewController + + if #available(tvOS 15.0, *) { + configureContextualActions() + } } + func createPlayerViewController(player: AVPlayer, withPlayerItem _: AVPlayerItem) -> RCTVideoPlayerViewController { let viewController = RCTVideoPlayerViewController() @@ -1717,21 +1764,24 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH } } + @available(tvOS 14.0, *) @objc func enterPictureInPicture() { + #if os(iOS) if _pip?._pipController == nil { initPictureinPicture() _playerViewController?.allowsPictureInPicturePlayback = true } _pip?.enterPictureInPicture() + #endif } @objc func exitPictureInPicture() { guard isPictureInPictureActive() else { return } - _pip?.exitPictureInPicture() #if os(iOS) + _pip?.exitPictureInPicture() if _enterPictureInPictureOnLeave { initPictureinPicture() _playerViewController?.allowsPictureInPicturePlayback = true diff --git a/ios/Video/RCTVideoManager.m b/ios/Video/RCTVideoManager.m index 330aef8546..a1e8e29f93 100644 --- a/ios/Video/RCTVideoManager.m +++ b/ios/Video/RCTVideoManager.m @@ -87,4 +87,8 @@ @interface RCT_EXTERN_MODULE (RCTVideoManager, RCTViewManager) : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) +// Should support contextual actions +RCT_EXPORT_VIEW_PROPERTY(contextualActions, NSArray); +RCT_EXPORT_VIEW_PROPERTY(onSkipIntro, RCTDirectEventBlock); +RCT_EXPORT_VIEW_PROPERTY(onNextEpisode, RCTDirectEventBlock); @end diff --git a/ios/Video/RCTVideoManager.swift b/ios/Video/RCTVideoManager.swift index b408e966b8..5f0ad6aaaa 100644 --- a/ios/Video/RCTVideoManager.swift +++ b/ios/Video/RCTVideoManager.swift @@ -72,6 +72,7 @@ class RCTVideoManager: RCTViewManager { }) } + @available(tvOS 14.0, *) @objc(enterPictureInPictureCmd:) func enterPictureInPictureCmd(_ reactTag: NSNumber) { performOnVideoView(withReactTag: reactTag, callback: { videoView in diff --git a/ios/Video/ReactNativeVideoManager.swift b/ios/Video/ReactNativeVideoManager.swift index 77767f9c7c..5057b393d6 100644 --- a/ios/Video/ReactNativeVideoManager.swift +++ b/ios/Video/ReactNativeVideoManager.swift @@ -6,31 +6,33 @@ import Foundation public class ReactNativeVideoManager: RNVPlugin { - private let expectedMaxVideoCount = 2 + private let expectedMaxVideoCount = 10 // create a private initializer private init() {} public static let shared: ReactNativeVideoManager = .init() - private var instanceCount = 0 - private var pluginList: [RNVPlugin] = Array() + var instanceList: [RCTVideo] = Array() + var pluginList: [RNVPlugin] = Array() /** - * register a new view + * register a new ReactExoplayerViewManager in the managed list */ - func registerView(newInstance _: RCTVideo) { - if instanceCount > expectedMaxVideoCount { + func registerView(newInstance: RCTVideo) { + if instanceList.count > expectedMaxVideoCount { DebugLog("multiple Video displayed ?") } - instanceCount += 1 + instanceList.append(newInstance) } /** - * unregister existing view + * unregister existing ReactExoplayerViewManager in the managed list */ - func unregisterView(newInstance _: RCTVideo) { - instanceCount -= 1 + func unregisterView(newInstance: RCTVideo) { + if let i = instanceList.firstIndex(of: newInstance) { + instanceList.remove(at: i) + } } /** diff --git a/src/types/events.ts b/src/types/events.ts index b99aa7abee..7da5c62a2c 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -267,4 +267,6 @@ export interface ReactVideoEvents { onTextTrackDataChanged?: (e: OnTextTrackDataChangedData) => void; // iOS onVideoTracks?: (e: OnVideoTracksData) => void; //Android onAspectRatio?: (e: OnVideoAspectRatioData) => void; + onSkipIntro?: () => void; // The logic for skipping the intro is in the Swift file no need to pass seek or onSeek + onNextEpisode?: () => void; } diff --git a/src/types/video.ts b/src/types/video.ts index d14fe7e24e..c0430ffa59 100644 --- a/src/types/video.ts +++ b/src/types/video.ts @@ -274,6 +274,16 @@ export type ControlsStyles = { liveLabel?: string; }; +export declare enum ContextualAction { + SKIP_INTRO = 'SkipIntro', + NEXT_EPISODE = 'NextEpisode', +} +export type ContextualActions = { + action: ContextualAction; + startAt?: number; + endAt?: number; +} + export interface ReactVideoRenderLoaderProps { source?: ReactVideoSource; style?: StyleProp; @@ -349,4 +359,5 @@ export interface ReactVideoProps extends ReactVideoEvents, ViewProps { debug?: DebugConfig; allowsExternalPlayback?: boolean; // iOS controlsStyles?: ControlsStyles; // Android + contextualActions?: ContextualActions[]; }