Merge remote-tracking branch 'origin/main' into @tomekzaw/flatten-preference-providers
This commit is contained in:
+2
-3
@@ -271,8 +271,8 @@ module.exports = function (_config) {
|
||||
},
|
||||
android: {
|
||||
compileSdkVersion: 36,
|
||||
targetSdkVersion: 35,
|
||||
buildToolsVersion: '35.0.0',
|
||||
targetSdkVersion: 36,
|
||||
buildToolsVersion: '36.0.0',
|
||||
buildReactNativeFromSource: IS_PRODUCTION,
|
||||
},
|
||||
},
|
||||
@@ -285,7 +285,6 @@ module.exports = function (_config) {
|
||||
sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
|
||||
},
|
||||
],
|
||||
'react-native-compressor',
|
||||
[
|
||||
'@bitdrift/react-native',
|
||||
{
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'expo-module-gradle-plugin'
|
||||
}
|
||||
|
||||
group = 'expo.modules.blueskyvideocompress'
|
||||
version = '1.0.0'
|
||||
|
||||
android {
|
||||
namespace "expo.modules.blueskyvideocompress"
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName "1.0.0"
|
||||
}
|
||||
}
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
package expo.modules.blueskyvideocompress
|
||||
|
||||
import android.media.MediaCodecInfo
|
||||
import android.media.MediaCodecList
|
||||
import android.media.MediaFormat
|
||||
import android.os.Build
|
||||
|
||||
object CodecSelector {
|
||||
// Source: https://github.com/numandev1/react-native-compressor/blob/f949b0868055178e7c8753e05202f784b1bcd589/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/compressor/Compressor.kt#L500
|
||||
private val AVC_DENYLIST = setOf(
|
||||
"c2.qti.avc.encoder"
|
||||
)
|
||||
|
||||
private val SOFTWARE_PREFIXES = listOf(
|
||||
"OMX.google.",
|
||||
"c2.android.",
|
||||
"c2.google."
|
||||
)
|
||||
|
||||
data class EncoderInfo(
|
||||
val name: String,
|
||||
val mime: String,
|
||||
val isHardware: Boolean
|
||||
)
|
||||
|
||||
fun findEncoder(mime: String, requireHardware: Boolean): EncoderInfo? {
|
||||
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
|
||||
val candidates = codecList.codecInfos
|
||||
.filter { it.isEncoder }
|
||||
.filter { it.supportedTypes.any { t -> t.equals(mime, ignoreCase = true) } }
|
||||
.filter { !it.name.contains("secure", ignoreCase = true) }
|
||||
.filter { !(mime == MediaFormat.MIMETYPE_VIDEO_AVC && AVC_DENYLIST.contains(it.name)) }
|
||||
|
||||
val hardware = candidates.filter { isHardware(it) }
|
||||
val selected = if (requireHardware) {
|
||||
hardware.firstOrNull()
|
||||
} else {
|
||||
hardware.firstOrNull() ?: candidates.firstOrNull()
|
||||
}
|
||||
selected ?: return null
|
||||
return EncoderInfo(
|
||||
name = selected.name,
|
||||
mime = mime,
|
||||
isHardware = isHardware(selected)
|
||||
)
|
||||
}
|
||||
|
||||
private fun isHardware(info: MediaCodecInfo): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
info.isHardwareAccelerated
|
||||
} else {
|
||||
SOFTWARE_PREFIXES.none { info.name.startsWith(it, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
-62
@@ -1,62 +0,0 @@
|
||||
package expo.modules.blueskyvideocompress
|
||||
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class ExpoBlueskyVideoCompressModule : Module() {
|
||||
private val activeCompressors = ConcurrentHashMap<Int, VideoCompressor>()
|
||||
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("ExpoBlueskyVideoCompress")
|
||||
|
||||
Events("onProgress")
|
||||
|
||||
AsyncFunction("probe") { uri: String ->
|
||||
val context = appContext.reactContext
|
||||
?: throw Error("React context is null")
|
||||
return@AsyncFunction VideoProber.probe(context, uri)
|
||||
}
|
||||
|
||||
AsyncFunction("compress") { uri: String, options: Map<String, Any?> ->
|
||||
val context = appContext.reactContext
|
||||
?: throw Error("React context is null")
|
||||
val targetBitrate = (options["targetBitrate"] as? Number)?.toInt() ?: 0
|
||||
val maxSize = (options["maxSize"] as? Number)?.toInt() ?: 1920
|
||||
val codecPref = (options["codec"] as? String) ?: "auto"
|
||||
val frameRateCap = ((options["frameRateCap"] as? Number)?.toInt() ?: 30).coerceAtLeast(1)
|
||||
val jobId = (options["jobId"] as? Number)?.toInt() ?: 0
|
||||
|
||||
val compressor = VideoCompressor(
|
||||
context = context,
|
||||
uriString = uri,
|
||||
targetBitrate = targetBitrate,
|
||||
maxSize = maxSize,
|
||||
codecPref = codecPref,
|
||||
frameRateCap = frameRateCap,
|
||||
jobId = jobId,
|
||||
onProgress = { id, progress ->
|
||||
sendEvent("onProgress", mapOf(
|
||||
"id" to id,
|
||||
"progress" to progress
|
||||
))
|
||||
}
|
||||
)
|
||||
|
||||
activeCompressors[jobId] = compressor
|
||||
|
||||
try {
|
||||
val result = compressor.compress()
|
||||
activeCompressors.remove(jobId)
|
||||
return@AsyncFunction result
|
||||
} catch (e: Exception) {
|
||||
activeCompressors.remove(jobId)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
Function("cancel") { jobId: Int ->
|
||||
activeCompressors.remove(jobId)?.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
-96
@@ -1,96 +0,0 @@
|
||||
package expo.modules.blueskyvideocompress
|
||||
|
||||
import android.opengl.EGL14
|
||||
import android.opengl.EGLConfig
|
||||
import android.opengl.EGLContext
|
||||
import android.opengl.EGLDisplay
|
||||
import android.opengl.EGLExt
|
||||
import android.opengl.EGLSurface
|
||||
import android.view.Surface
|
||||
|
||||
class InputSurface(private val surface: Surface) {
|
||||
private var eglDisplay: EGLDisplay = EGL14.EGL_NO_DISPLAY
|
||||
private var eglContext: EGLContext = EGL14.EGL_NO_CONTEXT
|
||||
private var eglSurface: EGLSurface = EGL14.EGL_NO_SURFACE
|
||||
|
||||
init {
|
||||
eglSetup()
|
||||
}
|
||||
|
||||
private fun eglSetup() {
|
||||
eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
|
||||
if (eglDisplay === EGL14.EGL_NO_DISPLAY) {
|
||||
throw RuntimeException("unable to get EGL14 display")
|
||||
}
|
||||
|
||||
val version = IntArray(2)
|
||||
if (!EGL14.eglInitialize(eglDisplay, version, 0, version, 1)) {
|
||||
throw RuntimeException("unable to initialize EGL14")
|
||||
}
|
||||
|
||||
val attribList = intArrayOf(
|
||||
EGL14.EGL_RED_SIZE, 8,
|
||||
EGL14.EGL_GREEN_SIZE, 8,
|
||||
EGL14.EGL_BLUE_SIZE, 8,
|
||||
EGL14.EGL_ALPHA_SIZE, 8,
|
||||
EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
|
||||
EGL14.EGL_SURFACE_TYPE, EGL14.EGL_WINDOW_BIT,
|
||||
EGL14.EGL_NONE
|
||||
)
|
||||
val configs = arrayOfNulls<EGLConfig>(1)
|
||||
val numConfigs = IntArray(1)
|
||||
EGL14.eglChooseConfig(eglDisplay, attribList, 0, configs, 0, 1, numConfigs, 0)
|
||||
checkEglError("eglChooseConfig")
|
||||
|
||||
val contextAttribs = intArrayOf(
|
||||
EGL14.EGL_CONTEXT_CLIENT_VERSION, 2,
|
||||
EGL14.EGL_NONE
|
||||
)
|
||||
eglContext = EGL14.eglCreateContext(
|
||||
eglDisplay, configs[0], EGL14.EGL_NO_CONTEXT, contextAttribs, 0
|
||||
)
|
||||
checkEglError("eglCreateContext")
|
||||
|
||||
val surfaceAttribs = intArrayOf(EGL14.EGL_NONE)
|
||||
eglSurface = EGL14.eglCreateWindowSurface(
|
||||
eglDisplay, configs[0], surface, surfaceAttribs, 0
|
||||
)
|
||||
checkEglError("eglCreateWindowSurface")
|
||||
}
|
||||
|
||||
fun makeCurrent() {
|
||||
EGL14.eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext)
|
||||
checkEglError("eglMakeCurrent")
|
||||
}
|
||||
|
||||
fun swapBuffers(): Boolean {
|
||||
return EGL14.eglSwapBuffers(eglDisplay, eglSurface)
|
||||
}
|
||||
|
||||
fun setPresentationTime(nsecs: Long) {
|
||||
EGLExt.eglPresentationTimeANDROID(eglDisplay, eglSurface, nsecs)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
if (eglDisplay !== EGL14.EGL_NO_DISPLAY) {
|
||||
EGL14.eglMakeCurrent(
|
||||
eglDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT
|
||||
)
|
||||
EGL14.eglDestroySurface(eglDisplay, eglSurface)
|
||||
EGL14.eglDestroyContext(eglDisplay, eglContext)
|
||||
EGL14.eglReleaseThread()
|
||||
EGL14.eglTerminate(eglDisplay)
|
||||
}
|
||||
surface.release()
|
||||
eglDisplay = EGL14.EGL_NO_DISPLAY
|
||||
eglContext = EGL14.EGL_NO_CONTEXT
|
||||
eglSurface = EGL14.EGL_NO_SURFACE
|
||||
}
|
||||
|
||||
private fun checkEglError(msg: String) {
|
||||
val error = EGL14.eglGetError()
|
||||
if (error != EGL14.EGL_SUCCESS) {
|
||||
throw RuntimeException("$msg: EGL error: 0x${Integer.toHexString(error)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
package expo.modules.blueskyvideocompress
|
||||
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.view.Surface
|
||||
|
||||
class OutputSurface : SurfaceTexture.OnFrameAvailableListener {
|
||||
private val renderer = TextureRenderer()
|
||||
private var surfaceTexture: SurfaceTexture? = null
|
||||
private val stMatrix = FloatArray(16)
|
||||
private val callbackThread = HandlerThread("OutputSurfaceCallbacks")
|
||||
val surface: Surface
|
||||
|
||||
@Volatile
|
||||
private var frameAvailable = false
|
||||
private val frameSyncObject = Object()
|
||||
|
||||
init {
|
||||
renderer.surfaceCreated()
|
||||
callbackThread.start()
|
||||
val handler = Handler(callbackThread.looper)
|
||||
surfaceTexture = SurfaceTexture(renderer.getTextureId()).also {
|
||||
it.setOnFrameAvailableListener(this, handler)
|
||||
}
|
||||
surface = Surface(surfaceTexture)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
surface.release()
|
||||
surfaceTexture?.release()
|
||||
surfaceTexture = null
|
||||
callbackThread.quitSafely()
|
||||
}
|
||||
|
||||
fun awaitNewImage() {
|
||||
val timeoutMs = 2500L
|
||||
synchronized(frameSyncObject) {
|
||||
while (!frameAvailable) {
|
||||
frameSyncObject.wait(timeoutMs)
|
||||
if (!frameAvailable) {
|
||||
throw RuntimeException("Surface frame wait timed out")
|
||||
}
|
||||
}
|
||||
frameAvailable = false
|
||||
}
|
||||
surfaceTexture!!.updateTexImage()
|
||||
}
|
||||
|
||||
fun drawImage() {
|
||||
surfaceTexture!!.getTransformMatrix(stMatrix)
|
||||
renderer.drawFrame(stMatrix)
|
||||
}
|
||||
|
||||
override fun onFrameAvailable(st: SurfaceTexture) {
|
||||
synchronized(frameSyncObject) {
|
||||
frameAvailable = true
|
||||
frameSyncObject.notifyAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
-156
@@ -1,156 +0,0 @@
|
||||
package expo.modules.blueskyvideocompress
|
||||
|
||||
import android.opengl.GLES11Ext
|
||||
import android.opengl.GLES20
|
||||
import android.opengl.Matrix
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.nio.FloatBuffer
|
||||
|
||||
class TextureRenderer {
|
||||
companion object {
|
||||
private const val FLOAT_SIZE_BYTES = 4
|
||||
private const val STRIDE_BYTES = 5 * FLOAT_SIZE_BYTES
|
||||
private const val POS_OFFSET = 0
|
||||
private const val UV_OFFSET = 3
|
||||
|
||||
private val VERTICES = floatArrayOf(
|
||||
-1.0f, -1.0f, 0f, 0f, 0f,
|
||||
1.0f, -1.0f, 0f, 1f, 0f,
|
||||
-1.0f, 1.0f, 0f, 0f, 1f,
|
||||
1.0f, 1.0f, 0f, 1f, 1f,
|
||||
)
|
||||
|
||||
private const val VERTEX_SHADER = """
|
||||
uniform mat4 uMVPMatrix;
|
||||
uniform mat4 uSTMatrix;
|
||||
attribute vec4 aPosition;
|
||||
attribute vec4 aTextureCoord;
|
||||
varying vec2 vTextureCoord;
|
||||
void main() {
|
||||
gl_Position = uMVPMatrix * aPosition;
|
||||
vTextureCoord = (uSTMatrix * aTextureCoord).xy;
|
||||
}
|
||||
"""
|
||||
|
||||
private const val FRAGMENT_SHADER = """
|
||||
#extension GL_OES_EGL_image_external : require
|
||||
precision mediump float;
|
||||
varying vec2 vTextureCoord;
|
||||
uniform samplerExternalOES sTexture;
|
||||
void main() {
|
||||
gl_FragColor = texture2D(sTexture, vTextureCoord);
|
||||
}
|
||||
"""
|
||||
}
|
||||
|
||||
private val vertices: FloatBuffer =
|
||||
ByteBuffer.allocateDirect(VERTICES.size * FLOAT_SIZE_BYTES)
|
||||
.order(ByteOrder.nativeOrder())
|
||||
.asFloatBuffer()
|
||||
.apply { put(VERTICES); position(0) }
|
||||
|
||||
private val mvpMatrix = FloatArray(16)
|
||||
private var program = 0
|
||||
private var textureId = -1
|
||||
private var uMVPMatrixHandle = 0
|
||||
private var uSTMatrixHandle = 0
|
||||
private var aPositionHandle = 0
|
||||
private var aTextureCoordHandle = 0
|
||||
|
||||
init {
|
||||
Matrix.setIdentityM(mvpMatrix, 0)
|
||||
}
|
||||
|
||||
fun getTextureId(): Int = textureId
|
||||
|
||||
fun surfaceCreated() {
|
||||
program = createProgram(VERTEX_SHADER, FRAGMENT_SHADER)
|
||||
|
||||
aPositionHandle = GLES20.glGetAttribLocation(program, "aPosition")
|
||||
aTextureCoordHandle = GLES20.glGetAttribLocation(program, "aTextureCoord")
|
||||
uMVPMatrixHandle = GLES20.glGetUniformLocation(program, "uMVPMatrix")
|
||||
uSTMatrixHandle = GLES20.glGetUniformLocation(program, "uSTMatrix")
|
||||
|
||||
val textures = IntArray(1)
|
||||
GLES20.glGenTextures(1, textures, 0)
|
||||
textureId = textures[0]
|
||||
|
||||
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId)
|
||||
GLES20.glTexParameterf(
|
||||
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
|
||||
GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR.toFloat()
|
||||
)
|
||||
GLES20.glTexParameterf(
|
||||
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
|
||||
GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR.toFloat()
|
||||
)
|
||||
GLES20.glTexParameteri(
|
||||
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
|
||||
GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE
|
||||
)
|
||||
GLES20.glTexParameteri(
|
||||
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
|
||||
GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE
|
||||
)
|
||||
}
|
||||
|
||||
fun drawFrame(stMatrix: FloatArray) {
|
||||
GLES20.glClearColor(0f, 0f, 0f, 1f)
|
||||
GLES20.glClear(GLES20.GL_DEPTH_BUFFER_BIT or GLES20.GL_COLOR_BUFFER_BIT)
|
||||
|
||||
GLES20.glUseProgram(program)
|
||||
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
|
||||
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId)
|
||||
|
||||
vertices.position(POS_OFFSET)
|
||||
GLES20.glVertexAttribPointer(
|
||||
aPositionHandle, 3, GLES20.GL_FLOAT, false, STRIDE_BYTES, vertices
|
||||
)
|
||||
GLES20.glEnableVertexAttribArray(aPositionHandle)
|
||||
|
||||
vertices.position(UV_OFFSET)
|
||||
GLES20.glVertexAttribPointer(
|
||||
aTextureCoordHandle, 2, GLES20.GL_FLOAT, false, STRIDE_BYTES, vertices
|
||||
)
|
||||
GLES20.glEnableVertexAttribArray(aTextureCoordHandle)
|
||||
|
||||
GLES20.glUniformMatrix4fv(uMVPMatrixHandle, 1, false, mvpMatrix, 0)
|
||||
GLES20.glUniformMatrix4fv(uSTMatrixHandle, 1, false, stMatrix, 0)
|
||||
|
||||
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
|
||||
GLES20.glDisableVertexAttribArray(aPositionHandle)
|
||||
GLES20.glDisableVertexAttribArray(aTextureCoordHandle)
|
||||
}
|
||||
|
||||
private fun createProgram(vertexSource: String, fragmentSource: String): Int {
|
||||
val vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexSource)
|
||||
val fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource)
|
||||
val program = GLES20.glCreateProgram()
|
||||
GLES20.glAttachShader(program, vertexShader)
|
||||
GLES20.glAttachShader(program, fragmentShader)
|
||||
GLES20.glLinkProgram(program)
|
||||
val linkStatus = IntArray(1)
|
||||
GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0)
|
||||
if (linkStatus[0] != GLES20.GL_TRUE) {
|
||||
val log = GLES20.glGetProgramInfoLog(program)
|
||||
GLES20.glDeleteProgram(program)
|
||||
throw RuntimeException("Could not link program: $log")
|
||||
}
|
||||
return program
|
||||
}
|
||||
|
||||
private fun loadShader(type: Int, source: String): Int {
|
||||
val shader = GLES20.glCreateShader(type)
|
||||
GLES20.glShaderSource(shader, source)
|
||||
GLES20.glCompileShader(shader)
|
||||
val compiled = IntArray(1)
|
||||
GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0)
|
||||
if (compiled[0] == 0) {
|
||||
val log = GLES20.glGetShaderInfoLog(shader)
|
||||
GLES20.glDeleteShader(shader)
|
||||
throw RuntimeException("Could not compile shader $type: $log")
|
||||
}
|
||||
return shader
|
||||
}
|
||||
}
|
||||
-582
@@ -1,582 +0,0 @@
|
||||
package expo.modules.blueskyvideocompress
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaCodecInfo
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMuxer
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.UUID
|
||||
|
||||
class VideoCompressor(
|
||||
private val context: Context,
|
||||
private val uriString: String,
|
||||
private val targetBitrate: Int,
|
||||
private val maxSize: Int,
|
||||
private val codecPref: String,
|
||||
private val frameRateCap: Int,
|
||||
private val jobId: Int,
|
||||
private val onProgress: (Int, Double) -> Unit
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "BskyVideoCompress"
|
||||
private const val TIMEOUT_DEQUEUE = 100L
|
||||
private const val I_FRAME_INTERVAL = 3
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var isCancelled = false
|
||||
|
||||
fun cancel() {
|
||||
isCancelled = true
|
||||
}
|
||||
|
||||
fun compress(): Map<String, Any> {
|
||||
// 'auto' targets h264 — server pipeline is HLS, which favors h264 (HEVC needs
|
||||
// fMP4 segments + commercial licensing). HEVC remains opt-in via codec: 'hevc'.
|
||||
val tryHevc = codecPref == "hevc"
|
||||
|
||||
if (tryHevc) {
|
||||
try {
|
||||
return doCompress(useHevc = true, allowSoftwareFallback = false)
|
||||
} catch (e: Exception) {
|
||||
if (codecPref == "hevc" || isCancelled) throw e
|
||||
Log.w(TAG, "HEVC encode failed, falling back to h264", e)
|
||||
}
|
||||
}
|
||||
return doCompress(useHevc = false, allowSoftwareFallback = true)
|
||||
}
|
||||
|
||||
private fun doCompress(useHevc: Boolean, allowSoftwareFallback: Boolean): Map<String, Any> {
|
||||
val mime = if (useHevc) MediaFormat.MIMETYPE_VIDEO_HEVC else MediaFormat.MIMETYPE_VIDEO_AVC
|
||||
val encoderInfo = CodecSelector.findEncoder(mime, requireHardware = !allowSoftwareFallback)
|
||||
?: throw RuntimeException("No encoder for $mime")
|
||||
|
||||
try {
|
||||
return runPipeline(encoderInfo, useHevc)
|
||||
} catch (e: Exception) {
|
||||
if (!allowSoftwareFallback || isCancelled || !encoderInfo.isHardware) throw e
|
||||
Log.w(TAG, "Hardware encoder ${encoderInfo.name} failed, trying software", e)
|
||||
val sw = CodecSelector.findEncoder(mime, requireHardware = false)
|
||||
?.takeIf { !it.isHardware }
|
||||
?: throw e
|
||||
return runPipeline(sw, useHevc)
|
||||
}
|
||||
}
|
||||
|
||||
private fun runPipeline(
|
||||
encoderInfo: CodecSelector.EncoderInfo,
|
||||
useHevc: Boolean
|
||||
): Map<String, Any> {
|
||||
val outputFile = File(context.cacheDir, "${UUID.randomUUID()}.mp4")
|
||||
|
||||
var extractor: MediaExtractor? = null
|
||||
var muxer: MediaMuxer? = null
|
||||
var encoder: MediaCodec? = null
|
||||
var decoder: MediaCodec? = null
|
||||
var inputSurface: InputSurface? = null
|
||||
var outputSurface: OutputSurface? = null
|
||||
var muxerStarted = false
|
||||
var outputDims = Pair(0, 0)
|
||||
var durationUs = 0L
|
||||
|
||||
try {
|
||||
val uri = Uri.parse(uriString)
|
||||
extractor = MediaExtractor()
|
||||
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
|
||||
extractor.setDataSource(context, uri, null)
|
||||
} else {
|
||||
extractor.setDataSource(uriString)
|
||||
}
|
||||
|
||||
var videoTrackIndex = -1
|
||||
var audioTrackIndex = -1
|
||||
var videoFormat: MediaFormat? = null
|
||||
var audioFormat: MediaFormat? = null
|
||||
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val format = extractor.getTrackFormat(i)
|
||||
val trackMime = format.getString(MediaFormat.KEY_MIME) ?: continue
|
||||
if (trackMime.startsWith("video/") && videoTrackIndex == -1) {
|
||||
videoTrackIndex = i
|
||||
videoFormat = format
|
||||
} else if (trackMime.startsWith("audio/") && audioTrackIndex == -1) {
|
||||
audioTrackIndex = i
|
||||
audioFormat = format
|
||||
}
|
||||
}
|
||||
|
||||
if (videoTrackIndex == -1 || videoFormat == null) {
|
||||
throw RuntimeException("No video track found")
|
||||
}
|
||||
|
||||
val sourceWidth = videoFormat.getInteger(MediaFormat.KEY_WIDTH)
|
||||
val sourceHeight = videoFormat.getInteger(MediaFormat.KEY_HEIGHT)
|
||||
val rotation = if (videoFormat.containsKey(MediaFormat.KEY_ROTATION)) {
|
||||
videoFormat.getInteger(MediaFormat.KEY_ROTATION)
|
||||
} else 0
|
||||
durationUs = if (videoFormat.containsKey(MediaFormat.KEY_DURATION)) {
|
||||
videoFormat.getLong(MediaFormat.KEY_DURATION)
|
||||
} else 0L
|
||||
val sourceFps = if (videoFormat.containsKey(MediaFormat.KEY_FRAME_RATE)) {
|
||||
videoFormat.getInteger(MediaFormat.KEY_FRAME_RATE)
|
||||
} else 30
|
||||
|
||||
outputDims = calculateOutputDims(sourceWidth, sourceHeight, rotation, maxSize)
|
||||
val shouldPassthroughAudio = audioFormat != null && canPassthroughAudio(audioFormat)
|
||||
val transcodedAudio: TranscodedAudio? = if (
|
||||
audioTrackIndex >= 0 && audioFormat != null && !shouldPassthroughAudio
|
||||
) {
|
||||
transcodeAudioToAAC(audioTrackIndex, audioFormat)
|
||||
} else null
|
||||
muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
||||
|
||||
val effectiveBitrate = if (targetBitrate > 0) {
|
||||
targetBitrate
|
||||
} else if (useHevc) 2_500_000 else 3_000_000
|
||||
|
||||
val encoderFormat = MediaFormat.createVideoFormat(
|
||||
encoderInfo.mime, outputDims.first, outputDims.second
|
||||
).apply {
|
||||
setInteger(
|
||||
MediaFormat.KEY_COLOR_FORMAT,
|
||||
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
|
||||
)
|
||||
setInteger(MediaFormat.KEY_BIT_RATE, effectiveBitrate)
|
||||
setInteger(
|
||||
MediaFormat.KEY_BITRATE_MODE,
|
||||
MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR
|
||||
)
|
||||
setInteger(MediaFormat.KEY_FRAME_RATE, frameRateCap)
|
||||
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, I_FRAME_INTERVAL)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
setInteger(MediaFormat.KEY_COLOR_STANDARD, MediaFormat.COLOR_STANDARD_BT709)
|
||||
setInteger(MediaFormat.KEY_COLOR_TRANSFER, MediaFormat.COLOR_TRANSFER_SDR_VIDEO)
|
||||
setInteger(MediaFormat.KEY_COLOR_RANGE, MediaFormat.COLOR_RANGE_LIMITED)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
setInteger(MediaFormat.KEY_PRIORITY, 0)
|
||||
setInteger(MediaFormat.KEY_OPERATING_RATE, frameRateCap)
|
||||
if (useHevc) {
|
||||
setInteger(
|
||||
MediaFormat.KEY_PROFILE,
|
||||
MediaCodecInfo.CodecProfileLevel.HEVCProfileMain
|
||||
)
|
||||
} else {
|
||||
setInteger(
|
||||
MediaFormat.KEY_PROFILE,
|
||||
MediaCodecInfo.CodecProfileLevel.AVCProfileHigh
|
||||
)
|
||||
setInteger(
|
||||
MediaFormat.KEY_LEVEL,
|
||||
MediaCodecInfo.CodecProfileLevel.AVCLevel41
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
encoder = MediaCodec.createByCodecName(encoderInfo.name)
|
||||
encoder.configure(encoderFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||
val encoderInputSurface = encoder.createInputSurface()
|
||||
inputSurface = InputSurface(encoderInputSurface)
|
||||
inputSurface.makeCurrent()
|
||||
outputSurface = OutputSurface()
|
||||
encoder.start()
|
||||
|
||||
decoder = MediaCodec.createDecoderByType(
|
||||
videoFormat.getString(MediaFormat.KEY_MIME) ?: "video/avc"
|
||||
)
|
||||
// Ask the decoder to tone-map HDR (HLG/PQ) sources to SDR. Vendors may
|
||||
// ignore the hint, but where supported it produces correct BT.709 pixels
|
||||
// for the encoder rather than HDR pixels mislabeled as SDR.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
videoFormat.setInteger(
|
||||
MediaFormat.KEY_COLOR_TRANSFER_REQUEST,
|
||||
MediaFormat.COLOR_TRANSFER_SDR_VIDEO
|
||||
)
|
||||
}
|
||||
decoder.configure(videoFormat, outputSurface.surface, null, 0)
|
||||
decoder.start()
|
||||
extractor.selectTrack(videoTrackIndex)
|
||||
|
||||
val frameDropEnabled = sourceFps > frameRateCap
|
||||
val targetFrameIntervalUs = if (frameDropEnabled) 1_000_000L / frameRateCap else 0L
|
||||
var nextTargetPtsUs = 0L
|
||||
|
||||
var muxerVideoTrack = -1
|
||||
var muxerAudioTrack = -1
|
||||
|
||||
val bufferInfo = MediaCodec.BufferInfo()
|
||||
var inputDone = false
|
||||
var decoderDone = false
|
||||
var outputDone = false
|
||||
var lastProgressMs = 0L
|
||||
|
||||
while (!outputDone && !isCancelled) {
|
||||
if (!inputDone) {
|
||||
val idx = decoder.dequeueInputBuffer(TIMEOUT_DEQUEUE)
|
||||
if (idx >= 0) {
|
||||
val buf = decoder.getInputBuffer(idx)
|
||||
if (buf != null) {
|
||||
val sz = extractor.readSampleData(buf, 0)
|
||||
if (sz < 0) {
|
||||
decoder.queueInputBuffer(
|
||||
idx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM
|
||||
)
|
||||
inputDone = true
|
||||
} else {
|
||||
decoder.queueInputBuffer(idx, 0, sz, extractor.sampleTime, 0)
|
||||
extractor.advance()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!decoderDone) {
|
||||
val status = decoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_DEQUEUE)
|
||||
if (status >= 0) {
|
||||
val isEos = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||
val shouldRender = if (isEos) {
|
||||
false
|
||||
} else if (frameDropEnabled) {
|
||||
if (bufferInfo.presentationTimeUs >= nextTargetPtsUs) {
|
||||
nextTargetPtsUs = bufferInfo.presentationTimeUs + targetFrameIntervalUs
|
||||
true
|
||||
} else false
|
||||
} else true
|
||||
decoder.releaseOutputBuffer(status, shouldRender)
|
||||
if (shouldRender) {
|
||||
outputSurface.awaitNewImage()
|
||||
outputSurface.drawImage()
|
||||
inputSurface.setPresentationTime(bufferInfo.presentationTimeUs * 1000)
|
||||
inputSurface.swapBuffers()
|
||||
}
|
||||
if (isEos) {
|
||||
encoder.signalEndOfInputStream()
|
||||
decoderDone = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var encoderDrained = false
|
||||
while (!outputDone && !isCancelled && !encoderDrained) {
|
||||
val encIdx = encoder.dequeueOutputBuffer(bufferInfo, 0)
|
||||
when {
|
||||
encIdx == MediaCodec.INFO_TRY_AGAIN_LATER -> encoderDrained = true
|
||||
encIdx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
||||
if (!muxerStarted) {
|
||||
muxerVideoTrack = muxer.addTrack(encoder.outputFormat)
|
||||
if (audioTrackIndex >= 0 && audioFormat != null) {
|
||||
if (shouldPassthroughAudio) {
|
||||
muxerAudioTrack = muxer.addTrack(audioFormat)
|
||||
} else if (transcodedAudio != null) {
|
||||
muxerAudioTrack = muxer.addTrack(transcodedAudio.outputFormat)
|
||||
}
|
||||
}
|
||||
muxer.start()
|
||||
muxerStarted = true
|
||||
}
|
||||
}
|
||||
encIdx >= 0 -> {
|
||||
val data = encoder.getOutputBuffer(encIdx)
|
||||
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {
|
||||
bufferInfo.size = 0
|
||||
}
|
||||
if (data != null && bufferInfo.size > 0 && muxerStarted) {
|
||||
muxer.writeSampleData(muxerVideoTrack, data, bufferInfo)
|
||||
}
|
||||
val isEos = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||
encoder.releaseOutputBuffer(encIdx, false)
|
||||
if (isEos) {
|
||||
outputDone = true
|
||||
} else if (durationUs > 0) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastProgressMs >= 100) {
|
||||
lastProgressMs = now
|
||||
val p = (bufferInfo.presentationTimeUs.toDouble() / durationUs)
|
||||
.coerceIn(0.0, 1.0)
|
||||
onProgress(jobId, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (audioTrackIndex >= 0 && muxerAudioTrack >= 0 && muxerStarted && !isCancelled) {
|
||||
if (shouldPassthroughAudio) {
|
||||
passthroughAudio(audioTrackIndex, muxer, muxerAudioTrack)
|
||||
} else if (transcodedAudio != null) {
|
||||
writeTranscodedAudio(transcodedAudio.samples, muxer, muxerAudioTrack)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try { decoder?.stop() } catch (_: Exception) {}
|
||||
try { decoder?.release() } catch (_: Exception) {}
|
||||
try { encoder?.stop() } catch (_: Exception) {}
|
||||
try { encoder?.release() } catch (_: Exception) {}
|
||||
try { outputSurface?.release() } catch (_: Exception) {}
|
||||
try { inputSurface?.release() } catch (_: Exception) {}
|
||||
try { extractor?.release() } catch (_: Exception) {}
|
||||
try {
|
||||
if (muxerStarted) muxer?.stop()
|
||||
muxer?.release()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
if (isCancelled) {
|
||||
outputFile.delete()
|
||||
throw RuntimeException("Compression cancelled")
|
||||
}
|
||||
|
||||
onProgress(jobId, 1.0)
|
||||
val durationSeconds = durationUs / 1_000_000.0
|
||||
|
||||
return mapOf(
|
||||
"uri" to "file://${outputFile.absolutePath}",
|
||||
"size" to outputFile.length(),
|
||||
"mimeType" to "video/mp4",
|
||||
"width" to outputDims.first,
|
||||
"height" to outputDims.second,
|
||||
"duration" to durationSeconds,
|
||||
"codec" to if (useHevc) "hevc" else "h264"
|
||||
)
|
||||
}
|
||||
|
||||
private fun passthroughAudio(audioTrackIndex: Int, muxer: MediaMuxer, muxerAudioTrack: Int) {
|
||||
val audioExtractor = MediaExtractor()
|
||||
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
|
||||
audioExtractor.setDataSource(context, Uri.parse(uriString), null)
|
||||
} else {
|
||||
audioExtractor.setDataSource(uriString)
|
||||
}
|
||||
audioExtractor.selectTrack(audioTrackIndex)
|
||||
audioExtractor.seekTo(0, MediaExtractor.SEEK_TO_CLOSEST_SYNC)
|
||||
|
||||
val buffer = ByteBuffer.allocate(256 * 1024)
|
||||
val info = MediaCodec.BufferInfo()
|
||||
try {
|
||||
while (!isCancelled) {
|
||||
val sz = audioExtractor.readSampleData(buffer, 0)
|
||||
if (sz < 0) break
|
||||
info.offset = 0
|
||||
info.size = sz
|
||||
info.presentationTimeUs = audioExtractor.sampleTime
|
||||
info.flags = audioExtractor.sampleFlags
|
||||
muxer.writeSampleData(muxerAudioTrack, buffer, info)
|
||||
audioExtractor.advance()
|
||||
}
|
||||
} finally {
|
||||
audioExtractor.release()
|
||||
}
|
||||
}
|
||||
|
||||
private fun canPassthroughAudio(format: MediaFormat): Boolean {
|
||||
val mime = format.getString(MediaFormat.KEY_MIME) ?: return false
|
||||
return mime == MediaFormat.MIMETYPE_AUDIO_AAC
|
||||
}
|
||||
|
||||
private data class TranscodedAudio(
|
||||
val outputFormat: MediaFormat,
|
||||
val samples: List<Sample>
|
||||
) {
|
||||
data class Sample(
|
||||
val bytes: ByteArray,
|
||||
val presentationTimeUs: Long,
|
||||
val flags: Int
|
||||
)
|
||||
}
|
||||
|
||||
// Re-encode non-AAC source audio (Opus, Vorbis, etc.) to AAC so the mp4 muxer
|
||||
// can take it. iOS always re-encodes to AAC; without this Android would drop
|
||||
// the audio track entirely.
|
||||
private fun transcodeAudioToAAC(
|
||||
audioTrackIndex: Int,
|
||||
sourceFormat: MediaFormat
|
||||
): TranscodedAudio? {
|
||||
val sourceMime = sourceFormat.getString(MediaFormat.KEY_MIME) ?: return null
|
||||
val sampleRate = if (sourceFormat.containsKey(MediaFormat.KEY_SAMPLE_RATE))
|
||||
sourceFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE) else 44100
|
||||
val channelCount = if (sourceFormat.containsKey(MediaFormat.KEY_CHANNEL_COUNT))
|
||||
sourceFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT).coerceIn(1, 2) else 2
|
||||
|
||||
val audioExtractor = MediaExtractor()
|
||||
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
|
||||
audioExtractor.setDataSource(context, Uri.parse(uriString), null)
|
||||
} else {
|
||||
audioExtractor.setDataSource(uriString)
|
||||
}
|
||||
audioExtractor.selectTrack(audioTrackIndex)
|
||||
|
||||
var decoder: MediaCodec? = null
|
||||
var encoder: MediaCodec? = null
|
||||
try {
|
||||
decoder = MediaCodec.createDecoderByType(sourceMime)
|
||||
decoder.configure(sourceFormat, null, null, 0)
|
||||
decoder.start()
|
||||
|
||||
val encoderFormat = MediaFormat.createAudioFormat(
|
||||
MediaFormat.MIMETYPE_AUDIO_AAC, sampleRate, channelCount
|
||||
).apply {
|
||||
setInteger(
|
||||
MediaFormat.KEY_AAC_PROFILE,
|
||||
MediaCodecInfo.CodecProfileLevel.AACObjectLC
|
||||
)
|
||||
setInteger(MediaFormat.KEY_BIT_RATE, 128_000)
|
||||
setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 256 * 1024)
|
||||
}
|
||||
encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
|
||||
encoder.configure(encoderFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||
encoder.start()
|
||||
|
||||
val samples = mutableListOf<TranscodedAudio.Sample>()
|
||||
var outputFormat: MediaFormat? = null
|
||||
var inputDone = false
|
||||
var decoderDone = false
|
||||
var encoderInputSignalled = false
|
||||
var encoderDone = false
|
||||
val info = MediaCodec.BufferInfo()
|
||||
|
||||
while (!encoderDone && !isCancelled) {
|
||||
if (!inputDone) {
|
||||
val idx = decoder.dequeueInputBuffer(TIMEOUT_DEQUEUE)
|
||||
if (idx >= 0) {
|
||||
val buf = decoder.getInputBuffer(idx)
|
||||
if (buf != null) {
|
||||
val sz = audioExtractor.readSampleData(buf, 0)
|
||||
if (sz < 0) {
|
||||
decoder.queueInputBuffer(
|
||||
idx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM
|
||||
)
|
||||
inputDone = true
|
||||
} else {
|
||||
decoder.queueInputBuffer(
|
||||
idx, 0, sz, audioExtractor.sampleTime, audioExtractor.sampleFlags
|
||||
)
|
||||
audioExtractor.advance()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!decoderDone) {
|
||||
val status = decoder.dequeueOutputBuffer(info, TIMEOUT_DEQUEUE)
|
||||
when {
|
||||
status == MediaCodec.INFO_TRY_AGAIN_LATER -> {}
|
||||
status == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {}
|
||||
status >= 0 -> {
|
||||
val isEos = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||
val data = decoder.getOutputBuffer(status)
|
||||
if (data != null && info.size > 0) {
|
||||
val encInIdx = encoder.dequeueInputBuffer(TIMEOUT_DEQUEUE)
|
||||
if (encInIdx >= 0) {
|
||||
val encInBuf = encoder.getInputBuffer(encInIdx)
|
||||
if (encInBuf != null) {
|
||||
encInBuf.clear()
|
||||
data.position(info.offset)
|
||||
data.limit(info.offset + info.size)
|
||||
encInBuf.put(data)
|
||||
encoder.queueInputBuffer(
|
||||
encInIdx, 0, info.size, info.presentationTimeUs, 0
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
decoder.releaseOutputBuffer(status, false)
|
||||
if (isEos) {
|
||||
if (!encoderInputSignalled) {
|
||||
val encInIdx = encoder.dequeueInputBuffer(TIMEOUT_DEQUEUE * 10)
|
||||
if (encInIdx >= 0) {
|
||||
encoder.queueInputBuffer(
|
||||
encInIdx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM
|
||||
)
|
||||
encoderInputSignalled = true
|
||||
}
|
||||
}
|
||||
decoderDone = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val encOutIdx = encoder.dequeueOutputBuffer(info, TIMEOUT_DEQUEUE)
|
||||
when {
|
||||
encOutIdx == MediaCodec.INFO_TRY_AGAIN_LATER -> {}
|
||||
encOutIdx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
||||
outputFormat = encoder.outputFormat
|
||||
}
|
||||
encOutIdx >= 0 -> {
|
||||
val data = encoder.getOutputBuffer(encOutIdx)
|
||||
val isEos = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||
val isConfig = info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0
|
||||
if (data != null && info.size > 0 && !isConfig) {
|
||||
val bytes = ByteArray(info.size)
|
||||
data.position(info.offset)
|
||||
data.get(bytes, 0, info.size)
|
||||
samples.add(
|
||||
TranscodedAudio.Sample(
|
||||
bytes = bytes,
|
||||
presentationTimeUs = info.presentationTimeUs,
|
||||
flags = info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG.inv()
|
||||
)
|
||||
)
|
||||
}
|
||||
encoder.releaseOutputBuffer(encOutIdx, false)
|
||||
if (isEos) encoderDone = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val fmt = outputFormat ?: return null
|
||||
return TranscodedAudio(fmt, samples)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Audio transcode failed; dropping audio", e)
|
||||
return null
|
||||
} finally {
|
||||
try { decoder?.stop() } catch (_: Exception) {}
|
||||
try { decoder?.release() } catch (_: Exception) {}
|
||||
try { encoder?.stop() } catch (_: Exception) {}
|
||||
try { encoder?.release() } catch (_: Exception) {}
|
||||
audioExtractor.release()
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeTranscodedAudio(
|
||||
samples: List<TranscodedAudio.Sample>,
|
||||
muxer: MediaMuxer,
|
||||
muxerAudioTrack: Int
|
||||
) {
|
||||
val info = MediaCodec.BufferInfo()
|
||||
for (sample in samples) {
|
||||
if (isCancelled) break
|
||||
val buffer = ByteBuffer.wrap(sample.bytes)
|
||||
info.offset = 0
|
||||
info.size = sample.bytes.size
|
||||
info.presentationTimeUs = sample.presentationTimeUs
|
||||
info.flags = sample.flags
|
||||
muxer.writeSampleData(muxerAudioTrack, buffer, info)
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateOutputDims(srcW: Int, srcH: Int, rotation: Int, maxSize: Int): Pair<Int, Int> {
|
||||
val isRotated = rotation == 90 || rotation == 270
|
||||
val displayW = if (isRotated) srcH else srcW
|
||||
val displayH = if (isRotated) srcW else srcH
|
||||
val scale: Float = when {
|
||||
displayW <= maxSize && displayH <= maxSize -> 1.0f
|
||||
displayW > displayH -> maxSize.toFloat() / displayW
|
||||
else -> maxSize.toFloat() / displayH
|
||||
}
|
||||
return Pair(
|
||||
roundToEven((displayW * scale).toInt()),
|
||||
roundToEven((displayH * scale).toInt())
|
||||
)
|
||||
}
|
||||
|
||||
private fun roundToEven(v: Int): Int = if (v % 2 == 0) v else v - 1
|
||||
}
|
||||
-125
@@ -1,125 +0,0 @@
|
||||
package expo.modules.blueskyvideocompress
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
|
||||
object VideoProber {
|
||||
fun probe(context: Context, uriString: String): Map<String, Any> {
|
||||
val uri = Uri.parse(uriString)
|
||||
val retriever = MediaMetadataRetriever()
|
||||
|
||||
try {
|
||||
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
|
||||
retriever.setDataSource(context, uri)
|
||||
} else {
|
||||
retriever.setDataSource(uriString)
|
||||
}
|
||||
|
||||
val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
|
||||
?.toIntOrNull() ?: 0
|
||||
val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
|
||||
?.toIntOrNull() ?: 0
|
||||
val durationMs = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
|
||||
?.toLongOrNull() ?: 0L
|
||||
val rotation = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
|
||||
?.toIntOrNull() ?: 0
|
||||
val bitrate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)
|
||||
?.toIntOrNull() ?: 0
|
||||
val hasAudio = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO)
|
||||
?.equals("yes") ?: false
|
||||
val frameRate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE)
|
||||
?.toFloatOrNull() ?: 0f
|
||||
|
||||
val fileSize = getFileSize(context, uriString)
|
||||
|
||||
val extractor = MediaExtractor()
|
||||
var codec = "unknown"
|
||||
var mimeType = "video/mp4"
|
||||
var extractedFrameRate = frameRate
|
||||
var isHDR = false
|
||||
|
||||
try {
|
||||
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
|
||||
extractor.setDataSource(context, uri, null)
|
||||
} else {
|
||||
extractor.setDataSource(uriString)
|
||||
}
|
||||
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val format = extractor.getTrackFormat(i)
|
||||
val mime = format.getString(MediaFormat.KEY_MIME)
|
||||
if (mime?.startsWith("video/") == true) {
|
||||
mimeType = mime
|
||||
codec = mime.removePrefix("video/")
|
||||
if (format.containsKey(MediaFormat.KEY_FRAME_RATE)) {
|
||||
extractedFrameRate = format.getInteger(MediaFormat.KEY_FRAME_RATE).toFloat()
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
|
||||
format.containsKey(MediaFormat.KEY_COLOR_TRANSFER)
|
||||
) {
|
||||
val transfer = format.getInteger(MediaFormat.KEY_COLOR_TRANSFER)
|
||||
isHDR = transfer == MediaFormat.COLOR_TRANSFER_HLG ||
|
||||
transfer == MediaFormat.COLOR_TRANSFER_ST2084
|
||||
}
|
||||
// Dolby Vision tracks use codec-specific mimes that aren't covered by
|
||||
// KEY_COLOR_TRANSFER on every device.
|
||||
if (mime.contains("dolby-vision", ignoreCase = true)) {
|
||||
isHDR = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
extractor.release()
|
||||
}
|
||||
|
||||
val durationSeconds = durationMs / 1000.0
|
||||
val effectiveBitrate = if (bitrate > 0) {
|
||||
bitrate
|
||||
} else if (durationSeconds > 0 && fileSize > 0) {
|
||||
(fileSize * 8 / durationSeconds).toInt()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
return mapOf(
|
||||
"width" to width,
|
||||
"height" to height,
|
||||
"duration" to durationSeconds,
|
||||
"bitrate" to effectiveBitrate,
|
||||
"fileSize" to fileSize,
|
||||
"mimeType" to mimeType,
|
||||
"codec" to codec,
|
||||
"hasAudio" to hasAudio,
|
||||
"frameRate" to extractedFrameRate.toDouble(),
|
||||
"rotation" to rotation,
|
||||
"isHDR" to isHDR
|
||||
)
|
||||
} finally {
|
||||
retriever.release()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFileSize(context: Context, uriString: String): Long {
|
||||
return try {
|
||||
if (uriString.startsWith("content://")) {
|
||||
context.contentResolver.openFileDescriptor(Uri.parse(uriString), "r")?.use {
|
||||
it.statSize
|
||||
} ?: 0L
|
||||
} else {
|
||||
val path = if (uriString.startsWith("file://")) {
|
||||
uriString.removePrefix("file://")
|
||||
} else {
|
||||
uriString
|
||||
}
|
||||
java.io.File(path).length()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"platforms": ["ios", "android"],
|
||||
"ios": {
|
||||
"modules": ["ExpoBlueskyVideoCompressModule"]
|
||||
},
|
||||
"android": {
|
||||
"modules": ["expo.modules.blueskyvideocompress.ExpoBlueskyVideoCompressModule"]
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import {type EventSubscription} from 'expo-modules-core'
|
||||
|
||||
import NativeModule from './src/ExpoBlueskyVideoCompressModule'
|
||||
import {
|
||||
type CodecPreference,
|
||||
type CompressCallbacks,
|
||||
type CompressOptions,
|
||||
type CompressResult,
|
||||
type VideoMetadata,
|
||||
} from './src/types'
|
||||
|
||||
export type {
|
||||
CodecPreference,
|
||||
CompressCallbacks,
|
||||
CompressOptions,
|
||||
CompressResult,
|
||||
VideoMetadata,
|
||||
}
|
||||
|
||||
class AbortError extends Error {
|
||||
name = 'AbortError'
|
||||
constructor() {
|
||||
super('Aborted')
|
||||
}
|
||||
}
|
||||
|
||||
let jobIdCounter = 0
|
||||
|
||||
export function probe(uri: string): Promise<VideoMetadata> {
|
||||
return NativeModule.probe(uri)
|
||||
}
|
||||
|
||||
export function compress(
|
||||
uri: string,
|
||||
options: CompressOptions = {},
|
||||
callbacks?: CompressCallbacks,
|
||||
): Promise<CompressResult> {
|
||||
const jobId = ++jobIdCounter
|
||||
let subscription: EventSubscription | undefined
|
||||
|
||||
if (callbacks?.signal?.aborted) {
|
||||
return Promise.reject(new AbortError())
|
||||
}
|
||||
|
||||
const nativeOptions = {
|
||||
targetBitrate: options.targetBitrate ?? 0,
|
||||
maxSize: options.maxSize ?? 1920,
|
||||
codec: options.codec ?? 'auto',
|
||||
frameRateCap: options.frameRateCap ?? 30,
|
||||
jobId,
|
||||
}
|
||||
|
||||
return new Promise<CompressResult>((resolve, reject) => {
|
||||
if (callbacks?.onProgress) {
|
||||
subscription = NativeModule.addListener(
|
||||
'onProgress',
|
||||
(event: {id: number; progress: number}) => {
|
||||
if (event.id === jobId) {
|
||||
callbacks.onProgress!(event.progress)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const abortHandler = () => {
|
||||
NativeModule.cancel(jobId)
|
||||
subscription?.remove()
|
||||
reject(new AbortError())
|
||||
}
|
||||
|
||||
if (callbacks?.signal) {
|
||||
callbacks.signal.addEventListener('abort', abortHandler, {once: true})
|
||||
}
|
||||
|
||||
NativeModule.compress(uri, nativeOptions)
|
||||
.then(result => {
|
||||
callbacks?.signal?.removeEventListener('abort', abortHandler)
|
||||
subscription?.remove()
|
||||
resolve(result)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
callbacks?.signal?.removeEventListener('abort', abortHandler)
|
||||
subscription?.remove()
|
||||
reject(error instanceof Error ? error : new Error(String(error)))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import VideoToolbox
|
||||
|
||||
enum CodecCapability {
|
||||
static let isHardwareHEVCEncodeAvailable: Bool = {
|
||||
var encoderListCF: CFArray?
|
||||
let status = VTCopyVideoEncoderList(nil, &encoderListCF)
|
||||
guard status == noErr, let encoderList = encoderListCF as? [[String: Any]] else {
|
||||
return false
|
||||
}
|
||||
return encoderList.contains { encoder in
|
||||
guard let codecTypeValue = encoder[kVTVideoEncoderList_CodecType as String] as? Int,
|
||||
codecTypeValue == Int(kCMVideoCodecType_HEVC) else {
|
||||
return false
|
||||
}
|
||||
if let isHardware = encoder[kVTVideoEncoderList_IsHardwareAccelerated as String] as? Bool {
|
||||
return isHardware
|
||||
}
|
||||
return true
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'ExpoBlueskyVideoCompress'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'Hardware-accelerated video compression for Bluesky'
|
||||
s.description = 'Hardware-accelerated h264/HEVC video compression using AVAssetReader/Writer and VideoToolbox on iOS'
|
||||
s.author = ''
|
||||
s.homepage = 'https://github.com/bluesky-social/social-app'
|
||||
s.platforms = { :ios => '15.1' }
|
||||
s.source = { git: '' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||
}
|
||||
|
||||
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
||||
end
|
||||
@@ -1,74 +0,0 @@
|
||||
import AVFoundation
|
||||
import ExpoModulesCore
|
||||
|
||||
public class ExpoBlueskyVideoCompressModule: Module {
|
||||
private var activeCompressors: [Int: VideoCompressor] = [:]
|
||||
private let activeCompressorsLock = NSLock()
|
||||
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("ExpoBlueskyVideoCompress")
|
||||
|
||||
Events("onProgress")
|
||||
|
||||
AsyncFunction("probe") { (uri: String) -> [String: Any] in
|
||||
let url = URL(string: uri) ?? URL(fileURLWithPath: uri)
|
||||
return try await VideoProber.probe(url: url)
|
||||
}
|
||||
|
||||
AsyncFunction("compress") { (uri: String, options: [String: Any]) -> [String: Any] in
|
||||
let url = URL(string: uri) ?? URL(fileURLWithPath: uri)
|
||||
let targetBitrate = options["targetBitrate"] as? Int ?? 0
|
||||
let maxSize = options["maxSize"] as? Int ?? 1920
|
||||
let codecPref = options["codec"] as? String ?? "auto"
|
||||
let frameRateCap = max(1, options["frameRateCap"] as? Int ?? 30)
|
||||
let jobId = options["jobId"] as? Int ?? 0
|
||||
|
||||
let compressor = VideoCompressor(
|
||||
url: url,
|
||||
targetBitrate: targetBitrate,
|
||||
maxSize: maxSize,
|
||||
codecPref: codecPref,
|
||||
frameRateCap: frameRateCap,
|
||||
jobId: jobId,
|
||||
onProgress: { [weak self] id, progress in
|
||||
self?.sendEvent("onProgress", [
|
||||
"id": id,
|
||||
"progress": progress,
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
self.setCompressor(jobId, compressor)
|
||||
|
||||
do {
|
||||
let result = try await compressor.compress()
|
||||
self.setCompressor(jobId, nil)
|
||||
return result
|
||||
} catch {
|
||||
self.setCompressor(jobId, nil)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
Function("cancel") { (jobId: Int) in
|
||||
self.cancelCompressor(jobId)
|
||||
}
|
||||
}
|
||||
|
||||
private func setCompressor(_ jobId: Int, _ compressor: VideoCompressor?) {
|
||||
activeCompressorsLock.lock()
|
||||
defer { activeCompressorsLock.unlock() }
|
||||
if let compressor = compressor {
|
||||
activeCompressors[jobId] = compressor
|
||||
} else {
|
||||
activeCompressors.removeValue(forKey: jobId)
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelCompressor(_ jobId: Int) {
|
||||
activeCompressorsLock.lock()
|
||||
let compressor = activeCompressors.removeValue(forKey: jobId)
|
||||
activeCompressorsLock.unlock()
|
||||
compressor?.cancel()
|
||||
}
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
import AVFoundation
|
||||
import VideoToolbox
|
||||
|
||||
class VideoCompressor {
|
||||
private let url: URL
|
||||
private let targetBitrate: Int
|
||||
private let maxSize: Int
|
||||
private let codecPref: String
|
||||
private let frameRateCap: Int
|
||||
private let jobId: Int
|
||||
private let onProgress: (Int, Double) -> Void
|
||||
private var isCancelled = false
|
||||
|
||||
init(
|
||||
url: URL,
|
||||
targetBitrate: Int,
|
||||
maxSize: Int,
|
||||
codecPref: String,
|
||||
frameRateCap: Int,
|
||||
jobId: Int,
|
||||
onProgress: @escaping (Int, Double) -> Void
|
||||
) {
|
||||
self.url = url
|
||||
self.targetBitrate = targetBitrate
|
||||
self.maxSize = maxSize
|
||||
self.codecPref = codecPref
|
||||
self.frameRateCap = frameRateCap
|
||||
self.jobId = jobId
|
||||
self.onProgress = onProgress
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
isCancelled = true
|
||||
}
|
||||
|
||||
func compress() async throws -> [String: Any] {
|
||||
let asset = AVURLAsset(
|
||||
url: url,
|
||||
options: [AVURLAssetPreferPreciseDurationAndTimingKey: true]
|
||||
)
|
||||
let duration = try await asset.load(.duration)
|
||||
let totalSeconds = CMTimeGetSeconds(duration)
|
||||
guard totalSeconds > 0 else { throw err("Invalid video duration", code: 2) }
|
||||
|
||||
let videoTracks = try await asset.loadTracks(withMediaType: .video)
|
||||
guard let videoTrack = videoTracks.first else {
|
||||
throw err("No video track found", code: 1)
|
||||
}
|
||||
|
||||
let naturalSize = try await videoTrack.load(.naturalSize)
|
||||
let preferredTransform = try await videoTrack.load(.preferredTransform)
|
||||
|
||||
let rotatedRect = CGRect(origin: .zero, size: naturalSize).applying(preferredTransform)
|
||||
let displaySize = CGSize(
|
||||
width: abs(rotatedRect.width),
|
||||
height: abs(rotatedRect.height)
|
||||
)
|
||||
let outputSize = scaleEvenly(displaySize: displaySize, maxSize: maxSize)
|
||||
|
||||
let audioTracks = try await asset.loadTracks(withMediaType: .audio)
|
||||
|
||||
// 'auto' targets h264 — server pipeline is HLS, which favors h264 (HEVC needs
|
||||
// fMP4 segments + commercial licensing). HEVC remains opt-in via codec: 'hevc'.
|
||||
let useHEVC: Bool
|
||||
switch codecPref {
|
||||
case "hevc": useHEVC = true
|
||||
default: useHEVC = false
|
||||
}
|
||||
let codecType: AVVideoCodecType = useHEVC ? .hevc : .h264
|
||||
let profileLevel: String = useHEVC
|
||||
? kVTProfileLevel_HEVC_Main_AutoLevel as String
|
||||
: kVTProfileLevel_H264_High_AutoLevel as String
|
||||
|
||||
let effectiveBitrate = targetBitrate > 0
|
||||
? targetBitrate
|
||||
: (useHEVC ? 2_500_000 : 3_000_000)
|
||||
|
||||
let outputURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
.appendingPathExtension("mp4")
|
||||
|
||||
let videoComposition = makeRotatingComposition(
|
||||
videoTrack: videoTrack,
|
||||
preferredTransform: preferredTransform,
|
||||
naturalSize: naturalSize,
|
||||
outputSize: outputSize,
|
||||
duration: duration
|
||||
)
|
||||
|
||||
let reader = try AVAssetReader(asset: asset)
|
||||
|
||||
let videoReaderSettings: [String: Any] = [
|
||||
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
|
||||
]
|
||||
let videoReaderOutput = AVAssetReaderVideoCompositionOutput(
|
||||
videoTracks: [videoTrack],
|
||||
videoSettings: videoReaderSettings
|
||||
)
|
||||
videoReaderOutput.videoComposition = videoComposition
|
||||
videoReaderOutput.alwaysCopiesSampleData = false
|
||||
guard reader.canAdd(videoReaderOutput) else {
|
||||
throw err("Cannot read video track", code: 3)
|
||||
}
|
||||
reader.add(videoReaderOutput)
|
||||
|
||||
var compressionProps: [String: Any] = [
|
||||
AVVideoAverageBitRateKey: effectiveBitrate,
|
||||
AVVideoProfileLevelKey: profileLevel,
|
||||
AVVideoMaxKeyFrameIntervalKey: max(frameRateCap * 3, 30),
|
||||
AVVideoExpectedSourceFrameRateKey: frameRateCap,
|
||||
AVVideoAllowFrameReorderingKey: false,
|
||||
kVTCompressionPropertyKey_RealTime as String: true,
|
||||
]
|
||||
let peakBytesPerSecond = Int(Double(effectiveBitrate) / 8.0 * 1.5)
|
||||
compressionProps[kVTCompressionPropertyKey_DataRateLimits as String] = [
|
||||
peakBytesPerSecond, 1.0
|
||||
] as CFArray
|
||||
|
||||
let videoColorProps: [String: Any] = [
|
||||
AVVideoColorPrimariesKey: AVVideoColorPrimaries_ITU_R_709_2,
|
||||
AVVideoTransferFunctionKey: AVVideoTransferFunction_ITU_R_709_2,
|
||||
AVVideoYCbCrMatrixKey: AVVideoYCbCrMatrix_ITU_R_709_2,
|
||||
]
|
||||
|
||||
let videoWriterSettings: [String: Any] = [
|
||||
AVVideoCodecKey: codecType,
|
||||
AVVideoWidthKey: outputSize.width,
|
||||
AVVideoHeightKey: outputSize.height,
|
||||
AVVideoColorPropertiesKey: videoColorProps,
|
||||
AVVideoCompressionPropertiesKey: compressionProps,
|
||||
]
|
||||
let videoWriterInput = AVAssetWriterInput(
|
||||
mediaType: .video,
|
||||
outputSettings: videoWriterSettings
|
||||
)
|
||||
videoWriterInput.expectsMediaDataInRealTime = false
|
||||
|
||||
let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4)
|
||||
writer.shouldOptimizeForNetworkUse = true
|
||||
writer.metadata = []
|
||||
guard writer.canAdd(videoWriterInput) else {
|
||||
throw err("Cannot write video track", code: 4)
|
||||
}
|
||||
writer.add(videoWriterInput)
|
||||
|
||||
var audioReaderOutput: AVAssetReaderTrackOutput?
|
||||
var audioWriterInput: AVAssetWriterInput?
|
||||
if let audioTrack = audioTracks.first {
|
||||
let audioDecoderSettings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatLinearPCM,
|
||||
AVLinearPCMBitDepthKey: 16,
|
||||
AVLinearPCMIsFloatKey: false,
|
||||
AVLinearPCMIsBigEndianKey: false,
|
||||
AVLinearPCMIsNonInterleaved: false,
|
||||
]
|
||||
let audioOutput = AVAssetReaderTrackOutput(
|
||||
track: audioTrack,
|
||||
outputSettings: audioDecoderSettings
|
||||
)
|
||||
audioOutput.alwaysCopiesSampleData = false
|
||||
if reader.canAdd(audioOutput) {
|
||||
reader.add(audioOutput)
|
||||
audioReaderOutput = audioOutput
|
||||
|
||||
let audioEncoderSettings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 44100,
|
||||
AVNumberOfChannelsKey: 2,
|
||||
AVEncoderBitRateKey: 128_000,
|
||||
]
|
||||
let audioInput = AVAssetWriterInput(
|
||||
mediaType: .audio,
|
||||
outputSettings: audioEncoderSettings
|
||||
)
|
||||
audioInput.expectsMediaDataInRealTime = false
|
||||
if writer.canAdd(audioInput) {
|
||||
writer.add(audioInput)
|
||||
audioWriterInput = audioInput
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard reader.startReading() else {
|
||||
throw reader.error ?? err("Reader failed to start", code: 8)
|
||||
}
|
||||
guard writer.startWriting() else {
|
||||
throw writer.error ?? err("Writer failed to start", code: 9)
|
||||
}
|
||||
writer.startSession(atSourceTime: .zero)
|
||||
|
||||
let minFrameIntervalSeconds = 1.0 / Double(frameRateCap)
|
||||
let minFrameInterval = CMTime(
|
||||
seconds: minFrameIntervalSeconds,
|
||||
preferredTimescale: 600
|
||||
)
|
||||
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
group.addTask { [self] in
|
||||
await processVideoTrack(
|
||||
readerOutput: videoReaderOutput,
|
||||
writerInput: videoWriterInput,
|
||||
totalDuration: totalSeconds,
|
||||
minFrameInterval: minFrameInterval
|
||||
)
|
||||
}
|
||||
if let audioOutput = audioReaderOutput, let audioInput = audioWriterInput {
|
||||
group.addTask { [self] in
|
||||
await processAudioTrack(
|
||||
readerOutput: audioOutput,
|
||||
writerInput: audioInput
|
||||
)
|
||||
}
|
||||
}
|
||||
await group.waitForAll()
|
||||
}
|
||||
|
||||
if isCancelled {
|
||||
writer.cancelWriting()
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
throw err("Compression cancelled", code: 5)
|
||||
}
|
||||
|
||||
if reader.status == .failed {
|
||||
let error = reader.error ?? err("Reader failed", code: 6)
|
||||
writer.cancelWriting()
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
throw error
|
||||
}
|
||||
|
||||
await writer.finishWriting()
|
||||
|
||||
if writer.status == .failed {
|
||||
let error = writer.error ?? err("Writer failed", code: 7)
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
throw error
|
||||
}
|
||||
|
||||
onProgress(jobId, 1.0)
|
||||
|
||||
let attributes = try FileManager.default.attributesOfItem(atPath: outputURL.path)
|
||||
let fileSize = attributes[.size] as? Int ?? 0
|
||||
|
||||
return [
|
||||
"uri": outputURL.absoluteString,
|
||||
"size": fileSize,
|
||||
"mimeType": "video/mp4",
|
||||
"width": outputSize.width,
|
||||
"height": outputSize.height,
|
||||
"duration": totalSeconds,
|
||||
"codec": useHEVC ? "hevc" : "h264",
|
||||
]
|
||||
}
|
||||
|
||||
private func processVideoTrack(
|
||||
readerOutput: AVAssetReaderOutput,
|
||||
writerInput: AVAssetWriterInput,
|
||||
totalDuration: Double,
|
||||
minFrameInterval: CMTime
|
||||
) async {
|
||||
var lastProgressTime: CFAbsoluteTime = 0
|
||||
var lastAppendedPTS: CMTime?
|
||||
var finished = false
|
||||
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||
writerInput.requestMediaDataWhenReady(
|
||||
on: DispatchQueue(label: "com.bsky.videocompress.video")
|
||||
) {
|
||||
let finish = {
|
||||
if !finished {
|
||||
finished = true
|
||||
writerInput.markAsFinished()
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
while writerInput.isReadyForMoreMediaData {
|
||||
if finished { return }
|
||||
if self.isCancelled {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
guard let sampleBuffer = readerOutput.copyNextSampleBuffer() else {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
|
||||
if let last = lastAppendedPTS {
|
||||
let delta = CMTimeSubtract(pts, last)
|
||||
if CMTimeCompare(delta, minFrameInterval) < 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
lastAppendedPTS = pts
|
||||
|
||||
if !writerInput.append(sampleBuffer) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
let now = CFAbsoluteTimeGetCurrent()
|
||||
if now - lastProgressTime >= 0.1 {
|
||||
lastProgressTime = now
|
||||
let progress = min(CMTimeGetSeconds(pts) / totalDuration, 1.0)
|
||||
self.onProgress(self.jobId, progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func processAudioTrack(
|
||||
readerOutput: AVAssetReaderOutput,
|
||||
writerInput: AVAssetWriterInput
|
||||
) async {
|
||||
var finished = false
|
||||
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||
writerInput.requestMediaDataWhenReady(
|
||||
on: DispatchQueue(label: "com.bsky.videocompress.audio")
|
||||
) {
|
||||
let finish = {
|
||||
if !finished {
|
||||
finished = true
|
||||
writerInput.markAsFinished()
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
while writerInput.isReadyForMoreMediaData {
|
||||
if finished { return }
|
||||
if self.isCancelled {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
guard let sampleBuffer = readerOutput.copyNextSampleBuffer() else {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
if !writerInput.append(sampleBuffer) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeRotatingComposition(
|
||||
videoTrack: AVAssetTrack,
|
||||
preferredTransform: CGAffineTransform,
|
||||
naturalSize: CGSize,
|
||||
outputSize: (width: Int, height: Int),
|
||||
duration: CMTime
|
||||
) -> AVMutableVideoComposition {
|
||||
let composition = AVMutableVideoComposition()
|
||||
composition.renderSize = CGSize(width: outputSize.width, height: outputSize.height)
|
||||
composition.frameDuration = CMTime(value: 1, timescale: Int32(frameRateCap))
|
||||
|
||||
let rotatedRect = CGRect(origin: .zero, size: naturalSize).applying(preferredTransform)
|
||||
let translate = CGAffineTransform(
|
||||
translationX: -rotatedRect.minX,
|
||||
y: -rotatedRect.minY
|
||||
)
|
||||
let displaySize = CGSize(
|
||||
width: abs(rotatedRect.width),
|
||||
height: abs(rotatedRect.height)
|
||||
)
|
||||
let scaleX = CGFloat(outputSize.width) / displaySize.width
|
||||
let scaleY = CGFloat(outputSize.height) / displaySize.height
|
||||
let scale = CGAffineTransform(scaleX: scaleX, y: scaleY)
|
||||
let combined = preferredTransform.concatenating(translate).concatenating(scale)
|
||||
|
||||
let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: videoTrack)
|
||||
layerInstruction.setTransform(combined, at: .zero)
|
||||
|
||||
let instruction = AVMutableVideoCompositionInstruction()
|
||||
instruction.timeRange = CMTimeRange(start: .zero, duration: duration)
|
||||
instruction.layerInstructions = [layerInstruction]
|
||||
|
||||
composition.instructions = [instruction]
|
||||
return composition
|
||||
}
|
||||
|
||||
private func scaleEvenly(displaySize: CGSize, maxSize: Int) -> (width: Int, height: Int) {
|
||||
let cap = CGFloat(maxSize)
|
||||
let scale: CGFloat
|
||||
if displaySize.width <= cap && displaySize.height <= cap {
|
||||
scale = 1.0
|
||||
} else if displaySize.width > displaySize.height {
|
||||
scale = cap / displaySize.width
|
||||
} else {
|
||||
scale = cap / displaySize.height
|
||||
}
|
||||
return (
|
||||
roundToEven(Int(displaySize.width * scale)),
|
||||
roundToEven(Int(displaySize.height * scale))
|
||||
)
|
||||
}
|
||||
|
||||
private func roundToEven(_ value: Int) -> Int {
|
||||
return value % 2 == 0 ? value : value - 1
|
||||
}
|
||||
|
||||
private func err(_ message: String, code: Int) -> NSError {
|
||||
return NSError(
|
||||
domain: "ExpoBlueskyVideoCompress",
|
||||
code: code,
|
||||
userInfo: [NSLocalizedDescriptionKey: message]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import AVFoundation
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct VideoProber {
|
||||
static func probe(url: URL) async throws -> [String: Any] {
|
||||
let asset = AVURLAsset(url: url)
|
||||
|
||||
let duration = try await asset.load(.duration)
|
||||
let tracks = try await asset.loadTracks(withMediaType: .video)
|
||||
|
||||
guard let videoTrack = tracks.first else {
|
||||
throw NSError(
|
||||
domain: "ExpoBlueskyVideoCompress",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "No video track found"]
|
||||
)
|
||||
}
|
||||
|
||||
let naturalSize = try await videoTrack.load(.naturalSize)
|
||||
let preferredTransform = try await videoTrack.load(.preferredTransform)
|
||||
let estimatedDataRate = try await videoTrack.load(.estimatedDataRate)
|
||||
let nominalFrameRate = try await videoTrack.load(.nominalFrameRate)
|
||||
let formatDescriptions = try await videoTrack.load(.formatDescriptions)
|
||||
|
||||
var codec = "unknown"
|
||||
var isHDR = false
|
||||
if let formatDescription = formatDescriptions.first {
|
||||
let subType = CMFormatDescriptionGetMediaSubType(formatDescription)
|
||||
codec = fourCCToString(subType)
|
||||
// Dolby Vision codecs are HDR by definition.
|
||||
let dolbyVisionSubtypes: Set<String> = ["dvhe", "dvh1", "dvav", "dva1"]
|
||||
if dolbyVisionSubtypes.contains(codec) {
|
||||
isHDR = true
|
||||
} else if let extensions = CMFormatDescriptionGetExtensions(formatDescription)
|
||||
as? [String: Any]
|
||||
{
|
||||
let transferKey = kCMFormatDescriptionExtension_TransferFunction as String
|
||||
if let transfer = extensions[transferKey] as? String {
|
||||
let hlg = kCMFormatDescriptionTransferFunction_ITU_R_2100_HLG as String
|
||||
let pq = kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ as String
|
||||
isHDR = transfer == hlg || transfer == pq
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let rotation = rotationFromTransform(preferredTransform)
|
||||
let isRotated = rotation == 90 || rotation == 270
|
||||
let width = isRotated ? Int(naturalSize.height) : Int(naturalSize.width)
|
||||
let height = isRotated ? Int(naturalSize.width) : Int(naturalSize.height)
|
||||
|
||||
let audioTracks = try await asset.loadTracks(withMediaType: .audio)
|
||||
let hasAudio = !audioTracks.isEmpty
|
||||
|
||||
let fileSize: Int
|
||||
if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||
let size = attributes[.size] as? Int {
|
||||
fileSize = size
|
||||
} else {
|
||||
fileSize = 0
|
||||
}
|
||||
|
||||
let mimeType: String
|
||||
if let utType = UTType(filenameExtension: url.pathExtension) {
|
||||
mimeType = utType.preferredMIMEType ?? "video/mp4"
|
||||
} else {
|
||||
mimeType = "video/mp4"
|
||||
}
|
||||
|
||||
let durationSeconds = CMTimeGetSeconds(duration)
|
||||
var bitrate = Int(estimatedDataRate)
|
||||
if bitrate == 0 && durationSeconds > 0 {
|
||||
bitrate = Int(Double(fileSize * 8) / durationSeconds)
|
||||
}
|
||||
|
||||
return [
|
||||
"width": width,
|
||||
"height": height,
|
||||
"duration": durationSeconds,
|
||||
"bitrate": bitrate,
|
||||
"fileSize": fileSize,
|
||||
"mimeType": mimeType,
|
||||
"codec": codec,
|
||||
"hasAudio": hasAudio,
|
||||
"frameRate": nominalFrameRate,
|
||||
"rotation": rotation,
|
||||
"isHDR": isHDR
|
||||
]
|
||||
}
|
||||
|
||||
private static func rotationFromTransform(_ transform: CGAffineTransform) -> Int {
|
||||
let angle = atan2(transform.b, transform.a)
|
||||
let degrees = Int(round(angle * 180.0 / .pi))
|
||||
return ((degrees % 360) + 360) % 360
|
||||
}
|
||||
|
||||
private static func fourCCToString(_ code: FourCharCode) -> String {
|
||||
let chars: [Character] = [
|
||||
Character(UnicodeScalar((code >> 24) & 0xFF)!),
|
||||
Character(UnicodeScalar((code >> 16) & 0xFF)!),
|
||||
Character(UnicodeScalar((code >> 8) & 0xFF)!),
|
||||
Character(UnicodeScalar(code & 0xFF)!)
|
||||
]
|
||||
return String(chars).trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import {type EventSubscription, requireNativeModule} from 'expo-modules-core'
|
||||
|
||||
import {
|
||||
type CompressResult,
|
||||
type NativeCompressOptions,
|
||||
type VideoMetadata,
|
||||
} from './types'
|
||||
|
||||
type ProgressEvent = {id: number; progress: number}
|
||||
|
||||
interface ExpoBlueskyVideoCompressModule {
|
||||
probe(uri: string): Promise<VideoMetadata>
|
||||
compress(uri: string, options: NativeCompressOptions): Promise<CompressResult>
|
||||
cancel(jobId: number): void
|
||||
addListener(
|
||||
eventName: 'onProgress',
|
||||
listener: (event: ProgressEvent) => void,
|
||||
): EventSubscription
|
||||
}
|
||||
|
||||
export default requireNativeModule<ExpoBlueskyVideoCompressModule>(
|
||||
'ExpoBlueskyVideoCompress',
|
||||
)
|
||||
@@ -1,45 +0,0 @@
|
||||
export type CodecPreference = 'auto' | 'hevc' | 'h264'
|
||||
|
||||
export type VideoMetadata = {
|
||||
width: number
|
||||
height: number
|
||||
duration: number
|
||||
bitrate: number
|
||||
fileSize: number
|
||||
mimeType: string
|
||||
codec: string
|
||||
hasAudio: boolean
|
||||
frameRate: number
|
||||
rotation: number
|
||||
isHDR: boolean
|
||||
}
|
||||
|
||||
export type CompressOptions = {
|
||||
targetBitrate?: number
|
||||
maxSize?: number
|
||||
codec?: CodecPreference
|
||||
frameRateCap?: number
|
||||
}
|
||||
|
||||
export type CompressCallbacks = {
|
||||
onProgress?: (progress: number) => void
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export type CompressResult = {
|
||||
uri: string
|
||||
size: number
|
||||
mimeType: string
|
||||
width: number
|
||||
height: number
|
||||
duration: number
|
||||
codec: 'h264' | 'hevc'
|
||||
}
|
||||
|
||||
export type NativeCompressOptions = {
|
||||
targetBitrate: number
|
||||
maxSize: number
|
||||
codec: CodecPreference
|
||||
frameRateCap: number
|
||||
jobId: number
|
||||
}
|
||||
@@ -1156,7 +1156,7 @@
|
||||
"count": 2
|
||||
},
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-unsafe-member-access": {
|
||||
"count": 2
|
||||
@@ -1191,7 +1191,7 @@
|
||||
"count": 3
|
||||
},
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 3
|
||||
"count": 2
|
||||
},
|
||||
"typescript/no-unsafe-member-access": {
|
||||
"count": 3
|
||||
|
||||
+1
-2
@@ -117,7 +117,7 @@
|
||||
"@bsky.app/tapper": "^0.6.1",
|
||||
"@bsky.app/video": "0.3.6",
|
||||
"@bsky.app/video-compressor": "0.2.0",
|
||||
"@bsky/sdk": "1.0.1",
|
||||
"@bsky/sdk": "1.1.0",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@expo/html-elements": "^0.12.5",
|
||||
@@ -229,7 +229,6 @@
|
||||
"react-is": "19",
|
||||
"react-keyed-flatten-children": "^5.0.0",
|
||||
"react-native": "0.86.0",
|
||||
"react-native-compressor": "1.13.0",
|
||||
"react-native-date-picker": "^5.0.13",
|
||||
"react-native-device-attest": "^0.1.6",
|
||||
"react-native-drawer-layout": "^4.2.3",
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
diff --git a/android/build.gradle b/android/build.gradle
|
||||
index 5071139f8ee5fbba085d2afe3b2093de8eda915c..84bee34a238c6510169f6b6bdb0fda0594c77136 100644
|
||||
--- a/android/build.gradle
|
||||
+++ b/android/build.gradle
|
||||
@@ -115,7 +115,6 @@ dependencies {
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"
|
||||
implementation 'org.mp4parser:isoparser:1.9.56'
|
||||
- implementation 'com.github.banketree:AndroidLame-kotlin:v0.0.1'
|
||||
implementation 'javazoom:jlayer:1.0.1'
|
||||
}
|
||||
|
||||
diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioCompressor.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioCompressor.kt
|
||||
deleted file mode 100644
|
||||
index 9292d3ee50776bd9d7760b8dcf6d123d44b4e31b..0000000000000000000000000000000000000000
|
||||
diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioExtractor.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioExtractor.kt
|
||||
deleted file mode 100644
|
||||
index c6551828014437a14dc8f2f19488b647dba1bbe1..0000000000000000000000000000000000000000
|
||||
diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioHelper.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioHelper.kt
|
||||
deleted file mode 100644
|
||||
index 42040b4916573463415ef2f57789b3c4fa25d135..0000000000000000000000000000000000000000
|
||||
diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt
|
||||
index 446d4fb8b69e7cfdb51b29603aa2d52aac1ab8c8..f02190992dac823b6bbf2d77880a25adc48f16c7 100644
|
||||
--- a/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt
|
||||
+++ b/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt
|
||||
@@ -11,7 +11,9 @@ class AudioMain(private val reactContext: ReactApplicationContext) {
|
||||
promise: Promise) {
|
||||
try {
|
||||
|
||||
- AudioCompressor.CompressAudio(fileUrl,optionMap,reactContext,promise)
|
||||
+ // Skip compression on Android to avoid libandroidlame dependency
|
||||
+ // Return the original file URL without compression
|
||||
+ promise.resolve(fileUrl)
|
||||
} catch (ex: Exception) {
|
||||
promise.reject(ex)
|
||||
}
|
||||
diff --git a/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt b/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt
|
||||
index c14b727e930f4114765bfbe15b742ddcdeaa392f..1198908fcc66eeeea5e537085d7632a0d4b04545 100644
|
||||
--- a/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt
|
||||
+++ b/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt
|
||||
@@ -7,7 +7,6 @@ import android.provider.OpenableColumns
|
||||
import android.util.Log
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
-import com.reactnativecompressor.Audio.AudioCompressor
|
||||
import com.reactnativecompressor.Video.VideoCompressor.CompressionListener
|
||||
import com.reactnativecompressor.Video.VideoCompressor.VideoCompressorClass
|
||||
import java.io.FileNotFoundException
|
||||
@@ -152,10 +151,6 @@ object Utils {
|
||||
}
|
||||
}
|
||||
|
||||
- fun addLog(log: String) {
|
||||
- Log.d(AudioCompressor.TAG, log)
|
||||
- }
|
||||
-
|
||||
val exifAttributes = arrayOf(
|
||||
"FNumber",
|
||||
"ApertureValue",
|
||||
@@ -1,5 +0,0 @@
|
||||
# react-native-compressor
|
||||
|
||||
Patch file taken from https://github.com/numandev1/react-native-compressor/pull/355#issuecomment-3180870738
|
||||
|
||||
This patch removes the audio compression feature on Android from the library. This is because `libandroidlame.so`, the native dependency, does not support 16kb page sizes, and the Play Store has made this mandatory as of 1st Nov 2025.
|
||||
Generated
+5
-22
@@ -208,7 +208,6 @@ overrides:
|
||||
'@react-native/normalize-colors': 0.86.0
|
||||
'@expo/image-utils': 0.8.12
|
||||
'@types/estree': 1.0.6
|
||||
react-native-compressor: 1.13.0
|
||||
react-native-reanimated: 4.5.3
|
||||
react-native-worklets: 0.11.3
|
||||
psl: 1.9.0
|
||||
@@ -226,7 +225,6 @@ patchedDependencies:
|
||||
expo-notifications@57.0.7: 542d7d2024b364ba601b7f2af041353dccde555db0e6ecf0b5948c90e6b33a75
|
||||
expo-updates@57.0.10: 04f28cb005b770e9ae8f0065eab96e43cbb1e58107f5f6ad1bdd18f6deb66487
|
||||
expo@57.0.8: 1722861d12907a2d412d04230545b2fcbfa5547a79f99d763cb3e8de6f3a6f2a
|
||||
react-native-compressor@1.13.0: 58379dfaace6ced8590cb341c77f2ca8099dfa8f7df6297032ec51de767a9925
|
||||
react-native-date-picker@5.0.13: 92943fb79d17d7342a29bbb12b0d8ee3cf6f7bca12ed322dc8d124a3e9fb75bd
|
||||
react-native-dotenv@3.4.11: 16b34eb974399935d3cf7c2526534f906991ccd876215176658347059cdd2021
|
||||
react-native-drawer-layout@4.2.3: 74f2c043cc22ab87054f219e7c7373a509b779b18bfa79d27e7d051d73355130
|
||||
@@ -297,8 +295,8 @@ importers:
|
||||
specifier: 0.2.0
|
||||
version: 0.2.0(expo@57.0.8)(react-native@0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)
|
||||
'@bsky/sdk':
|
||||
specifier: 1.0.1
|
||||
version: 1.0.1(@atproto/lex@0.3.8)
|
||||
specifier: 1.1.0
|
||||
version: 1.1.0(@atproto/lex@0.3.8)
|
||||
'@emoji-mart/data':
|
||||
specifier: ^1.2.1
|
||||
version: 1.2.1
|
||||
@@ -632,9 +630,6 @@ importers:
|
||||
react-native:
|
||||
specifier: 0.86.0
|
||||
version: 0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1)
|
||||
react-native-compressor:
|
||||
specifier: 1.13.0
|
||||
version: 1.13.0(patch_hash=58379dfaace6ced8590cb341c77f2ca8099dfa8f7df6297032ec51de767a9925)(react-native@0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)
|
||||
react-native-date-picker:
|
||||
specifier: ^5.0.13
|
||||
version: 5.0.13(patch_hash=92943fb79d17d7342a29bbb12b0d8ee3cf6f7bca12ed322dc8d124a3e9fb75bd)(react-native@0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)
|
||||
@@ -1750,8 +1745,8 @@ packages:
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
'@bsky/sdk@1.0.1':
|
||||
resolution: {integrity: sha512-COhrmIZFsUJ1gj4gvqm2UWkRhTbmXZAL1osTZKgSaFftD4Xy0RRl8S9SRluCxo4/VPw/R3p7NQZ4X3BQFbUjVQ==}
|
||||
'@bsky/sdk@1.1.0':
|
||||
resolution: {integrity: sha512-6ZSrhNa6KC0arcimBG79Otk+FGZ15Br1YaEeatncbJYfWXGsw9l9nPjZ38IVpSW7UqQ/uhZ+WSQGnVUAdekzaw==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
peerDependencies:
|
||||
'@atproto/lex': ^0.3.0
|
||||
@@ -8169,13 +8164,6 @@ packages:
|
||||
react: '>=18.0.0'
|
||||
react-is: '>=18.0.0'
|
||||
|
||||
react-native-compressor@1.13.0:
|
||||
resolution: {integrity: sha512-vJYbrcjz2S7kgE3Q7444F71CjEDq5Qg6sGn67N9fJ0I1K6EhB/ZxpULdDe3FxJ4h/ncJc0oxOzNWZhxtGTqcQw==}
|
||||
engines: {node: '>= 16.0.0'}
|
||||
peerDependencies:
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
react-native-date-picker@5.0.13:
|
||||
resolution: {integrity: sha512-qCLUODZVsJetO5zuoXjw1D39K527XWqBG8sOfhWdHyPzf13h8RXR1/RSKd1N0fdRDi5GdyizYmB0lPAK12/hbw==}
|
||||
peerDependencies:
|
||||
@@ -10770,7 +10758,7 @@ snapshots:
|
||||
react: 19.2.3
|
||||
react-native: 0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1)
|
||||
|
||||
'@bsky/sdk@1.0.1(@atproto/lex@0.3.8)':
|
||||
'@bsky/sdk@1.1.0(@atproto/lex@0.3.8)':
|
||||
dependencies:
|
||||
'@atproto-labs/handle-resolver': 0.4.8
|
||||
'@atproto/lex': 0.3.8
|
||||
@@ -18175,11 +18163,6 @@ snapshots:
|
||||
react: 19.2.3
|
||||
react-is: 19.2.6
|
||||
|
||||
react-native-compressor@1.13.0(patch_hash=58379dfaace6ced8590cb341c77f2ca8099dfa8f7df6297032ec51de767a9925)(react-native@0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3):
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
react-native: 0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1)
|
||||
|
||||
react-native-date-picker@5.0.13(patch_hash=92943fb79d17d7342a29bbb12b0d8ee3cf6f7bca12ed322dc8d124a3e9fb75bd)(react-native@0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3):
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
|
||||
@@ -17,7 +17,6 @@ overrides:
|
||||
'@react-native/normalize-colors': '0.86.0'
|
||||
'@expo/image-utils': '0.8.12'
|
||||
'@types/estree': '1.0.6'
|
||||
'react-native-compressor': '1.13.0'
|
||||
'react-native-reanimated': '4.5.3'
|
||||
'react-native-worklets': '0.11.3'
|
||||
'psl': '1.9.0'
|
||||
@@ -38,7 +37,6 @@ patchedDependencies:
|
||||
'expo-notifications@57.0.7': patches/expo-notifications@57.0.7.patch
|
||||
'expo-updates@57.0.10': patches/expo-updates@57.0.10.patch
|
||||
expo@57.0.8: patches/expo@57.0.8.patch
|
||||
'react-native-compressor@1.13.0': patches/react-native-compressor@1.13.0.patch
|
||||
'react-native-date-picker@5.0.13': patches/react-native-date-picker@5.0.13.patch
|
||||
'react-native-dotenv@3.4.11': patches/react-native-dotenv@3.4.11.patch
|
||||
'react-native-drawer-layout@4.2.3': patches/react-native-drawer-layout@4.2.3.patch
|
||||
|
||||
+1
-2
@@ -126,9 +126,8 @@ function InnerApp() {
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn(`session: resume failed`, {message: e})
|
||||
} finally {
|
||||
setIsReady(true)
|
||||
}
|
||||
setIsReady(true)
|
||||
}
|
||||
const account = readLastActiveAccount()
|
||||
void onLaunch(account)
|
||||
|
||||
+1
-2
@@ -105,9 +105,8 @@ function InnerApp() {
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('session: resumeSession failed', {message: e})
|
||||
} finally {
|
||||
setIsReady(true)
|
||||
}
|
||||
setIsReady(true)
|
||||
}
|
||||
const account = readLastActiveAccount()
|
||||
void onLaunch(account)
|
||||
|
||||
@@ -117,6 +117,7 @@ const Context = createContext<AnalyticsBaseContextType>({
|
||||
geolocation: device.get(['geolocationServiceResponse']) || {
|
||||
countryCode: '',
|
||||
regionCode: '',
|
||||
city: '',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -181,6 +182,7 @@ export function AnalyticsContext({
|
||||
}
|
||||
const deviceId = useDeviceId() ?? 'unknown'
|
||||
const sessionId = useSessionId()
|
||||
// only IP based, never GPS
|
||||
const geolocation = useGeolocationServiceResponse()
|
||||
const parentContext = useContext(Context)
|
||||
/*
|
||||
|
||||
@@ -530,7 +530,7 @@ export function AuxiliaryView({
|
||||
transform: [
|
||||
{
|
||||
translateY:
|
||||
(ensureOnScreenTranslationSV.get() || translationSV.get()) *
|
||||
Math.max(ensureOnScreenTranslationSV.get(), translationSV.get()) *
|
||||
animationSV.get(),
|
||||
},
|
||||
{scale: interpolate(animationSV.get(), [0, 1], [0.2, 1])},
|
||||
@@ -544,21 +544,17 @@ export function AuxiliaryView({
|
||||
const onLayout = useCallback(() => {
|
||||
if (!measurement) return
|
||||
|
||||
let translation = 0
|
||||
|
||||
// vibes based, just assuming it'll fit within this space. revisit if we use
|
||||
// AuxiliaryView for something tall
|
||||
const TOP_INSET = topInset + 80
|
||||
|
||||
const distanceMessageFromTop = measurement.y - TOP_INSET
|
||||
if (distanceMessageFromTop < 0) {
|
||||
translation = -distanceMessageFromTop
|
||||
}
|
||||
const minimumTranslation = -distanceMessageFromTop
|
||||
|
||||
// normally, the context menu is responsible for measuring itself and moving everything into the right place
|
||||
// however, in auxiliary-only mode, that doesn't happen, so we need to do it ourselves here
|
||||
if (mode === 'auxiliary-only') {
|
||||
translationSV.set(translation)
|
||||
translationSV.set(Math.max(minimumTranslation, 0))
|
||||
ensureOnScreenTranslationSV.set(0)
|
||||
}
|
||||
// however, we also need to make sure that for super tall triggers, we don't go off the screen
|
||||
@@ -567,7 +563,7 @@ export function AuxiliaryView({
|
||||
// we'll just have to live with it for now, fixing it would be possible but be a large complexity
|
||||
// increase for an edge case
|
||||
else {
|
||||
ensureOnScreenTranslationSV.set(translation)
|
||||
ensureOnScreenTranslationSV.set(minimumTranslation)
|
||||
}
|
||||
}, [mode, measurement, translationSV, topInset, ensureOnScreenTranslationSV])
|
||||
|
||||
|
||||
@@ -429,14 +429,13 @@ let PostMenuItems = ({
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
ax.metric('postMenu:blockAccount', {
|
||||
uri: postUri,
|
||||
authorDid: postAuthor.did,
|
||||
logContext,
|
||||
feedDescriptor: feedFeedback.feedDescriptor,
|
||||
})
|
||||
}
|
||||
ax.metric('postMenu:blockAccount', {
|
||||
uri: postUri,
|
||||
authorDid: postAuthor.did,
|
||||
logContext,
|
||||
feedDescriptor: feedFeedback.feedDescriptor,
|
||||
})
|
||||
}
|
||||
|
||||
const onMuteAuthor = async () => {
|
||||
@@ -452,14 +451,13 @@ let PostMenuItems = ({
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
ax.metric('postMenu:unmuteAccount', {
|
||||
uri: postUri,
|
||||
authorDid: postAuthor.did,
|
||||
logContext,
|
||||
feedDescriptor: feedFeedback.feedDescriptor,
|
||||
})
|
||||
}
|
||||
ax.metric('postMenu:unmuteAccount', {
|
||||
uri: postUri,
|
||||
authorDid: postAuthor.did,
|
||||
logContext,
|
||||
feedDescriptor: feedFeedback.feedDescriptor,
|
||||
})
|
||||
} else {
|
||||
try {
|
||||
await queueMute()
|
||||
@@ -472,14 +470,13 @@ let PostMenuItems = ({
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
ax.metric('postMenu:muteAccount', {
|
||||
uri: postUri,
|
||||
authorDid: postAuthor.did,
|
||||
logContext,
|
||||
feedDescriptor: feedFeedback.feedDescriptor,
|
||||
})
|
||||
}
|
||||
ax.metric('postMenu:muteAccount', {
|
||||
uri: postUri,
|
||||
authorDid: postAuthor.did,
|
||||
logContext,
|
||||
feedDescriptor: feedFeedback.feedDescriptor,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -270,10 +270,6 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
|
||||
|
||||
// Track seen profiles
|
||||
const seenProfilesRef = useRef<Set<string>>(new Set())
|
||||
const itemsRef = useRef(items)
|
||||
itemsRef.current = items
|
||||
const selectedInterestRef = useRef(selectedInterest)
|
||||
selectedInterestRef.current = selectedInterest
|
||||
|
||||
const onViewableItemsChanged = useNonReactiveCallback(
|
||||
({viewableItems}: {viewableItems: ViewToken[]}) => {
|
||||
@@ -282,7 +278,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
|
||||
if (item.type === 'profile') {
|
||||
if (!seenProfilesRef.current.has(item.profile.did)) {
|
||||
seenProfilesRef.current.add(item.profile.did)
|
||||
const position = itemsRef.current.findIndex(
|
||||
const position = items.findIndex(
|
||||
i => i.type === 'profile' && i.profile.did === item.profile.did,
|
||||
)
|
||||
ax.metric('suggestedUser:seen', {
|
||||
@@ -292,9 +288,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
|
||||
position: position !== -1 ? position : 0,
|
||||
suggestedDid: item.profile.did,
|
||||
category:
|
||||
selectedInterestRef.current === FOR_YOU_TAB
|
||||
? null
|
||||
: selectedInterestRef.current,
|
||||
selectedInterest === FOR_YOU_TAB ? null : selectedInterest,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import {Fragment, useMemo, useRef} from 'react'
|
||||
import {
|
||||
Keyboard,
|
||||
Platform,
|
||||
type StyleProp,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {Keyboard, type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import {AtUri} from '@atproto/syntax'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -32,7 +26,7 @@ import {Group3_Stroke2_Corner0_Rounded as GroupIcon} from '#/components/icons/Gr
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_NATIVE} from '#/env'
|
||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||
import {app} from '#/lexicons'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
@@ -110,14 +104,9 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
|
||||
}
|
||||
onPress={onPressOpen}
|
||||
{...(isThreadAuthor
|
||||
? Platform.select({
|
||||
web: {
|
||||
onHoverIn: prefetch,
|
||||
},
|
||||
native: {
|
||||
onPressIn: prefetch,
|
||||
},
|
||||
})
|
||||
? IS_WEB
|
||||
? {onHoverIn: prefetch}
|
||||
: {onPressIn: prefetch}
|
||||
: {})}
|
||||
hitSlop={HITSLOP_10}>
|
||||
{({hovered, focused, pressed}) => (
|
||||
|
||||
@@ -9,7 +9,7 @@ import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {mergeRefs} from '#/lib/merge-refs'
|
||||
import {atoms as a, ios, platform, useTheme} from '#/alf'
|
||||
import {atoms as a, platform, useTheme} from '#/alf'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_ANDROID, IS_IOS} from '#/env'
|
||||
@@ -94,7 +94,11 @@ export function OTPInput({
|
||||
</View>
|
||||
<TextInput
|
||||
// SMS autofill is borked on iOS if you open the keyboard immediately -sfn
|
||||
onLayout={ios(() => setTimeout(() => innerRef.current?.focus(), 100))}
|
||||
onLayout={
|
||||
IS_IOS
|
||||
? () => setTimeout(() => innerRef.current?.focus(), 100)
|
||||
: undefined
|
||||
}
|
||||
autoFocus={IS_ANDROID}
|
||||
accessible
|
||||
accessibilityLabel={label}
|
||||
|
||||
@@ -95,9 +95,8 @@ function DeviceLocationRequestDialogInner({onLocationAcquired}: Props) {
|
||||
safeMessage: e.message,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setIsRequesting(false)
|
||||
}
|
||||
setIsRequesting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -241,9 +241,8 @@ export function PostInteractionSettingsDialogControlledInner(
|
||||
type: 'error',
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
setIsSaving(false)
|
||||
}, [
|
||||
_,
|
||||
ax,
|
||||
|
||||
@@ -47,7 +47,13 @@ export function SearchInput({
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={MagnifyingGlassIcon} />
|
||||
<TextField.Input
|
||||
inputRef={mergeRefs([internalRef, ref])}
|
||||
/*
|
||||
* Deferred into the callback: React Compiler only special-cases the
|
||||
* `ref` prop, so a merged ref built during render and handed to
|
||||
* `inputRef` reads as accessing a ref. `mergeRefs` already returns a
|
||||
* fresh function per render, so this adds no identity churn.
|
||||
*/
|
||||
inputRef={node => mergeRefs([internalRef, ref])(node)}
|
||||
label={label || l`Search`}
|
||||
value={value}
|
||||
placeholder={l`Search`}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_WEB} from '#/env'
|
||||
|
||||
const Context = createContext<{
|
||||
inputRef: React.RefObject<React.ComponentRef<typeof TextInput> | null> | null
|
||||
@@ -101,11 +102,13 @@ export function Root({children, isInvalid = false, style}: RootProps) {
|
||||
{zIndex: 0},
|
||||
style,
|
||||
]}
|
||||
{...web({
|
||||
onClick: () => inputRef.current?.focus(),
|
||||
onMouseOver: onHoverIn,
|
||||
onMouseOut: onHoverOut,
|
||||
})}>
|
||||
{...(IS_WEB
|
||||
? {
|
||||
onClick: () => inputRef.current?.focus(),
|
||||
onMouseOver: onHoverIn,
|
||||
onMouseOut: onHoverOut,
|
||||
}
|
||||
: {})}>
|
||||
{children}
|
||||
</View>
|
||||
</Context.Provider>
|
||||
|
||||
@@ -319,9 +319,8 @@ function Inner(
|
||||
type: 'setError',
|
||||
error,
|
||||
})
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
setIsPending(false)
|
||||
}, [logger, submitReport, props, state, ax, l, videoTimestampSeconds])
|
||||
|
||||
useCallOnce(() => {
|
||||
|
||||
@@ -9,4 +9,5 @@ export const GEOLOCATION_SERVICE_URL = `${GEOLOCATION_URL}/geolocation`
|
||||
export const FALLBACK_GEOLOCATION_SERVICE_RESPONSE: Geolocation = {
|
||||
countryCode: undefined,
|
||||
regionCode: undefined,
|
||||
city: undefined,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export type Geolocation = {
|
||||
countryCode: string | undefined
|
||||
regionCode: string | undefined
|
||||
/** Only populated by the IP-based geolocation service. */
|
||||
city?: string
|
||||
serviceGeolocation?: Geolocation
|
||||
deviceGeolocation?: Geolocation
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import {type Client} from '@atproto/lex'
|
||||
|
||||
import {CustomFeedAPI} from './custom'
|
||||
|
||||
jest.mock('#/state/preferences/languages', () => ({
|
||||
getAppLanguageAsContentLanguage: () => '',
|
||||
getContentLanguages: () => [],
|
||||
}))
|
||||
|
||||
jest.mock('./utils', () => ({
|
||||
createBskyTopicsHeader: () => ({}),
|
||||
isBlueskyOwnedFeed: () => false,
|
||||
}))
|
||||
|
||||
describe('CustomFeedAPI', () => {
|
||||
it('preserves the cursor from an empty logged-out fallback page', async () => {
|
||||
const originalFetch = global.fetch
|
||||
const fetchMock: jest.MockedFunction<typeof fetch> = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(Response.json({feed: []}))
|
||||
.mockResolvedValueOnce(Response.json({feed: [], cursor: 'next'}))
|
||||
global.fetch = fetchMock
|
||||
const api = new CustomFeedAPI({
|
||||
client: {did: undefined} as unknown as Client,
|
||||
feedParams: {
|
||||
feed: 'at://did:example:feed/app.bsky.feed.generator/test',
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await expect(api.fetch({cursor: undefined, limit: 10})).resolves.toEqual({
|
||||
cursor: 'next',
|
||||
feed: [],
|
||||
})
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
global.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -96,7 +96,7 @@ export class CustomFeedAPI implements FeedAPI {
|
||||
const feed =
|
||||
data.feed.length > limit ? data.feed.slice(0, limit) : data.feed
|
||||
return {
|
||||
cursor: feed.length ? data.cursor : undefined,
|
||||
cursor: data.cursor,
|
||||
feed,
|
||||
}
|
||||
}
|
||||
@@ -154,11 +154,7 @@ async function loggedOutFetch(
|
||||
|
||||
// no data, try again with language headers removed
|
||||
data = await getFeedOrNull(params, '')
|
||||
if (data?.feed?.length) {
|
||||
return data
|
||||
}
|
||||
|
||||
return null
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,10 +48,8 @@ export class LikesFeedAPI implements FeedAPI {
|
||||
cursor,
|
||||
limit,
|
||||
})
|
||||
// HACKFIX: the API incorrectly returns a cursor when there are no items -sfn
|
||||
const isEmptyPage = data.feed.length === 0
|
||||
return {
|
||||
cursor: isEmptyPage ? undefined : data.cursor,
|
||||
cursor: data.cursor,
|
||||
feed: data.feed,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import {type Client} from '@atproto/lex'
|
||||
|
||||
import {app} from '#/lexicons'
|
||||
import {MergeFeedAPI} from './merge'
|
||||
|
||||
const post = {} as app.bsky.feed.defs.FeedViewPost
|
||||
|
||||
describe('MergeFeedAPI', () => {
|
||||
it('drains a terminal following queue without restarting the source', async () => {
|
||||
const api = new MergeFeedAPI({
|
||||
client: {} as Client,
|
||||
feedParams: {},
|
||||
feedTuners: [],
|
||||
})
|
||||
api.reset()
|
||||
api.following.queue = [post, post, post]
|
||||
api.following.hasMore = false
|
||||
|
||||
const first = await api.fetch({cursor: 'started', limit: 2})
|
||||
const second = await api.fetch({cursor: first.cursor, limit: 2})
|
||||
|
||||
expect(first.feed).toHaveLength(2)
|
||||
expect(first.cursor).toBeDefined()
|
||||
expect(second.feed).toHaveLength(1)
|
||||
expect(second.cursor).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stops when the following source repeats its cursor', async () => {
|
||||
const call = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({feed: [], cursor: 'a'})
|
||||
.mockResolvedValueOnce({feed: [], cursor: 'a'})
|
||||
const api = new MergeFeedAPI({
|
||||
client: {call} as unknown as Client,
|
||||
feedParams: {},
|
||||
feedTuners: [],
|
||||
})
|
||||
|
||||
const first = await api.fetch({cursor: undefined, limit: 1})
|
||||
const second = await api.fetch({cursor: first.cursor, limit: 1})
|
||||
|
||||
expect(first.cursor).toBeDefined()
|
||||
expect(second.cursor).toBeUndefined()
|
||||
expect(call).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('stops when the following source returns an empty cursor', async () => {
|
||||
const call = jest.fn().mockResolvedValue({feed: [], cursor: ''})
|
||||
const api = new MergeFeedAPI({
|
||||
client: {call} as unknown as Client,
|
||||
feedParams: {},
|
||||
feedTuners: [],
|
||||
})
|
||||
|
||||
const result = await api.fetch({cursor: undefined, limit: 1})
|
||||
|
||||
expect(result.cursor).toBeUndefined()
|
||||
expect(call).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops when the following source cycles to an earlier cursor', async () => {
|
||||
const call = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({feed: [], cursor: 'a'})
|
||||
.mockResolvedValueOnce({feed: [], cursor: 'b'})
|
||||
.mockResolvedValueOnce({feed: [], cursor: 'a'})
|
||||
const api = new MergeFeedAPI({
|
||||
client: {call} as unknown as Client,
|
||||
feedParams: {},
|
||||
feedTuners: [],
|
||||
})
|
||||
|
||||
const first = await api.fetch({cursor: undefined, limit: 1})
|
||||
const second = await api.fetch({cursor: first.cursor, limit: 1})
|
||||
const third = await api.fetch({cursor: second.cursor, limit: 1})
|
||||
|
||||
expect(third.cursor).toBeUndefined()
|
||||
expect(call).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('drains a terminal custom-feed queue without restarting the source', async () => {
|
||||
const call = jest.fn()
|
||||
const api = new MergeFeedAPI({
|
||||
client: {call} as unknown as Client,
|
||||
feedParams: {
|
||||
mergeFeedEnabled: true,
|
||||
mergeFeedSources: [
|
||||
'at://did:example:feed/app.bsky.feed.generator/test',
|
||||
],
|
||||
},
|
||||
feedTuners: [],
|
||||
})
|
||||
api.reset()
|
||||
api.following.hasMore = false
|
||||
api.customFeeds[0].queue = [post, post, post]
|
||||
api.customFeeds[0].hasMore = false
|
||||
|
||||
const first = await api.fetch({cursor: 'started', limit: 2})
|
||||
const second = await api.fetch({cursor: first.cursor, limit: 2})
|
||||
|
||||
expect(first.feed).toHaveLength(2)
|
||||
expect(second.feed).toHaveLength(1)
|
||||
expect(second.cursor).toBeUndefined()
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -108,8 +108,8 @@ export class MergeFeedAPI implements FeedAPI {
|
||||
|
||||
const promises = []
|
||||
|
||||
// always keep following topped up
|
||||
if (this.following.numReady < limit) {
|
||||
// always keep following topped up while the source has another page
|
||||
if (this.following.hasMore && this.following.numReady < limit) {
|
||||
await this.following.fetchNext(60)
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ export class MergeFeedAPI implements FeedAPI {
|
||||
!this.following.hasMore && this.following.numReady < limit
|
||||
if (this.params.mergeFeedEnabled || outOfFollows) {
|
||||
for (const feed of feeds) {
|
||||
if (feed.numReady < 5) {
|
||||
if (feed.hasMore && feed.numReady < 5) {
|
||||
promises.push(feed.fetchNext(10))
|
||||
}
|
||||
}
|
||||
@@ -145,8 +145,12 @@ export class MergeFeedAPI implements FeedAPI {
|
||||
}
|
||||
}
|
||||
|
||||
const hasMore =
|
||||
this.following.hasMore ||
|
||||
this.following.numReady > 0 ||
|
||||
this.customFeeds.some(feed => feed.hasMore || feed.numReady > 0)
|
||||
return {
|
||||
cursor: String(this.itemCursor),
|
||||
cursor: hasMore ? String(this.itemCursor) : undefined,
|
||||
feed: posts,
|
||||
}
|
||||
}
|
||||
@@ -155,8 +159,8 @@ export class MergeFeedAPI implements FeedAPI {
|
||||
const i = this.itemCursor++
|
||||
const candidateFeeds = this.customFeeds.filter(f => f.numReady > 0)
|
||||
const canSample = candidateFeeds.length > 0
|
||||
const hasFollows = this.following.hasMore
|
||||
const hasFollowsReady = this.following.numReady > 0
|
||||
const hasFollows = this.following.hasMore || hasFollowsReady
|
||||
|
||||
// this condition establishes the frequency that custom feeds are woven into follows
|
||||
const shouldSample =
|
||||
@@ -187,6 +191,7 @@ class MergeFeedSource {
|
||||
feedTuners: FeedTunerFn[]
|
||||
sourceInfo: ReasonFeedSource | undefined
|
||||
cursor: string | undefined = undefined
|
||||
seenCursors = new Set<string>()
|
||||
queue: app.bsky.feed.defs.FeedViewPost[] = []
|
||||
hasMore = true
|
||||
|
||||
@@ -221,11 +226,16 @@ class MergeFeedSource {
|
||||
const page = await this._getFeed(this.cursor, n)
|
||||
if (page) {
|
||||
this.cursor = page.cursor
|
||||
if (page.feed.length) {
|
||||
this.queue = this.queue.concat(page.feed)
|
||||
const cursor = this.cursor
|
||||
if (cursor) {
|
||||
this.hasMore = !this.seenCursors.has(cursor)
|
||||
this.seenCursors.add(cursor)
|
||||
} else {
|
||||
this.hasMore = false
|
||||
}
|
||||
if (page.feed.length) {
|
||||
this.queue = this.queue.concat(page.feed)
|
||||
}
|
||||
} else {
|
||||
this.hasMore = false
|
||||
}
|
||||
|
||||
@@ -54,9 +54,8 @@ export function useAccountSwitcher() {
|
||||
Toast.show(_(msg`Please sign in as @${account.handle}`), {
|
||||
type: 'warning',
|
||||
})
|
||||
} finally {
|
||||
setPendingDid(null)
|
||||
}
|
||||
setPendingDid(null)
|
||||
},
|
||||
[_, ax, resumeSession, requestSwitchToAccount, pendingDid],
|
||||
)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import {useRef} from 'react'
|
||||
import {useState} from 'react'
|
||||
import {Animated} from 'react-native'
|
||||
|
||||
export function useAnimatedValue(initialValue: number) {
|
||||
const lazyRef = useRef<Animated.Value>(undefined)
|
||||
|
||||
if (lazyRef.current === undefined) {
|
||||
lazyRef.current = new Animated.Value(initialValue)
|
||||
}
|
||||
|
||||
return lazyRef.current
|
||||
/*
|
||||
* A lazy `useState` initialiser rather than a lazily-populated ref: both
|
||||
* construct once and keep the same instance, but reading a ref during render
|
||||
* is a Rules of React violation.
|
||||
*/
|
||||
const [value] = useState(() => new Animated.Value(initialValue))
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -74,8 +74,13 @@ export function useDraggableScroll<
|
||||
}
|
||||
}, [cursor])
|
||||
|
||||
/*
|
||||
* Deferred into the callback so the merge does not happen during the render of
|
||||
* whichever component uses this hook - see SearchInput for the full reasoning.
|
||||
*/
|
||||
const refs = useMemo(
|
||||
() => mergeRefs(outerRef ? [ref, outerRef] : [ref]),
|
||||
() => (node: Scrollable | null) =>
|
||||
mergeRefs(outerRef ? [ref, outerRef] : [ref])(node),
|
||||
[ref, outerRef],
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import {getInfoAsync} from 'expo-file-system/legacy'
|
||||
|
||||
export async function getUriSize(uri: string): Promise<number> {
|
||||
const info = await getInfoAsync(uri)
|
||||
if (!info.exists) {
|
||||
throw new Error('Failed to read image size')
|
||||
}
|
||||
return info.size
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export async function getUriSize(uri: string): Promise<number> {
|
||||
const response = await fetch(uri)
|
||||
const blob = await response.blob()
|
||||
return blob.size
|
||||
}
|
||||
@@ -44,8 +44,6 @@ export async function compressVideo(
|
||||
}
|
||||
|
||||
// Pre-check the threshold ourselves so we can label the skip in telemetry.
|
||||
// rnc would do the same skip internally via minimumFileSizeForCompress, but
|
||||
// that path is invisible to us.
|
||||
const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes(
|
||||
file.mimeType as SupportedMimeTypes,
|
||||
)
|
||||
|
||||
@@ -31,7 +31,7 @@ function assignRef<T>(
|
||||
* @returns The function `mergeRefs` is being returned. It takes an array of mutable or legacy refs and
|
||||
* returns a ref callback function that can be used to merge multiple refs into a single ref.
|
||||
*/
|
||||
export function mergeRefs<T>(refs: (Ref<T> | undefined)[]): Ref<T> {
|
||||
export function mergeRefs<T>(refs: (Ref<T> | undefined)[]): RefCallback<T> {
|
||||
return (value: T | null) => {
|
||||
const cleanups: (() => void)[] = []
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useEffect, useRef, useState} from 'react'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {AppState, type AppStateStatus} from 'react-native'
|
||||
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
|
||||
import {
|
||||
@@ -168,8 +168,8 @@ function QueryProviderInner({
|
||||
children: React.ReactNode
|
||||
currentDid: string | undefined
|
||||
}) {
|
||||
const initialDid = useRef(currentDid)
|
||||
if (currentDid !== initialDid.current) {
|
||||
const [initialDid] = useState(currentDid)
|
||||
if (currentDid !== initialDid) {
|
||||
throw Error(
|
||||
'Something is very wrong. Expected did to be stable due to key above.',
|
||||
)
|
||||
|
||||
+299
-299
File diff suppressed because it is too large
Load Diff
@@ -95,9 +95,8 @@ export function Deactivated() {
|
||||
logger.error(e, {
|
||||
message: 'Failed to activate account',
|
||||
})
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
setPending(false)
|
||||
}, [_, pdsClient, refreshSession, setPending, setError, queryClient])
|
||||
|
||||
return (
|
||||
|
||||
@@ -103,9 +103,8 @@ export function ListHiddenScreen({
|
||||
msg`There was an issue. Please check your internet connection and try again.`,
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
setIsProcessing(false)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -58,9 +58,8 @@ export const ChooseAccountForm = ({
|
||||
})
|
||||
// Move to login form.
|
||||
onSelectAccount(account)
|
||||
} finally {
|
||||
setPendingDid(null)
|
||||
}
|
||||
setPendingDid(null)
|
||||
},
|
||||
[
|
||||
currentAccount,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useRef, useState} from 'react'
|
||||
import {useEffect, useRef, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import PagerView from 'react-native-pager-view'
|
||||
import {type PagerViewOnPageSelectedEvent} from 'react-native-pager-view'
|
||||
@@ -26,9 +26,17 @@ export function ValuePropositionPager({
|
||||
|
||||
if (step !== activePage) {
|
||||
setActivePage(step)
|
||||
ref.current?.setPage(step)
|
||||
}
|
||||
|
||||
/*
|
||||
* In an effect rather than inline above: driving the pager is a side effect,
|
||||
* and reading a ref during render is a Rules of React violation. `initialPage`
|
||||
* already covers the first render, so the mount run is a no-op.
|
||||
*/
|
||||
useEffect(() => {
|
||||
ref.current?.setPage(step)
|
||||
}, [step])
|
||||
|
||||
const images = [PROP_1[t.name], PROP_2[t.name], PROP_3[t.name]]
|
||||
|
||||
return (
|
||||
|
||||
@@ -22,7 +22,7 @@ import {IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants'
|
||||
import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions'
|
||||
import {compressIfNeeded} from '#/lib/media/manip'
|
||||
import {openCropper} from '#/lib/media/picker'
|
||||
import {getDataUriSize} from '#/lib/media/util'
|
||||
import {getUriSize} from '#/lib/media/uriSize'
|
||||
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
|
||||
import {isCancelledError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
@@ -133,7 +133,7 @@ export function StepProfile() {
|
||||
height: rendered.height,
|
||||
width: rendered.width,
|
||||
path: result.uri,
|
||||
size: getDataUriSize(result.uri),
|
||||
size: await getUriSize(result.uri),
|
||||
},
|
||||
]
|
||||
} catch {
|
||||
|
||||
@@ -102,9 +102,8 @@ function Inner() {
|
||||
logger.error('Failed to request password reset', {safeMessage: e})
|
||||
setError(cleanError(e))
|
||||
}
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
setIsProcessing(false)
|
||||
}
|
||||
|
||||
const onChangePassword = async () => {
|
||||
@@ -151,9 +150,8 @@ function Inner() {
|
||||
logger.error('Failed to set new password', {safeMessage: e})
|
||||
setError(cleanError(e))
|
||||
}
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
setIsProcessing(false)
|
||||
}
|
||||
|
||||
const onBlur = () => {
|
||||
|
||||
@@ -64,9 +64,8 @@ function DeactivateAccountDialogInner({
|
||||
logger.error(e, {
|
||||
message: 'Failed to deactivate account',
|
||||
})
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
setPending(false)
|
||||
}, [client, control, logoutCurrentAccount, _, setPending])
|
||||
|
||||
return (
|
||||
|
||||
@@ -49,9 +49,8 @@ export function DisableEmail2FADialog({
|
||||
setStage(Stages.ConfirmCode)
|
||||
} catch (e) {
|
||||
setError(cleanError(String(e)))
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
setIsProcessing(false)
|
||||
}
|
||||
|
||||
const onConfirmDisable = async () => {
|
||||
@@ -80,9 +79,8 @@ export function DisableEmail2FADialog({
|
||||
} else {
|
||||
setError(cleanError(e))
|
||||
}
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
setIsProcessing(false)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -53,9 +53,8 @@ export function ExportCarDialog({
|
||||
} catch (e) {
|
||||
logger.error('Error occurred while downloading CAR file', {message: e})
|
||||
Toast.show(l`Error occurred while saving file`, {type: 'error'})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
setLoading(false)
|
||||
}, [l, currentAccount, pdsClient])
|
||||
|
||||
const downloadChatData = useCallback(async () => {
|
||||
@@ -84,9 +83,8 @@ export function ExportCarDialog({
|
||||
} catch (e) {
|
||||
logger.error('Error occurred while downloading chat data', {message: e})
|
||||
Toast.show(l`Error occurred while saving file`, {type: 'error'})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
setLoading(false)
|
||||
}, [l, currentAccount, chatClient])
|
||||
|
||||
return (
|
||||
|
||||
@@ -51,9 +51,8 @@ export function StepCaptchaNative() {
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
logger.error(e)
|
||||
} finally {
|
||||
setReady(true)
|
||||
}
|
||||
setReady(true)
|
||||
})()
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -219,6 +219,7 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
|
||||
a.w_full,
|
||||
a.py_lg,
|
||||
a.flex_row,
|
||||
a.flex_wrap,
|
||||
a.gap_md,
|
||||
a.align_center,
|
||||
]}>
|
||||
@@ -227,7 +228,7 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
|
||||
style={
|
||||
gtMobile
|
||||
? [a.flex_1, a.flex, a.flex_row, a.justify_end]
|
||||
: []
|
||||
: [a.flex_shrink]
|
||||
}>
|
||||
<Text
|
||||
style={[
|
||||
|
||||
@@ -60,9 +60,8 @@ export function SignupQueued() {
|
||||
}
|
||||
} catch (e: any) {
|
||||
logger.error('Failed to check signup queue', {err: e.toString()})
|
||||
} finally {
|
||||
setProcessing(false)
|
||||
}
|
||||
setProcessing(false)
|
||||
}, [
|
||||
setProcessing,
|
||||
setEstimatedTime,
|
||||
|
||||
@@ -2,9 +2,9 @@ import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
} from 'react'
|
||||
|
||||
import {useCurrentConvoId} from './current-convo-id'
|
||||
@@ -44,8 +44,7 @@ export function useMessageDraft() {
|
||||
export function useSaveMessageDraft(message: string) {
|
||||
const {currentConvoId} = useCurrentConvoId()
|
||||
const {dispatch} = useMessageDraftsContext()
|
||||
const messageRef = useRef(message)
|
||||
messageRef.current = message
|
||||
const getMessage = useEffectEvent(() => message)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -53,7 +52,7 @@ export function useSaveMessageDraft(message: string) {
|
||||
dispatch({
|
||||
type: 'set',
|
||||
convoId: currentConvoId,
|
||||
draft: messageRef.current,
|
||||
draft: getMessage(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* 3. Don't call this query's `refetch()` if you're trying to sync latest; call `checkUnread()` instead.
|
||||
*/
|
||||
|
||||
import {useCallback, useEffect, useMemo, useRef} from 'react'
|
||||
import {useCallback, useMemo, useRef} from 'react'
|
||||
import {AtUri} from '@atproto/syntax'
|
||||
import {moderatePost} from '@bsky/sdk/moderation'
|
||||
import {
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
didOrHandleUriMatches,
|
||||
embedViewRecordToPostView,
|
||||
getEmbeddedPost,
|
||||
useAutoPagination,
|
||||
} from '../util'
|
||||
import {type FeedPage} from './types'
|
||||
import {useUnreadNotificationsApi} from './unread'
|
||||
@@ -220,54 +221,9 @@ export function useNotificationFeedQuery(opts: {
|
||||
),
|
||||
})
|
||||
|
||||
// The server may end up returning an empty page, a page with too few items,
|
||||
// or a page with items that end up getting filtered out. When we fetch pages,
|
||||
// we'll keep track of how many items we actually hope to see. If the server
|
||||
// doesn't return enough items, we're going to continue asking for more items.
|
||||
const lastItemCount = useRef(0)
|
||||
const wantedItemCount = useRef(0)
|
||||
const autoPaginationAttemptCount = useRef(0)
|
||||
useEffect(() => {
|
||||
const {data, isLoading, isRefetching, isFetchingNextPage, hasNextPage} =
|
||||
query
|
||||
// Count the items that we already have.
|
||||
let itemCount = 0
|
||||
for (const page of data?.pages || []) {
|
||||
itemCount += page.items.length
|
||||
}
|
||||
|
||||
// If items got truncated, reset the state we're tracking below.
|
||||
if (itemCount !== lastItemCount.current) {
|
||||
if (itemCount < lastItemCount.current) {
|
||||
wantedItemCount.current = itemCount
|
||||
}
|
||||
lastItemCount.current = itemCount
|
||||
}
|
||||
|
||||
// Now track how many items we really want, and fetch more if needed.
|
||||
if (isLoading || isRefetching) {
|
||||
// During the initial fetch, we want to get an entire page's worth of items.
|
||||
wantedItemCount.current = PAGE_SIZE
|
||||
} else if (isFetchingNextPage) {
|
||||
if (itemCount > wantedItemCount.current) {
|
||||
// We have more items than wantedItemCount, so wantedItemCount must be out of date.
|
||||
// Some other code must have called fetchNextPage(), for example, from onEndReached.
|
||||
// Adjust the wantedItemCount to reflect that we want one more full page of items.
|
||||
wantedItemCount.current = itemCount + PAGE_SIZE
|
||||
}
|
||||
} else if (hasNextPage) {
|
||||
// At this point we're not fetching anymore, so it's time to make a decision.
|
||||
// If we didn't receive enough items from the server, paginate again until we do.
|
||||
if (itemCount < wantedItemCount.current) {
|
||||
autoPaginationAttemptCount.current++
|
||||
if (autoPaginationAttemptCount.current < 50 /* failsafe */) {
|
||||
query.fetchNextPage()
|
||||
}
|
||||
} else {
|
||||
autoPaginationAttemptCount.current = 0
|
||||
}
|
||||
}
|
||||
}, [query])
|
||||
const itemCount =
|
||||
query.data?.pages.reduce((count, page) => count + page.items.length, 0) ?? 0
|
||||
useAutoPagination(query, itemCount, PAGE_SIZE)
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useCallback, useEffect, useMemo, useRef} from 'react'
|
||||
import {useCallback, useMemo, useRef} from 'react'
|
||||
import {AppState} from 'react-native'
|
||||
import {type Client} from '@atproto/lex'
|
||||
import {type AtIdentifierString, AtUri, type AtUriString} from '@atproto/syntax'
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
didOrHandleUriMatches,
|
||||
embedViewRecordToPostView,
|
||||
getEmbeddedPost,
|
||||
useAutoPagination,
|
||||
} from './util'
|
||||
|
||||
type ActorDid = string
|
||||
@@ -368,56 +369,13 @@ export function usePostFeedQuery(
|
||||
),
|
||||
})
|
||||
|
||||
// The server may end up returning an empty page, a page with too few items,
|
||||
// or a page with items that end up getting filtered out. When we fetch pages,
|
||||
// we'll keep track of how many items we actually hope to see. If the server
|
||||
// doesn't return enough items, we're going to continue asking for more items.
|
||||
const lastItemCount = useRef(0)
|
||||
const wantedItemCount = useRef(0)
|
||||
const autoPaginationAttemptCount = useRef(0)
|
||||
useEffect(() => {
|
||||
const {data, isLoading, isRefetching, isFetchingNextPage, hasNextPage} =
|
||||
query
|
||||
// Count the items that we already have.
|
||||
let itemCount = 0
|
||||
for (const page of data?.pages || []) {
|
||||
for (const slice of page.slices) {
|
||||
itemCount += slice.items.length
|
||||
}
|
||||
let itemCount = 0
|
||||
for (const page of query.data?.pages || []) {
|
||||
for (const slice of page.slices) {
|
||||
itemCount += slice.items.length
|
||||
}
|
||||
|
||||
// If items got truncated, reset the state we're tracking below.
|
||||
if (itemCount !== lastItemCount.current) {
|
||||
if (itemCount < lastItemCount.current) {
|
||||
wantedItemCount.current = itemCount
|
||||
}
|
||||
lastItemCount.current = itemCount
|
||||
}
|
||||
|
||||
// Now track how many items we really want, and fetch more if needed.
|
||||
if (isLoading || isRefetching) {
|
||||
// During the initial fetch, we want to get an entire page's worth of items.
|
||||
wantedItemCount.current = MIN_POSTS
|
||||
} else if (isFetchingNextPage) {
|
||||
if (itemCount > wantedItemCount.current) {
|
||||
// We have more items than wantedItemCount, so wantedItemCount must be out of date.
|
||||
// Some other code must have called fetchNextPage(), for example, from onEndReached.
|
||||
// Adjust the wantedItemCount to reflect that we want one more full page of items.
|
||||
wantedItemCount.current = itemCount + MIN_POSTS
|
||||
}
|
||||
} else if (hasNextPage) {
|
||||
// At this point we're not fetching anymore, so it's time to make a decision.
|
||||
// If we didn't receive enough items from the server, paginate again until we do.
|
||||
if (itemCount < wantedItemCount.current) {
|
||||
autoPaginationAttemptCount.current++
|
||||
if (autoPaginationAttemptCount.current < 50 /* failsafe */) {
|
||||
query.fetchNextPage()
|
||||
}
|
||||
} else {
|
||||
autoPaginationAttemptCount.current = 0
|
||||
}
|
||||
}
|
||||
}, [query])
|
||||
}
|
||||
useAutoPagination(query, itemCount, MIN_POSTS)
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
type UsePreferencesQueryResponse,
|
||||
} from '#/state/queries/preferences/types'
|
||||
import {createQueryKey} from '#/state/queries/util'
|
||||
import {useAppviewClient, usePdsClient} from '#/state/session'
|
||||
import {useAppviewClient, useChatClient, usePdsClient} from '#/state/session'
|
||||
import {applyLabelersToClient, saveLabelers} from '#/state/session/moderation'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {makeAgeRestrictedModerationPrefs} from '#/ageAssurance/util'
|
||||
@@ -58,6 +58,7 @@ export const preferencesQueryKey = createQueryKey(
|
||||
export function usePreferencesQuery() {
|
||||
const client = usePdsClient()
|
||||
const appviewClient = useAppviewClient()
|
||||
const chatClient = useChatClient()
|
||||
const aa = useAgeAssurance()
|
||||
|
||||
const query = useQuery({
|
||||
@@ -85,12 +86,12 @@ export function usePreferencesQuery() {
|
||||
* from a labeler would not affect server-attached labels until the
|
||||
* session bundle was rebuilt.
|
||||
*
|
||||
* The subscriptions go on the appview client, which is what stamps
|
||||
* `atproto-accept-labelers` on its own requests. The Bluesky moderation
|
||||
* DID is dropped so the globally redacted authority is not also listed
|
||||
* unredacted.
|
||||
* The subscriptions go on both services that hydrate moderated content.
|
||||
* The Bluesky moderation DID is dropped so the globally redacted
|
||||
* authority is not also listed unredacted.
|
||||
*/
|
||||
applyLabelersToClient(appviewClient, labelerDids)
|
||||
applyLabelersToClient(chatClient, labelerDids)
|
||||
|
||||
/*
|
||||
* `BskyPreferences` is now the sdk's own type, so the assembled
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import {renderHook} from '@testing-library/react-native'
|
||||
|
||||
import {useAutoPagination} from './util'
|
||||
|
||||
function query(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
data: {pageParams: [undefined]},
|
||||
isLoading: false,
|
||||
isRefetching: false,
|
||||
isFetchingNextPage: false,
|
||||
hasNextPage: true,
|
||||
fetchNextPage: jest.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useAutoPagination', () => {
|
||||
it('fetches another page when visible items are missing', () => {
|
||||
const value = query()
|
||||
|
||||
renderHook(() => useAutoPagination(value, 0, 10))
|
||||
|
||||
expect(value.fetchNextPage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops when the requested number of items is visible', () => {
|
||||
const value = query()
|
||||
|
||||
renderHook(() => useAutoPagination(value, 10, 10))
|
||||
|
||||
expect(value.fetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops when the server repeats a cursor', () => {
|
||||
const value = query({data: {pageParams: [undefined, 'a', 'a']}})
|
||||
|
||||
renderHook(() => useAutoPagination(value, 0, 10))
|
||||
|
||||
expect(value.fetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops when structured page params repeat a cursor', () => {
|
||||
const value = query({
|
||||
data: {pageParams: [undefined, {cursor: 'a'}, {cursor: 'a'}]},
|
||||
})
|
||||
|
||||
renderHook(() => useAutoPagination(value, 0, 10))
|
||||
|
||||
expect(value.fetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops when a cursor repeats non-adjacently', () => {
|
||||
const value = query({data: {pageParams: [undefined, 'a', 'b', 'a']}})
|
||||
|
||||
renderHook(() => useAutoPagination(value, 0, 10))
|
||||
|
||||
expect(value.fetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fills one page after switching to a smaller cached query', () => {
|
||||
const first = query({hasNextPage: false})
|
||||
const second = query()
|
||||
let value = first
|
||||
let itemCount = 10
|
||||
const {rerender} = renderHook(() => useAutoPagination(value, itemCount, 10))
|
||||
|
||||
value = second
|
||||
itemCount = 5
|
||||
rerender(undefined)
|
||||
|
||||
expect(second.fetchNextPage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('resets the attempt limit after switching to cached data with the same item count', () => {
|
||||
const fetchNextPage = jest.fn().mockResolvedValue(undefined)
|
||||
const data = {pageParams: [undefined]}
|
||||
let value = query({fetchNextPage, data})
|
||||
const itemCount = 0
|
||||
const {rerender} = renderHook(() => useAutoPagination(value, itemCount, 10))
|
||||
|
||||
for (let i = 1; i < 50; i++) {
|
||||
value = query({
|
||||
fetchNextPage,
|
||||
data,
|
||||
})
|
||||
rerender(undefined)
|
||||
}
|
||||
expect(fetchNextPage).toHaveBeenCalledTimes(49)
|
||||
|
||||
const second = query({
|
||||
data: {
|
||||
pageParams: Array.from({length: 51}, (_, i) => `new-cursor-${i}`),
|
||||
},
|
||||
})
|
||||
value = second
|
||||
rerender(undefined)
|
||||
|
||||
expect(second.fetchNextPage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import {useEffect, useRef} from 'react'
|
||||
import {type AtUri} from '@atproto/syntax'
|
||||
import {
|
||||
type InfiniteData,
|
||||
@@ -8,6 +9,82 @@ import {
|
||||
import {app} from '#/lexicons'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
type AutoPaginationQuery = {
|
||||
data?: {pageParams: unknown[]}
|
||||
isLoading: boolean
|
||||
isRefetching: boolean
|
||||
isFetchingNextPage: boolean
|
||||
hasNextPage: boolean
|
||||
fetchNextPage: () => Promise<unknown>
|
||||
}
|
||||
|
||||
export function useAutoPagination(
|
||||
query: AutoPaginationQuery,
|
||||
itemCount: number,
|
||||
pageSize: number,
|
||||
) {
|
||||
const lastItemCount = useRef(0)
|
||||
const lastPageParams = useRef(query.data?.pageParams)
|
||||
const wantedItemCount = useRef(pageSize)
|
||||
const attemptCount = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const cursorOf = (param: unknown) =>
|
||||
param && typeof param === 'object' && 'cursor' in param
|
||||
? param.cursor
|
||||
: param
|
||||
const pageParams = query.data?.pageParams
|
||||
const previousPageParams = lastPageParams.current
|
||||
const continuedPagination =
|
||||
pageParams &&
|
||||
previousPageParams &&
|
||||
pageParams.length > previousPageParams.length &&
|
||||
previousPageParams.every((param, index) =>
|
||||
Object.is(cursorOf(param), cursorOf(pageParams[index])),
|
||||
)
|
||||
if (
|
||||
pageParams !== previousPageParams &&
|
||||
previousPageParams &&
|
||||
!continuedPagination
|
||||
) {
|
||||
wantedItemCount.current = pageSize
|
||||
attemptCount.current = 0
|
||||
}
|
||||
lastPageParams.current = pageParams
|
||||
|
||||
if (itemCount !== lastItemCount.current) {
|
||||
attemptCount.current = 0
|
||||
if (itemCount < lastItemCount.current) {
|
||||
wantedItemCount.current = Math.max(itemCount, pageSize)
|
||||
}
|
||||
lastItemCount.current = itemCount
|
||||
}
|
||||
|
||||
if (query.isLoading || query.isRefetching) {
|
||||
wantedItemCount.current = pageSize
|
||||
attemptCount.current = 0
|
||||
} else if (query.isFetchingNextPage) {
|
||||
if (itemCount > wantedItemCount.current) {
|
||||
wantedItemCount.current = itemCount + pageSize
|
||||
}
|
||||
} else if (query.hasNextPage) {
|
||||
if (itemCount < wantedItemCount.current) {
|
||||
const currentCursor = cursorOf(pageParams?.at(-1))
|
||||
const repeatedCursor = pageParams
|
||||
?.slice(0, -1)
|
||||
.some(param => Object.is(cursorOf(param), currentCursor))
|
||||
if (repeatedCursor) return
|
||||
attemptCount.current++
|
||||
if (attemptCount.current < 50) {
|
||||
void query.fetchNextPage()
|
||||
}
|
||||
} else {
|
||||
attemptCount.current = 0
|
||||
}
|
||||
}
|
||||
}, [itemCount, pageSize, query])
|
||||
}
|
||||
|
||||
export type StructuredQueryKey<T extends Record<string, unknown>> = readonly [
|
||||
string,
|
||||
T,
|
||||
|
||||
@@ -251,19 +251,35 @@ describe('buildChatClient', () => {
|
||||
expect(headers.get('authorization')).toBe('Bearer access-jwt')
|
||||
})
|
||||
|
||||
it('emits no labeler header', async () => {
|
||||
/* the global authorities do not apply: a chat call is not an appview read */
|
||||
it('emits a global app labeler once, redacted', async () => {
|
||||
const client = buildChatClient(makeSession(fetchMock))
|
||||
configureGlobalAppLabelers(['did:plc:global-labeler'])
|
||||
|
||||
await buildChatClient(makeSession(fetchMock))
|
||||
.call(chat.bsky.convo.listConvos, {})
|
||||
.catch(() => {})
|
||||
await client.call(chat.bsky.convo.listConvos, {}).catch(() => {})
|
||||
|
||||
expect(
|
||||
headersFor(fetchMock, 'chat.bsky.convo.listConvos').get(
|
||||
'atproto-accept-labelers',
|
||||
),
|
||||
).toBeNull()
|
||||
const labelers = headersFor(fetchMock, 'chat.bsky.convo.listConvos').get(
|
||||
'atproto-accept-labelers',
|
||||
)
|
||||
const entries = labelers!
|
||||
.split(',')
|
||||
.map(l => l.trim())
|
||||
.filter(l => l.includes('did:plc:global-labeler'))
|
||||
expect(entries).toEqual(['did:plc:global-labeler;redact'])
|
||||
})
|
||||
|
||||
it('emits an account subscription exactly once', async () => {
|
||||
const client = buildChatClient(makeSession(fetchMock))
|
||||
client.setLabelers(['did:plc:labeler'])
|
||||
|
||||
await client.call(chat.bsky.convo.listConvos, {}).catch(() => {})
|
||||
|
||||
const labelers = headersFor(fetchMock, 'chat.bsky.convo.listConvos').get(
|
||||
'atproto-accept-labelers',
|
||||
)
|
||||
const entries = labelers!
|
||||
.split(',')
|
||||
.filter(l => l.includes('did:plc:labeler'))
|
||||
expect(entries).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import {type Client} from '@atproto/lex'
|
||||
import {api} from '@bsky/sdk'
|
||||
import {beforeEach, describe, expect, it, jest} from '@jest/globals'
|
||||
|
||||
jest.mock('#/storage', () => ({
|
||||
account: {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
},
|
||||
device: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import {account} from '#/storage'
|
||||
import {configureGlobalAppLabelers} from '../additional-moderation-authorities'
|
||||
import {configureModerationForAccount} from '../moderation'
|
||||
import {makeAccount} from './mock-fetch'
|
||||
|
||||
describe('configureModerationForAccount', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
configureGlobalAppLabelers([])
|
||||
})
|
||||
|
||||
it('applies cached account labelers to appview and chat', () => {
|
||||
const appviewClient = {setLabelers: jest.fn()} as unknown as Client
|
||||
const chatClient = {setLabelers: jest.fn()} as unknown as Client
|
||||
jest
|
||||
.mocked(account.get)
|
||||
.mockReturnValue(['did:plc:account-labeler', api.moderation.did])
|
||||
|
||||
configureModerationForAccount(
|
||||
{appviewClient, chatClient},
|
||||
makeAccount({handle: 'alice.example.com'}),
|
||||
)
|
||||
|
||||
expect(appviewClient.setLabelers).toHaveBeenCalledWith([
|
||||
'did:plc:account-labeler',
|
||||
])
|
||||
expect(chatClient.setLabelers).toHaveBeenCalledWith([
|
||||
'did:plc:account-labeler',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -98,10 +98,10 @@ export function configureAdditionalModerationAuthorities() {
|
||||
* reads, so a request carries the same `;redact` authorities whether or not
|
||||
* there is a session behind it.
|
||||
*
|
||||
* It is a single global producer by design. The PDS and chat clients opt out
|
||||
* with `appLabelers: null` (see `clients.ts`) because those services take no
|
||||
* moderation authorities, leaving exactly one producer on an appview request and
|
||||
* none elsewhere.
|
||||
* It is a single global producer by design. The PDS client opts out with
|
||||
* `appLabelers: null` (see `clients.ts`) because that service takes no
|
||||
* moderation authorities. Appview and chat requests each carry one copy from
|
||||
* their respective clients.
|
||||
*/
|
||||
export function configureGlobalAppLabelers(dids: string[]) {
|
||||
Client.configure({appLabelers: dids as `did:${string}:${string}`[]})
|
||||
|
||||
@@ -54,12 +54,13 @@ export function buildPdsClient(agent: Agent): Client {
|
||||
* env-configurable `CHAT_PROXY_DID` rather than a hard-coded constant, so it can
|
||||
* be retargeted per environment.
|
||||
*
|
||||
* `appLabelers: null` for the same reason as the PDS client: the chat service
|
||||
* takes no moderation authorities.
|
||||
* Unlike the PDS client, chat carries moderation authorities. The service uses
|
||||
* them to hydrate labels on profiles embedded in conversation responses, so
|
||||
* this client reads the global `Client.appLabelers` and receives the account's
|
||||
* subscriptions through `configureModerationForAccount`.
|
||||
*/
|
||||
export function buildChatClient(agent: Agent): Client {
|
||||
return createLexClient(agent, {
|
||||
appLabelers: null,
|
||||
service: CHAT_PROXY_SERVICE,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import {type SessionAccount} from './types'
|
||||
|
||||
/** The moderation surface of a session bundle. */
|
||||
type ModerationSession = {appviewClient: Client}
|
||||
type ModerationSession = {appviewClient: Client; chatClient: Client}
|
||||
|
||||
/**
|
||||
* Cache an account's subscribed labeler DIDs. Called on every preferences
|
||||
@@ -45,8 +45,8 @@ export function readLabelers(did: string): string[] | undefined {
|
||||
* lex collects the two lists into a `Set` keyed on the suffixed string, so
|
||||
* neither dedupes against the other.
|
||||
*
|
||||
* Only the appview client takes subscriptions - the PDS and chat clients suppress
|
||||
* labelers entirely (see clients.ts).
|
||||
* Appview and chat both take subscriptions. The PDS suppresses labelers because
|
||||
* repo and identity requests do not hydrate moderated content (see clients.ts).
|
||||
*/
|
||||
export function applyLabelersToClient(
|
||||
client: Client,
|
||||
@@ -85,6 +85,7 @@ export function configureModerationForAccount(
|
||||
const labelerDids = readLabelers(account.did)
|
||||
if (labelerDids) {
|
||||
applyLabelersToClient(bundle.appviewClient, labelerDids)
|
||||
applyLabelersToClient(bundle.chatClient, labelerDids)
|
||||
} else {
|
||||
// If there are no headers in the storage, we'll not send them on the initial requests.
|
||||
// If we wanted to fix this, we could block on the preferences query here.
|
||||
|
||||
@@ -408,7 +408,7 @@ export const ComposePost = ({
|
||||
asset.mimeType !== 'image/gif'
|
||||
) {
|
||||
try {
|
||||
const probed = await getVideoMetadata(asset.uri)
|
||||
const probed = await getVideoMetadata(asset.uri, asset.mimeType)
|
||||
asset = {
|
||||
...asset,
|
||||
mimeType: probed.mimeType ?? asset.mimeType,
|
||||
@@ -538,8 +538,8 @@ export const ComposePost = ({
|
||||
let uri = videoInfo.uri
|
||||
if (IS_ANDROID) {
|
||||
// Android: expo-file-system double-encodes filenames with special chars.
|
||||
// The file exists, but react-native-compressor's MediaMetadataRetriever
|
||||
// can't handle the double-encoded URI. Copy to a temp file with a simple name.
|
||||
// The native metadata probe can't handle the double-encoded URI, so
|
||||
// copy it to a temp file with a simple name.
|
||||
const sourceFile = new FileSystem.File(videoInfo.uri)
|
||||
const tempFileName = `draft-video-${Date.now()}.${mimeToExt(videoInfo.mimeType)}`
|
||||
const tempFile = new FileSystem.File(
|
||||
@@ -553,7 +553,7 @@ export const ComposePost = ({
|
||||
})
|
||||
uri = tempFile.uri
|
||||
}
|
||||
asset = await getVideoMetadata(uri)
|
||||
asset = await getVideoMetadata(uri, videoInfo.mimeType)
|
||||
}
|
||||
|
||||
// Start video processing using existing flow
|
||||
@@ -1213,7 +1213,7 @@ export const ComposePost = ({
|
||||
if (initQuote) {
|
||||
// We want to wait for the quote count to update before we call `onPost`, which will refetch data
|
||||
void whenAppViewReady(client, initQuote.uri, res => {
|
||||
const anchor = res.thread.at(0)
|
||||
const anchor = res?.thread.at(0)
|
||||
if (
|
||||
bsky.isType(app.bsky.unspecced.defs.threadItemPost, anchor?.value) &&
|
||||
anchor.value.post.quoteCount !== initQuote.quoteCount
|
||||
@@ -2478,7 +2478,9 @@ function useKeyboardVerticalOffset() {
|
||||
async function whenAppViewReady(
|
||||
client: Client,
|
||||
uri: string,
|
||||
fn: (res: app.bsky.unspecced.getPostThreadV2.$OutputBody) => boolean,
|
||||
fn: (
|
||||
res: app.bsky.unspecced.getPostThreadV2.$OutputBody | undefined,
|
||||
) => boolean,
|
||||
) {
|
||||
await until(
|
||||
5, // 5 tries
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import {clearCache, createVideoThumbnail} from 'react-native-compressor'
|
||||
import Animated, {FadeIn} from 'react-native-reanimated'
|
||||
import {File} from 'expo-file-system'
|
||||
import {Image} from 'expo-image'
|
||||
import {
|
||||
getThumbnailAsync,
|
||||
type VideoThumbnailsResult,
|
||||
} from 'expo-video-thumbnails'
|
||||
import {type QueryClient, useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
@@ -8,15 +12,32 @@ import {atoms as a} from '#/alf'
|
||||
export const RQKEY = 'video-thumbnail'
|
||||
|
||||
export function clearThumbnailCache(queryClient: QueryClient) {
|
||||
clearCache().catch(() => {})
|
||||
void queryClient.resetQueries({queryKey: [RQKEY]})
|
||||
for (const [, thumbnail] of queryClient.getQueriesData<VideoThumbnailsResult>(
|
||||
{queryKey: [RQKEY]},
|
||||
)) {
|
||||
if (thumbnail) {
|
||||
deleteThumbnail(thumbnail)
|
||||
}
|
||||
}
|
||||
queryClient.removeQueries({queryKey: [RQKEY]})
|
||||
}
|
||||
|
||||
function deleteThumbnail(thumbnail: VideoThumbnailsResult) {
|
||||
try {
|
||||
new File(thumbnail.uri).delete()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function VideoTranscodeBackdrop({uri}: {uri: string}) {
|
||||
const {data: thumbnail} = useQuery({
|
||||
queryKey: [RQKEY, uri],
|
||||
queryFn: async () => {
|
||||
return await createVideoThumbnail(uri)
|
||||
queryFn: async ({signal}) => {
|
||||
const result = await getThumbnailAsync(uri)
|
||||
if (signal.aborted) {
|
||||
deleteThumbnail(result)
|
||||
throw new Error('Thumbnail generation canceled')
|
||||
}
|
||||
return result
|
||||
},
|
||||
})
|
||||
|
||||
@@ -25,7 +46,7 @@ export function VideoTranscodeBackdrop({uri}: {uri: string}) {
|
||||
<Animated.View style={a.flex_1} entering={FadeIn}>
|
||||
<Image
|
||||
style={a.flex_1}
|
||||
source={thumbnail.path}
|
||||
source={thumbnail.uri}
|
||||
cachePolicy="none"
|
||||
accessibilityIgnoresInvertColors
|
||||
blurRadius={15}
|
||||
|
||||
@@ -1,29 +1,41 @@
|
||||
import {getVideoMetaData} from 'react-native-compressor'
|
||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||
import {probe} from '@bsky.app/video-compressor'
|
||||
|
||||
import {extToMime} from '#/lib/media/video/util'
|
||||
|
||||
export async function getVideoMetadata(
|
||||
file: File | string,
|
||||
fallbackMimeType?: string,
|
||||
): Promise<ImagePickerAsset> {
|
||||
if (typeof file !== 'string')
|
||||
throw new Error(
|
||||
'getVideoMetadata was passed a File, when on native it should be a uri',
|
||||
)
|
||||
const metadata = await getVideoMetaData(file)
|
||||
const metadata = await probe(file)
|
||||
return {
|
||||
uri: file,
|
||||
mimeType: extToMime(metadata.extension),
|
||||
mimeType: getMimeTypeFromUri(file) ?? fallbackMimeType ?? metadata.mimeType,
|
||||
fileSize: metadata.fileSize,
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
/*
|
||||
* react-native-compressor reports seconds; the rest of the app treats
|
||||
* `ImagePickerAsset.duration` as milliseconds (matching expo-image-picker).
|
||||
* The probe reports seconds; `ImagePickerAsset.duration` uses milliseconds.
|
||||
*/
|
||||
duration: metadata.duration * 1000,
|
||||
}
|
||||
}
|
||||
|
||||
function getMimeTypeFromUri(uri: string): string | undefined {
|
||||
const extension = uri.match(/\.([^.?#/]+)(?:[?#]|$)/)?.[1]
|
||||
if (!extension) return
|
||||
|
||||
try {
|
||||
return extToMime(extension)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export function hasWebCodecs(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export function hasWebCodecs(): boolean {
|
||||
|
||||
export async function getVideoMetadata(
|
||||
file: File | string,
|
||||
_fallbackMimeType?: string,
|
||||
): Promise<ImagePickerAsset> {
|
||||
if (typeof file === 'string')
|
||||
throw new Error(
|
||||
|
||||
@@ -79,7 +79,7 @@ export function ProfileFeedgens({
|
||||
error,
|
||||
refetch,
|
||||
} = useProfileFeedgensQuery(did, opts)
|
||||
const isEmpty = !isPending && !data?.pages[0]?.feeds.length
|
||||
const isEmpty = !isPending && !data?.pages.some(page => page.feeds.length)
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const navigation = useNavigation()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
@@ -90,7 +90,7 @@ export function ListMembers({
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useListMembersQuery(list)
|
||||
const isEmpty = !isFetching && !data?.pages[0].items.length
|
||||
const isEmpty = !isFetching && !data?.pages.some(page => page.items.length)
|
||||
const isOwner =
|
||||
currentAccount && data?.pages[0].list.creator.did === currentAccount.did
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ export function ProfileLists({
|
||||
error,
|
||||
refetch,
|
||||
} = useProfileListsQuery(did, opts)
|
||||
const isEmpty = !isPending && !data?.pages[0]?.lists.length
|
||||
const isEmpty = !isPending && !data?.pages.some(page => page.lists.length)
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const navigation = useNavigation()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
@@ -103,9 +103,8 @@ export function NotificationFeed({
|
||||
logger.error('Failed to refresh notifications feed', {
|
||||
message: err,
|
||||
})
|
||||
} finally {
|
||||
setIsPTRing(false)
|
||||
}
|
||||
setIsPTRing(false)
|
||||
}, [refreshNotifications, setIsPTRing])
|
||||
|
||||
const onEndReached = useCallback(async () => {
|
||||
|
||||
@@ -920,9 +920,8 @@ function SayHelloBtn({profile}: {profile: app.bsky.actor.defs.ProfileView}) {
|
||||
})
|
||||
} catch (e) {
|
||||
logger.error('Failed to get conversation', {safeMessage: e})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -36,7 +36,7 @@ export function ModerationBlockedAccounts({}: Props) {
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useMyBlockedAccountsQuery()
|
||||
const isEmpty = !isFetching && !data?.pages[0]?.blocks.length
|
||||
const isEmpty = !isFetching && !data?.pages.some(page => page.blocks.length)
|
||||
const profiles = useMemo(() => {
|
||||
if (data?.pages) {
|
||||
return data.pages.flatMap(page => page.blocks)
|
||||
|
||||
@@ -36,7 +36,7 @@ export function ModerationMutedAccounts({}: Props) {
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useMyMutedAccountsQuery()
|
||||
const isEmpty = !isFetching && !data?.pages[0]?.mutes.length
|
||||
const isEmpty = !isFetching && !data?.pages.some(page => page.mutes.length)
|
||||
const profiles = useMemo(() => {
|
||||
if (data?.pages) {
|
||||
return data.pages.flatMap(page => page.mutes)
|
||||
|
||||
@@ -559,9 +559,8 @@ function ComposeBtn({minimal}: {minimal: boolean}) {
|
||||
handle = await fetchHandle(handle)
|
||||
} catch (e) {
|
||||
handle = undefined
|
||||
} finally {
|
||||
setIsFetchingHandle(false)
|
||||
}
|
||||
setIsFetchingHandle(false)
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
Reference in New Issue
Block a user