android player
smoother basic caching prep cache somewhat works backup other files android impl blegh lets go touchup add prefetch to js use caching
This commit is contained in:
@@ -4,12 +4,9 @@ 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)
|
||||
AsyncFunction("prefetchAsync") { (source: String, promise: Promise) in
|
||||
PlayerItemManager.shared.saveToCache(source: source)
|
||||
promise.resolve()
|
||||
}
|
||||
|
||||
View(ExpoBlueskyVideoPlayerView.self) {
|
||||
@@ -19,16 +16,27 @@ public class ExpoBlueskyVideoPlayerModule: Module {
|
||||
view.source = prop
|
||||
}
|
||||
|
||||
Prop("autoplay") { (view: ExpoBlueskyVideoPlayerView, prop: Bool) in
|
||||
view.autoplay = prop
|
||||
}
|
||||
|
||||
AsyncFunction("getIsPlayingAsync") { (view: ExpoBlueskyVideoPlayerView, promise: Promise) in
|
||||
promise.resolve(view.isPlaying)
|
||||
}
|
||||
|
||||
AsyncFunction("playAsync") { (view: ExpoBlueskyVideoPlayerView) in
|
||||
view.play()
|
||||
AsyncFunction("toggleAsync") { (view: ExpoBlueskyVideoPlayerView, promise: Promise) in
|
||||
view.toggle()
|
||||
promise.resolve()
|
||||
}
|
||||
|
||||
AsyncFunction("pauseAsync") { (view: ExpoBlueskyVideoPlayerView) in
|
||||
AsyncFunction("playAsync") { (view: ExpoBlueskyVideoPlayerView, promise: Promise) in
|
||||
view.play()
|
||||
promise.resolve()
|
||||
}
|
||||
|
||||
AsyncFunction("pauseAsync") { (view: ExpoBlueskyVideoPlayerView, promise: Promise) in
|
||||
view.pause()
|
||||
promise.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
public class ExpoBlueskyVideoPlayerView: ExpoView, AVPlayerViewControllerDelegate {
|
||||
public var source: String? = nil
|
||||
public var isPlaying: Bool = true
|
||||
public var source: String? = nil {
|
||||
didSet {
|
||||
if self.controller != nil, let source = source {
|
||||
self.controller?.initForView(source, view: self)
|
||||
}
|
||||
}
|
||||
}
|
||||
public var isPlaying = true
|
||||
public var autoplay = true
|
||||
|
||||
private var controller: PlayerController? = nil
|
||||
|
||||
@@ -25,12 +32,11 @@ public class ExpoBlueskyVideoPlayerView: ExpoView, AVPlayerViewControllerDelegat
|
||||
return
|
||||
}
|
||||
|
||||
if let controller = PlayerControllerManager.shared.getPlayer() {
|
||||
controller.setFrame(rect: bounds)
|
||||
controller.initForView(source, view: self)
|
||||
self.addSubview(controller.view)
|
||||
self.controller = controller
|
||||
}
|
||||
let controller = PlayerControllerManager.shared.getController()
|
||||
controller.setFrame(rect: bounds)
|
||||
controller.initForView(source, view: self)
|
||||
self.addSubview(controller.view)
|
||||
self.controller = controller
|
||||
} else {
|
||||
self.controller?.release()
|
||||
}
|
||||
@@ -45,4 +51,12 @@ public class ExpoBlueskyVideoPlayerView: ExpoView, AVPlayerViewControllerDelegat
|
||||
self.isPlaying = false
|
||||
self.controller?.pause()
|
||||
}
|
||||
|
||||
func toggle() {
|
||||
if self.isPlaying {
|
||||
self.pause()
|
||||
} else {
|
||||
self.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import AVKit
|
||||
|
||||
class PlayerController: AVPlayerViewController, AVPlayerViewControllerDelegate {
|
||||
private var playerLooper: AVPlayerLooper? = nil
|
||||
var source: String? = nil
|
||||
var _superview: ExpoBlueskyVideoPlayerView? = nil
|
||||
var needsCaching = true
|
||||
|
||||
var isInUse = false
|
||||
var playerItem: AVPlayerItem? {
|
||||
@@ -11,13 +11,7 @@ class PlayerController: AVPlayerViewController, AVPlayerViewControllerDelegate {
|
||||
return self.player?.currentItem
|
||||
}
|
||||
set {
|
||||
if newValue == nil {
|
||||
if let player = self.player as? AVQueuePlayer {
|
||||
player.removeAllItems()
|
||||
}
|
||||
} else {
|
||||
self.player?.replaceCurrentItem(with: newValue)
|
||||
}
|
||||
self.player?.replaceCurrentItem(with: newValue)
|
||||
}
|
||||
}
|
||||
var itemStatus: AVPlayerItem.Status? {
|
||||
@@ -32,7 +26,11 @@ class PlayerController: AVPlayerViewController, AVPlayerViewControllerDelegate {
|
||||
|
||||
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
self.player = AVQueuePlayer()
|
||||
|
||||
let player = AVPlayer()
|
||||
player.actionAtItemEnd = .none
|
||||
player.volume = .zero
|
||||
self.player = player
|
||||
|
||||
self.delegate = self
|
||||
self.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
@@ -44,63 +42,69 @@ class PlayerController: AVPlayerViewController, AVPlayerViewControllerDelegate {
|
||||
if #available(iOS 16.0, *) {
|
||||
self.allowsVideoFrameAnalysis = false
|
||||
}
|
||||
|
||||
self.player?.actionAtItemEnd = .pause
|
||||
self.player?.volume = .zero
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.release()
|
||||
}
|
||||
|
||||
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()
|
||||
@objc func playerItemDidReachEnd(notification: Notification) {
|
||||
if let playerItem = notification.object as? AVPlayerItem {
|
||||
playerItem.seek(to: CMTime.zero, completionHandler: nil)
|
||||
|
||||
if self.needsCaching, let asset = self.playerItem?.asset as? AVURLAsset {
|
||||
PlayerItemManager.shared.saveToCache(source: asset.url.absoluteString)
|
||||
self.needsCaching = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setLooper() {
|
||||
guard let player = self.player as? AVQueuePlayer, let playerItem = self.playerItem else {
|
||||
return
|
||||
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 {
|
||||
if _superview?.autoplay == true, _superview?.isPlaying == true {
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if let playerItemAndInfo = PlayerItemManager.shared.getItem(source: source) {
|
||||
let playerItem = playerItemAndInfo.0
|
||||
self.needsCaching = !playerItemAndInfo.1
|
||||
|
||||
playerItem.addObserver(self, forKeyPath: "status", options: [.old, .new], context: nil)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(playerItemDidReachEnd(notification:)),
|
||||
name: AVPlayerItem.didPlayToEndTimeNotification,
|
||||
object: playerItem
|
||||
)
|
||||
|
||||
self.isInUse = true
|
||||
self.source = source
|
||||
self.playerItem = playerItem
|
||||
self._superview = view
|
||||
|
||||
if view.isPlaying, self.itemStatus == .readyToPlay {
|
||||
self.play()
|
||||
self.setLooper()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func release() {
|
||||
NotificationCenter.default.removeObserver(
|
||||
self,
|
||||
name: AVPlayerItem.didPlayToEndTimeNotification,
|
||||
object: self.playerItem
|
||||
)
|
||||
|
||||
self.needsCaching = true
|
||||
self.isInUse = false
|
||||
self.pause()
|
||||
self.source = nil
|
||||
self.playerLooper = nil
|
||||
self.playerItem = nil
|
||||
self.removeFromParent()
|
||||
}
|
||||
|
||||
func play() {
|
||||
|
||||
@@ -4,29 +4,37 @@ class PlayerControllerManager {
|
||||
static let shared = PlayerControllerManager()
|
||||
|
||||
private var controllers: [PlayerController] = []
|
||||
private let max = 10
|
||||
|
||||
func getPlayer() -> PlayerController? {
|
||||
func getController() -> PlayerController {
|
||||
if let controller = self.controllers.first(where: { $0.isInUse == false }) {
|
||||
return controller
|
||||
} else if controllers.count < self.max {
|
||||
} else {
|
||||
let controller = PlayerController()
|
||||
controllers.append(controller)
|
||||
self.controllers.append(controller)
|
||||
return controller
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func releasePlayer(controller: PlayerController) {
|
||||
if let controller = controllers.first(where: { $0 === controller }) {
|
||||
func releaseController(controller: PlayerController) {
|
||||
if let controller = self.controllers.first(where: { $0 === controller }) {
|
||||
controller.release()
|
||||
}
|
||||
}
|
||||
|
||||
func findBySource(source: String) -> PlayerController? {
|
||||
if let controller = controllers.first(where: { $0.source == source}) {
|
||||
func findByItem(_ item: AVPlayerItem) -> PlayerController? {
|
||||
if let controller = self.controllers.first(where: { $0.playerItem === item}) {
|
||||
return controller
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanupExcessiveControllers() {
|
||||
if self.controllers.count <= 15 {
|
||||
return
|
||||
}
|
||||
|
||||
self.controllers.removeAll { c in
|
||||
return !c.isInUse
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +1,58 @@
|
||||
import Foundation
|
||||
import AVKit
|
||||
import CryptoKit
|
||||
|
||||
class PlayerItemManager {
|
||||
static let shared = PlayerItemManager()
|
||||
|
||||
private var items: [(String, AVPlayerItem)] = []
|
||||
private let max = 30
|
||||
private var prefetchQueue: [String] = []
|
||||
|
||||
private func addItem(source: String) -> AVPlayerItem? {
|
||||
guard let url = URL(string: source) else {
|
||||
return nil
|
||||
func getItem(source: String) -> (AVPlayerItem, Bool)? {
|
||||
let path = createPath(source)
|
||||
|
||||
if FileManager.default.fileExists(atPath: path.path) {
|
||||
return (AVPlayerItem(url: path), true)
|
||||
} else if let url = URL(string: source) {
|
||||
return (AVPlayerItem(url: url), false)
|
||||
}
|
||||
|
||||
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
|
||||
return nil
|
||||
}
|
||||
|
||||
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 {
|
||||
func saveToCache(source: String) {
|
||||
guard let sourceUrl = URL(string: source) else {
|
||||
return
|
||||
}
|
||||
|
||||
if let controller = PlayerControllerManager.shared.findBySource(source: source) {
|
||||
controller.release()
|
||||
let path = createPath(sourceUrl.absoluteString)
|
||||
let downloadTask = URLSession.shared.downloadTask(with:sourceUrl) { tempLocalURL, response, error in
|
||||
if let tempLocalURL = tempLocalURL, error == nil {
|
||||
try? FileManager.default.moveItem(at: tempLocalURL, to: path)
|
||||
}
|
||||
}
|
||||
downloadTask.resume()
|
||||
}
|
||||
|
||||
private func getHash(_ source: String) -> String {
|
||||
let data = Data(source.utf8)
|
||||
let hash = Insecure.SHA1.hash(data: data)
|
||||
return hash.map { String(format: "%02hhx", $0)}.joined()
|
||||
}
|
||||
|
||||
private func getGifsDirectory() -> URL {
|
||||
let paths = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
|
||||
var cacheDir = paths[0]
|
||||
cacheDir = cacheDir.appendingPathComponent("gifs", isDirectory: true)
|
||||
|
||||
if !FileManager.default.fileExists(atPath: cacheDir.path) {
|
||||
try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: false)
|
||||
}
|
||||
|
||||
return cacheDir
|
||||
}
|
||||
|
||||
private func createPath(_ source: String) -> URL {
|
||||
let hash = getHash(source)
|
||||
var gifsDir = getGifsDirectory()
|
||||
return gifsDir.appendingPathComponent("\(hash).mp4", isDirectory: false)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user