about summary refs log tree commit diff
path: root/modules/expo-bluesky-gif-view
diff options
context:
space:
mode:
Diffstat (limited to 'modules/expo-bluesky-gif-view')
-rw-r--r--modules/expo-bluesky-gif-view/android/build.gradle98
-rw-r--r--modules/expo-bluesky-gif-view/android/src/main/AndroidManifest.xml2
-rw-r--r--modules/expo-bluesky-gif-view/android/src/main/java/expo/modules/blueskygifview/AppCompatImageViewExtended.kt37
-rw-r--r--modules/expo-bluesky-gif-view/android/src/main/java/expo/modules/blueskygifview/ExpoBlueskyGifViewModule.kt54
-rw-r--r--modules/expo-bluesky-gif-view/android/src/main/java/expo/modules/blueskygifview/GifView.kt180
-rw-r--r--modules/expo-bluesky-gif-view/expo-module.config.json9
-rw-r--r--modules/expo-bluesky-gif-view/index.ts1
-rw-r--r--modules/expo-bluesky-gif-view/ios/ExpoBlueskyGifView.podspec23
-rw-r--r--modules/expo-bluesky-gif-view/ios/ExpoBlueskyGifViewModule.swift47
-rw-r--r--modules/expo-bluesky-gif-view/ios/GifView.swift185
-rw-r--r--modules/expo-bluesky-gif-view/ios/Util.swift17
-rw-r--r--modules/expo-bluesky-gif-view/src/GifView.tsx39
-rw-r--r--modules/expo-bluesky-gif-view/src/GifView.types.ts15
-rw-r--r--modules/expo-bluesky-gif-view/src/GifView.web.tsx82
14 files changed, 789 insertions, 0 deletions
diff --git a/modules/expo-bluesky-gif-view/android/build.gradle b/modules/expo-bluesky-gif-view/android/build.gradle
new file mode 100644
index 000000000..c209a35ae
--- /dev/null
+++ b/modules/expo-bluesky-gif-view/android/build.gradle
@@ -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'
+}
diff --git a/modules/expo-bluesky-gif-view/android/src/main/AndroidManifest.xml b/modules/expo-bluesky-gif-view/android/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..bdae66c8f
--- /dev/null
+++ b/modules/expo-bluesky-gif-view/android/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+<manifest>
+</manifest>
diff --git a/modules/expo-bluesky-gif-view/android/src/main/java/expo/modules/blueskygifview/AppCompatImageViewExtended.kt b/modules/expo-bluesky-gif-view/android/src/main/java/expo/modules/blueskygifview/AppCompatImageViewExtended.kt
new file mode 100644
index 000000000..5d2084845
--- /dev/null
+++ b/modules/expo-bluesky-gif-view/android/src/main/java/expo/modules/blueskygifview/AppCompatImageViewExtended.kt
@@ -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()
+    }
+  }
+}
\ No newline at end of file
diff --git a/modules/expo-bluesky-gif-view/android/src/main/java/expo/modules/blueskygifview/ExpoBlueskyGifViewModule.kt b/modules/expo-bluesky-gif-view/android/src/main/java/expo/modules/blueskygifview/ExpoBlueskyGifViewModule.kt
new file mode 100644
index 000000000..625e1d45f
--- /dev/null
+++ b/modules/expo-bluesky-gif-view/android/src/main/java/expo/modules/blueskygifview/ExpoBlueskyGifViewModule.kt
@@ -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()
+      }
+    }
+  }
+}
diff --git a/modules/expo-bluesky-gif-view/android/src/main/java/expo/modules/blueskygifview/GifView.kt b/modules/expo-bluesky-gif-view/android/src/main/java/expo/modules/blueskygifview/GifView.kt
new file mode 100644
index 000000000..be5830df7
--- /dev/null
+++ b/modules/expo-bluesky-gif-view/android/src/main/java/expo/modules/blueskygifview/GifView.kt
@@ -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>
+}
diff --git a/modules/expo-bluesky-gif-view/expo-module.config.json b/modules/expo-bluesky-gif-view/expo-module.config.json
new file mode 100644
index 000000000..0756c8e24
--- /dev/null
+++ b/modules/expo-bluesky-gif-view/expo-module.config.json
@@ -0,0 +1,9 @@
+{
+  "platforms": ["ios", "android", "web"],
+  "ios": {
+    "modules": ["ExpoBlueskyGifViewModule"]
+  },
+  "android": {
+    "modules": ["expo.modules.blueskygifview.ExpoBlueskyGifViewModule"]
+  }
+}
diff --git a/modules/expo-bluesky-gif-view/index.ts b/modules/expo-bluesky-gif-view/index.ts
new file mode 100644
index 000000000..0244a5491
--- /dev/null
+++ b/modules/expo-bluesky-gif-view/index.ts
@@ -0,0 +1 @@
+export {GifView} from './src/GifView'
diff --git a/modules/expo-bluesky-gif-view/ios/ExpoBlueskyGifView.podspec b/modules/expo-bluesky-gif-view/ios/ExpoBlueskyGifView.podspec
new file mode 100644
index 000000000..ddd0877b2
--- /dev/null
+++ b/modules/expo-bluesky-gif-view/ios/ExpoBlueskyGifView.podspec
@@ -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
diff --git a/modules/expo-bluesky-gif-view/ios/ExpoBlueskyGifViewModule.swift b/modules/expo-bluesky-gif-view/ios/ExpoBlueskyGifViewModule.swift
new file mode 100644
index 000000000..7c7132290
--- /dev/null
+++ b/modules/expo-bluesky-gif-view/ios/ExpoBlueskyGifViewModule.swift
@@ -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()
+      }
+    }
+  }
+}
diff --git a/modules/expo-bluesky-gif-view/ios/GifView.swift b/modules/expo-bluesky-gif-view/ios/GifView.swift
new file mode 100644
index 000000000..de722d7a6
--- /dev/null
+++ b/modules/expo-bluesky-gif-view/ios/GifView.swift
@@ -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
+    ])
+  }
+}
diff --git a/modules/expo-bluesky-gif-view/ios/Util.swift b/modules/expo-bluesky-gif-view/ios/Util.swift
new file mode 100644
index 000000000..55ed4152a
--- /dev/null
+++ b/modules/expo-bluesky-gif-view/ios/Util.swift
@@ -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
+  }
+}
diff --git a/modules/expo-bluesky-gif-view/src/GifView.tsx b/modules/expo-bluesky-gif-view/src/GifView.tsx
new file mode 100644
index 000000000..87258de17
--- /dev/null
+++ b/modules/expo-bluesky-gif-view/src/GifView.tsx
@@ -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} />
+  }
+}
diff --git a/modules/expo-bluesky-gif-view/src/GifView.types.ts b/modules/expo-bluesky-gif-view/src/GifView.types.ts
new file mode 100644
index 000000000..29ec277f2
--- /dev/null
+++ b/modules/expo-bluesky-gif-view/src/GifView.types.ts
@@ -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
+}
diff --git a/modules/expo-bluesky-gif-view/src/GifView.web.tsx b/modules/expo-bluesky-gif-view/src/GifView.web.tsx
new file mode 100644
index 000000000..c197e01a1
--- /dev/null
+++ b/modules/expo-bluesky-gif-view/src/GifView.web.tsx
@@ -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}
+      />
+    )
+  }
+}