From 4cb5f8a005a90acb23e9559a9d11d075ad5f96d1 Mon Sep 17 00:00:00 2001 From: Hailey Date: Wed, 17 Apr 2024 17:59:34 -0700 Subject: [PATCH] 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 --- .../expo-module.config.json | 9 ++ modules/expo-bluesky-video-player/index.ts | 1 + .../ios/ExpoBlueskyVideoPlayer.podspec | 21 ++++ .../ios/ExpoBlueskyVideoPlayerModule.swift | 35 ++++++ .../ios/ExpoBlueskyVideoPlayerView.swift | 48 ++++++++ .../ios/PlayerController.swift | 113 ++++++++++++++++++ .../ios/PlayerControllerManager.swift | 32 +++++ .../ios/PlayerItemManager.swift | 44 +++++++ .../ios/Records.swift | 10 ++ .../src/NativeVideoPlayer.tsx | 15 +++ .../src/VideoPlayer.tsx | 32 +++++ .../src/VideoPlayer.types.ts | 12 ++ .../src/VideoPlayer.web.tsx | 13 ++ 13 files changed, 385 insertions(+) create mode 100644 modules/expo-bluesky-video-player/expo-module.config.json create mode 100644 modules/expo-bluesky-video-player/index.ts create mode 100644 modules/expo-bluesky-video-player/ios/ExpoBlueskyVideoPlayer.podspec create mode 100644 modules/expo-bluesky-video-player/ios/ExpoBlueskyVideoPlayerModule.swift create mode 100644 modules/expo-bluesky-video-player/ios/ExpoBlueskyVideoPlayerView.swift create mode 100644 modules/expo-bluesky-video-player/ios/PlayerController.swift create mode 100644 modules/expo-bluesky-video-player/ios/PlayerControllerManager.swift create mode 100644 modules/expo-bluesky-video-player/ios/PlayerItemManager.swift create mode 100644 modules/expo-bluesky-video-player/ios/Records.swift create mode 100644 modules/expo-bluesky-video-player/src/NativeVideoPlayer.tsx create mode 100644 modules/expo-bluesky-video-player/src/VideoPlayer.tsx create mode 100644 modules/expo-bluesky-video-player/src/VideoPlayer.types.ts create mode 100644 modules/expo-bluesky-video-player/src/VideoPlayer.web.tsx diff --git a/modules/expo-bluesky-video-player/expo-module.config.json b/modules/expo-bluesky-video-player/expo-module.config.json new file mode 100644 index 0000000000..58c107dedd --- /dev/null +++ b/modules/expo-bluesky-video-player/expo-module.config.json @@ -0,0 +1,9 @@ +{ + "platforms": ["ios", "tvos", "android", "web"], + "ios": { + "modules": ["ExpoBlueskyVideoPlayerModule"] + }, + "android": { + "modules": ["expo.modules.blueskyvideoplayer.ExpoBlueskyVideoPlayerModule"] + } +} diff --git a/modules/expo-bluesky-video-player/index.ts b/modules/expo-bluesky-video-player/index.ts new file mode 100644 index 0000000000..c10f273d39 --- /dev/null +++ b/modules/expo-bluesky-video-player/index.ts @@ -0,0 +1 @@ +export {VideoPlayer} from './src/VideoPlayer' diff --git a/modules/expo-bluesky-video-player/ios/ExpoBlueskyVideoPlayer.podspec b/modules/expo-bluesky-video-player/ios/ExpoBlueskyVideoPlayer.podspec new file mode 100644 index 0000000000..9c0525c409 --- /dev/null +++ b/modules/expo-bluesky-video-player/ios/ExpoBlueskyVideoPlayer.podspec @@ -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 diff --git a/modules/expo-bluesky-video-player/ios/ExpoBlueskyVideoPlayerModule.swift b/modules/expo-bluesky-video-player/ios/ExpoBlueskyVideoPlayerModule.swift new file mode 100644 index 0000000000..46a38b9eb6 --- /dev/null +++ b/modules/expo-bluesky-video-player/ios/ExpoBlueskyVideoPlayerModule.swift @@ -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() + } + } + } +} diff --git a/modules/expo-bluesky-video-player/ios/ExpoBlueskyVideoPlayerView.swift b/modules/expo-bluesky-video-player/ios/ExpoBlueskyVideoPlayerView.swift new file mode 100644 index 0000000000..afdec24312 --- /dev/null +++ b/modules/expo-bluesky-video-player/ios/ExpoBlueskyVideoPlayerView.swift @@ -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() + } +} diff --git a/modules/expo-bluesky-video-player/ios/PlayerController.swift b/modules/expo-bluesky-video-player/ios/PlayerController.swift new file mode 100644 index 0000000000..6eb9765928 --- /dev/null +++ b/modules/expo-bluesky-video-player/ios/PlayerController.swift @@ -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() + } +} diff --git a/modules/expo-bluesky-video-player/ios/PlayerControllerManager.swift b/modules/expo-bluesky-video-player/ios/PlayerControllerManager.swift new file mode 100644 index 0000000000..86b26412a3 --- /dev/null +++ b/modules/expo-bluesky-video-player/ios/PlayerControllerManager.swift @@ -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 + } +} diff --git a/modules/expo-bluesky-video-player/ios/PlayerItemManager.swift b/modules/expo-bluesky-video-player/ios/PlayerItemManager.swift new file mode 100644 index 0000000000..bfcd6249aa --- /dev/null +++ b/modules/expo-bluesky-video-player/ios/PlayerItemManager.swift @@ -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() + } + } +} diff --git a/modules/expo-bluesky-video-player/ios/Records.swift b/modules/expo-bluesky-video-player/ios/Records.swift new file mode 100644 index 0000000000..5496e6cdf5 --- /dev/null +++ b/modules/expo-bluesky-video-player/ios/Records.swift @@ -0,0 +1,10 @@ +import ExpoModulesCore + +struct OnLoadEvent: Record { + @Field + var height = 0 + @Field + var width = 0 + @Field + var duration = 0 +} diff --git a/modules/expo-bluesky-video-player/src/NativeVideoPlayer.tsx b/modules/expo-bluesky-video-player/src/NativeVideoPlayer.tsx new file mode 100644 index 0000000000..1ad76fdf62 --- /dev/null +++ b/modules/expo-bluesky-video-player/src/NativeVideoPlayer.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import {requireNativeViewManager} from 'expo-modules-core' + +import {VideoPlayerViewProps} from './VideoPlayer.types' + +const NativeView: React.ComponentType = + requireNativeViewManager('ExpoBlueskyVideoPlayer') + +export default React.forwardRef( + // @ts-ignore TODO type these later + function NativeVideoPlayer(props: VideoPlayerViewProps, ref) { + // @ts-ignore + return + }, +) diff --git a/modules/expo-bluesky-video-player/src/VideoPlayer.tsx b/modules/expo-bluesky-video-player/src/VideoPlayer.tsx new file mode 100644 index 0000000000..2a1b3d458c --- /dev/null +++ b/modules/expo-bluesky-video-player/src/VideoPlayer.tsx @@ -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 { + nativeRef: React.RefObject + + constructor(props: VideoPlayerViewProps | Readonly) { + super(props) + this.nativeRef = createRef() + } + + static async setShouldAutoplayAsync(shouldAutoplay: boolean): Promise { + await VideoModule.setShouldAutoplayAsync(shouldAutoplay) + } + + async playAsync(): Promise { + await this.nativeRef.current.playAsync() + } + + async pauseAsync(): Promise { + await this.nativeRef.current.pauseAsync() + } + + render() { + return + } +} diff --git a/modules/expo-bluesky-video-player/src/VideoPlayer.types.ts b/modules/expo-bluesky-video-player/src/VideoPlayer.types.ts new file mode 100644 index 0000000000..9d2f277a2e --- /dev/null +++ b/modules/expo-bluesky-video-player/src/VideoPlayer.types.ts @@ -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 +} diff --git a/modules/expo-bluesky-video-player/src/VideoPlayer.web.tsx b/modules/expo-bluesky-video-player/src/VideoPlayer.web.tsx new file mode 100644 index 0000000000..02f7508a24 --- /dev/null +++ b/modules/expo-bluesky-video-player/src/VideoPlayer.web.tsx @@ -0,0 +1,13 @@ +import * as React from 'react' + +import {ExpoBlueskyVideoPlayerViewProps} from './ExpoBlueskyVideoPlayer.types' + +export default function ExpoBlueskyVideoPlayerView( + props: ExpoBlueskyVideoPlayerViewProps, +) { + return ( +
+ {props.name} +
+ ) +}