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
Hailey 2024-04-22 18:54:15 -07:00 committed by GitHub
parent fe9b3f0432
commit cbb817b5b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1223 additions and 265 deletions

View File

@ -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', () => {

View File

@ -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'
}

View File

@ -0,0 +1,2 @@
<manifest>
</manifest>

View File

@ -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()
}
}
}

View File

@ -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()
}
}
}
}

View File

@ -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>
}

View File

@ -0,0 +1,9 @@
{
"platforms": ["ios", "android", "web"],
"ios": {
"modules": ["ExpoBlueskyGifViewModule"]
},
"android": {
"modules": ["expo.modules.blueskygifview.ExpoBlueskyGifViewModule"]
}
}

View File

@ -0,0 +1 @@
export {GifView} from './src/GifView'

View File

@ -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

View File

@ -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()
}
}
}
}

View File

@ -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
])
}
}

View File

@ -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
}
}

View File

@ -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} />
}
}

View File

@ -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
}

View File

@ -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}
/>
)
}
}

View File

@ -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'

View File

@ -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,
}
}
}

View File

@ -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 ? (

View File

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

View File

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

View File

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

View File

@ -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,