GIF Viewer (#3605)
* 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
* android player
smoother
basic caching
prep cache
somewhat works
backup
other files
android impl
blegh
lets go
touchup
add prefetch to js
use caching
* bogus testing commit
* add dims to type
* save
* add the dimensions to the embed info
* add a new case
* add a new case
* limit this case to giphy
* use gate
* Revert "bogus testing commit"
This reverts commit b3c8751b71.
* add web player base
* flip mp4/webp
* basic mp4 player for web
* move some stuff into `ExternalLinkEmbed` instead
* use a class component for web
* remove extra component
* add `onPlayerStateChange` event type on web
* layer properly
* fix tests
* add new test
* about ready. native portions done, a few touch ups on web needed
show placeholder on ios
fix type
rm log
display thumbnail until video is ready to play
add oncanplay, playsinline
remove unused method
add `isLoaded` change event
release player when finished
apply gc to the view
cleanup logs
android gc
rm log
automatic gc for assets
make `nativeRef` private
remove unnecessary `await`
cleanup
rev log
only play on prepare whenever needed
rm unused
perfperfperf
rm var
comment + android width
native height calculations
rm pressable
add event dispatcher on android
add event dispatcher on ios
* ready to test ios
fix autoplay ios
clean
oops
* autoplay on web
* normalize across all platforms
add check for `ALT:`
separate gif embed logic to another file
handle permissions requests
flatten web styles
normalize styles
normalize styles
prefetch functions
pause animatable on foreground android
nits
one more oops
idk where that code went
lint
rethink the usage
wrap up
android
clear bg
update gradle
more android
rename dir
update android namespace
web
ios
add deps
use webp
rm unused
update types
use webp on mobile
* rm gate from types
* remove unused event param
* only start placeholder op if doesn't exist in disk cache
* fix gifs animating on app resume android
* remove comment
* add `isLoaded` for ios
* add `isLoaded` to Android
* onload for web
* add visual loading state
* rm a log
* implement isloaded for android
* dialogs
* replace `webpSource` with `source`
* update prop name
* Move to Tenor for GIFs (#3654)
* update some urls
* right order for dimensions
* add GIF coder for ios
* remove giphy check
* rewrite tenor urls
* remove all the unnecessary stuff for consent
* rm print
* rm log
* check if id and filename are strings
* full size playback controls
* pass tests
* add accessibility to gifs
* use `onPlay` and `onPause`
* rm unused logic for description
* add accessibility label to the controls
* add gif into to external embed in composer
* make it optional
* gif dimensions
* make the jsx look nicer
---------
Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
apply plugin: 'com.android.library'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'maven-publish'
|
||||
|
||||
group = 'expo.modules.blueskygifview'
|
||||
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.blueskygifview"
|
||||
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 'androidx.appcompat:appcompat:1.6.1'
|
||||
def GLIDE_VERSION = "4.13.2"
|
||||
|
||||
implementation project(':expo-modules-core')
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}"
|
||||
|
||||
// Keep glide version up to date with expo-image so that we don't have duplicate deps
|
||||
implementation 'com.github.bumptech.glide:glide:4.13.2'
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<manifest>
|
||||
</manifest>
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package expo.modules.blueskygifview
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.drawable.Animatable
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
|
||||
class AppCompatImageViewExtended(context: Context, private val parent: GifView): AppCompatImageView(context) {
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
|
||||
if (this.drawable is Animatable) {
|
||||
if (!parent.isLoaded) {
|
||||
parent.isLoaded = true
|
||||
parent.firePlayerStateChange()
|
||||
}
|
||||
|
||||
if (!parent.isPlaying) {
|
||||
this.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
val drawable = this.drawable
|
||||
if (drawable is Animatable) {
|
||||
drawable.stop()
|
||||
}
|
||||
}
|
||||
|
||||
fun play() {
|
||||
val drawable = this.drawable
|
||||
if (drawable is Animatable) {
|
||||
drawable.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package expo.modules.blueskygifview
|
||||
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
class ExpoBlueskyGifViewModule : Module() {
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("ExpoBlueskyGifView")
|
||||
|
||||
AsyncFunction("prefetchAsync") { sources: List<String> ->
|
||||
val activity = appContext.currentActivity ?: return@AsyncFunction
|
||||
val glide = Glide.with(activity)
|
||||
|
||||
sources.forEach { source ->
|
||||
glide
|
||||
.download(source)
|
||||
.diskCacheStrategy(DiskCacheStrategy.DATA)
|
||||
.submit()
|
||||
}
|
||||
}
|
||||
|
||||
View(GifView::class) {
|
||||
Events(
|
||||
"onPlayerStateChange"
|
||||
)
|
||||
|
||||
Prop("source") { view: GifView, source: String ->
|
||||
view.source = source
|
||||
}
|
||||
|
||||
Prop("placeholderSource") { view: GifView, source: String ->
|
||||
view.placeholderSource = source
|
||||
}
|
||||
|
||||
Prop("autoplay") { view: GifView, autoplay: Boolean ->
|
||||
view.autoplay = autoplay
|
||||
}
|
||||
|
||||
AsyncFunction("playAsync") { view: GifView ->
|
||||
view.play()
|
||||
}
|
||||
|
||||
AsyncFunction("pauseAsync") { view: GifView ->
|
||||
view.pause()
|
||||
}
|
||||
|
||||
AsyncFunction("toggleAsync") { view: GifView ->
|
||||
view.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
package expo.modules.blueskygifview
|
||||
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.graphics.drawable.Drawable
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.load.engine.GlideException
|
||||
import com.bumptech.glide.request.RequestListener
|
||||
import com.bumptech.glide.request.target.Target
|
||||
import expo.modules.kotlin.AppContext
|
||||
import expo.modules.kotlin.exception.Exceptions
|
||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||
import expo.modules.kotlin.views.ExpoView
|
||||
|
||||
class GifView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
|
||||
// Events
|
||||
private val onPlayerStateChange by EventDispatcher()
|
||||
|
||||
// Glide
|
||||
private val activity = appContext.currentActivity ?: throw Exceptions.MissingActivity()
|
||||
private val glide = Glide.with(activity)
|
||||
val imageView = AppCompatImageViewExtended(context, this)
|
||||
var isPlaying = true
|
||||
var isLoaded = false
|
||||
|
||||
// Requests
|
||||
private var placeholderRequest: Target<Drawable>? = null
|
||||
private var webpRequest: Target<Drawable>? = null
|
||||
|
||||
// Props
|
||||
var placeholderSource: String? = null
|
||||
var source: String? = null
|
||||
var autoplay: Boolean = true
|
||||
set(value) {
|
||||
field = value
|
||||
|
||||
if (value) {
|
||||
this.play()
|
||||
} else {
|
||||
this.pause()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//<editor-fold desc="Lifecycle">
|
||||
|
||||
init {
|
||||
this.setBackgroundColor(Color.TRANSPARENT)
|
||||
|
||||
this.imageView.setBackgroundColor(Color.TRANSPARENT)
|
||||
this.imageView.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
|
||||
this.addView(this.imageView)
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
if (this.imageView.drawable == null || this.imageView.drawable !is Animatable) {
|
||||
this.load()
|
||||
} else if (this.isPlaying) {
|
||||
this.imageView.play()
|
||||
}
|
||||
super.onAttachedToWindow()
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
this.imageView.pause()
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
//</editor-fold>
|
||||
|
||||
//<editor-fold desc="Loading">
|
||||
|
||||
private fun load() {
|
||||
if (placeholderSource == null || source == null) {
|
||||
return
|
||||
}
|
||||
|
||||
this.webpRequest = glide.load(source)
|
||||
.diskCacheStrategy(DiskCacheStrategy.DATA)
|
||||
.skipMemoryCache(false)
|
||||
.listener(object: RequestListener<Drawable> {
|
||||
override fun onResourceReady(
|
||||
resource: Drawable?,
|
||||
model: Any?,
|
||||
target: Target<Drawable>?,
|
||||
dataSource: com.bumptech.glide.load.DataSource?,
|
||||
isFirstResource: Boolean
|
||||
): Boolean {
|
||||
if (placeholderRequest != null) {
|
||||
glide.clear(placeholderRequest)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onLoadFailed(
|
||||
e: GlideException?,
|
||||
model: Any?,
|
||||
target: Target<Drawable>?,
|
||||
isFirstResource: Boolean
|
||||
): Boolean {
|
||||
return true
|
||||
}
|
||||
})
|
||||
.into(this.imageView)
|
||||
|
||||
if (this.imageView.drawable == null || this.imageView.drawable !is Animatable) {
|
||||
this.placeholderRequest = glide.load(placeholderSource)
|
||||
.diskCacheStrategy(DiskCacheStrategy.DATA)
|
||||
// Let's not bloat the memory cache with placeholders
|
||||
.skipMemoryCache(true)
|
||||
.listener(object: RequestListener<Drawable> {
|
||||
override fun onResourceReady(
|
||||
resource: Drawable?,
|
||||
model: Any?,
|
||||
target: Target<Drawable>?,
|
||||
dataSource: com.bumptech.glide.load.DataSource?,
|
||||
isFirstResource: Boolean
|
||||
): Boolean {
|
||||
// Incase this request finishes after the webp, let's just not set
|
||||
// the drawable. This shouldn't happen because the request should get cancelled
|
||||
if (imageView.drawable == null) {
|
||||
imageView.setImageDrawable(resource)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onLoadFailed(
|
||||
e: GlideException?,
|
||||
model: Any?,
|
||||
target: Target<Drawable>?,
|
||||
isFirstResource: Boolean
|
||||
): Boolean {
|
||||
return true
|
||||
}
|
||||
})
|
||||
.submit()
|
||||
}
|
||||
}
|
||||
|
||||
//</editor-fold>
|
||||
|
||||
//<editor-fold desc="Controls">
|
||||
|
||||
fun play() {
|
||||
this.imageView.play()
|
||||
this.isPlaying = true
|
||||
this.firePlayerStateChange()
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
this.imageView.pause()
|
||||
this.isPlaying = false
|
||||
this.firePlayerStateChange()
|
||||
}
|
||||
|
||||
fun toggle() {
|
||||
if (this.isPlaying) {
|
||||
this.pause()
|
||||
} else {
|
||||
this.play()
|
||||
}
|
||||
}
|
||||
|
||||
//</editor-fold>
|
||||
|
||||
//<editor-fold desc="Util">
|
||||
|
||||
fun firePlayerStateChange() {
|
||||
onPlayerStateChange(mapOf(
|
||||
"isPlaying" to this.isPlaying,
|
||||
"isLoaded" to this.isLoaded,
|
||||
))
|
||||
}
|
||||
|
||||
//</editor-fold>
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"platforms": ["ios", "android", "web"],
|
||||
"ios": {
|
||||
"modules": ["ExpoBlueskyGifViewModule"]
|
||||
},
|
||||
"android": {
|
||||
"modules": ["expo.modules.blueskygifview.ExpoBlueskyGifViewModule"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {GifView} from './src/GifView'
|
||||
@@ -0,0 +1,23 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'ExpoBlueskyGifView'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'A simple GIF player for Bluesky'
|
||||
s.description = 'A simple GIF player for Bluesky'
|
||||
s.author = ''
|
||||
s.homepage = 'https://github.com/bluesky-social/social-app'
|
||||
s.platforms = { :ios => '13.4', :tvos => '13.4' }
|
||||
s.source = { git: '' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
s.dependency 'SDWebImage', '~> 5.17.0'
|
||||
s.dependency 'SDWebImageWebPCoder', '~> 0.13.0'
|
||||
|
||||
# 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
|
||||
@@ -0,0 +1,47 @@
|
||||
import ExpoModulesCore
|
||||
import SDWebImage
|
||||
import SDWebImageWebPCoder
|
||||
|
||||
public class ExpoBlueskyGifViewModule: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("ExpoBlueskyGifView")
|
||||
|
||||
OnCreate {
|
||||
SDImageCodersManager.shared.addCoder(SDImageGIFCoder.shared)
|
||||
}
|
||||
|
||||
AsyncFunction("prefetchAsync") { (sources: [URL]) in
|
||||
SDWebImagePrefetcher.shared.prefetchURLs(sources, context: Util.createContext(), progress: nil)
|
||||
}
|
||||
|
||||
View(GifView.self) {
|
||||
Events(
|
||||
"onPlayerStateChange"
|
||||
)
|
||||
|
||||
Prop("source") { (view: GifView, prop: String) in
|
||||
view.source = prop
|
||||
}
|
||||
|
||||
Prop("placeholderSource") { (view: GifView, prop: String) in
|
||||
view.placeholderSource = prop
|
||||
}
|
||||
|
||||
Prop("autoplay") { (view: GifView, prop: Bool) in
|
||||
view.autoplay = prop
|
||||
}
|
||||
|
||||
AsyncFunction("toggleAsync") { (view: GifView) in
|
||||
view.toggle()
|
||||
}
|
||||
|
||||
AsyncFunction("playAsync") { (view: GifView) in
|
||||
view.play()
|
||||
}
|
||||
|
||||
AsyncFunction("pauseAsync") { (view: GifView) in
|
||||
view.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import ExpoModulesCore
|
||||
import SDWebImage
|
||||
import SDWebImageWebPCoder
|
||||
|
||||
typealias SDWebImageContext = [SDWebImageContextOption: Any]
|
||||
|
||||
public class GifView: ExpoView, AVPlayerViewControllerDelegate {
|
||||
// Events
|
||||
private let onPlayerStateChange = EventDispatcher()
|
||||
|
||||
// SDWebImage
|
||||
private let imageView = SDAnimatedImageView(frame: .zero)
|
||||
private let imageManager = SDWebImageManager(
|
||||
cache: SDImageCache.shared,
|
||||
loader: SDImageLoadersManager.shared
|
||||
)
|
||||
private var isPlaying = true
|
||||
private var isLoaded = false
|
||||
|
||||
// Requests
|
||||
private var webpOperation: SDWebImageCombinedOperation?
|
||||
private var placeholderOperation: SDWebImageCombinedOperation?
|
||||
|
||||
// Props
|
||||
var source: String? = nil
|
||||
var placeholderSource: String? = nil
|
||||
var autoplay = true {
|
||||
didSet {
|
||||
if !autoplay {
|
||||
self.pause()
|
||||
} else {
|
||||
self.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
public required init(appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
self.clipsToBounds = true
|
||||
|
||||
self.imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
self.imageView.layer.masksToBounds = false
|
||||
self.imageView.backgroundColor = .clear
|
||||
self.imageView.contentMode = .scaleToFill
|
||||
|
||||
// We have to explicitly set this to false. If we don't, every time
|
||||
// the view comes into the viewport, it will start animating again
|
||||
self.imageView.autoPlayAnimatedImage = false
|
||||
|
||||
self.addSubview(self.imageView)
|
||||
}
|
||||
|
||||
public override func willMove(toWindow newWindow: UIWindow?) {
|
||||
if newWindow == nil {
|
||||
// Don't cancel the placeholder operation, because we really want that to complete for
|
||||
// when we scroll back up
|
||||
self.webpOperation?.cancel()
|
||||
self.placeholderOperation?.cancel()
|
||||
} else if self.imageView.image == nil {
|
||||
self.load()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Loading
|
||||
|
||||
private func load() {
|
||||
guard let source = self.source, let placeholderSource = self.placeholderSource else {
|
||||
return
|
||||
}
|
||||
|
||||
self.webpOperation?.cancel()
|
||||
self.placeholderOperation?.cancel()
|
||||
|
||||
// We only need to start an operation for the placeholder if it doesn't exist
|
||||
// in the cache already. Cache key is by default the absolute URL of the image.
|
||||
// See:
|
||||
// https://github.com/SDWebImage/SDWebImage/blob/master/Docs/HowToUse.md#using-asynchronous-image-caching-independently
|
||||
if !SDImageCache.shared.diskImageDataExists(withKey: source),
|
||||
let url = URL(string: placeholderSource)
|
||||
{
|
||||
self.placeholderOperation = imageManager.loadImage(
|
||||
with: url,
|
||||
options: [.retryFailed],
|
||||
context: Util.createContext(),
|
||||
progress: onProgress(_:_:_:),
|
||||
completed: onLoaded(_:_:_:_:_:_:)
|
||||
)
|
||||
}
|
||||
|
||||
if let url = URL(string: source) {
|
||||
self.webpOperation = imageManager.loadImage(
|
||||
with: url,
|
||||
options: [.retryFailed],
|
||||
context: Util.createContext(),
|
||||
progress: onProgress(_:_:_:),
|
||||
completed: onLoaded(_:_:_:_:_:_:)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func setImage(_ image: UIImage) {
|
||||
if self.imageView.image == nil || image.sd_isAnimated {
|
||||
self.imageView.image = image
|
||||
}
|
||||
|
||||
if image.sd_isAnimated {
|
||||
self.firePlayerStateChange()
|
||||
if isPlaying {
|
||||
self.imageView.startAnimating()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Loading blocks
|
||||
|
||||
private func onProgress(_ receivedSize: Int, _ expectedSize: Int, _ imageUrl: URL?) {}
|
||||
|
||||
private func onLoaded(
|
||||
_ image: UIImage?,
|
||||
_ data: Data?,
|
||||
_ error: Error?,
|
||||
_ cacheType: SDImageCacheType,
|
||||
_ finished: Bool,
|
||||
_ imageUrl: URL?
|
||||
) {
|
||||
guard finished else {
|
||||
return
|
||||
}
|
||||
|
||||
if let placeholderSource = self.placeholderSource,
|
||||
imageUrl?.absoluteString == placeholderSource,
|
||||
self.imageView.image == nil,
|
||||
let image = image
|
||||
{
|
||||
self.setImage(image)
|
||||
return
|
||||
}
|
||||
|
||||
if let source = self.source,
|
||||
imageUrl?.absoluteString == source,
|
||||
// UIImage perf suckssss if the image is animated
|
||||
let data = data,
|
||||
let animatedImage = SDAnimatedImage(data: data)
|
||||
{
|
||||
self.placeholderOperation?.cancel()
|
||||
self.isPlaying = self.autoplay
|
||||
self.isLoaded = true
|
||||
self.setImage(animatedImage)
|
||||
self.firePlayerStateChange()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Playback Controls
|
||||
|
||||
func play() {
|
||||
self.imageView.startAnimating()
|
||||
self.isPlaying = true
|
||||
self.firePlayerStateChange()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
self.imageView.stopAnimating()
|
||||
self.isPlaying = false
|
||||
self.firePlayerStateChange()
|
||||
}
|
||||
|
||||
func toggle() {
|
||||
if self.isPlaying {
|
||||
self.pause()
|
||||
} else {
|
||||
self.play()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Util
|
||||
|
||||
private func firePlayerStateChange() {
|
||||
onPlayerStateChange([
|
||||
"isPlaying": self.isPlaying,
|
||||
"isLoaded": self.isLoaded
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import SDWebImage
|
||||
|
||||
class Util {
|
||||
static func createContext() -> SDWebImageContext {
|
||||
var context = SDWebImageContext()
|
||||
|
||||
// SDAnimatedImage for some reason has issues whenever loaded from memory. Instead, we
|
||||
// will just use the disk. SDWebImage will manage this cache for us, so we don't need
|
||||
// to worry about clearing it.
|
||||
context[.originalQueryCacheType] = SDImageCacheType.disk.rawValue
|
||||
context[.originalStoreCacheType] = SDImageCacheType.disk.rawValue
|
||||
context[.queryCacheType] = SDImageCacheType.disk.rawValue
|
||||
context[.storeCacheType] = SDImageCacheType.disk.rawValue
|
||||
|
||||
return context
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react'
|
||||
import {requireNativeModule} from 'expo'
|
||||
import {requireNativeViewManager} from 'expo-modules-core'
|
||||
|
||||
import {GifViewProps} from './GifView.types'
|
||||
|
||||
const NativeModule = requireNativeModule('ExpoBlueskyGifView')
|
||||
const NativeView: React.ComponentType<
|
||||
GifViewProps & {ref: React.RefObject<any>}
|
||||
> = requireNativeViewManager('ExpoBlueskyGifView')
|
||||
|
||||
export class GifView extends React.PureComponent<GifViewProps> {
|
||||
// TODO native types, should all be the same as those in this class
|
||||
private nativeRef: React.RefObject<any> = React.createRef()
|
||||
|
||||
constructor(props: GifViewProps | Readonly<GifViewProps>) {
|
||||
super(props)
|
||||
}
|
||||
|
||||
static async prefetchAsync(sources: string[]): Promise<void> {
|
||||
return await NativeModule.prefetchAsync(sources)
|
||||
}
|
||||
|
||||
async playAsync(): Promise<void> {
|
||||
await this.nativeRef.current.playAsync()
|
||||
}
|
||||
|
||||
async pauseAsync(): Promise<void> {
|
||||
await this.nativeRef.current.pauseAsync()
|
||||
}
|
||||
|
||||
async toggleAsync(): Promise<void> {
|
||||
await this.nativeRef.current.toggleAsync()
|
||||
}
|
||||
|
||||
render() {
|
||||
return <NativeView {...this.props} ref={this.nativeRef} />
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import {ViewProps} from 'react-native'
|
||||
|
||||
export interface GifViewStateChangeEvent {
|
||||
nativeEvent: {
|
||||
isPlaying: boolean
|
||||
isLoaded: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface GifViewProps extends ViewProps {
|
||||
autoplay?: boolean
|
||||
source?: string
|
||||
placeholderSource?: string
|
||||
onPlayerStateChange?: (event: GifViewStateChangeEvent) => void
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import * as React from 'react'
|
||||
import {StyleSheet} from 'react-native'
|
||||
|
||||
import {GifViewProps} from './GifView.types'
|
||||
|
||||
export class GifView extends React.PureComponent<GifViewProps> {
|
||||
private readonly videoPlayerRef: React.RefObject<HTMLMediaElement> =
|
||||
React.createRef()
|
||||
private isLoaded = false
|
||||
|
||||
constructor(props: GifViewProps | Readonly<GifViewProps>) {
|
||||
super(props)
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Readonly<GifViewProps>) {
|
||||
if (prevProps.autoplay !== this.props.autoplay) {
|
||||
if (this.props.autoplay) {
|
||||
this.playAsync()
|
||||
} else {
|
||||
this.pauseAsync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async prefetchAsync(_: string[]): Promise<void> {
|
||||
console.warn('prefetchAsync is not supported on web')
|
||||
}
|
||||
|
||||
private firePlayerStateChangeEvent = () => {
|
||||
this.props.onPlayerStateChange?.({
|
||||
nativeEvent: {
|
||||
isPlaying: !this.videoPlayerRef.current?.paused,
|
||||
isLoaded: this.isLoaded,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private onLoad = () => {
|
||||
// Prevent multiple calls to onLoad because onCanPlay will fire after each loop
|
||||
if (this.isLoaded) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isLoaded = true
|
||||
this.firePlayerStateChangeEvent()
|
||||
}
|
||||
|
||||
async playAsync(): Promise<void> {
|
||||
this.videoPlayerRef.current?.play()
|
||||
}
|
||||
|
||||
async pauseAsync(): Promise<void> {
|
||||
this.videoPlayerRef.current?.pause()
|
||||
}
|
||||
|
||||
async toggleAsync(): Promise<void> {
|
||||
if (this.videoPlayerRef.current?.paused) {
|
||||
await this.playAsync()
|
||||
} else {
|
||||
await this.pauseAsync()
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<video
|
||||
src={this.props.source}
|
||||
autoPlay={this.props.autoplay ? 'autoplay' : undefined}
|
||||
preload={this.props.autoplay ? 'auto' : undefined}
|
||||
playsInline={true}
|
||||
loop="loop"
|
||||
muted="muted"
|
||||
style={StyleSheet.flatten(this.props.style)}
|
||||
onCanPlay={this.onLoad}
|
||||
onPlay={this.firePlayerStateChangeEvent}
|
||||
onPause={this.firePlayerStateChangeEvent}
|
||||
aria-label={this.props.accessibilityLabel}
|
||||
ref={this.videoPlayerRef}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user