ios player
autoplay after recycle remove all items from AVPlayer queue recurururururursion use managers in the view add prefetch make sure player items stay in order add controller and item managers start of the view create module, ios
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"platforms": ["ios", "tvos", "android", "web"],
|
||||
"ios": {
|
||||
"modules": ["ExpoBlueskyVideoPlayerModule"]
|
||||
},
|
||||
"android": {
|
||||
"modules": ["expo.modules.blueskyvideoplayer.ExpoBlueskyVideoPlayerModule"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {VideoPlayer} from './src/VideoPlayer'
|
||||
@@ -0,0 +1,21 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'ExpoBlueskyVideoPlayer'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'A sample project summary'
|
||||
s.description = 'A sample project description'
|
||||
s.author = ''
|
||||
s.homepage = 'https://docs.expo.dev/modules/'
|
||||
s.platforms = { :ios => '13.4', :tvos => '13.4' }
|
||||
s.source = { git: '' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
|
||||
# Swift/Objective-C compatibility
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||
}
|
||||
|
||||
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
public class ExpoBlueskyVideoPlayerModule: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("ExpoBlueskyVideoPlayer")
|
||||
|
||||
AsyncFunction("setShouldAutoplayAsync") { (value: Bool) in
|
||||
|
||||
}
|
||||
|
||||
AsyncFunction("prefetchAsync") { (source: String) in
|
||||
PlayerItemManager.shared.getOrAddItem(source: source)
|
||||
}
|
||||
|
||||
View(ExpoBlueskyVideoPlayerView.self) {
|
||||
Events(["onLoad"])
|
||||
|
||||
Prop("source") { (view: ExpoBlueskyVideoPlayerView, prop: String) in
|
||||
view.source = prop
|
||||
}
|
||||
|
||||
AsyncFunction("getIsPlayingAsync") { (view: ExpoBlueskyVideoPlayerView, promise: Promise) in
|
||||
promise.resolve(view.isPlaying)
|
||||
}
|
||||
|
||||
AsyncFunction("playAsync") { (view: ExpoBlueskyVideoPlayerView) in
|
||||
view.play()
|
||||
}
|
||||
|
||||
AsyncFunction("pauseAsync") { (view: ExpoBlueskyVideoPlayerView) in
|
||||
view.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
public class ExpoBlueskyVideoPlayerView: ExpoView, AVPlayerViewControllerDelegate {
|
||||
public var source: String? = nil
|
||||
public var isPlaying: Bool = true
|
||||
|
||||
private var controller: PlayerController? = nil
|
||||
|
||||
public override var bounds: CGRect {
|
||||
didSet {
|
||||
if let controller = controller {
|
||||
controller.setFrame(rect: bounds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public required init(appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
self.clipsToBounds = true
|
||||
}
|
||||
|
||||
public override func willMove(toWindow newWindow: UIWindow?) {
|
||||
if newWindow != nil {
|
||||
guard let source = self.source else {
|
||||
return
|
||||
}
|
||||
|
||||
if let controller = PlayerControllerManager.shared.getPlayer() {
|
||||
controller.setFrame(rect: bounds)
|
||||
controller.initForView(source, view: self)
|
||||
self.addSubview(controller.view)
|
||||
self.controller = controller
|
||||
}
|
||||
} else {
|
||||
self.controller?.release()
|
||||
}
|
||||
}
|
||||
|
||||
func play() {
|
||||
self.isPlaying = true
|
||||
self.controller?.play()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
self.isPlaying = false
|
||||
self.controller?.pause()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import AVKit
|
||||
|
||||
class PlayerController: AVPlayerViewController, AVPlayerViewControllerDelegate {
|
||||
private var playerLooper: AVPlayerLooper? = nil
|
||||
var source: String? = nil
|
||||
var _superview: ExpoBlueskyVideoPlayerView? = nil
|
||||
|
||||
var isInUse = false
|
||||
var playerItem: AVPlayerItem? {
|
||||
get {
|
||||
return self.player?.currentItem
|
||||
}
|
||||
set {
|
||||
if newValue == nil {
|
||||
if let player = self.player as? AVQueuePlayer {
|
||||
player.removeAllItems()
|
||||
}
|
||||
} else {
|
||||
self.player?.replaceCurrentItem(with: newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
var itemStatus: AVPlayerItem.Status? {
|
||||
get {
|
||||
return self.playerItem?.status
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
self.player = AVQueuePlayer()
|
||||
|
||||
self.delegate = self
|
||||
self.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
self.view.backgroundColor = .clear
|
||||
self.showsPlaybackControls = false
|
||||
self.allowsPictureInPicturePlayback = false
|
||||
self.entersFullScreenWhenPlaybackBegins = true
|
||||
self.updatesNowPlayingInfoCenter = false
|
||||
if #available(iOS 16.0, *) {
|
||||
self.allowsVideoFrameAnalysis = false
|
||||
}
|
||||
|
||||
self.player?.actionAtItemEnd = .pause
|
||||
self.player?.volume = .zero
|
||||
}
|
||||
|
||||
func setFrame(rect: CGRect) {
|
||||
self.view.frame = rect
|
||||
}
|
||||
|
||||
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
||||
if keyPath == "status", let playerItem = object as? AVPlayerItem {
|
||||
if playerItem.status == .readyToPlay, let player = self.player as? AVQueuePlayer {
|
||||
// GIFs frequently have some black frames at the end of the video. To account for that, we offset the duration by 100ms,
|
||||
// which should be enough frames to prevent a flicker.
|
||||
player.play()
|
||||
self.setLooper()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setLooper() {
|
||||
guard let player = self.player as? AVQueuePlayer, let playerItem = self.playerItem else {
|
||||
return
|
||||
}
|
||||
|
||||
let duration = playerItem.duration
|
||||
self.playerLooper = AVPlayerLooper(
|
||||
player: player,
|
||||
templateItem: playerItem,
|
||||
timeRange: CMTimeRange(
|
||||
start: CMTime(value: 0, timescale: duration.timescale),
|
||||
duration: CMTime(value: duration.value - 100, timescale: 1000)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func initForView(_ source: String, view: ExpoBlueskyVideoPlayerView) {
|
||||
if let playerItem = PlayerItemManager.shared.getOrAddItem(source: source) {
|
||||
playerItem.addObserver(self, forKeyPath: "status", options: [.old, .new], context: nil)
|
||||
self.isInUse = true
|
||||
self.source = source
|
||||
self.playerItem = playerItem
|
||||
self._superview = view
|
||||
|
||||
if view.isPlaying, self.itemStatus == .readyToPlay {
|
||||
self.play()
|
||||
self.setLooper()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func release() {
|
||||
self.isInUse = false
|
||||
self.pause()
|
||||
self.source = nil
|
||||
self.playerLooper = nil
|
||||
self.playerItem = nil
|
||||
}
|
||||
|
||||
func play() {
|
||||
self.player?.play()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
self.player?.pause()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import AVKit
|
||||
|
||||
class PlayerControllerManager {
|
||||
static let shared = PlayerControllerManager()
|
||||
|
||||
private var controllers: [PlayerController] = []
|
||||
private let max = 10
|
||||
|
||||
func getPlayer() -> PlayerController? {
|
||||
if let controller = self.controllers.first(where: { $0.isInUse == false }) {
|
||||
return controller
|
||||
} else if controllers.count < self.max {
|
||||
let controller = PlayerController()
|
||||
controllers.append(controller)
|
||||
return controller
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func releasePlayer(controller: PlayerController) {
|
||||
if let controller = controllers.first(where: { $0 === controller }) {
|
||||
controller.release()
|
||||
}
|
||||
}
|
||||
|
||||
func findBySource(source: String) -> PlayerController? {
|
||||
if let controller = controllers.first(where: { $0.source == source}) {
|
||||
return controller
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import AVKit
|
||||
|
||||
class PlayerItemManager {
|
||||
static let shared = PlayerItemManager()
|
||||
|
||||
private var items: [(String, AVPlayerItem)] = []
|
||||
private let max = 30
|
||||
|
||||
private func addItem(source: String) -> AVPlayerItem? {
|
||||
guard let url = URL(string: source) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if items.count >= self.max {
|
||||
let first = self.items.removeFirst()
|
||||
self.removeItem(source: first.0)
|
||||
}
|
||||
|
||||
let item = AVPlayerItem(url: url)
|
||||
self.items.append((source, item))
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
func getOrAddItem(source: String) -> AVPlayerItem? {
|
||||
if let index = items.firstIndex(where: { $0.0 == source }) {
|
||||
let item = self.items[index]
|
||||
self.items.move(fromOffsets: IndexSet(integer: index), toOffset: self.items.count - 1)
|
||||
return item.1
|
||||
}
|
||||
|
||||
return self.addItem(source: source)
|
||||
}
|
||||
|
||||
func removeItem(source: String?) {
|
||||
guard let source = source else {
|
||||
return
|
||||
}
|
||||
|
||||
if let controller = PlayerControllerManager.shared.findBySource(source: source) {
|
||||
controller.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
struct OnLoadEvent: Record {
|
||||
@Field
|
||||
var height = 0
|
||||
@Field
|
||||
var width = 0
|
||||
@Field
|
||||
var duration = 0
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react'
|
||||
import {requireNativeViewManager} from 'expo-modules-core'
|
||||
|
||||
import {VideoPlayerViewProps} from './VideoPlayer.types'
|
||||
|
||||
const NativeView: React.ComponentType<VideoPlayerViewProps> =
|
||||
requireNativeViewManager('ExpoBlueskyVideoPlayer')
|
||||
|
||||
export default React.forwardRef<VideoPlayerViewProps>(
|
||||
// @ts-ignore TODO type these later
|
||||
function NativeVideoPlayer(props: VideoPlayerViewProps, ref) {
|
||||
// @ts-ignore
|
||||
return <NativeView {...props} ref={ref} />
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
import React, {createRef} from 'react'
|
||||
import {requireNativeModule} from 'expo-modules-core'
|
||||
|
||||
import NativeVideoPlayer from './NativeVideoPlayer'
|
||||
import {VideoPlayerViewProps} from './VideoPlayer.types'
|
||||
|
||||
const VideoModule = requireNativeModule('ExpoBlueskyVideoPlayer')
|
||||
|
||||
export class VideoPlayer extends React.PureComponent<VideoPlayerViewProps> {
|
||||
nativeRef: React.RefObject<any>
|
||||
|
||||
constructor(props: VideoPlayerViewProps | Readonly<VideoPlayerViewProps>) {
|
||||
super(props)
|
||||
this.nativeRef = createRef()
|
||||
}
|
||||
|
||||
static async setShouldAutoplayAsync(shouldAutoplay: boolean): Promise<void> {
|
||||
await VideoModule.setShouldAutoplayAsync(shouldAutoplay)
|
||||
}
|
||||
|
||||
async playAsync(): Promise<void> {
|
||||
await this.nativeRef.current.playAsync()
|
||||
}
|
||||
|
||||
async pauseAsync(): Promise<void> {
|
||||
await this.nativeRef.current.pauseAsync()
|
||||
}
|
||||
|
||||
render() {
|
||||
return <NativeVideoPlayer {...this.props} ref={this.nativeRef} />
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {ViewProps} from 'react-native'
|
||||
|
||||
export interface VideoPlayerLoadEvent {
|
||||
height: number
|
||||
width: number
|
||||
duration: number
|
||||
}
|
||||
|
||||
export interface VideoPlayerViewProps extends ViewProps {
|
||||
source: string | null
|
||||
onLoad: (event: VideoPlayerLoadEvent) => void
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import {ExpoBlueskyVideoPlayerViewProps} from './ExpoBlueskyVideoPlayer.types'
|
||||
|
||||
export default function ExpoBlueskyVideoPlayerView(
|
||||
props: ExpoBlueskyVideoPlayerViewProps,
|
||||
) {
|
||||
return (
|
||||
<div>
|
||||
<span>{props.name}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user