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 b3c8751b71f7108de9aa843b22ded4e0249fa854. * 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>zio/stable
parent
fe9b3f0432
commit
cbb817b5b7
|
@ -459,6 +459,12 @@ describe('parseEmbedPlayerFromUrl', () => {
|
|||
'https://tenor.com/view',
|
||||
'https://tenor.com/view/gifId.gif',
|
||||
'https://tenor.com/intl/view/gifId.gif',
|
||||
|
||||
'https://media.tenor.com/someID_AAAAC/someName.gif?hh=100&ww=100',
|
||||
'https://media.tenor.com/someID_AAAAC/someName.gif',
|
||||
'https://media.tenor.com/someID/someName.gif',
|
||||
'https://media.tenor.com/someID',
|
||||
'https://media.tenor.com',
|
||||
]
|
||||
|
||||
const outputs = [
|
||||
|
@ -628,137 +634,129 @@ describe('parseEmbedPlayerFromUrl', () => {
|
|||
},
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
{
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: 'https://giphy.com/gifs/39248209509382934029',
|
||||
playerUri: 'https://i.giphy.com/media/39248209509382934029/200.mp4',
|
||||
playerUri: 'https://i.giphy.com/media/39248209509382934029/200.webp',
|
||||
},
|
||||
{
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: 'https://giphy.com/gifs/gifId',
|
||||
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
|
||||
},
|
||||
{
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: 'https://giphy.com/gifs/gifId',
|
||||
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
|
||||
},
|
||||
{
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: 'https://giphy.com/gifs/gifId',
|
||||
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
|
||||
},
|
||||
{
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: 'https://giphy.com/gifs/gifId',
|
||||
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
|
||||
},
|
||||
{
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: 'https://giphy.com/gifs/gifId',
|
||||
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
|
||||
},
|
||||
{
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: 'https://giphy.com/gifs/gifId',
|
||||
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
{
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: 'https://giphy.com/gifs/gifId',
|
||||
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
|
||||
},
|
||||
|
||||
{
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: 'https://giphy.com/gifs/gifId',
|
||||
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
|
||||
},
|
||||
{
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: 'https://giphy.com/gifs/gifId',
|
||||
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
|
||||
},
|
||||
{
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: 'https://giphy.com/gifs/gifId',
|
||||
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
|
||||
},
|
||||
{
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: 'https://giphy.com/gifs/gifId',
|
||||
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
|
||||
},
|
||||
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
{
|
||||
type: 'tenor_gif',
|
||||
source: 'tenor',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
playerUri: 'https://t.gifs.bsky.app/someID_AAAAM/someName.gif',
|
||||
dimensions: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: 'https://giphy.com/gifs/gifId',
|
||||
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
|
||||
},
|
||||
{
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: 'https://giphy.com/gifs/gifId',
|
||||
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
|
||||
},
|
||||
{
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: 'https://giphy.com/gifs/gifId',
|
||||
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
|
||||
},
|
||||
{
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: 'https://giphy.com/gifs/gifId',
|
||||
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
|
||||
},
|
||||
{
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: 'https://giphy.com/gifs/gifId',
|
||||
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
|
||||
},
|
||||
{
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: 'https://giphy.com/gifs/gifId',
|
||||
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
{
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: 'https://giphy.com/gifs/gifId',
|
||||
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
|
||||
},
|
||||
|
||||
{
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: 'https://giphy.com/gifs/gifId',
|
||||
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
|
||||
},
|
||||
{
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: 'https://giphy.com/gifs/gifId',
|
||||
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
|
||||
},
|
||||
{
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: 'https://giphy.com/gifs/gifId',
|
||||
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
|
||||
},
|
||||
{
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: 'https://giphy.com/gifs/gifId',
|
||||
playerUri: 'https://i.giphy.com/media/gifId/200.webp',
|
||||
},
|
||||
|
||||
{
|
||||
type: 'tenor_gif',
|
||||
source: 'tenor',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
playerUri: 'https://tenor.com/view/gifId.gif',
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
type: 'tenor_gif',
|
||||
source: 'tenor',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
playerUri: 'https://tenor.com/view/gifId.gif',
|
||||
},
|
||||
{
|
||||
type: 'tenor_gif',
|
||||
source: 'tenor',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
playerUri: 'https://tenor.com/intl/view/gifId.gif',
|
||||
},
|
||||
]
|
||||
|
||||
it('correctly grabs the correct id from uri', () => {
|
||||
|
|
|
@ -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>
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -4,7 +4,6 @@ export type Gate =
|
|||
| 'disable_min_shell_on_foregrounding_v2'
|
||||
| 'disable_poll_on_discover_v2'
|
||||
| 'hide_vertical_scroll_indicators'
|
||||
| 'new_gif_player'
|
||||
| 'show_follow_back_label_v2'
|
||||
| 'start_session_with_following_v2'
|
||||
| 'use_new_suggestions_endpoint'
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {Dimensions} from 'react-native'
|
||||
import {Dimensions, Platform} from 'react-native'
|
||||
|
||||
import {isWeb} from 'platform/detection'
|
||||
const {height: SCREEN_HEIGHT} = Dimensions.get('window')
|
||||
|
@ -255,16 +255,6 @@ export function parseEmbedPlayerFromUrl(
|
|||
if (urlp.hostname === 'giphy.com' || urlp.hostname === 'www.giphy.com') {
|
||||
const [_, gifs, nameAndId] = urlp.pathname.split('/')
|
||||
|
||||
const h = urlp.searchParams.get('hh')
|
||||
const w = urlp.searchParams.get('ww')
|
||||
let dimensions
|
||||
if (h && w) {
|
||||
dimensions = {
|
||||
height: Number(h),
|
||||
width: Number(w),
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* nameAndId is a string that consists of the name (dash separated) and the id of the gif (the last part of the name)
|
||||
* We want to get the id of the gif, then direct to media.giphy.com/media/{id}/giphy.webp so we can
|
||||
|
@ -281,10 +271,7 @@ export function parseEmbedPlayerFromUrl(
|
|||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: `https://giphy.com/gifs/${gifId}`,
|
||||
playerUri: `https://i.giphy.com/media/${gifId}/${
|
||||
dimensions ? '200.mp4' : '200.webp'
|
||||
}`,
|
||||
dimensions,
|
||||
playerUri: `https://i.giphy.com/media/${gifId}/200.webp`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -350,21 +337,34 @@ export function parseEmbedPlayerFromUrl(
|
|||
}
|
||||
}
|
||||
|
||||
if (urlp.hostname === 'tenor.com' || urlp.hostname === 'www.tenor.com') {
|
||||
const [_, pathOrIntl, pathOrFilename, intlFilename] =
|
||||
urlp.pathname.split('/')
|
||||
const isIntl = pathOrFilename === 'view'
|
||||
const filename = isIntl ? intlFilename : pathOrFilename
|
||||
if (urlp.hostname === 'media.tenor.com') {
|
||||
let [_, id, filename] = urlp.pathname.split('/')
|
||||
|
||||
if ((pathOrIntl === 'view' || pathOrFilename === 'view') && filename) {
|
||||
const includesExt = filename.split('.').pop() === 'gif'
|
||||
const h = urlp.searchParams.get('hh')
|
||||
const w = urlp.searchParams.get('ww')
|
||||
let dimensions
|
||||
if (h && w) {
|
||||
dimensions = {
|
||||
height: Number(h),
|
||||
width: Number(w),
|
||||
}
|
||||
}
|
||||
|
||||
if (id && filename && dimensions && id.includes('AAAAC')) {
|
||||
if (Platform.OS === 'web') {
|
||||
id = id.replace('AAAAC', 'AAAP3')
|
||||
filename = filename.replace('.gif', '.webm')
|
||||
} else {
|
||||
id = id.replace('AAAAC', 'AAAAM')
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'tenor_gif',
|
||||
source: 'tenor',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
playerUri: `${url}${!includesExt ? '.gif' : ''}`,
|
||||
playerUri: `https://t.gifs.bsky.app/${id}/${filename}`,
|
||||
dimensions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -121,6 +121,7 @@ export const ComposePost = observer(function ComposePost({
|
|||
initQuote,
|
||||
)
|
||||
const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
|
||||
const [extGif, setExtGif] = useState<Gif>()
|
||||
const [labels, setLabels] = useState<string[]>([])
|
||||
const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([])
|
||||
const gallery = useMemo(
|
||||
|
@ -318,7 +319,7 @@ export const ComposePost = observer(function ComposePost({
|
|||
const onSelectGif = useCallback(
|
||||
(gif: Gif) => {
|
||||
setExtLink({
|
||||
uri: `${gif.media_formats.gif.url}?hh=${gif.media_formats.gif.dims[0]}&ww=${gif.media_formats.gif.dims[1]}`,
|
||||
uri: `${gif.media_formats.gif.url}?hh=${gif.media_formats.gif.dims[1]}&ww=${gif.media_formats.gif.dims[0]}`,
|
||||
isLoading: true,
|
||||
meta: {
|
||||
url: gif.media_formats.gif.url,
|
||||
|
@ -328,6 +329,7 @@ export const ComposePost = observer(function ComposePost({
|
|||
description: `ALT: ${gif.content_description}`,
|
||||
},
|
||||
})
|
||||
setExtGif(gif)
|
||||
},
|
||||
[setExtLink],
|
||||
)
|
||||
|
@ -473,7 +475,11 @@ export const ComposePost = observer(function ComposePost({
|
|||
{gallery.isEmpty && extLink && (
|
||||
<ExternalEmbed
|
||||
link={extLink}
|
||||
onRemove={() => setExtLink(undefined)}
|
||||
gif={extGif}
|
||||
onRemove={() => {
|
||||
setExtLink(undefined)
|
||||
setExtGif(undefined)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{quote ? (
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import React from 'react'
|
||||
import {TouchableOpacity, View} from 'react-native'
|
||||
import {StyleProp, TouchableOpacity, View, ViewStyle} from 'react-native'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {ExternalEmbedDraft} from 'lib/api/index'
|
||||
import {s} from 'lib/styles'
|
||||
import {Gif} from 'state/queries/tenor'
|
||||
import {ExternalLinkEmbed} from 'view/com/util/post-embeds/ExternalLinkEmbed'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Loader} from '#/components/Loader'
|
||||
|
@ -14,9 +15,11 @@ import {Text} from '#/components/Typography'
|
|||
export const ExternalEmbed = ({
|
||||
link,
|
||||
onRemove,
|
||||
gif,
|
||||
}: {
|
||||
link?: ExternalEmbedDraft
|
||||
onRemove: () => void
|
||||
gif?: Gif
|
||||
}) => {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
@ -34,45 +37,38 @@ export const ExternalEmbed = ({
|
|||
|
||||
if (!link) return null
|
||||
|
||||
const loadingStyle: ViewStyle | undefined = gif
|
||||
? {
|
||||
aspectRatio:
|
||||
gif.media_formats.gif.dims[0] / gif.media_formats.gif.dims[1],
|
||||
width: '100%',
|
||||
}
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.border,
|
||||
a.rounded_sm,
|
||||
a.mt_2xl,
|
||||
a.mb_xl,
|
||||
a.overflow_hidden,
|
||||
t.atoms.border_contrast_medium,
|
||||
]}>
|
||||
<View style={[a.mb_xl, a.overflow_hidden, t.atoms.border_contrast_medium]}>
|
||||
{link.isLoading ? (
|
||||
<View
|
||||
style={[
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.py_5xl,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Container style={loadingStyle}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
</Container>
|
||||
) : link.meta?.error ? (
|
||||
<View
|
||||
style={[a.justify_center, a.p_md, a.gap_xs, t.atoms.bg_contrast_25]}>
|
||||
<Container style={[a.align_start, a.p_md, a.gap_xs]}>
|
||||
<Text numberOfLines={1} style={t.atoms.text_contrast_high}>
|
||||
{link.uri}
|
||||
</Text>
|
||||
<Text numberOfLines={2} style={[{color: t.palette.negative_400}]}>
|
||||
{link.meta.error}
|
||||
{link.meta?.error}
|
||||
</Text>
|
||||
</View>
|
||||
</Container>
|
||||
) : linkInfo ? (
|
||||
<View style={{pointerEvents: 'none'}}>
|
||||
<View style={{pointerEvents: !gif ? 'none' : 'auto'}}>
|
||||
<ExternalLinkEmbed link={linkInfo} />
|
||||
</View>
|
||||
) : null}
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
top: 16,
|
||||
right: 10,
|
||||
height: 36,
|
||||
width: 36,
|
||||
|
@ -91,3 +87,29 @@ export const ExternalEmbed = ({
|
|||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function Container({
|
||||
style,
|
||||
children,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.mt_sm,
|
||||
a.rounded_sm,
|
||||
a.border,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.py_5xl,
|
||||
t.atoms.bg_contrast_25,
|
||||
t.atoms.border_contrast_medium,
|
||||
style,
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,27 +1,32 @@
|
|||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import React, {useCallback} from 'react'
|
||||
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {AppBskyEmbedExternal} from '@atproto/api'
|
||||
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {useGate} from 'lib/statsig/statsig'
|
||||
import {shareUrl} from 'lib/sharing'
|
||||
import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
|
||||
import {toNiceDomain} from 'lib/strings/url-helpers'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {useExternalEmbedsPrefs} from 'state/preferences'
|
||||
import {Link} from 'view/com/util/Link'
|
||||
import {ExternalGifEmbed} from 'view/com/util/post-embeds/ExternalGifEmbed'
|
||||
import {ExternalPlayer} from 'view/com/util/post-embeds/ExternalPlayerEmbed'
|
||||
import {GifEmbed} from 'view/com/util/post-embeds/GifEmbed'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Text} from '../text/Text'
|
||||
|
||||
export const ExternalLinkEmbed = ({
|
||||
link,
|
||||
style,
|
||||
}: {
|
||||
link: AppBskyEmbedExternal.ViewExternal
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) => {
|
||||
const pal = usePalette('default')
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const externalEmbedPrefs = useExternalEmbedsPrefs()
|
||||
const gate = useGate()
|
||||
|
||||
const embedPlayerParams = React.useMemo(() => {
|
||||
const params = parseEmbedPlayerFromUrl(link.uri)
|
||||
|
@ -30,71 +35,96 @@ export const ExternalLinkEmbed = ({
|
|||
return params
|
||||
}
|
||||
}, [link.uri, externalEmbedPrefs])
|
||||
const isCompatibleGiphy =
|
||||
embedPlayerParams?.source === 'giphy' &&
|
||||
embedPlayerParams.dimensions &&
|
||||
gate('new_gif_player')
|
||||
|
||||
if (embedPlayerParams?.source === 'tenor') {
|
||||
return <GifEmbed params={embedPlayerParams} link={link} />
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{link.thumb && !embedPlayerParams ? (
|
||||
<Image
|
||||
style={{aspectRatio: 1.91}}
|
||||
source={{uri: link.thumb}}
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
) : undefined}
|
||||
{isCompatibleGiphy ? (
|
||||
<View />
|
||||
) : embedPlayerParams?.isGif ? (
|
||||
<ExternalGifEmbed link={link} params={embedPlayerParams} />
|
||||
) : embedPlayerParams ? (
|
||||
<ExternalPlayer link={link} params={embedPlayerParams} />
|
||||
) : undefined}
|
||||
<View style={[styles.info, {paddingHorizontal: isMobile ? 10 : 14}]}>
|
||||
{!isCompatibleGiphy && (
|
||||
<View style={[a.flex_col, a.rounded_sm, a.overflow_hidden, a.mt_sm]}>
|
||||
<LinkWrapper link={link} style={style}>
|
||||
{link.thumb && !embedPlayerParams ? (
|
||||
<Image
|
||||
style={{
|
||||
aspectRatio: 1.91,
|
||||
borderTopRightRadius: 6,
|
||||
borderTopLeftRadius: 6,
|
||||
}}
|
||||
source={{uri: link.thumb}}
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
) : undefined}
|
||||
{embedPlayerParams?.isGif ? (
|
||||
<ExternalGifEmbed link={link} params={embedPlayerParams} />
|
||||
) : embedPlayerParams ? (
|
||||
<ExternalPlayer link={link} params={embedPlayerParams} />
|
||||
) : undefined}
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.py_sm,
|
||||
{
|
||||
paddingHorizontal: isMobile ? 10 : 14,
|
||||
},
|
||||
]}>
|
||||
<Text
|
||||
type="sm"
|
||||
numberOfLines={1}
|
||||
style={[pal.textLight, styles.extUri]}>
|
||||
style={[pal.textLight, {marginVertical: 2}]}>
|
||||
{toNiceDomain(link.uri)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!embedPlayerParams?.isGif && !embedPlayerParams?.dimensions && (
|
||||
<Text type="lg-bold" numberOfLines={3} style={[pal.text]}>
|
||||
{link.title || link.uri}
|
||||
</Text>
|
||||
)}
|
||||
{link.description && !embedPlayerParams?.hideDetails ? (
|
||||
<Text
|
||||
type="md"
|
||||
numberOfLines={link.thumb ? 2 : 4}
|
||||
style={[pal.text, styles.extDescription]}>
|
||||
{link.description}
|
||||
</Text>
|
||||
) : undefined}
|
||||
</View>
|
||||
{!embedPlayerParams?.isGif && !embedPlayerParams?.dimensions && (
|
||||
<Text type="lg-bold" numberOfLines={3} style={[pal.text]}>
|
||||
{link.title || link.uri}
|
||||
</Text>
|
||||
)}
|
||||
{link.description ? (
|
||||
<Text
|
||||
type="md"
|
||||
numberOfLines={link.thumb ? 2 : 4}
|
||||
style={[pal.text, a.mt_xs]}>
|
||||
{link.description}
|
||||
</Text>
|
||||
) : undefined}
|
||||
</View>
|
||||
</LinkWrapper>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'column',
|
||||
borderRadius: 6,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
info: {
|
||||
width: '100%',
|
||||
bottom: 0,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
extUri: {
|
||||
marginTop: 2,
|
||||
},
|
||||
extDescription: {
|
||||
marginTop: 4,
|
||||
},
|
||||
})
|
||||
function LinkWrapper({
|
||||
link,
|
||||
style,
|
||||
children,
|
||||
}: {
|
||||
link: AppBskyEmbedExternal.ViewExternal
|
||||
style?: StyleProp<ViewStyle>
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
const onShareExternal = useCallback(() => {
|
||||
if (link.uri && isNative) {
|
||||
shareUrl(link.uri)
|
||||
}
|
||||
}, [link.uri])
|
||||
|
||||
return (
|
||||
<Link
|
||||
asAnchor
|
||||
anchorNoUnderline
|
||||
href={link.uri}
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.border,
|
||||
a.rounded_sm,
|
||||
t.atoms.border_contrast_medium,
|
||||
style,
|
||||
]}
|
||||
hoverStyle={t.atoms.border_contrast_high}
|
||||
onLongPress={onShareExternal}>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,140 @@
|
|||
import React from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {AppBskyEmbedExternal} from '@atproto/api'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {EmbedPlayerParams} from 'lib/strings/embed-player'
|
||||
import {useAutoplayDisabled} from 'state/preferences'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {GifView} from '../../../../../modules/expo-bluesky-gif-view'
|
||||
import {GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types'
|
||||
|
||||
function PlaybackControls({
|
||||
onPress,
|
||||
isPlaying,
|
||||
isLoaded,
|
||||
}: {
|
||||
onPress: () => void
|
||||
isPlaying: boolean
|
||||
isLoaded: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityHint={_(msg`Play or pause the GIF`)}
|
||||
accessibilityLabel={isPlaying ? _(msg`Pause`) : _(msg`Play`)}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
!isLoaded && a.border,
|
||||
t.atoms.border_contrast_medium,
|
||||
a.inset_0,
|
||||
a.w_full,
|
||||
a.h_full,
|
||||
{
|
||||
zIndex: 2,
|
||||
backgroundColor: !isLoaded
|
||||
? t.atoms.bg_contrast_25.backgroundColor
|
||||
: !isPlaying
|
||||
? 'rgba(0, 0, 0, 0.3)'
|
||||
: undefined,
|
||||
},
|
||||
]}
|
||||
onPress={onPress}>
|
||||
{!isLoaded ? (
|
||||
<View>
|
||||
<View style={[a.align_center, a.justify_center]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
</View>
|
||||
) : !isPlaying ? (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_full,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{
|
||||
backgroundColor: t.palette.primary_500,
|
||||
width: 60,
|
||||
height: 60,
|
||||
},
|
||||
]}>
|
||||
<FontAwesomeIcon
|
||||
icon="play"
|
||||
size={42}
|
||||
color="white"
|
||||
style={{marginLeft: 8}}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
export function GifEmbed({
|
||||
params,
|
||||
link,
|
||||
}: {
|
||||
params: EmbedPlayerParams
|
||||
link: AppBskyEmbedExternal.ViewExternal
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const autoplayDisabled = useAutoplayDisabled()
|
||||
|
||||
const playerRef = React.useRef<GifView>(null)
|
||||
|
||||
const [playerState, setPlayerState] = React.useState<{
|
||||
isPlaying: boolean
|
||||
isLoaded: boolean
|
||||
}>({
|
||||
isPlaying: !autoplayDisabled,
|
||||
isLoaded: false,
|
||||
})
|
||||
|
||||
const onPlayerStateChange = React.useCallback(
|
||||
(e: GifViewStateChangeEvent) => {
|
||||
setPlayerState(e.nativeEvent)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const onPress = React.useCallback(() => {
|
||||
playerRef.current?.toggleAsync()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View style={[a.rounded_sm, a.overflow_hidden, a.mt_sm]}>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
a.overflow_hidden,
|
||||
{
|
||||
aspectRatio: params.dimensions!.width / params.dimensions!.height,
|
||||
},
|
||||
]}>
|
||||
<PlaybackControls
|
||||
onPress={onPress}
|
||||
isPlaying={playerState.isPlaying}
|
||||
isLoaded={playerState.isLoaded}
|
||||
/>
|
||||
<GifView
|
||||
source={params.playerUri}
|
||||
placeholderSource={link.thumb}
|
||||
style={[a.flex_1, a.rounded_sm]}
|
||||
autoplay={!autoplayDisabled}
|
||||
onPlayerStateChange={onPlayerStateChange}
|
||||
ref={playerRef}
|
||||
accessibilityHint={_(msg`Animated GIF`)}
|
||||
accessibilityLabel={link.description.replace('ALT: ', '')}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
|
@ -1,34 +1,32 @@
|
|||
import React, {useCallback} from 'react'
|
||||
import React from 'react'
|
||||
import {
|
||||
StyleSheet,
|
||||
InteractionManager,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
ViewStyle,
|
||||
Text,
|
||||
InteractionManager,
|
||||
} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedExternal,
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyFeedDefs,
|
||||
AppBskyGraphDefs,
|
||||
ModerationDecision,
|
||||
} from '@atproto/api'
|
||||
import {Link} from '../Link'
|
||||
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
|
||||
import {useLightboxControls, ImagesLightbox} from '#/state/lightbox'
|
||||
|
||||
import {ImagesLightbox, useLightboxControls} from '#/state/lightbox'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
|
||||
import {MaybeQuoteEmbed} from './QuoteEmbed'
|
||||
import {AutoSizedImage} from '../images/AutoSizedImage'
|
||||
import {ListEmbed} from './ListEmbed'
|
||||
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
|
||||
import {ContentHider} from '../../../../components/moderation/ContentHider'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {shareUrl} from '#/lib/sharing'
|
||||
import {AutoSizedImage} from '../images/AutoSizedImage'
|
||||
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
|
||||
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
|
||||
import {ListEmbed} from './ListEmbed'
|
||||
import {MaybeQuoteEmbed} from './QuoteEmbed'
|
||||
|
||||
type Embed =
|
||||
| AppBskyEmbedRecord.View
|
||||
|
@ -49,16 +47,6 @@ export function PostEmbeds({
|
|||
const pal = usePalette('default')
|
||||
const {openLightbox} = useLightboxControls()
|
||||
|
||||
const externalUri = AppBskyEmbedExternal.isView(embed)
|
||||
? embed.external.uri
|
||||
: null
|
||||
|
||||
const onShareExternal = useCallback(() => {
|
||||
if (externalUri && isNative) {
|
||||
shareUrl(externalUri)
|
||||
}
|
||||
}, [externalUri])
|
||||
|
||||
// quote post with media
|
||||
// =
|
||||
if (AppBskyEmbedRecordWithMedia.isView(embed)) {
|
||||
|
@ -161,18 +149,9 @@ export function PostEmbeds({
|
|||
// =
|
||||
if (AppBskyEmbedExternal.isView(embed)) {
|
||||
const link = embed.external
|
||||
|
||||
return (
|
||||
<ContentHider modui={moderation?.ui('contentMedia')}>
|
||||
<Link
|
||||
asAnchor
|
||||
anchorNoUnderline
|
||||
href={link.uri}
|
||||
style={[styles.extOuter, pal.view, pal.borderDark, style]}
|
||||
hoverStyle={{borderColor: pal.colors.borderLinkHover}}
|
||||
onLongPress={onShareExternal}>
|
||||
<ExternalLinkEmbed link={link} />
|
||||
</Link>
|
||||
<ExternalLinkEmbed link={link} style={style} />
|
||||
</ContentHider>
|
||||
)
|
||||
}
|
||||
|
@ -187,11 +166,6 @@ const styles = StyleSheet.create({
|
|||
singleImage: {
|
||||
borderRadius: 8,
|
||||
},
|
||||
extOuter: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
marginTop: 4,
|
||||
},
|
||||
altContainer: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
borderRadius: 6,
|
||||
|
|
Loading…
Reference in New Issue