Skip to content

Commit

Permalink
ios player
Browse files Browse the repository at this point in the history
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
  • Loading branch information
haileyok committed Apr 18, 2024
1 parent 7e16276 commit 4cb5f8a
Show file tree
Hide file tree
Showing 13 changed files with 385 additions and 0 deletions.
9 changes: 9 additions & 0 deletions modules/expo-bluesky-video-player/expo-module.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"platforms": ["ios", "tvos", "android", "web"],
"ios": {
"modules": ["ExpoBlueskyVideoPlayerModule"]
},
"android": {
"modules": ["expo.modules.blueskyvideoplayer.ExpoBlueskyVideoPlayerModule"]
}
}
1 change: 1 addition & 0 deletions modules/expo-bluesky-video-player/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {VideoPlayer} from './src/VideoPlayer'
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
113 changes: 113 additions & 0 deletions modules/expo-bluesky-video-player/ios/PlayerController.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
44 changes: 44 additions & 0 deletions modules/expo-bluesky-video-player/ios/PlayerItemManager.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
10 changes: 10 additions & 0 deletions modules/expo-bluesky-video-player/ios/Records.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import ExpoModulesCore

struct OnLoadEvent: Record {
@Field
var height = 0
@Field
var width = 0
@Field
var duration = 0
}
15 changes: 15 additions & 0 deletions modules/expo-bluesky-video-player/src/NativeVideoPlayer.tsx
Original file line number Diff line number Diff line change
@@ -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} />
},
)
32 changes: 32 additions & 0 deletions modules/expo-bluesky-video-player/src/VideoPlayer.tsx
Original file line number Diff line number Diff line change
@@ -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} />
}
}
12 changes: 12 additions & 0 deletions modules/expo-bluesky-video-player/src/VideoPlayer.types.ts
Original file line number Diff line number Diff line change
@@ -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
}
13 changes: 13 additions & 0 deletions modules/expo-bluesky-video-player/src/VideoPlayer.web.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
}

0 comments on commit 4cb5f8a

Please sign in to comment.