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:
Hailey
2024-04-17 17:59:34 -07:00
parent 7e16276c04
commit 4cb5f8a005
13 changed files with 385 additions and 0 deletions
@@ -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>
)
}