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:
@@ -0,0 +1,95 @@
|
|||||||
|
apply plugin: 'com.android.library'
|
||||||
|
apply plugin: 'kotlin-android'
|
||||||
|
apply plugin: 'maven-publish'
|
||||||
|
|
||||||
|
group = 'expo.modules.blueskyvideoplayer'
|
||||||
|
version = '0.5.0'
|
||||||
|
|
||||||
|
buildscript {
|
||||||
|
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||||
|
if (expoModulesCorePlugin.exists()) {
|
||||||
|
apply from: expoModulesCorePlugin
|
||||||
|
applyKotlinExpoModulesCorePlugin()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple helper that allows the root project to override versions declared by this library.
|
||||||
|
ext.safeExtGet = { prop, fallback ->
|
||||||
|
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensures backward compatibility
|
||||||
|
ext.getKotlinVersion = {
|
||||||
|
if (ext.has("kotlinVersion")) {
|
||||||
|
ext.kotlinVersion()
|
||||||
|
} else {
|
||||||
|
ext.safeExtGet("kotlinVersion", "1.8.10")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${getKotlinVersion()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEvaluate {
|
||||||
|
publishing {
|
||||||
|
publications {
|
||||||
|
release(MavenPublication) {
|
||||||
|
from components.release
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repositories {
|
||||||
|
maven {
|
||||||
|
url = mavenLocal().url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdkVersion safeExtGet("compileSdkVersion", 33)
|
||||||
|
|
||||||
|
def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION
|
||||||
|
if (agpVersion.tokenize('.')[0].toInteger() < 8) {
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_11
|
||||||
|
targetCompatibility JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = JavaVersion.VERSION_11.majorVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace "expo.modules.blueskyvideoplayer"
|
||||||
|
defaultConfig {
|
||||||
|
minSdkVersion safeExtGet("minSdkVersion", 21)
|
||||||
|
targetSdkVersion safeExtGet("targetSdkVersion", 34)
|
||||||
|
versionCode 1
|
||||||
|
versionName "0.5.0"
|
||||||
|
}
|
||||||
|
lintOptions {
|
||||||
|
abortOnError false
|
||||||
|
}
|
||||||
|
publishing {
|
||||||
|
singleVariant("release") {
|
||||||
|
withSourcesJar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation project(':expo-modules-core')
|
||||||
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}"
|
||||||
|
implementation 'androidx.media3:media3-exoplayer:1.3.1'
|
||||||
|
implementation 'androidx.media3:media3-ui:1.3.1'
|
||||||
|
implementation 'com.squareup.okhttp3:okhttp:3.14.9'
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<manifest>
|
||||||
|
</manifest>
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
package expo.modules.blueskyvideoplayer
|
||||||
|
|
||||||
|
import expo.modules.kotlin.Promise
|
||||||
|
import expo.modules.kotlin.modules.Module
|
||||||
|
import expo.modules.kotlin.modules.ModuleDefinition
|
||||||
|
|
||||||
|
class ExpoBlueskyVideoPlayerModule : Module() {
|
||||||
|
override fun definition() = ModuleDefinition {
|
||||||
|
Name("ExpoBlueskyVideoPlayer")
|
||||||
|
|
||||||
|
AsyncFunction("prefetchAsync") {
|
||||||
|
{ source: String, promise: Promise ->
|
||||||
|
MediaItemManager(appContext).saveToCache(source)
|
||||||
|
promise.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
View(ExpoBlueskyVideoPlayerView::class) {
|
||||||
|
Prop("source") { view: ExpoBlueskyVideoPlayerView, source: String ->
|
||||||
|
view.source = source
|
||||||
|
}
|
||||||
|
|
||||||
|
Prop("autoplay") { view: ExpoBlueskyVideoPlayerView, autoplay: Boolean ->
|
||||||
|
view.autoplay = autoplay
|
||||||
|
}
|
||||||
|
|
||||||
|
Prop("getIsPlayingAsync") { view, promise: Promise ->
|
||||||
|
promise.resolve(view.isPlaying)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("playAsync") { view: ExpoBlueskyVideoPlayerView ->
|
||||||
|
view.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("pauseAsync") { view: ExpoBlueskyVideoPlayerView ->
|
||||||
|
view.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("toggleAsync") { view: ExpoBlueskyVideoPlayerView ->
|
||||||
|
view.toggle()
|
||||||
|
}
|
||||||
|
|
||||||
|
OnViewDidUpdateProps {
|
||||||
|
val source = it.source
|
||||||
|
if (source != null) {
|
||||||
|
it.updateSource(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
package expo.modules.blueskyvideoplayer
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.ui.PlayerView
|
||||||
|
import expo.modules.kotlin.AppContext
|
||||||
|
import expo.modules.kotlin.views.ExpoView
|
||||||
|
|
||||||
|
class ExpoBlueskyVideoPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
|
||||||
|
private var playerView: PlayerView
|
||||||
|
private var player: ExoPlayer
|
||||||
|
|
||||||
|
var autoplay: Boolean = true
|
||||||
|
var source: String? = null
|
||||||
|
var isPlaying = true
|
||||||
|
|
||||||
|
init {
|
||||||
|
this.playerView = PlayerView(context).apply {
|
||||||
|
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||||
|
useController = false
|
||||||
|
}
|
||||||
|
this.player = ExoPlayer.Builder(context).build().also {
|
||||||
|
playerView.player = it
|
||||||
|
}.apply {
|
||||||
|
repeatMode = ExoPlayer.REPEAT_MODE_ONE
|
||||||
|
volume = 0f
|
||||||
|
}
|
||||||
|
this.addView(this.playerView)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAttachedToWindow() {
|
||||||
|
if (this.autoplay && this.isPlaying) {
|
||||||
|
this.player.play()
|
||||||
|
}
|
||||||
|
super.onAttachedToWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromWindow() {
|
||||||
|
this.player.pause()
|
||||||
|
super.onDetachedFromWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSource(source: String) {
|
||||||
|
val mediaItem = MediaItemManager(appContext).getItem(source)
|
||||||
|
player.setMediaItem(mediaItem)
|
||||||
|
player.prepare()
|
||||||
|
player.playWhenReady = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun play() {
|
||||||
|
this.player.play()
|
||||||
|
this.isPlaying = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pause() {
|
||||||
|
this.player.pause()
|
||||||
|
this.isPlaying = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggle() {
|
||||||
|
if (this.isPlaying) {
|
||||||
|
this.player.pause()
|
||||||
|
this.isPlaying = false
|
||||||
|
} else {
|
||||||
|
this.player.play()
|
||||||
|
this.isPlaying = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
package expo.modules.blueskyvideoplayer
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import expo.modules.kotlin.AppContext
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okio.IOException
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
class MediaItemManager(private val appContext: AppContext) {
|
||||||
|
companion object {
|
||||||
|
private val client = OkHttpClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getItem(source: String): MediaItem {
|
||||||
|
val path = createPath(source)
|
||||||
|
return if (File(path).exists()) {
|
||||||
|
MediaItem.fromUri(Uri.parse(path))
|
||||||
|
} else {
|
||||||
|
saveToCache(source)
|
||||||
|
MediaItem.fromUri(Uri.parse(source))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveToCache(source: String) {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(source)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
client.newCall(request).enqueue(object : okhttp3.Callback {
|
||||||
|
override fun onFailure(call: okhttp3.Call, e: IOException) {
|
||||||
|
e.printStackTrace() // Handle the error
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResponse(call: okhttp3.Call, response: okhttp3.Response) {
|
||||||
|
val path = createPath(source)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val file = File(path)
|
||||||
|
val fos = FileOutputStream(file)
|
||||||
|
val inputStream = response.body?.byteStream()
|
||||||
|
|
||||||
|
inputStream?.use { input ->
|
||||||
|
fos.use { fileOut ->
|
||||||
|
input.copyTo(fileOut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getHash(source: String): String {
|
||||||
|
val md = MessageDigest.getInstance("SHA-1")
|
||||||
|
val byteArray = md.digest(source.toByteArray())
|
||||||
|
return byteArray.joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getGifsDirectory(): String {
|
||||||
|
val gifsDirectory = appContext.cacheDirectory.path + "/gifs"
|
||||||
|
if (!File(gifsDirectory).exists()) {
|
||||||
|
File(gifsDirectory).mkdir()
|
||||||
|
}
|
||||||
|
return gifsDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createPath(source: String): String {
|
||||||
|
return getGifsDirectory() + "/" + getHash(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,9 @@ public class ExpoBlueskyVideoPlayerModule: Module {
|
|||||||
public func definition() -> ModuleDefinition {
|
public func definition() -> ModuleDefinition {
|
||||||
Name("ExpoBlueskyVideoPlayer")
|
Name("ExpoBlueskyVideoPlayer")
|
||||||
|
|
||||||
AsyncFunction("setShouldAutoplayAsync") { (value: Bool) in
|
AsyncFunction("prefetchAsync") { (source: String, promise: Promise) in
|
||||||
|
PlayerItemManager.shared.saveToCache(source: source)
|
||||||
}
|
promise.resolve()
|
||||||
|
|
||||||
AsyncFunction("prefetchAsync") { (source: String) in
|
|
||||||
PlayerItemManager.shared.getOrAddItem(source: source)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
View(ExpoBlueskyVideoPlayerView.self) {
|
View(ExpoBlueskyVideoPlayerView.self) {
|
||||||
@@ -19,16 +16,27 @@ public class ExpoBlueskyVideoPlayerModule: Module {
|
|||||||
view.source = prop
|
view.source = prop
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Prop("autoplay") { (view: ExpoBlueskyVideoPlayerView, prop: Bool) in
|
||||||
|
view.autoplay = prop
|
||||||
|
}
|
||||||
|
|
||||||
AsyncFunction("getIsPlayingAsync") { (view: ExpoBlueskyVideoPlayerView, promise: Promise) in
|
AsyncFunction("getIsPlayingAsync") { (view: ExpoBlueskyVideoPlayerView, promise: Promise) in
|
||||||
promise.resolve(view.isPlaying)
|
promise.resolve(view.isPlaying)
|
||||||
}
|
}
|
||||||
|
|
||||||
AsyncFunction("playAsync") { (view: ExpoBlueskyVideoPlayerView) in
|
AsyncFunction("toggleAsync") { (view: ExpoBlueskyVideoPlayerView, promise: Promise) in
|
||||||
view.play()
|
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()
|
view.pause()
|
||||||
|
promise.resolve()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import ExpoModulesCore
|
import ExpoModulesCore
|
||||||
|
|
||||||
public class ExpoBlueskyVideoPlayerView: ExpoView, AVPlayerViewControllerDelegate {
|
public class ExpoBlueskyVideoPlayerView: ExpoView, AVPlayerViewControllerDelegate {
|
||||||
public var source: String? = nil
|
public var source: String? = nil {
|
||||||
public var isPlaying: Bool = true
|
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
|
private var controller: PlayerController? = nil
|
||||||
|
|
||||||
@@ -25,12 +32,11 @@ public class ExpoBlueskyVideoPlayerView: ExpoView, AVPlayerViewControllerDelegat
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if let controller = PlayerControllerManager.shared.getPlayer() {
|
let controller = PlayerControllerManager.shared.getController()
|
||||||
controller.setFrame(rect: bounds)
|
controller.setFrame(rect: bounds)
|
||||||
controller.initForView(source, view: self)
|
controller.initForView(source, view: self)
|
||||||
self.addSubview(controller.view)
|
self.addSubview(controller.view)
|
||||||
self.controller = controller
|
self.controller = controller
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
self.controller?.release()
|
self.controller?.release()
|
||||||
}
|
}
|
||||||
@@ -45,4 +51,12 @@ public class ExpoBlueskyVideoPlayerView: ExpoView, AVPlayerViewControllerDelegat
|
|||||||
self.isPlaying = false
|
self.isPlaying = false
|
||||||
self.controller?.pause()
|
self.controller?.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toggle() {
|
||||||
|
if self.isPlaying {
|
||||||
|
self.pause()
|
||||||
|
} else {
|
||||||
|
self.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import AVKit
|
import AVKit
|
||||||
|
|
||||||
class PlayerController: AVPlayerViewController, AVPlayerViewControllerDelegate {
|
class PlayerController: AVPlayerViewController, AVPlayerViewControllerDelegate {
|
||||||
private var playerLooper: AVPlayerLooper? = nil
|
|
||||||
var source: String? = nil
|
var source: String? = nil
|
||||||
var _superview: ExpoBlueskyVideoPlayerView? = nil
|
var _superview: ExpoBlueskyVideoPlayerView? = nil
|
||||||
|
var needsCaching = true
|
||||||
|
|
||||||
var isInUse = false
|
var isInUse = false
|
||||||
var playerItem: AVPlayerItem? {
|
var playerItem: AVPlayerItem? {
|
||||||
@@ -11,13 +11,7 @@ class PlayerController: AVPlayerViewController, AVPlayerViewControllerDelegate {
|
|||||||
return self.player?.currentItem
|
return self.player?.currentItem
|
||||||
}
|
}
|
||||||
set {
|
set {
|
||||||
if newValue == nil {
|
self.player?.replaceCurrentItem(with: newValue)
|
||||||
if let player = self.player as? AVQueuePlayer {
|
|
||||||
player.removeAllItems()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.player?.replaceCurrentItem(with: newValue)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var itemStatus: AVPlayerItem.Status? {
|
var itemStatus: AVPlayerItem.Status? {
|
||||||
@@ -32,7 +26,11 @@ class PlayerController: AVPlayerViewController, AVPlayerViewControllerDelegate {
|
|||||||
|
|
||||||
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
|
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
|
||||||
super.init(nibName: nil, bundle: nil)
|
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.delegate = self
|
||||||
self.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
self.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||||
@@ -44,63 +42,69 @@ class PlayerController: AVPlayerViewController, AVPlayerViewControllerDelegate {
|
|||||||
if #available(iOS 16.0, *) {
|
if #available(iOS 16.0, *) {
|
||||||
self.allowsVideoFrameAnalysis = false
|
self.allowsVideoFrameAnalysis = false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.player?.actionAtItemEnd = .pause
|
deinit {
|
||||||
self.player?.volume = .zero
|
self.release()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setFrame(rect: CGRect) {
|
func setFrame(rect: CGRect) {
|
||||||
self.view.frame = rect
|
self.view.frame = rect
|
||||||
}
|
}
|
||||||
|
|
||||||
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
@objc func playerItemDidReachEnd(notification: Notification) {
|
||||||
if keyPath == "status", let playerItem = object as? AVPlayerItem {
|
if let playerItem = notification.object as? AVPlayerItem {
|
||||||
if playerItem.status == .readyToPlay, let player = self.player as? AVQueuePlayer {
|
playerItem.seek(to: CMTime.zero, completionHandler: nil)
|
||||||
// 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.
|
if self.needsCaching, let asset = self.playerItem?.asset as? AVURLAsset {
|
||||||
player.play()
|
PlayerItemManager.shared.saveToCache(source: asset.url.absoluteString)
|
||||||
self.setLooper()
|
self.needsCaching = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setLooper() {
|
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
||||||
guard let player = self.player as? AVQueuePlayer, let playerItem = self.playerItem else {
|
if keyPath == "status", let playerItem = object as? AVPlayerItem {
|
||||||
return
|
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) {
|
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)
|
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.isInUse = true
|
||||||
self.source = source
|
self.source = source
|
||||||
self.playerItem = playerItem
|
self.playerItem = playerItem
|
||||||
self._superview = view
|
self._superview = view
|
||||||
|
|
||||||
if view.isPlaying, self.itemStatus == .readyToPlay {
|
|
||||||
self.play()
|
|
||||||
self.setLooper()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func release() {
|
func release() {
|
||||||
|
NotificationCenter.default.removeObserver(
|
||||||
|
self,
|
||||||
|
name: AVPlayerItem.didPlayToEndTimeNotification,
|
||||||
|
object: self.playerItem
|
||||||
|
)
|
||||||
|
|
||||||
|
self.needsCaching = true
|
||||||
self.isInUse = false
|
self.isInUse = false
|
||||||
self.pause()
|
self.pause()
|
||||||
self.source = nil
|
|
||||||
self.playerLooper = nil
|
|
||||||
self.playerItem = nil
|
self.playerItem = nil
|
||||||
|
self.removeFromParent()
|
||||||
}
|
}
|
||||||
|
|
||||||
func play() {
|
func play() {
|
||||||
|
|||||||
@@ -4,29 +4,37 @@ class PlayerControllerManager {
|
|||||||
static let shared = PlayerControllerManager()
|
static let shared = PlayerControllerManager()
|
||||||
|
|
||||||
private var controllers: [PlayerController] = []
|
private var controllers: [PlayerController] = []
|
||||||
private let max = 10
|
|
||||||
|
|
||||||
func getPlayer() -> PlayerController? {
|
func getController() -> PlayerController {
|
||||||
if let controller = self.controllers.first(where: { $0.isInUse == false }) {
|
if let controller = self.controllers.first(where: { $0.isInUse == false }) {
|
||||||
return controller
|
return controller
|
||||||
} else if controllers.count < self.max {
|
} else {
|
||||||
let controller = PlayerController()
|
let controller = PlayerController()
|
||||||
controllers.append(controller)
|
self.controllers.append(controller)
|
||||||
return controller
|
return controller
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func releasePlayer(controller: PlayerController) {
|
func releaseController(controller: PlayerController) {
|
||||||
if let controller = controllers.first(where: { $0 === controller }) {
|
if let controller = self.controllers.first(where: { $0 === controller }) {
|
||||||
controller.release()
|
controller.release()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func findBySource(source: String) -> PlayerController? {
|
func findByItem(_ item: AVPlayerItem) -> PlayerController? {
|
||||||
if let controller = controllers.first(where: { $0.source == source}) {
|
if let controller = self.controllers.first(where: { $0.playerItem === item}) {
|
||||||
return controller
|
return controller
|
||||||
}
|
}
|
||||||
return nil
|
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 AVKit
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
class PlayerItemManager {
|
class PlayerItemManager {
|
||||||
static let shared = PlayerItemManager()
|
static let shared = PlayerItemManager()
|
||||||
|
|
||||||
private var items: [(String, AVPlayerItem)] = []
|
private var prefetchQueue: [String] = []
|
||||||
private let max = 30
|
|
||||||
|
|
||||||
private func addItem(source: String) -> AVPlayerItem? {
|
func getItem(source: String) -> (AVPlayerItem, Bool)? {
|
||||||
guard let url = URL(string: source) else {
|
let path = createPath(source)
|
||||||
return nil
|
|
||||||
|
if FileManager.default.fileExists(atPath: path.path) {
|
||||||
|
return (AVPlayerItem(url: path), true)
|
||||||
|
} else if let url = URL(string: source) {
|
||||||
|
return (AVPlayerItem(url: url), false)
|
||||||
}
|
}
|
||||||
|
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? {
|
func saveToCache(source: String) {
|
||||||
if let index = items.firstIndex(where: { $0.0 == source }) {
|
guard let sourceUrl = URL(string: source) else {
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if let controller = PlayerControllerManager.shared.findBySource(source: source) {
|
let path = createPath(sourceUrl.absoluteString)
|
||||||
controller.release()
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ export class VideoPlayer extends React.PureComponent<VideoPlayerViewProps> {
|
|||||||
this.nativeRef = createRef()
|
this.nativeRef = createRef()
|
||||||
}
|
}
|
||||||
|
|
||||||
static async setShouldAutoplayAsync(shouldAutoplay: boolean): Promise<void> {
|
static async prefetchAsync(source: string): Promise<void> {
|
||||||
await VideoModule.setShouldAutoplayAsync(shouldAutoplay)
|
await VideoModule.prefetchAsync(source)
|
||||||
}
|
}
|
||||||
|
|
||||||
async playAsync(): Promise<void> {
|
async playAsync(): Promise<void> {
|
||||||
@@ -26,6 +26,10 @@ export class VideoPlayer extends React.PureComponent<VideoPlayerViewProps> {
|
|||||||
await this.nativeRef.current.pauseAsync()
|
await this.nativeRef.current.pauseAsync()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async toggleAsync(): Promise<void> {
|
||||||
|
await this.nativeRef.current.toggleAsync()
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return <NativeVideoPlayer {...this.props} ref={this.nativeRef} />
|
return <NativeVideoPlayer {...this.props} ref={this.nativeRef} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface VideoPlayerLoadEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoPlayerViewProps extends ViewProps {
|
export interface VideoPlayerViewProps extends ViewProps {
|
||||||
source: string | null
|
autoplay?: boolean
|
||||||
onLoad: (event: VideoPlayerLoadEvent) => void
|
source?: string
|
||||||
|
onLoad?: (event: VideoPlayerLoadEvent) => void
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ function ListImpl<ItemT>(
|
|||||||
contentOffset = {x: 0, y: headerOffset * -1}
|
contentOffset = {x: 0, y: headerOffset * -1}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(props.removeClippedSubviews)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlatList_INTERNAL
|
<FlatList_INTERNAL
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
Reference in New Issue
Block a user