feat: add notification controls (#3723)

* feat(ios): add `showNotificationControls` prop

* feat(android): add `showNotificationControls` prop

* add docs

* feat!: add `metadata` property to srouce

This is breaking change for iOS/tvOS as we are moving some properties, but I believe that this will more readable and more user friendly

* chore(ios): remove UI blocking function

* code review changes for android

* update example

* fix readme

* fix typos

* update docs

* fix typo

* chore: improve sample metadata notification

* update codegen types

* rename properties

* update tvOS example

* reset metadata on source change

* update docs

---------

Co-authored-by: Olivier Bouillet <freeboub@gmail.com>
This commit is contained in:
Krzysztof Moch
2024-05-07 12:30:57 +02:00
committed by GitHub
parent c59d00a0f0
commit 8ad4be459b
18 changed files with 908 additions and 105 deletions

View File

@@ -0,0 +1,28 @@
struct CustomMetadata {
let title: String?
let subtitle: String?
let artist: String?
let description: String?
let imageUri: String?
let json: NSDictionary?
init(_ json: NSDictionary?) {
guard let json else {
self.json = nil
title = nil
subtitle = nil
artist = nil
description = nil
imageUri = nil
return
}
self.json = json
title = json["title"] as? String ?? ""
subtitle = json["subtitle"] as? String ?? ""
artist = json["artist"] as? String ?? ""
description = json["description"] as? String ?? ""
imageUri = json["imageUri"] as? String ?? ""
}
}

View File

@@ -8,11 +8,7 @@ struct VideoSource {
let startPosition: Float64?
let cropStart: Int64?
let cropEnd: Int64?
// Custom Metadata
let title: String?
let subtitle: String?
let description: String?
let customImageUri: String?
let customMetadata: CustomMetadata?
let json: NSDictionary?
@@ -28,10 +24,7 @@ struct VideoSource {
self.startPosition = nil
self.cropStart = nil
self.cropEnd = nil
self.title = nil
self.subtitle = nil
self.description = nil
self.customImageUri = nil
self.customMetadata = nil
return
}
self.json = json
@@ -54,9 +47,6 @@ struct VideoSource {
self.startPosition = json["startPosition"] as? Float64
self.cropStart = (json["cropStart"] as? Float64).flatMap { Int64(round($0)) }
self.cropEnd = (json["cropEnd"] as? Float64).flatMap { Int64(round($0)) }
self.title = json["title"] as? String
self.subtitle = json["subtitle"] as? String
self.description = json["description"] as? String
self.customImageUri = json["customImageUri"] as? String
self.customMetadata = CustomMetadata(json["metadata"] as? NSDictionary)
}
}

View File

@@ -389,15 +389,21 @@ enum RCTVideoUtils {
return item.copy() as! AVMetadataItem
}
static func createImageMetadataItem(imageUri: String) -> Data? {
if let uri = URL(string: imageUri),
let imgData = try? Data(contentsOf: uri),
let image = UIImage(data: imgData),
let pngData = image.pngData() {
return pngData
static func createImageMetadataItem(imageUri: String) async -> Data? {
guard let url = URL(string: imageUri) else {
return nil
}
return nil
do {
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data), let pngData = image.pngData() else {
return nil
}
return pngData
} catch {
print("Error fetching image data: \(error.localizedDescription)")
return nil
}
}
static func getCurrentWindow() -> UIWindow? {

View File

@@ -0,0 +1,281 @@
import Foundation
import MediaPlayer
class NowPlayingInfoCenterManager {
static let shared = NowPlayingInfoCenterManager()
private let SEEK_INTERVAL_SECONDS: Double = 10
private weak var currentPlayer: AVPlayer?
private var players = NSHashTable<AVPlayer>.weakObjects()
private var observers: [Int: NSKeyValueObservation] = [:]
private var playbackObserver: Any?
private var playTarget: Any?
private var pauseTarget: Any?
private var skipForwardTarget: Any?
private var skipBackwardTarget: Any?
private var playbackPositionTarget: Any?
private var seekTarget: Any?
private var receivingRemoveControlEvents = false {
didSet {
if receivingRemoveControlEvents {
try? AVAudioSession.sharedInstance().setCategory(.playback)
try? AVAudioSession.sharedInstance().setActive(true)
UIApplication.shared.beginReceivingRemoteControlEvents()
} else {
UIApplication.shared.endReceivingRemoteControlEvents()
}
}
}
deinit {
cleanup()
}
func registerPlayer(player: AVPlayer) {
if players.contains(player) {
return
}
if receivingRemoveControlEvents == false {
receivingRemoveControlEvents = true
}
if let oldObserver = observers[player.hashValue] {
oldObserver.invalidate()
}
observers[player.hashValue] = observePlayers(player: player)
players.add(player)
if currentPlayer == nil {
setCurrentPlayer(player: player)
}
}
func removePlayer(player: AVPlayer) {
if !players.contains(player) {
return
}
if let observer = observers[players.hashValue] {
observer.invalidate()
}
observers.removeValue(forKey: players.hashValue)
players.remove(player)
if currentPlayer == player {
currentPlayer = nil
updateMetadata()
}
if players.allObjects.isEmpty {
cleanup()
}
}
public func cleanup() {
observers.removeAll()
players.removeAllObjects()
if let playbackObserver {
currentPlayer?.removeTimeObserver(playbackObserver)
}
let commandCenter = MPRemoteCommandCenter.shared()
invalidateTargets(commandCenter)
MPNowPlayingInfoCenter.default().nowPlayingInfo = [:]
receivingRemoveControlEvents = false
}
private func setCurrentPlayer(player: AVPlayer) {
if player == currentPlayer {
return
}
if let playbackObserver {
currentPlayer?.removeTimeObserver(playbackObserver)
}
currentPlayer = player
registerTargets()
updateMetadata()
// one second observer
playbackObserver = player.addPeriodicTimeObserver(
forInterval: CMTime(value: 1, timescale: 4),
queue: .global(),
using: { [weak self] _ in
self?.updatePlaybackInfo()
}
)
}
private func registerTargets() {
let commandCenter = MPRemoteCommandCenter.shared()
invalidateTargets(commandCenter)
playTarget = commandCenter.playCommand.addTarget { [weak self] _ in
guard let self, let player = self.currentPlayer else {
return .commandFailed
}
if player.rate == 0 {
player.play()
}
return .success
}
pauseTarget = commandCenter.pauseCommand.addTarget { [weak self] _ in
guard let self, let player = self.currentPlayer else {
return .commandFailed
}
if player.rate != 0 {
player.pause()
}
return .success
}
skipBackwardTarget = commandCenter.skipBackwardCommand.addTarget { [weak self] _ in
guard let self, let player = self.currentPlayer else {
return .commandFailed
}
let newTime = player.currentTime() - CMTime(seconds: SEEK_INTERVAL_SECONDS, preferredTimescale: .max)
player.seek(to: newTime)
return .success
}
skipForwardTarget = commandCenter.skipForwardCommand.addTarget { [weak self] _ in
guard let self, let player = self.currentPlayer else {
return .commandFailed
}
let newTime = player.currentTime() + CMTime(seconds: SEEK_INTERVAL_SECONDS, preferredTimescale: .max)
player.seek(to: newTime)
return .success
}
playbackPositionTarget = commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
guard let self, let player = self.currentPlayer else {
return .commandFailed
}
if let event = event as? MPChangePlaybackPositionCommandEvent {
player.seek(to: CMTime(seconds: event.positionTime, preferredTimescale: .max)) { _ in
player.play()
}
return .success
}
return .commandFailed
}
}
private func invalidateTargets(_ commandCenter: MPRemoteCommandCenter) {
commandCenter.playCommand.removeTarget(playTarget)
commandCenter.pauseCommand.removeTarget(pauseTarget)
commandCenter.skipForwardCommand.removeTarget(skipForwardTarget)
commandCenter.skipBackwardCommand.removeTarget(skipBackwardTarget)
commandCenter.changePlaybackPositionCommand.removeTarget(playbackPositionTarget)
}
public func updateMetadata() {
guard let player = currentPlayer, let currentItem = player.currentItem else {
let commandCenter = MPRemoteCommandCenter.shared()
invalidateTargets(commandCenter)
MPNowPlayingInfoCenter.default().nowPlayingInfo = [:]
return
}
// commonMetadata is metadata from asset, externalMetadata is custom metadata set by user
let metadata = currentItem.asset.commonMetadata + currentItem.externalMetadata
var nowPlayingInfo: [String: Any] = [:]
if let titleItem = AVMetadataItem.metadataItems(from: metadata, filteredByIdentifier: .commonIdentifierTitle).first?.value {
nowPlayingInfo[MPMediaItemPropertyTitle] = titleItem
} else {
nowPlayingInfo[MPMediaItemPropertyTitle] = ""
}
if let artistItem = AVMetadataItem.metadataItems(from: metadata, filteredByIdentifier: .commonIdentifierArtist).first?.value {
nowPlayingInfo[MPMediaItemPropertyArtist] = artistItem
} else {
nowPlayingInfo[MPMediaItemPropertyArtist] = ""
}
// I have some issue with this - setting artworkItem when it not set dont return nil but also is crashing application
// this is very hacky workaround for it
if let artworkItem = AVMetadataItem.metadataItems(from: metadata, filteredByIdentifier: .commonIdentifierArtwork).first?.value as? Data {
if let image = UIImage(data: artworkItem) {
nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size, requestHandler: { _ in
return image
})
}
} else {
nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: UIImage().size, requestHandler: { _ in
UIImage()
})
}
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = currentItem.duration.seconds
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentItem.currentTime().seconds
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = player.rate
nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = MPNowPlayingInfoMediaType.video.rawValue
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
private func updatePlaybackInfo() {
guard let player = currentPlayer, let currentItem = player.currentItem else {
return
}
// We dont want to update playback if we did not set metadata yet
if var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo {
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = currentItem.duration.seconds
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentItem.currentTime().seconds.rounded()
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = player.rate
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
}
private func findNewCurrentPlayer() {
if let newPlayer = players.allObjects.first(where: {
$0.rate != 0
}) {
setCurrentPlayer(player: newPlayer)
}
}
// We will observe players rate to find last active player that info will be displayed
private func observePlayers(player: AVPlayer) -> NSKeyValueObservation {
return player.observe(\.rate) { [weak self] player, change in
guard let self else { return }
let rate = change.newValue
// case where there is new player that is not paused
// In this case event is triggered by non currentPlayer
if rate != 0 && currentPlayer != player {
setCurrentPlayer(player: player)
return
}
// case where currentPlayer was paused
// In this case event is triggeret by currentPlayer
if rate == 0 && currentPlayer == player {
findNewCurrentPlayer()
}
}
}
}

View File

@@ -66,6 +66,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
private var _filterEnabled = false
private var _presentingViewController: UIViewController?
private var _startPosition: Float64 = -1
private var _showNotificationControls = false
private var _pictureInPictureEnabled = false {
didSet {
#if os(iOS)
@@ -244,6 +245,10 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
self.removePlayerLayer()
_playerObserver.clearPlayer()
if let player = _player {
NowPlayingInfoCenterManager.shared.removePlayer(player: player)
}
#if os(iOS)
_pip = nil
#endif
@@ -358,54 +363,54 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
// MARK: - Player and source
func preparePlayerItem() async throws -> AVPlayerItem {
guard let source = self._source else {
guard let source = _source else {
DebugLog("The source not exist")
self.isSetSourceOngoing = false
self.applyNextSource()
isSetSourceOngoing = false
applyNextSource()
throw NSError(domain: "", code: 0, userInfo: nil)
}
if let uri = source.uri, uri.starts(with: "ph://") {
let photoAsset = await RCTVideoUtils.preparePHAsset(uri: uri)
return await self.playerItemPrepareText(asset: photoAsset, assetOptions: nil, uri: source.uri ?? "")
return await playerItemPrepareText(asset: photoAsset, assetOptions: nil, uri: source.uri ?? "")
}
guard let assetResult = RCTVideoUtils.prepareAsset(source: source),
let asset = assetResult.asset,
let assetOptions = assetResult.assetOptions else {
DebugLog("Could not find video URL in source '\(String(describing: self._source))'")
self.isSetSourceOngoing = false
self.applyNextSource()
DebugLog("Could not find video URL in source '\(String(describing: _source))'")
isSetSourceOngoing = false
applyNextSource()
throw NSError(domain: "", code: 0, userInfo: 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 '\(String(describing: self._source))'")
self.isSetSourceOngoing = false
self.applyNextSource()
DebugLog("Could not find video URL in source '\(String(describing: _source))'")
isSetSourceOngoing = false
applyNextSource()
throw NSError(domain: "", code: 0, userInfo: nil)
}
if let startPosition = self._source?.startPosition {
self._startPosition = startPosition / 1000
if let startPosition = _source?.startPosition {
_startPosition = startPosition / 1000
}
#if USE_VIDEO_CACHING
if self._videoCache.shouldCache(source: source, textTracks: self._textTracks) {
return try await self._videoCache.playerItemForSourceUsingCache(uri: source.uri, assetOptions: assetOptions)
if _videoCache.shouldCache(source: source, textTracks: _textTracks) {
return try await _videoCache.playerItemForSourceUsingCache(uri: source.uri, assetOptions: assetOptions)
}
#endif
if self._drm != nil || self._localSourceEncryptionKeyScheme != nil {
self._resouceLoaderDelegate = RCTResourceLoaderDelegate(
if _drm != nil || _localSourceEncryptionKeyScheme != nil {
_resouceLoaderDelegate = RCTResourceLoaderDelegate(
asset: asset,
drm: self._drm,
localSourceEncryptionKeyScheme: self._localSourceEncryptionKeyScheme,
onVideoError: self.onVideoError,
onGetLicense: self.onGetLicense,
reactTag: self.reactTag
drm: _drm,
localSourceEncryptionKeyScheme: _localSourceEncryptionKeyScheme,
onVideoError: onVideoError,
onGetLicense: onGetLicense,
reactTag: reactTag
)
}
@@ -413,53 +418,62 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
}
func setupPlayer(playerItem: AVPlayerItem) async throws {
if !self.isSetSourceOngoing {
if !isSetSourceOngoing {
DebugLog("setSrc has been canceled last step")
return
}
self._player?.pause()
self._playerItem = playerItem
self._playerObserver.playerItem = self._playerItem
self.setPreferredForwardBufferDuration(self._preferredForwardBufferDuration)
self.setPlaybackRange(playerItem, withCropStart: self._source?.cropStart, withCropEnd: self._source?.cropEnd)
self.setFilter(self._filterName)
if let maxBitRate = self._maxBitRate {
self._playerItem?.preferredPeakBitRate = Double(maxBitRate)
_player?.pause()
_playerItem = playerItem
_playerObserver.playerItem = _playerItem
setPreferredForwardBufferDuration(_preferredForwardBufferDuration)
setPlaybackRange(playerItem, withCropStart: _source?.cropStart, withCropEnd: _source?.cropEnd)
setFilter(_filterName)
if let maxBitRate = _maxBitRate {
_playerItem?.preferredPeakBitRate = Double(maxBitRate)
}
self._player = self._player ?? AVPlayer()
if _player == nil {
_player = AVPlayer()
_player!.replaceCurrentItem(with: playerItem)
self._player?.replaceCurrentItem(with: playerItem)
// We need to register player after we set current item and only for init
NowPlayingInfoCenterManager.shared.registerPlayer(player: _player!)
} else {
_player?.replaceCurrentItem(with: playerItem)
self._playerObserver.player = self._player
self.applyModifiers()
self._player?.actionAtItemEnd = .none
// later we can just call "updateMetadata:
NowPlayingInfoCenterManager.shared.updateMetadata()
}
_playerObserver.player = _player
applyModifiers()
_player?.actionAtItemEnd = .none
if #available(iOS 10.0, *) {
self.setAutomaticallyWaitsToMinimizeStalling(self._automaticallyWaitsToMinimizeStalling)
setAutomaticallyWaitsToMinimizeStalling(_automaticallyWaitsToMinimizeStalling)
}
#if USE_GOOGLE_IMA
if self._adTagUrl != nil {
if _adTagUrl != nil {
// Set up your content playhead and contentComplete callback.
self._contentPlayhead = IMAAVPlayerContentPlayhead(avPlayer: self._player!)
_contentPlayhead = IMAAVPlayerContentPlayhead(avPlayer: _player!)
self._imaAdsManager.setUpAdsLoader()
_imaAdsManager.setUpAdsLoader()
}
#endif
// Perform on next run loop, otherwise onVideoLoadStart is nil
self.onVideoLoadStart?([
onVideoLoadStart?([
"src": [
"uri": self._source?.uri ?? NSNull(),
"type": self._source?.type ?? NSNull(),
"isNetwork": NSNumber(value: self._source?.isNetwork ?? false),
"uri": _source?.uri ?? NSNull(),
"type": _source?.type ?? NSNull(),
"isNetwork": NSNumber(value: _source?.isNetwork ?? false),
],
"drm": self._drm?.json ?? NSNull(),
"target": self.reactTag,
"drm": _drm?.json ?? NSNull(),
"target": reactTag,
])
self.isSetSourceOngoing = false
self.applyNextSource()
isSetSourceOngoing = false
applyNextSource()
}
@objc
@@ -478,6 +492,11 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
self._player?.replaceCurrentItem(with: nil)
self.isSetSourceOngoing = false
self.applyNextSource()
if let player = self._player {
NowPlayingInfoCenterManager.shared.removePlayer(player: player)
}
DebugLog("setSrc Stopping playback")
return
}
@@ -500,6 +519,10 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
self.onVideoError?(["error": error.localizedDescription])
self.isSetSourceOngoing = false
self.applyNextSource()
if let player = self._player {
NowPlayingInfoCenterManager.shared.removePlayer(player: player)
}
}
}
}
@@ -523,7 +546,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
func playerItemPrepareText(asset: AVAsset!, assetOptions: NSDictionary?, uri: String) async -> AVPlayerItem {
if (self._textTracks == nil) || self._textTracks?.isEmpty == true || (uri.hasSuffix(".m3u8")) {
return self.playerItemPropegateMetadata(AVPlayerItem(asset: asset))
return await self.playerItemPropegateMetadata(AVPlayerItem(asset: asset))
}
// AVPlayer can't airplay AVMutableCompositions
@@ -540,26 +563,30 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
self.setTextTracks(validTextTracks)
}
return self.playerItemPropegateMetadata(AVPlayerItem(asset: mixComposition))
return await self.playerItemPropegateMetadata(AVPlayerItem(asset: mixComposition))
}
func playerItemPropegateMetadata(_ playerItem: AVPlayerItem!) -> AVPlayerItem {
func playerItemPropegateMetadata(_ playerItem: AVPlayerItem!) async -> AVPlayerItem {
var mapping: [AVMetadataIdentifier: Any] = [:]
if let title = _source?.title {
if let title = _source?.customMetadata?.title {
mapping[.commonIdentifierTitle] = title
}
if let subtitle = _source?.subtitle {
if let artist = _source?.customMetadata?.artist {
mapping[.commonIdentifierArtist] = artist
}
if let subtitle = _source?.customMetadata?.subtitle {
mapping[.iTunesMetadataTrackSubTitle] = subtitle
}
if let description = _source?.description {
if let description = _source?.customMetadata?.description {
mapping[.commonIdentifierDescription] = description
}
if let customImageUri = _source?.customImageUri,
let imageData = RCTVideoUtils.createImageMetadataItem(imageUri: customImageUri) {
if let imageUri = _source?.customMetadata?.imageUri,
let imageData = await RCTVideoUtils.createImageMetadataItem(imageUri: imageUri) {
mapping[.commonIdentifierArtwork] = imageData
}
@@ -1006,6 +1033,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
func createPlayerViewController(player: AVPlayer, withPlayerItem _: AVPlayerItem) -> RCTVideoPlayerViewController {
let viewController = RCTVideoPlayerViewController()
viewController.showsPlaybackControls = self._controls
viewController.updatesNowPlayingInfoCenter = false
viewController.rctDelegate = self
viewController.preferredOrientation = _fullscreenOrientation
@@ -1057,6 +1085,21 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
}
}
@objc
func setShowNotificationControls(_ showNotificationControls: Bool) {
guard let player = _player else {
return
}
if showNotificationControls {
NowPlayingInfoCenterManager.shared.registerPlayer(player: player)
} else {
NowPlayingInfoCenterManager.shared.removePlayer(player: player)
}
_showNotificationControls = showNotificationControls
}
@objc
func setProgressUpdateInterval(_ progressUpdateInterval: Float) {
_playerObserver.replaceTimeObserverIfSet(Float64(progressUpdateInterval))
@@ -1182,7 +1225,11 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
// MARK: - Lifecycle
override func removeFromSuperview() {
_player?.pause()
if let player = _player {
player.pause()
NowPlayingInfoCenterManager.shared.removePlayer(player: player)
}
_player = nil
_resouceLoaderDelegate = nil
_playerObserver.clearPlayer()
@@ -1354,6 +1401,10 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
}
func handlePlaybackFailed() {
if let player = _player {
NowPlayingInfoCenterManager.shared.removePlayer(player: player)
}
guard let _playerItem else { return }
onVideoError?(
[

View File

@@ -37,6 +37,7 @@ RCT_EXPORT_VIEW_PROPERTY(progressUpdateInterval, float);
RCT_EXPORT_VIEW_PROPERTY(restoreUserInterfaceForPIPStopCompletionHandler, BOOL);
RCT_EXPORT_VIEW_PROPERTY(localSourceEncryptionKeyScheme, NSString);
RCT_EXPORT_VIEW_PROPERTY(subtitleStyle, NSDictionary);
RCT_EXPORT_VIEW_PROPERTY(showNotificationControls, BOOL)
/* Should support: onLoadStart, onLoad, and onError to stay consistent with Image */
RCT_EXPORT_VIEW_PROPERTY(onVideoLoadStart, RCTDirectEventBlock);
RCT_EXPORT_VIEW_PROPERTY(onVideoLoad, RCTDirectEventBlock);