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} +
+ ) +}