mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 04:30:54 +02:00
performance improvements + ui/ux fixes
This commit is contained in:
@@ -90,5 +90,34 @@ class AstraScopeModule : Module() {
|
|||||||
Function("setFallbackGain") { linear: Double ->
|
Function("setFallbackGain") { linear: Double ->
|
||||||
GainBridge.fallbackGain = linear.toFloat()
|
GainBridge.fallbackGain = linear.toFloat()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
View(AstraScopeView::class) {
|
||||||
|
Prop("mode") { view, value: String -> view.mode = ScopeMode.from(value) }
|
||||||
|
Prop("source") { view, value: String -> view.source = ScopeSource.from(value) }
|
||||||
|
Prop("active") { view, value: Boolean -> view.requestedActive = value }
|
||||||
|
Prop("reducedMotion") { view, value: Boolean -> view.reducedMotion = value }
|
||||||
|
Prop("frameMs") { view, value: Double -> view.frameMs = value }
|
||||||
|
Prop("analysisFrameMs") { view, value: Double -> view.analysisFrameMs = value }
|
||||||
|
Prop("smoothing") { view, value: Double -> view.smoothing = value.toFloat() }
|
||||||
|
Prop("pointCount") { view, value: Int -> view.pointCount = value }
|
||||||
|
Prop("dbMin") { view, value: Double -> view.dbMin = value.toFloat() }
|
||||||
|
Prop("dbMax") { view, value: Double -> view.dbMax = value.toFloat() }
|
||||||
|
Prop("tiltDbPerOctave") { view, value: Double -> view.tiltDbPerOctave = value.toFloat() }
|
||||||
|
Prop("color") { view, value: Int -> view.scopeColor = value }
|
||||||
|
Prop("lineWidth") { view, value: Double -> view.lineWidthDp = value.toFloat() }
|
||||||
|
Prop("lineOpacity") { view, value: Double -> view.lineOpacity = value.toFloat() }
|
||||||
|
Prop("fillOpacity") { view, value: Double -> view.fillOpacity = value.toFloat() }
|
||||||
|
Prop("glow") { view, value: Boolean -> view.glow = value }
|
||||||
|
Prop("glowOpacity") { view, value: Double -> view.glowOpacity = value.toFloat() }
|
||||||
|
Prop("edgeFade") { view, value: Boolean -> view.edgeFade = value }
|
||||||
|
Prop("edgeFadeWidth") { view, value: Double -> view.edgeFadeWidthDp = value.toFloat() }
|
||||||
|
Prop("gain") { view, value: Double -> view.gain = value.toFloat() }
|
||||||
|
Prop("values") { view, value: List<Double>? ->
|
||||||
|
view.staticValues = value?.let { values ->
|
||||||
|
FloatArray(values.size) { index -> values[index].toFloat() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OnViewDidUpdateProps { view -> view.commitProps() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
package expo.modules.astrascope
|
||||||
|
|
||||||
|
import kotlin.math.ln
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.pow
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
internal enum class ScopeMode {
|
||||||
|
SPECTRUM,
|
||||||
|
OSCILLOSCOPE;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun from(value: String): ScopeMode =
|
||||||
|
if (value == "oscilloscope") OSCILLOSCOPE else SPECTRUM
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal enum class ScopeSource {
|
||||||
|
PRE,
|
||||||
|
POST;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun from(value: String): ScopeSource = if (value == "post") POST else PRE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure scope math kept separate from the Android view so cadence, projection,
|
||||||
|
* clamping, decay, and lifecycle generation changes have inexpensive JVM tests.
|
||||||
|
*/
|
||||||
|
internal object AstraScopeProjection {
|
||||||
|
const val SPECTRUM_BINS = 1024
|
||||||
|
const val OSCILLOSCOPE_POINTS = 256
|
||||||
|
const val DECAY_PER_FRAME = 0.72f
|
||||||
|
const val REST_EPSILON = 0.004f
|
||||||
|
|
||||||
|
private const val MIN_FREQUENCY = 20.0
|
||||||
|
private const val MAX_FREQUENCY = 20_000.0
|
||||||
|
private const val TILT_REFERENCE_HZ = 1_000.0
|
||||||
|
private const val LN_2 = 0.6931471805599453
|
||||||
|
|
||||||
|
fun cadenceMs(requestedMs: Double, refreshRate: Float): Long {
|
||||||
|
if (requestedMs > 0.0) return max(1L, requestedMs.roundToInt().toLong())
|
||||||
|
val safeRate = if (refreshRate.isFinite() && refreshRate >= 30f) refreshRate else 60f
|
||||||
|
return max(1L, (1_000.0 / safeRate).roundToInt().toLong())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clamp01(value: Float): Float = when {
|
||||||
|
!value.isFinite() || value <= 0f -> 0f
|
||||||
|
value >= 1f -> 1f
|
||||||
|
else -> value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projects linear FFT bins into log-frequency display points, matching the
|
||||||
|
* former React/Skia renderer's 20 Hz–20 kHz presentation and tilt.
|
||||||
|
*/
|
||||||
|
fun writeSpectrum(
|
||||||
|
raw: java.nio.FloatBuffer,
|
||||||
|
rawCount: Int,
|
||||||
|
out: FloatArray,
|
||||||
|
pointCount: Int,
|
||||||
|
dbMin: Float,
|
||||||
|
dbMax: Float,
|
||||||
|
tiltDbPerOctave: Float,
|
||||||
|
sampleRate: Float = 48_000f
|
||||||
|
) {
|
||||||
|
val bins = min(rawCount, raw.capacity())
|
||||||
|
val points = min(pointCount, out.size)
|
||||||
|
if (bins <= 0 || points < 2) {
|
||||||
|
out.fill(0f, 0, max(0, points))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val nyquist = max(1.0, sampleRate.toDouble() / 2.0)
|
||||||
|
val minFrequency = min(MIN_FREQUENCY, nyquist)
|
||||||
|
val maxFrequency = max(minFrequency + 1.0, min(MAX_FREQUENCY, nyquist))
|
||||||
|
val binWidth = nyquist / bins.toDouble()
|
||||||
|
val range = max(1f, dbMax - dbMin)
|
||||||
|
|
||||||
|
for (point in 0 until points) {
|
||||||
|
val t0 = point.toDouble() / (points - 1).toDouble()
|
||||||
|
val t1 = min(1.0, (point + 1).toDouble() / (points - 1).toDouble())
|
||||||
|
val frequency0 = frequencyAt(t0, minFrequency, maxFrequency)
|
||||||
|
val frequency1 = frequencyAt(t1, minFrequency, maxFrequency)
|
||||||
|
val centerFrequency = (frequency0 + frequency1) * 0.5
|
||||||
|
val bin0 = frequency0 / binWidth
|
||||||
|
val bin1 = frequency1 / binWidth
|
||||||
|
val centerBin = (bin0 + bin1) * 0.5
|
||||||
|
|
||||||
|
val rawDb = if (kotlin.math.abs(bin1 - bin0) <= 1.0) {
|
||||||
|
interpolated(raw, bins, min(centerBin, (bins - 1).toDouble()))
|
||||||
|
} else {
|
||||||
|
peak(raw, bins, bin0, bin1)
|
||||||
|
}
|
||||||
|
val tiltedDb =
|
||||||
|
rawDb + tiltDbPerOctave * (ln(max(1.0, centerFrequency) / TILT_REFERENCE_HZ) / LN_2).toFloat()
|
||||||
|
out[point] = clamp01((tiltedDb - dbMin) / range)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the largest absolute value left after one decay step. */
|
||||||
|
fun decay(values: FloatArray, count: Int, factor: Float = DECAY_PER_FRAME): Float {
|
||||||
|
var peak = 0f
|
||||||
|
val n = min(count, values.size)
|
||||||
|
for (index in 0 until n) {
|
||||||
|
val next = values[index] * factor
|
||||||
|
values[index] = next
|
||||||
|
peak = max(peak, kotlin.math.abs(next))
|
||||||
|
}
|
||||||
|
return peak
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun frequencyAt(t: Double, minFrequency: Double, maxFrequency: Double): Double =
|
||||||
|
minFrequency * (maxFrequency / minFrequency).pow(t)
|
||||||
|
|
||||||
|
private fun interpolated(
|
||||||
|
values: java.nio.FloatBuffer,
|
||||||
|
count: Int,
|
||||||
|
position: Double
|
||||||
|
): Float {
|
||||||
|
val lower = position.toInt().coerceIn(0, count - 1)
|
||||||
|
val upper = min(count - 1, lower + 1)
|
||||||
|
val mix = (position - lower.toDouble()).toFloat()
|
||||||
|
return values.get(lower) + (values.get(upper) - values.get(lower)) * mix
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun peak(
|
||||||
|
values: java.nio.FloatBuffer,
|
||||||
|
count: Int,
|
||||||
|
startPosition: Double,
|
||||||
|
endPosition: Double
|
||||||
|
): Float {
|
||||||
|
val start = startPosition.toInt().coerceIn(0, count - 1)
|
||||||
|
val end = kotlin.math.ceil(endPosition).toInt().coerceIn(start, count - 1)
|
||||||
|
var result = values.get(start)
|
||||||
|
for (index in (start + 1)..end) result = max(result, values.get(index))
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A monotonically increasing token makes queued/running work self-cancelling
|
||||||
|
* after detach, backgrounding, pause, size loss, or a prop-generation change.
|
||||||
|
*/
|
||||||
|
internal class ScopeRenderGate {
|
||||||
|
@Volatile
|
||||||
|
private var generation = 0
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var eligible: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun update(nextEligible: Boolean): Int {
|
||||||
|
eligible = nextEligible
|
||||||
|
generation += 1
|
||||||
|
return generation
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isCurrent(token: Int): Boolean = eligible && generation == token
|
||||||
|
|
||||||
|
fun currentGeneration(): Int = generation
|
||||||
|
}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
package expo.modules.astrascope
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.ComposeShader
|
||||||
|
import android.graphics.LinearGradient
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Path
|
||||||
|
import android.graphics.PorterDuff
|
||||||
|
import android.graphics.Shader
|
||||||
|
import android.graphics.SurfaceTexture
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.HandlerThread
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.TextureView
|
||||||
|
import expo.modules.kotlin.AppContext
|
||||||
|
import expo.modules.kotlin.views.ExpoView
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
import java.nio.FloatBuffer
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
private object ScopeRenderDispatcher {
|
||||||
|
private val thread = HandlerThread("AstraScopeRender").apply { start() }
|
||||||
|
val handler = Handler(thread.looper)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android-native spectrum/oscilloscope surface. The shared worker owns every
|
||||||
|
* FFT read and path mutation; the UI thread only paints the most recently
|
||||||
|
* prepared front path.
|
||||||
|
*/
|
||||||
|
internal class AstraScopeView(
|
||||||
|
context: Context,
|
||||||
|
appContext: AppContext
|
||||||
|
) : ExpoView(context, appContext) {
|
||||||
|
var mode = ScopeMode.SPECTRUM
|
||||||
|
var source = ScopeSource.PRE
|
||||||
|
var requestedActive = false
|
||||||
|
var reducedMotion = false
|
||||||
|
var frameMs = 32.0
|
||||||
|
var analysisFrameMs = 32.0
|
||||||
|
var smoothing = 0.92f
|
||||||
|
var pointCount = 120
|
||||||
|
var dbMin = -90f
|
||||||
|
var dbMax = -10f
|
||||||
|
var tiltDbPerOctave = 3.5f
|
||||||
|
var scopeColor = Color.WHITE
|
||||||
|
var lineWidthDp = 2f
|
||||||
|
var lineOpacity = 1f
|
||||||
|
var fillOpacity = 1f
|
||||||
|
var glow = false
|
||||||
|
var glowOpacity = 0.18f
|
||||||
|
var edgeFade = false
|
||||||
|
var edgeFadeWidthDp = 28f
|
||||||
|
var gain = 1f
|
||||||
|
var staticValues: FloatArray? = null
|
||||||
|
|
||||||
|
private val density = resources.displayMetrics.density
|
||||||
|
private val pathLock = Any()
|
||||||
|
private val linePaths = arrayOf(Path(), Path())
|
||||||
|
private val fillPaths = arrayOf(Path(), Path())
|
||||||
|
private var frontPath = 0
|
||||||
|
private val renderedValues = FloatArray(MAX_RENDER_POINTS)
|
||||||
|
private var renderedPointCount = 0
|
||||||
|
|
||||||
|
private val spectrumBytes =
|
||||||
|
ByteBuffer.allocateDirect(AstraScopeProjection.SPECTRUM_BINS * Float.SIZE_BYTES)
|
||||||
|
.order(ByteOrder.nativeOrder())
|
||||||
|
private val spectrumFloats: FloatBuffer = spectrumBytes.asFloatBuffer()
|
||||||
|
private val oscilloscopeBytes =
|
||||||
|
ByteBuffer.allocateDirect(AstraScopeProjection.OSCILLOSCOPE_POINTS * Float.SIZE_BYTES)
|
||||||
|
.order(ByteOrder.nativeOrder())
|
||||||
|
private val oscilloscopeFloats: FloatBuffer = oscilloscopeBytes.asFloatBuffer()
|
||||||
|
|
||||||
|
private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
private val glowPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
private val textureView = TextureView(context)
|
||||||
|
private val renderGate = ScopeRenderGate()
|
||||||
|
private var attached = false
|
||||||
|
private var windowVisible = false
|
||||||
|
@Volatile
|
||||||
|
private var surfaceAvailable = false
|
||||||
|
private var scheduledToken = 0
|
||||||
|
private var lastAnalysisAt = 0L
|
||||||
|
private var lastDrawAt = 0L
|
||||||
|
private var hasNewFrame = false
|
||||||
|
@Volatile
|
||||||
|
private var displayRefreshRate = 60f
|
||||||
|
|
||||||
|
private val renderRunnable = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
val token = scheduledToken
|
||||||
|
if (!renderGate.isCurrent(token)) return
|
||||||
|
|
||||||
|
val now = SystemClock.uptimeMillis()
|
||||||
|
if (!requestedActive) {
|
||||||
|
renderDecayFrame(token)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val analysisCadence = AstraScopeProjection.cadenceMs(analysisFrameMs, displayRefreshRate)
|
||||||
|
val drawCadence = AstraScopeProjection.cadenceMs(frameMs, displayRefreshRate)
|
||||||
|
|
||||||
|
if (lastAnalysisAt == 0L || now - lastAnalysisAt >= analysisCadence) {
|
||||||
|
lastAnalysisAt = now
|
||||||
|
hasNewFrame = readScopeFrame()
|
||||||
|
}
|
||||||
|
if (hasNewFrame && (lastDrawAt == 0L || now - lastDrawAt >= drawCadence)) {
|
||||||
|
if (!renderGate.isCurrent(token)) return
|
||||||
|
preparePaths()
|
||||||
|
hasNewFrame = false
|
||||||
|
lastDrawAt = now
|
||||||
|
publishFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
val loopCadence = min(analysisCadence, drawCadence)
|
||||||
|
if (renderGate.isCurrent(token)) {
|
||||||
|
ScopeRenderDispatcher.handler.postDelayed(this, loopCadence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
clipChildren = false
|
||||||
|
clipToPadding = false
|
||||||
|
textureView.isOpaque = false
|
||||||
|
textureView.surfaceTextureListener = object : TextureView.SurfaceTextureListener {
|
||||||
|
override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) {
|
||||||
|
surfaceAvailable = true
|
||||||
|
restartRendering()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) {
|
||||||
|
configurePaints()
|
||||||
|
restartRendering()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
|
||||||
|
surfaceAvailable = false
|
||||||
|
cancelRendering()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSurfaceTextureUpdated(surface: SurfaceTexture) = Unit
|
||||||
|
}
|
||||||
|
addView(
|
||||||
|
textureView,
|
||||||
|
LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
configurePaints()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hasOverlappingRendering(): Boolean = false
|
||||||
|
|
||||||
|
fun commitProps() {
|
||||||
|
configurePaints()
|
||||||
|
restartRendering()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAttachedToWindow() {
|
||||||
|
super.onAttachedToWindow()
|
||||||
|
attached = true
|
||||||
|
windowVisible = windowVisibility == View.VISIBLE
|
||||||
|
restartRendering()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromWindow() {
|
||||||
|
attached = false
|
||||||
|
cancelRendering()
|
||||||
|
super.onDetachedFromWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onWindowVisibilityChanged(visibility: Int) {
|
||||||
|
super.onWindowVisibilityChanged(visibility)
|
||||||
|
windowVisible = visibility == View.VISIBLE
|
||||||
|
if (attached) restartRendering()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
|
||||||
|
super.onSizeChanged(width, height, oldWidth, oldHeight)
|
||||||
|
configurePaints()
|
||||||
|
restartRendering()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun restartRendering() {
|
||||||
|
ScopeRenderDispatcher.handler.removeCallbacks(renderRunnable)
|
||||||
|
lastAnalysisAt = 0L
|
||||||
|
lastDrawAt = 0L
|
||||||
|
hasNewFrame = false
|
||||||
|
|
||||||
|
val eligible = attached && windowVisible && surfaceAvailable && width > 0 && height > 0
|
||||||
|
displayRefreshRate = display?.refreshRate ?: 60f
|
||||||
|
scheduledToken = renderGate.update(eligible)
|
||||||
|
if (eligible) ScopeRenderDispatcher.handler.post(renderRunnable)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelRendering() {
|
||||||
|
scheduledToken = renderGate.update(false)
|
||||||
|
ScopeRenderDispatcher.handler.removeCallbacks(renderRunnable)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readScopeFrame(): Boolean {
|
||||||
|
val staticSnapshot = staticValues
|
||||||
|
if (!requestedActive && staticSnapshot != null) {
|
||||||
|
val count = min(min(staticSnapshot.size, pointCount), renderedValues.size)
|
||||||
|
for (index in 0 until count) {
|
||||||
|
renderedValues[index] = AstraScopeProjection.clamp01(staticSnapshot[index])
|
||||||
|
}
|
||||||
|
renderedPointCount = count
|
||||||
|
return count >= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (mode) {
|
||||||
|
ScopeMode.SPECTRUM -> {
|
||||||
|
val count = if (source == ScopeSource.POST) {
|
||||||
|
ScopeBridge.nativeFillSpectrumPostEq(
|
||||||
|
spectrumBytes,
|
||||||
|
AstraScopeProjection.SPECTRUM_BINS,
|
||||||
|
smoothing
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ScopeBridge.nativeFillSpectrum(
|
||||||
|
spectrumBytes,
|
||||||
|
AstraScopeProjection.SPECTRUM_BINS,
|
||||||
|
smoothing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (count <= 0) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
renderedPointCount = pointCount.coerceIn(2, renderedValues.size)
|
||||||
|
AstraScopeProjection.writeSpectrum(
|
||||||
|
spectrumFloats,
|
||||||
|
count,
|
||||||
|
renderedValues,
|
||||||
|
renderedPointCount,
|
||||||
|
dbMin,
|
||||||
|
dbMax,
|
||||||
|
tiltDbPerOctave
|
||||||
|
)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ScopeMode.OSCILLOSCOPE -> {
|
||||||
|
val count = ScopeBridge.nativeFillOscilloscope(
|
||||||
|
oscilloscopeBytes,
|
||||||
|
AstraScopeProjection.OSCILLOSCOPE_POINTS
|
||||||
|
)
|
||||||
|
renderedPointCount = min(count, renderedValues.size)
|
||||||
|
if (renderedPointCount < 2) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
for (index in 0 until renderedPointCount) {
|
||||||
|
renderedValues[index] =
|
||||||
|
(oscilloscopeFloats.get(index) * gain).coerceIn(-1f, 1f)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderDecayFrame(token: Int) {
|
||||||
|
val staticSnapshot = staticValues
|
||||||
|
if (staticSnapshot != null && mode == ScopeMode.SPECTRUM) {
|
||||||
|
readScopeFrame()
|
||||||
|
preparePaths()
|
||||||
|
publishFrame()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val count = renderedPointCount
|
||||||
|
val peak = if (reducedMotion || count < 2) {
|
||||||
|
renderedValues.fill(0f)
|
||||||
|
0f
|
||||||
|
} else {
|
||||||
|
AstraScopeProjection.decay(renderedValues, count)
|
||||||
|
}
|
||||||
|
if (peak < AstraScopeProjection.REST_EPSILON) {
|
||||||
|
renderedValues.fill(0f, 0, count)
|
||||||
|
}
|
||||||
|
preparePaths()
|
||||||
|
publishFrame()
|
||||||
|
|
||||||
|
if (peak >= AstraScopeProjection.REST_EPSILON && renderGate.isCurrent(token)) {
|
||||||
|
val cadence = AstraScopeProjection.cadenceMs(frameMs, displayRefreshRate)
|
||||||
|
ScopeRenderDispatcher.handler.postDelayed(renderRunnable, cadence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun preparePaths() {
|
||||||
|
val count = renderedPointCount
|
||||||
|
val canvasWidth = width.toFloat()
|
||||||
|
val canvasHeight = height.toFloat()
|
||||||
|
if (count < 2 || canvasWidth <= 0f || canvasHeight <= 0f) return
|
||||||
|
|
||||||
|
val back = 1 - frontPath
|
||||||
|
val line = linePaths[back]
|
||||||
|
val fill = fillPaths[back]
|
||||||
|
line.reset()
|
||||||
|
fill.reset()
|
||||||
|
|
||||||
|
val pad = lineWidthDp * density
|
||||||
|
val usableHeight = max(0f, canvasHeight - pad * 2f)
|
||||||
|
fun xAt(index: Int) = index.toFloat() / (count - 1).toFloat() * canvasWidth
|
||||||
|
fun yAt(index: Int): Float {
|
||||||
|
return if (mode == ScopeMode.SPECTRUM) {
|
||||||
|
pad + (1f - AstraScopeProjection.clamp01(renderedValues[index])) * usableHeight
|
||||||
|
} else {
|
||||||
|
canvasHeight * 0.5f - renderedValues[index] * max(0f, canvasHeight * 0.5f - pad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
line.moveTo(0f, yAt(0))
|
||||||
|
if (mode == ScopeMode.SPECTRUM) {
|
||||||
|
for (index in 1 until count) {
|
||||||
|
val previousX = xAt(index - 1)
|
||||||
|
val previousY = yAt(index - 1)
|
||||||
|
line.quadTo(
|
||||||
|
previousX,
|
||||||
|
previousY,
|
||||||
|
(previousX + xAt(index)) * 0.5f,
|
||||||
|
(previousY + yAt(index)) * 0.5f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
line.lineTo(canvasWidth, yAt(count - 1))
|
||||||
|
fill.addPath(line)
|
||||||
|
fill.lineTo(canvasWidth, canvasHeight)
|
||||||
|
fill.lineTo(0f, canvasHeight)
|
||||||
|
fill.close()
|
||||||
|
} else {
|
||||||
|
for (index in 1 until count) line.lineTo(xAt(index), yAt(index))
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized(pathLock) {
|
||||||
|
frontPath = back
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun configurePaints() {
|
||||||
|
synchronized(pathLock) {
|
||||||
|
val lineWidth = max(0.5f, lineWidthDp * density)
|
||||||
|
configureStrokePaint(strokePaint, lineWidth, lineOpacity)
|
||||||
|
configureStrokePaint(glowPaint, lineWidth * 3f, glowOpacity)
|
||||||
|
fillPaint.style = Paint.Style.FILL
|
||||||
|
fillPaint.shader = createFillShader()
|
||||||
|
fillPaint.color = withAlpha(scopeColor, 0.38f * fillOpacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SurfaceTexture publication does not invalidate the React/Android view tree.
|
||||||
|
* The serialized scope worker prepares and rasterizes this small transparent
|
||||||
|
* layer; the platform compositor presents it with the retained scene.
|
||||||
|
*/
|
||||||
|
private fun publishFrame() {
|
||||||
|
if (!surfaceAvailable) return
|
||||||
|
val canvas = textureView.lockCanvas() ?: return
|
||||||
|
try {
|
||||||
|
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
|
||||||
|
synchronized(pathLock) {
|
||||||
|
if (mode == ScopeMode.SPECTRUM && fillOpacity > 0f) {
|
||||||
|
canvas.drawPath(fillPaths[frontPath], fillPaint)
|
||||||
|
}
|
||||||
|
if (glow) canvas.drawPath(linePaths[frontPath], glowPaint)
|
||||||
|
canvas.drawPath(linePaths[frontPath], strokePaint)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
textureView.unlockCanvasAndPost(canvas)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun configureStrokePaint(paint: Paint, width: Float, opacity: Float) {
|
||||||
|
paint.style = Paint.Style.STROKE
|
||||||
|
paint.strokeCap = Paint.Cap.ROUND
|
||||||
|
paint.strokeJoin = Paint.Join.ROUND
|
||||||
|
paint.strokeWidth = width
|
||||||
|
paint.color = withAlpha(scopeColor, opacity)
|
||||||
|
paint.shader = if (edgeFade && this.width > 0 && edgeFadeWidthDp > 0f) {
|
||||||
|
val fade = min(edgeFadeWidthDp * density, this.width * 0.5f)
|
||||||
|
LinearGradient(
|
||||||
|
0f,
|
||||||
|
0f,
|
||||||
|
this.width.toFloat(),
|
||||||
|
0f,
|
||||||
|
intArrayOf(
|
||||||
|
withAlpha(scopeColor, 0f),
|
||||||
|
withAlpha(scopeColor, opacity),
|
||||||
|
withAlpha(scopeColor, opacity),
|
||||||
|
withAlpha(scopeColor, 0f)
|
||||||
|
),
|
||||||
|
floatArrayOf(0f, fade / this.width, 1f - fade / this.width, 1f),
|
||||||
|
Shader.TileMode.CLAMP
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createFillShader(): Shader? {
|
||||||
|
if (width <= 0 || height <= 0 || fillOpacity <= 0f) return null
|
||||||
|
val vertical = LinearGradient(
|
||||||
|
0f,
|
||||||
|
0f,
|
||||||
|
0f,
|
||||||
|
height.toFloat(),
|
||||||
|
intArrayOf(
|
||||||
|
withAlpha(scopeColor, 0.38f * fillOpacity),
|
||||||
|
withAlpha(scopeColor, 0.08f * fillOpacity),
|
||||||
|
withAlpha(scopeColor, 0f)
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
Shader.TileMode.CLAMP
|
||||||
|
)
|
||||||
|
if (!edgeFade || edgeFadeWidthDp <= 0f) return vertical
|
||||||
|
|
||||||
|
val fade = min(edgeFadeWidthDp * density, width * 0.5f)
|
||||||
|
val mask = LinearGradient(
|
||||||
|
0f,
|
||||||
|
0f,
|
||||||
|
width.toFloat(),
|
||||||
|
0f,
|
||||||
|
intArrayOf(Color.TRANSPARENT, Color.WHITE, Color.WHITE, Color.TRANSPARENT),
|
||||||
|
floatArrayOf(0f, fade / width, 1f - fade / width, 1f),
|
||||||
|
Shader.TileMode.CLAMP
|
||||||
|
)
|
||||||
|
return ComposeShader(vertical, mask, PorterDuff.Mode.MULTIPLY)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun withAlpha(color: Int, opacity: Float): Int {
|
||||||
|
val baseAlpha = Color.alpha(color) / 255f
|
||||||
|
val alpha = (255f * baseAlpha * opacity.coerceIn(0f, 1f)).roundToInt()
|
||||||
|
return Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color))
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val MAX_RENDER_POINTS = 512
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
package expo.modules.astrascope
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AstraScopeProjectionTest {
|
||||||
|
@Test
|
||||||
|
fun projectionClampsDbValuesAndKeepsLogSpectrumShape() {
|
||||||
|
val rawBytes = ByteBuffer.allocateDirect(16 * Float.SIZE_BYTES).order(ByteOrder.nativeOrder())
|
||||||
|
val raw = rawBytes.asFloatBuffer()
|
||||||
|
for (index in 0 until 16) raw.put(index, -100f + index * 10f)
|
||||||
|
val out = FloatArray(8)
|
||||||
|
|
||||||
|
AstraScopeProjection.writeSpectrum(raw, 16, out, 8, -90f, -10f, 0f)
|
||||||
|
|
||||||
|
assertTrue(out.first() >= 0f)
|
||||||
|
assertTrue(out.last() <= 1f)
|
||||||
|
for (index in 1 until out.size) assertTrue(out[index] >= out[index - 1])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clampRejectsInvalidAndOutOfRangeValues() {
|
||||||
|
assertEquals(0f, AstraScopeProjection.clamp01(Float.NaN), 0f)
|
||||||
|
assertEquals(0f, AstraScopeProjection.clamp01(-2f), 0f)
|
||||||
|
assertEquals(1f, AstraScopeProjection.clamp01(3f), 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun decayMatchesLegacyPauseEnvelope() {
|
||||||
|
val values = floatArrayOf(-1f, 0.5f, 0.1f)
|
||||||
|
|
||||||
|
val peak = AstraScopeProjection.decay(values, values.size)
|
||||||
|
|
||||||
|
assertEquals(0.72f, peak, 0.0001f)
|
||||||
|
assertEquals(-0.72f, values[0], 0.0001f)
|
||||||
|
assertEquals(0.36f, values[1], 0.0001f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cadenceUsesRequestedPolicyOrDisplayRefresh() {
|
||||||
|
assertEquals(32L, AstraScopeProjection.cadenceMs(32.0, 120f))
|
||||||
|
assertEquals(16L, AstraScopeProjection.cadenceMs(16.0, 120f))
|
||||||
|
assertEquals(8L, AstraScopeProjection.cadenceMs(0.0, 120f))
|
||||||
|
assertEquals(17L, AstraScopeProjection.cadenceMs(0.0, 60f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sourceAndModeSelectionDefaultSafely() {
|
||||||
|
assertEquals(ScopeSource.POST, ScopeSource.from("post"))
|
||||||
|
assertEquals(ScopeSource.PRE, ScopeSource.from("unexpected"))
|
||||||
|
assertEquals(ScopeMode.OSCILLOSCOPE, ScopeMode.from("oscilloscope"))
|
||||||
|
assertEquals(ScopeMode.SPECTRUM, ScopeMode.from("unexpected"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun lifecycleGenerationCancelsDetachedOrSupersededWork() {
|
||||||
|
val gate = ScopeRenderGate()
|
||||||
|
val first = gate.update(true)
|
||||||
|
assertTrue(gate.isCurrent(first))
|
||||||
|
|
||||||
|
val second = gate.update(true)
|
||||||
|
assertFalse(gate.isCurrent(first))
|
||||||
|
assertTrue(gate.isCurrent(second))
|
||||||
|
|
||||||
|
gate.update(false)
|
||||||
|
assertFalse(gate.isCurrent(second))
|
||||||
|
assertFalse(gate.eligible)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,16 +2,16 @@
|
|||||||
|
|
||||||
// Process-wide scope driver: a single-producer / single-consumer bridge between
|
// Process-wide scope driver: a single-producer / single-consumer bridge between
|
||||||
// the ExoPlayer audio thread (which pushes PCM via the tap AudioProcessor) and
|
// the ExoPlayer audio thread (which pushes PCM via the tap AudioProcessor) and
|
||||||
// the JS render thread (which pulls the latest spectrum frame once per frame).
|
// the serialized native scope worker (or a compatibility JS getter).
|
||||||
//
|
//
|
||||||
// Threading contract:
|
// Threading contract:
|
||||||
// - pushInterleaved() + configure() run on the AUDIO thread. They are
|
// - pushInterleaved() + configure() run on the AUDIO thread. They are
|
||||||
// allocation-free and lock-free: they only touch the ring (atomic write
|
// allocation-free and lock-free: they only touch the ring (atomic write
|
||||||
// position) and an atomic pending-sample-rate. They NEVER touch the
|
// position) and an atomic pending-sample-rate. They NEVER touch the
|
||||||
// analyzer (no FFT on the audio callback).
|
// analyzer (no FFT on the audio callback).
|
||||||
// - fillSpectrum() runs on the single JS/render thread. It owns the analyzer
|
// - fill methods own analyzer/consumer state. A consumer mutex serializes the
|
||||||
// and all consumer-only state. It snapshots the most recent fftSize mono
|
// native renderer with legacy synchronous JS getters without ever touching
|
||||||
// samples from the ring and runs Visualizer::Spectrum::process there.
|
// the audio-thread producer path.
|
||||||
//
|
//
|
||||||
// The ring holds mono samples (the producer downmixes), sized well above the
|
// The ring holds mono samples (the producer downmixes), sized well above the
|
||||||
// FFT window so a 60fps consumer never misses recent audio; on a snapshot we
|
// FFT window so a 60fps consumer never misses recent audio; on a snapshot we
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <mutex>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace astra {
|
namespace astra {
|
||||||
@@ -69,6 +70,7 @@ class ScopeDriver {
|
|||||||
// samples, run the FFT, copy up to `cap` dB magnitudes into `out`.
|
// samples, run the FFT, copy up to `cap` dB magnitudes into `out`.
|
||||||
// Returns the number of bins written.
|
// Returns the number of bins written.
|
||||||
size_t fillSpectrum(float* out, size_t cap, float smoothing) {
|
size_t fillSpectrum(float* out, size_t cap, float smoothing) {
|
||||||
|
std::lock_guard<std::mutex> lock(consumerMutex_);
|
||||||
if (out == nullptr || cap == 0) {
|
if (out == nullptr || cap == 0) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -110,6 +112,7 @@ class ScopeDriver {
|
|||||||
// pitch-locked trigger. We drain a bounded recent slice into its internal
|
// pitch-locked trigger. We drain a bounded recent slice into its internal
|
||||||
// circular buffer, then return render-ready points from the triggered window.
|
// circular buffer, then return render-ready points from the triggered window.
|
||||||
size_t fillOscilloscope(float* out, size_t cap) {
|
size_t fillOscilloscope(float* out, size_t cap) {
|
||||||
|
std::lock_guard<std::mutex> lock(consumerMutex_);
|
||||||
if (out == nullptr || cap == 0) {
|
if (out == nullptr || cap == 0) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -212,6 +215,7 @@ class ScopeDriver {
|
|||||||
|
|
||||||
// Render thread. Latest post-EQ spectrum window -> `out` (dB magnitudes).
|
// Render thread. Latest post-EQ spectrum window -> `out` (dB magnitudes).
|
||||||
size_t fillSpectrumPostEq(float* out, size_t cap, float smoothing) {
|
size_t fillSpectrumPostEq(float* out, size_t cap, float smoothing) {
|
||||||
|
std::lock_guard<std::mutex> lock(consumerMutex_);
|
||||||
if (out == nullptr || cap == 0) {
|
if (out == nullptr || cap == 0) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -250,6 +254,7 @@ class ScopeDriver {
|
|||||||
size_t binCount() const { return spectrum_.getFFTSize() / 2; }
|
size_t binCount() const { return spectrum_.getFFTSize() / 2; }
|
||||||
|
|
||||||
void reset() {
|
void reset() {
|
||||||
|
std::lock_guard<std::mutex> lock(consumerMutex_);
|
||||||
spectrum_.reset();
|
spectrum_.reset();
|
||||||
postEqSpectrum_.reset();
|
postEqSpectrum_.reset();
|
||||||
osc_.reset();
|
osc_.reset();
|
||||||
@@ -339,6 +344,7 @@ class ScopeDriver {
|
|||||||
std::atomic<int> pendingSampleRate_{44100};
|
std::atomic<int> pendingSampleRate_{44100};
|
||||||
|
|
||||||
// Consumer-only state.
|
// Consumer-only state.
|
||||||
|
std::mutex consumerMutex_;
|
||||||
std::vector<float> scratch_;
|
std::vector<float> scratch_;
|
||||||
Visualizer::Spectrum spectrum_;
|
Visualizer::Spectrum spectrum_;
|
||||||
int appliedSampleRate_{0};
|
int appliedSampleRate_{0};
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { requireNativeModule, type NativeModule } from 'expo-modules-core';
|
import {
|
||||||
|
requireNativeModule,
|
||||||
|
requireNativeViewManager,
|
||||||
|
type NativeModule,
|
||||||
|
} from 'expo-modules-core';
|
||||||
|
import type { ViewProps } from 'react-native';
|
||||||
|
|
||||||
/** Number of spectrum bins returned by getSpectrumFrame (fftSize/2, fftSize=2048). */
|
/** Number of spectrum bins returned by getSpectrumFrame (fftSize/2, fftSize=2048). */
|
||||||
export const SPECTRUM_BINS = 1024;
|
export const SPECTRUM_BINS = 1024;
|
||||||
@@ -70,3 +75,32 @@ declare class AstraScopeModuleType extends NativeModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const AstraScope = requireNativeModule<AstraScopeModuleType>('AstraScope');
|
export const AstraScope = requireNativeModule<AstraScopeModuleType>('AstraScope');
|
||||||
|
|
||||||
|
/** Internal prop contract for the allocation-free Android scope surface. */
|
||||||
|
export interface AstraScopeViewProps extends ViewProps {
|
||||||
|
mode: 'spectrum' | 'oscilloscope';
|
||||||
|
source?: 'pre' | 'post';
|
||||||
|
active: boolean;
|
||||||
|
reducedMotion?: boolean;
|
||||||
|
frameMs: number;
|
||||||
|
analysisFrameMs?: number;
|
||||||
|
smoothing?: number;
|
||||||
|
pointCount?: number;
|
||||||
|
dbMin?: number;
|
||||||
|
dbMax?: number;
|
||||||
|
tiltDbPerOctave?: number;
|
||||||
|
/** Android ARGB color produced by React Native's processColor(). */
|
||||||
|
color: number;
|
||||||
|
lineWidth?: number;
|
||||||
|
lineOpacity?: number;
|
||||||
|
fillOpacity?: number;
|
||||||
|
glow?: boolean;
|
||||||
|
glowOpacity?: number;
|
||||||
|
edgeFade?: boolean;
|
||||||
|
edgeFadeWidth?: number;
|
||||||
|
gain?: number;
|
||||||
|
values?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AstraScopeView =
|
||||||
|
requireNativeViewManager<AstraScopeViewProps>('AstraScope');
|
||||||
|
|||||||
+3
-3
@@ -64,7 +64,7 @@
|
|||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"lint": "expo lint",
|
"lint": "expo lint",
|
||||||
"test:queue-actions": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/queue/queueActions.test.mts",
|
"test:queue-actions": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/queue/queueActions.test.mts src/components/queue/queuePerformance.test.mts",
|
||||||
"test:desktop-remote": "node --experimental-strip-types --test src/services/desktopRemotePairing.test.mts",
|
"test:desktop-remote": "node --experimental-strip-types --test src/services/desktopRemotePairing.test.mts",
|
||||||
"test:dynamic-playlists": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/playlists/dynamicPlaylist.test.mts",
|
"test:dynamic-playlists": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/playlists/dynamicPlaylist.test.mts",
|
||||||
"test:album-grouping": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/library/albumGrouping.test.mts src/shared/library/albumEligibility.test.mts src/library/albumIdentity.test.mts src/library/albumSummary.test.mts",
|
"test:album-grouping": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/library/albumGrouping.test.mts src/shared/library/albumEligibility.test.mts src/library/albumIdentity.test.mts src/library/albumSummary.test.mts",
|
||||||
@@ -75,14 +75,14 @@
|
|||||||
"test:signal": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/signalShare.test.mts src/audio/signalShareIntent.test.mts src/audio/signalScanGeometry.test.mts src/audio/signalLocalMatch.test.mts",
|
"test:signal": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/signalShare.test.mts src/audio/signalShareIntent.test.mts src/audio/signalScanGeometry.test.mts src/audio/signalLocalMatch.test.mts",
|
||||||
"test:eq-math": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eq.test.mts",
|
"test:eq-math": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eq.test.mts",
|
||||||
"test:audio-startup": "node --experimental-strip-types --test src/audio/dspStartupCoordinator.test.mts src/audio/dspStartupGain.test.mts",
|
"test:audio-startup": "node --experimental-strip-types --test src/audio/dspStartupCoordinator.test.mts src/audio/dspStartupGain.test.mts",
|
||||||
"test:seek-bar": "node --experimental-strip-types --test src/audio/playbackClock.test.mts src/components/waveformScrubDetents.test.mts",
|
"test:seek-bar": "node --experimental-strip-types --test src/audio/playbackClock.test.mts src/audio/playbackProgressProjection.test.mts src/components/waveformScrubDetents.test.mts",
|
||||||
"test:lyrics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lyrics/parsing.test.mts src/lyrics/presentation.test.mts src/lyrics/displaySettings.test.mts src/lyrics/embedded.test.mts src/lyrics/resolver.test.mts",
|
"test:lyrics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lyrics/parsing.test.mts src/lyrics/presentation.test.mts src/lyrics/displaySettings.test.mts src/lyrics/embedded.test.mts src/lyrics/resolver.test.mts",
|
||||||
"test:sleep": "node --experimental-strip-types --test src/audio/sleepTimerState.test.mts",
|
"test:sleep": "node --experimental-strip-types --test src/audio/sleepTimerState.test.mts",
|
||||||
"test:troubleshooting": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/cacheInvalidation.test.mts",
|
"test:troubleshooting": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/cacheInvalidation.test.mts",
|
||||||
"test:settings-search": "node --experimental-strip-types --test src/components/search/settingsSearchRoutes.test.mts",
|
"test:settings-search": "node --experimental-strip-types --test src/components/search/settingsSearchRoutes.test.mts",
|
||||||
"test:now-playing-layout": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/player/nowPlayingLayout.test.mts src/components/player/nowPlayingPreferences.test.mts src/components/player/nowPlayingDismiss.test.mts src/playback/playbackTargetPresentation.test.mts",
|
"test:now-playing-layout": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/player/nowPlayingLayout.test.mts src/components/player/nowPlayingPreferences.test.mts src/components/player/nowPlayingDismiss.test.mts src/playback/playbackTargetPresentation.test.mts",
|
||||||
"test:memory-lifecycle": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/delayedPresence.test.mts scripts/android-memory-profile.test.mjs",
|
"test:memory-lifecycle": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/delayedPresence.test.mts scripts/android-memory-profile.test.mjs",
|
||||||
"test:ui-navigation": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/stores/playerPresence.test.mts src/navigation/tabsAnchor.test.mts src/navigation/libraryDetailBack.test.mts",
|
"test:ui-navigation": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/stores/playerPresence.test.mts src/navigation/tabsAnchor.test.mts src/navigation/libraryDetailBack.test.mts src/navigation/tabTransition.test.mts",
|
||||||
"test:haptics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/haptics.test.mts",
|
"test:haptics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/haptics.test.mts",
|
||||||
"test:app-dialog": "node --experimental-strip-types --test src/components/dialogs/dialogQueue.test.mts",
|
"test:app-dialog": "node --experimental-strip-types --test src/components/dialogs/dialogQueue.test.mts",
|
||||||
"test:home-greeting": "node --experimental-strip-types --test src/home/homeGreeting.test.mts",
|
"test:home-greeting": "node --experimental-strip-types --test src/home/homeGreeting.test.mts",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useMemo, useRef } from 'react';
|
|||||||
import { Tabs } from 'expo-router';
|
import { Tabs } from 'expo-router';
|
||||||
import { TabBar, type TabItem } from '@/components/TabBar';
|
import { TabBar, type TabItem } from '@/components/TabBar';
|
||||||
import {
|
import {
|
||||||
|
TAB_SCENE_ANIMATION,
|
||||||
TAB_TRANSITION_SETTLE_MS,
|
TAB_TRANSITION_SETTLE_MS,
|
||||||
TAB_TRANSITION_SPEC,
|
TAB_TRANSITION_SPEC,
|
||||||
} from '@/navigation/tabTransition';
|
} from '@/navigation/tabTransition';
|
||||||
@@ -19,8 +20,8 @@ export default function TabsLayout() {
|
|||||||
headerShown: false,
|
headerShown: false,
|
||||||
freezeOnBlur: false,
|
freezeOnBlur: false,
|
||||||
sceneStyle: { backgroundColor: colors.bgPrimary },
|
sceneStyle: { backgroundColor: colors.bgPrimary },
|
||||||
// Directional slide + cross-fade between tabs, following tab order.
|
// Retained scenes cross-fade without translating two full pages.
|
||||||
animation: 'shift' as const,
|
animation: TAB_SCENE_ANIMATION,
|
||||||
transitionSpec: TAB_TRANSITION_SPEC,
|
transitionSpec: TAB_TRANSITION_SPEC,
|
||||||
}),
|
}),
|
||||||
[colors.bgPrimary]
|
[colors.bgPrimary]
|
||||||
@@ -37,7 +38,7 @@ export default function TabsLayout() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const handlePress = (item: TabItem) => {
|
const handlePress = (item: TabItem) => {
|
||||||
// Interrupting the native-driver shift animation can drop its
|
// Interrupting the native-driver scene animation can drop its
|
||||||
// completion frame and leave the incoming scene invisible; swallow
|
// completion frame and leave the incoming scene invisible; swallow
|
||||||
// taps until the current transition has finished.
|
// taps until the current transition has finished.
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
clampPlaybackFraction,
|
||||||
|
reconcilePlaybackProgress,
|
||||||
|
} from './playbackProgressProjection.ts';
|
||||||
|
|
||||||
|
test('playing snapshots animate linearly from the reconciled position', () => {
|
||||||
|
const command = reconcilePlaybackProgress({
|
||||||
|
currentTime: 25,
|
||||||
|
duration: 100,
|
||||||
|
isPlaying: true,
|
||||||
|
active: true,
|
||||||
|
trackKey: 'a',
|
||||||
|
});
|
||||||
|
assert.deepEqual(command, {
|
||||||
|
fraction: 0.25,
|
||||||
|
animate: true,
|
||||||
|
animationDurationMs: 75_000,
|
||||||
|
trackChanged: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pause snaps immediately and schedules no projection', () => {
|
||||||
|
const command = reconcilePlaybackProgress({
|
||||||
|
currentTime: 25,
|
||||||
|
duration: 100,
|
||||||
|
isPlaying: false,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
assert.equal(command.fraction, 0.25);
|
||||||
|
assert.equal(command.animate, false);
|
||||||
|
assert.equal(command.animationDurationMs, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scrub and pending-seek overrides take precedence over stale live time', () => {
|
||||||
|
const command = reconcilePlaybackProgress({
|
||||||
|
currentTime: 10,
|
||||||
|
duration: 100,
|
||||||
|
isPlaying: true,
|
||||||
|
active: true,
|
||||||
|
overrideFraction: 0.8,
|
||||||
|
});
|
||||||
|
assert.equal(command.fraction, 0.8);
|
||||||
|
assert.equal(command.animate, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('duration changes reconcile the fraction and remaining animation time', () => {
|
||||||
|
const command = reconcilePlaybackProgress({
|
||||||
|
currentTime: 50,
|
||||||
|
duration: 200,
|
||||||
|
isPlaying: true,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
assert.equal(command.fraction, 0.25);
|
||||||
|
assert.equal(command.animationDurationMs, 150_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('track changes are identified and snap to the new track snapshot', () => {
|
||||||
|
const command = reconcilePlaybackProgress(
|
||||||
|
{
|
||||||
|
currentTime: 2,
|
||||||
|
duration: 80,
|
||||||
|
isPlaying: true,
|
||||||
|
active: true,
|
||||||
|
trackKey: 'new',
|
||||||
|
},
|
||||||
|
'old'
|
||||||
|
);
|
||||||
|
assert.equal(command.trackChanged, true);
|
||||||
|
assert.equal(command.fraction, 0.025);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hidden surfaces and invalid values are pinned without animation', () => {
|
||||||
|
const command = reconcilePlaybackProgress({
|
||||||
|
currentTime: 10,
|
||||||
|
duration: 100,
|
||||||
|
isPlaying: true,
|
||||||
|
active: false,
|
||||||
|
});
|
||||||
|
assert.equal(command.animate, false);
|
||||||
|
assert.equal(clampPlaybackFraction(Number.NaN), 0);
|
||||||
|
assert.equal(clampPlaybackFraction(2), 1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
export interface PlaybackProgressSnapshot {
|
||||||
|
currentTime: number;
|
||||||
|
duration: number;
|
||||||
|
isPlaying: boolean;
|
||||||
|
active: boolean;
|
||||||
|
trackKey?: string | number | null;
|
||||||
|
overrideFraction?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaybackProgressReconciliation {
|
||||||
|
fraction: number;
|
||||||
|
animate: boolean;
|
||||||
|
animationDurationMs: number;
|
||||||
|
trackChanged: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampPlaybackFraction(value: number): number {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return 0;
|
||||||
|
if (value >= 1) return 1;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns an RNTP snapshot into one UI-thread command. Seek/scrub overrides win,
|
||||||
|
* pauses and hidden surfaces snap, and active playback runs linearly to the end
|
||||||
|
* until the next authoritative snapshot reconciles it.
|
||||||
|
*/
|
||||||
|
export function reconcilePlaybackProgress(
|
||||||
|
snapshot: PlaybackProgressSnapshot,
|
||||||
|
previousTrackKey?: string | number | null
|
||||||
|
): PlaybackProgressReconciliation {
|
||||||
|
const duration = Number.isFinite(snapshot.duration) ? Math.max(0, snapshot.duration) : 0;
|
||||||
|
const currentTime = Number.isFinite(snapshot.currentTime)
|
||||||
|
? Math.max(0, snapshot.currentTime)
|
||||||
|
: 0;
|
||||||
|
const liveFraction = duration > 0
|
||||||
|
? clampPlaybackFraction(currentTime / duration)
|
||||||
|
: 0;
|
||||||
|
const hasOverride = snapshot.overrideFraction != null;
|
||||||
|
const fraction = hasOverride
|
||||||
|
? clampPlaybackFraction(snapshot.overrideFraction as number)
|
||||||
|
: liveFraction;
|
||||||
|
const trackChanged =
|
||||||
|
previousTrackKey !== undefined && previousTrackKey !== snapshot.trackKey;
|
||||||
|
const animate =
|
||||||
|
!hasOverride &&
|
||||||
|
snapshot.active &&
|
||||||
|
snapshot.isPlaying &&
|
||||||
|
duration > 0 &&
|
||||||
|
fraction < 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
fraction,
|
||||||
|
animate,
|
||||||
|
animationDurationMs: animate
|
||||||
|
? Math.max(0, Math.round((duration - currentTime) * 1_000))
|
||||||
|
: 0,
|
||||||
|
trackChanged,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Easing,
|
||||||
|
ReduceMotion,
|
||||||
|
cancelAnimation,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
type SharedValue,
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
import {
|
||||||
|
reconcilePlaybackProgress,
|
||||||
|
type PlaybackProgressSnapshot,
|
||||||
|
} from './playbackProgressProjection';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconciles coarse RNTP snapshots while projecting the visible progress
|
||||||
|
* continuously on Reanimated's UI thread.
|
||||||
|
*/
|
||||||
|
export function useAnimatedPlaybackProgress(
|
||||||
|
snapshot: PlaybackProgressSnapshot
|
||||||
|
): SharedValue<number> {
|
||||||
|
const {
|
||||||
|
active,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
isPlaying,
|
||||||
|
overrideFraction,
|
||||||
|
trackKey,
|
||||||
|
} = snapshot;
|
||||||
|
const initial = reconcilePlaybackProgress(snapshot);
|
||||||
|
const progress = useSharedValue(initial.fraction);
|
||||||
|
const previousTrackKey = useRef(trackKey);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const command = reconcilePlaybackProgress(
|
||||||
|
{
|
||||||
|
active,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
isPlaying,
|
||||||
|
overrideFraction,
|
||||||
|
trackKey,
|
||||||
|
},
|
||||||
|
previousTrackKey.current
|
||||||
|
);
|
||||||
|
previousTrackKey.current = trackKey;
|
||||||
|
cancelAnimation(progress);
|
||||||
|
progress.value = command.fraction;
|
||||||
|
if (command.animate) {
|
||||||
|
progress.value = withTiming(1, {
|
||||||
|
duration: command.animationDurationMs,
|
||||||
|
easing: Easing.linear,
|
||||||
|
reduceMotion: ReduceMotion.Never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
progress,
|
||||||
|
active,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
isPlaying,
|
||||||
|
overrideFraction,
|
||||||
|
trackKey,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => () => cancelAnimation(progress), [progress]);
|
||||||
|
return progress;
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
|
|||||||
import { skipToNext, skipToPrevious, togglePlay } from '@/audio/playbackController';
|
import { skipToNext, skipToPrevious, togglePlay } from '@/audio/playbackController';
|
||||||
import { useScopeActive } from '@/scope/scopeStore';
|
import { useScopeActive } from '@/scope/scopeStore';
|
||||||
import { artworkThumbFromSource } from '@/library/artwork';
|
import { artworkThumbFromSource } from '@/library/artwork';
|
||||||
import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime';
|
import { useAnimatedPlaybackProgress } from '@/audio/useAnimatedPlaybackProgress';
|
||||||
import { useAppForeground } from '@/lib/useAppForeground';
|
import { useAppForeground } from '@/lib/useAppForeground';
|
||||||
import { playHaptic } from '@/lib/haptics';
|
import { playHaptic } from '@/lib/haptics';
|
||||||
import { PlaybackTargetPicker } from './PlaybackTargetPicker';
|
import { PlaybackTargetPicker } from './PlaybackTargetPicker';
|
||||||
@@ -87,26 +87,47 @@ function MiniProgress({
|
|||||||
currentTime,
|
currentTime,
|
||||||
duration,
|
duration,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
|
active,
|
||||||
|
trackKey,
|
||||||
}: {
|
}: {
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
|
active: boolean;
|
||||||
|
trackKey: string | null;
|
||||||
}) {
|
}) {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const smoothTime = useSmoothPlaybackTime(currentTime, duration, isPlaying);
|
const progress = useAnimatedPlaybackProgress({
|
||||||
const progress = duration > 0 ? Math.min(1, smoothTime / duration) : 0;
|
currentTime,
|
||||||
|
duration,
|
||||||
|
isPlaying,
|
||||||
|
active,
|
||||||
|
trackKey,
|
||||||
|
});
|
||||||
|
const progressStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ scaleX: progress.value }],
|
||||||
|
}));
|
||||||
return (
|
return (
|
||||||
<View style={styles.progressTrack}>
|
<View style={styles.progressTrack}>
|
||||||
<View style={[styles.progressFill, { width: `${progress * 100}%` }]} />
|
<Animated.View style={[styles.progressFill, progressStyle]} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Phone-target progress: subscribes here so the 2Hz tick skips the whole pill. */
|
/** Phone-target progress: subscribes here so the 2Hz tick skips the whole pill. */
|
||||||
function PhoneMiniProgress({ isPlaying }: { isPlaying: boolean }) {
|
function PhoneMiniProgress({ isPlaying, active }: { isPlaying: boolean; active: boolean }) {
|
||||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||||
const duration = usePlayerStore((s) => s.duration);
|
const duration = usePlayerStore((s) => s.duration);
|
||||||
return <MiniProgress currentTime={currentTime} duration={duration} isPlaying={isPlaying} />;
|
const trackKey = usePlayerStore((s) => s.currentTrack?.path ?? null);
|
||||||
|
return (
|
||||||
|
<MiniProgress
|
||||||
|
currentTime={currentTime}
|
||||||
|
duration={duration}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
active={active}
|
||||||
|
trackKey={trackKey}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -520,9 +541,11 @@ export function MiniPlayer() {
|
|||||||
currentTime={presentation.currentTime}
|
currentTime={presentation.currentTime}
|
||||||
duration={presentation.duration}
|
duration={presentation.duration}
|
||||||
isPlaying={isPlaying}
|
isPlaying={isPlaying}
|
||||||
|
active={!playerOpen}
|
||||||
|
trackKey={presentation.trackKey}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<PhoneMiniProgress isPlaying={isPlaying} />
|
<PhoneMiniProgress isPlaying={isPlaying} active={!playerOpen} />
|
||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -632,6 +655,11 @@ const useStyles = createThemedStyles((colors) => ({
|
|||||||
backgroundColor: colors.glassBorder,
|
backgroundColor: colors.glassBorder,
|
||||||
},
|
},
|
||||||
progressFill: {
|
progressFill: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
transformOrigin: 'left center',
|
||||||
height: 2,
|
height: 2,
|
||||||
backgroundColor: colors.accent,
|
backgroundColor: colors.accent,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,23 +1,7 @@
|
|||||||
import {
|
import { processColor } from 'react-native';
|
||||||
useEffect,
|
|
||||||
useLayoutEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef
|
|
||||||
} from 'react';
|
|
||||||
import { useReducedMotion } from 'react-native-reanimated';
|
import { useReducedMotion } from 'react-native-reanimated';
|
||||||
import {
|
import { AstraScopeView } from '../../modules/astra-scope';
|
||||||
PaintStyle,
|
|
||||||
Skia,
|
|
||||||
SkiaPictureView,
|
|
||||||
StrokeCap,
|
|
||||||
StrokeJoin,
|
|
||||||
TileMode,
|
|
||||||
type SkPath,
|
|
||||||
type SkPicture
|
|
||||||
} from '@shopify/react-native-skia';
|
|
||||||
import { AstraScope, OSCILLOSCOPE_POINTS } from '../../modules/astra-scope';
|
|
||||||
import { useScopeStore } from '@/scope/scopeStore';
|
import { useScopeStore } from '@/scope/scopeStore';
|
||||||
import { DEFAULT_OSC_GAIN } from '@/scope/oscilloscopeGain';
|
|
||||||
import { useColors } from '@/theme/themed';
|
import { useColors } from '@/theme/themed';
|
||||||
|
|
||||||
interface OscilloscopeWaveProps {
|
interface OscilloscopeWaveProps {
|
||||||
@@ -33,157 +17,11 @@ interface OscilloscopeWaveProps {
|
|||||||
edgeFadeWidth?: number;
|
edgeFadeWidth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SkiaViewApiShape = {
|
|
||||||
setJsiProperty: <T>(nativeId: number, name: string, value: T) => void;
|
|
||||||
requestRedraw: (nativeId: number) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const values = new Float32Array(OSCILLOSCOPE_POINTS);
|
|
||||||
|
|
||||||
// Deactivation decay: pull the last live frame toward the rest line over
|
|
||||||
// ~250ms instead of snapping flat, so pausing reads as powering down.
|
|
||||||
const DECAY_PER_FRAME = 0.72;
|
|
||||||
const REST_EPSILON = 0.004;
|
|
||||||
|
|
||||||
type SkiaDisposable = { dispose: () => void };
|
|
||||||
|
|
||||||
function disposeSkiaResources(resources: readonly (SkiaDisposable | null)[]) {
|
|
||||||
for (let i = resources.length - 1; i >= 0; i--) resources[i]?.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
function skiaViewApi(): SkiaViewApiShape | null {
|
|
||||||
const globalWithSkia = globalThis as typeof globalThis & { SkiaViewApi?: SkiaViewApiShape };
|
|
||||||
return globalWithSkia.SkiaViewApi ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function withAlpha(hex: string, alpha: number): string {
|
|
||||||
const r = parseInt(hex.slice(1, 3), 16);
|
|
||||||
const g = parseInt(hex.slice(3, 5), 16);
|
|
||||||
const b = parseInt(hex.slice(5, 7), 16);
|
|
||||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeStrokePaint(color: string, width: number, alpha = 1) {
|
|
||||||
const paint = Skia.Paint();
|
|
||||||
paint.setAntiAlias(true);
|
|
||||||
paint.setColor(Skia.Color(alpha === 1 ? color : withAlpha(color, alpha)));
|
|
||||||
paint.setStrokeWidth(width);
|
|
||||||
paint.setStyle(PaintStyle.Stroke);
|
|
||||||
paint.setStrokeCap(StrokeCap.Round);
|
|
||||||
paint.setStrokeJoin(StrokeJoin.Round);
|
|
||||||
return paint;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EDGE_FADE_WIDTH = 28;
|
const EDGE_FADE_WIDTH = 28;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Edge fade baked into the stroke paint: a horizontal gradient shader whose
|
* Thin React wrapper for the native oscilloscope. Gain changes happen only at
|
||||||
* alpha ramps in from transparent at both ends, so the trace dissolves at its
|
* track boundaries; audio frames and drawing never cross React or the JS thread.
|
||||||
* edges over any background — solid screen or blurred artwork.
|
|
||||||
*/
|
|
||||||
function makeFadedStrokeShader(
|
|
||||||
color: string,
|
|
||||||
alpha: number,
|
|
||||||
width: number,
|
|
||||||
fadeWidth: number
|
|
||||||
) {
|
|
||||||
const f = Math.min(fadeWidth, width * 0.5);
|
|
||||||
return Skia.Shader.MakeLinearGradient(
|
|
||||||
{ x: 0, y: 0 },
|
|
||||||
{ x: width, y: 0 },
|
|
||||||
[
|
|
||||||
Skia.Color(withAlpha(color, 0)),
|
|
||||||
Skia.Color(withAlpha(color, alpha)),
|
|
||||||
Skia.Color(withAlpha(color, alpha)),
|
|
||||||
Skia.Color(withAlpha(color, 0)),
|
|
||||||
],
|
|
||||||
[0, f / width, 1 - f / width, 1],
|
|
||||||
TileMode.Clamp
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeWavePath(
|
|
||||||
samples: Float32Array,
|
|
||||||
sampleCount: number,
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
lineWidth: number,
|
|
||||||
gain: number,
|
|
||||||
path: SkPath
|
|
||||||
) {
|
|
||||||
path.reset();
|
|
||||||
const n = Math.min(sampleCount, samples.length);
|
|
||||||
if (n < 2 || width <= 0 || height <= 0) return;
|
|
||||||
|
|
||||||
const mid = height / 2;
|
|
||||||
const amp = mid - lineWidth;
|
|
||||||
const xAt = (i: number) => (i / (n - 1)) * width;
|
|
||||||
const yAt = (i: number) => {
|
|
||||||
let v = samples[i] * gain;
|
|
||||||
// Per-track gain targets ~85% of full scale, so this only catches the rare
|
|
||||||
// intra-track peak that runs a touch hotter than the analyzed sample peak.
|
|
||||||
if (v < -1) v = -1;
|
|
||||||
else if (v > 1) v = 1;
|
|
||||||
return mid - v * amp;
|
|
||||||
};
|
|
||||||
|
|
||||||
path.moveTo(0, yAt(0));
|
|
||||||
for (let i = 1; i < n; i++) {
|
|
||||||
path.lineTo(xAt(i), yAt(i));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPicture(
|
|
||||||
samples: Float32Array,
|
|
||||||
sampleCount: number,
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
color: string,
|
|
||||||
lineWidth: number,
|
|
||||||
glow: boolean,
|
|
||||||
gain: number,
|
|
||||||
edgeFade: boolean,
|
|
||||||
edgeFadeWidth: number
|
|
||||||
): SkPicture {
|
|
||||||
const recorder = Skia.PictureRecorder();
|
|
||||||
const canvas = recorder.beginRecording(Skia.XYWHRect(0, 0, width, height));
|
|
||||||
const path = Skia.Path.Make();
|
|
||||||
const resources: SkiaDisposable[] = [recorder, path];
|
|
||||||
writeWavePath(samples, sampleCount, width, height, lineWidth, gain, path);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (glow) {
|
|
||||||
const glowPaint = makeStrokePaint(color, lineWidth * 3, 0.18);
|
|
||||||
resources.push(glowPaint);
|
|
||||||
if (edgeFade) {
|
|
||||||
const glowShader = makeFadedStrokeShader(color, 0.18, width, edgeFadeWidth);
|
|
||||||
resources.push(glowShader);
|
|
||||||
glowPaint.setShader(glowShader);
|
|
||||||
}
|
|
||||||
canvas.drawPath(path, glowPaint);
|
|
||||||
}
|
|
||||||
const strokePaint = makeStrokePaint(color, lineWidth);
|
|
||||||
resources.push(strokePaint);
|
|
||||||
if (edgeFade) {
|
|
||||||
const strokeShader = makeFadedStrokeShader(color, 1, width, edgeFadeWidth);
|
|
||||||
resources.push(strokeShader);
|
|
||||||
strokePaint.setShader(strokeShader);
|
|
||||||
}
|
|
||||||
canvas.drawPath(path, strokePaint);
|
|
||||||
return recorder.finishRecordingAsPicture();
|
|
||||||
} finally {
|
|
||||||
disposeSkiaResources(resources);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Imperative oscilloscope renderer. This mirrors desktop/prism's hot path:
|
|
||||||
* a frame loop pulls native scope data and draws directly into a canvas-like
|
|
||||||
* surface instead of routing each frame through React reconciliation.
|
|
||||||
*
|
|
||||||
* Amplitude uses a per-track display gain (scopeStore.oscGain, set once per track by
|
|
||||||
* useNormalizationSync) — read fresh each frame so it tracks song changes, but held
|
|
||||||
* constant within a track so the music's own dynamics are preserved.
|
|
||||||
*/
|
*/
|
||||||
export function OscilloscopeWave({
|
export function OscilloscopeWave({
|
||||||
active,
|
active,
|
||||||
@@ -196,167 +34,34 @@ export function OscilloscopeWave({
|
|||||||
edgeFade = false,
|
edgeFade = false,
|
||||||
edgeFadeWidth = EDGE_FADE_WIDTH,
|
edgeFadeWidth = EDGE_FADE_WIDTH,
|
||||||
}: OscilloscopeWaveProps) {
|
}: OscilloscopeWaveProps) {
|
||||||
const themeColors = useColors();
|
const colors = useColors();
|
||||||
const color = colorProp ?? themeColors.accent;
|
const reducedMotion = useReducedMotion();
|
||||||
const reduceMotion = useReducedMotion();
|
const gain = useScopeStore((state) => state.oscGain);
|
||||||
const viewRef = useRef<SkiaPictureView | null>(null);
|
const color = processColor(colorProp ?? colors.accent);
|
||||||
const initialPicture = useMemo(
|
|
||||||
() =>
|
if (width <= 0 || height <= 0 || typeof color !== 'number') return null;
|
||||||
buildPicture(
|
return (
|
||||||
values,
|
<AstraScopeView
|
||||||
values.length,
|
mode="oscilloscope"
|
||||||
Math.max(1, width),
|
source="pre"
|
||||||
Math.max(1, height),
|
active={active && !reducedMotion}
|
||||||
color,
|
reducedMotion={reducedMotion}
|
||||||
lineWidth,
|
frameMs={frameMs}
|
||||||
glow,
|
analysisFrameMs={frameMs}
|
||||||
DEFAULT_OSC_GAIN,
|
color={color}
|
||||||
edgeFade,
|
lineWidth={lineWidth}
|
||||||
edgeFadeWidth
|
lineOpacity={1}
|
||||||
),
|
fillOpacity={0}
|
||||||
[color, edgeFade, edgeFadeWidth, glow, height, lineWidth, width]
|
glow={glow}
|
||||||
|
glowOpacity={0.18}
|
||||||
|
edgeFade={edgeFade}
|
||||||
|
edgeFadeWidth={edgeFadeWidth}
|
||||||
|
gain={gain}
|
||||||
|
pointerEvents="none"
|
||||||
|
collapsable={false}
|
||||||
|
style={{ width, height }}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => () => initialPicture.dispose(), [initialPicture]);
|
|
||||||
|
|
||||||
useLayoutEffect(
|
|
||||||
() => () => {
|
|
||||||
const view = viewRef.current;
|
|
||||||
const api = skiaViewApi();
|
|
||||||
if (!view || !api) return;
|
|
||||||
api.setJsiProperty(view.nativeId, 'picture', null);
|
|
||||||
api.requestRedraw(view.nativeId);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const view = viewRef.current;
|
|
||||||
const api = skiaViewApi();
|
|
||||||
if (!view || !api || width <= 0 || height <= 0) return;
|
|
||||||
|
|
||||||
let mounted = true;
|
|
||||||
let raf = 0;
|
|
||||||
let lastDraw = 0;
|
|
||||||
const drawThreshold = frameMs > 0 ? Math.max(0, frameMs - 0.5) : 0;
|
|
||||||
|
|
||||||
// Paints and the path live for the whole effect run; per-frame allocation
|
|
||||||
// was measurable GC/JSI churn at 60fps.
|
|
||||||
const strokePaint = makeStrokePaint(color, lineWidth);
|
|
||||||
const glowPaint = glow ? makeStrokePaint(color, lineWidth * 3, 0.18) : null;
|
|
||||||
const effectResources: SkiaDisposable[] = [strokePaint];
|
|
||||||
if (glowPaint) effectResources.push(glowPaint);
|
|
||||||
if (edgeFade) {
|
|
||||||
const strokeShader = makeFadedStrokeShader(color, 1, width, edgeFadeWidth);
|
|
||||||
effectResources.push(strokeShader);
|
|
||||||
strokePaint.setShader(strokeShader);
|
|
||||||
if (glowPaint) {
|
|
||||||
const glowShader = makeFadedStrokeShader(color, 0.18, width, edgeFadeWidth);
|
|
||||||
effectResources.push(glowShader);
|
|
||||||
glowPaint.setShader(glowShader);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const bounds = Skia.XYWHRect(0, 0, width, height);
|
|
||||||
const path = Skia.Path.Make();
|
|
||||||
effectResources.push(path);
|
|
||||||
let currentPicture: SkPicture | null = null;
|
|
||||||
|
|
||||||
const draw = (sampleCount: number) => {
|
|
||||||
const gain = useScopeStore.getState().oscGain;
|
|
||||||
writeWavePath(values, sampleCount, width, height, lineWidth, gain, path);
|
|
||||||
const recorder = Skia.PictureRecorder();
|
|
||||||
const canvas = recorder.beginRecording(bounds);
|
|
||||||
if (glowPaint) canvas.drawPath(path, glowPaint);
|
|
||||||
canvas.drawPath(path, strokePaint);
|
|
||||||
const nextPicture = recorder.finishRecordingAsPicture();
|
|
||||||
recorder.dispose();
|
|
||||||
api.setJsiProperty(view.nativeId, 'picture', nextPicture);
|
|
||||||
api.requestRedraw(view.nativeId);
|
|
||||||
currentPicture?.dispose();
|
|
||||||
currentPicture = nextPicture;
|
|
||||||
};
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
mounted = false;
|
|
||||||
cancelAnimationFrame(raf);
|
|
||||||
api.setJsiProperty(view.nativeId, 'picture', null);
|
|
||||||
api.requestRedraw(view.nativeId);
|
|
||||||
currentPicture?.dispose();
|
|
||||||
currentPicture = null;
|
|
||||||
disposeSkiaResources(effectResources);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!active) {
|
|
||||||
// Deactivation (pause, occlusion): decay whatever the tap last wrote
|
|
||||||
// toward the rest line, then settle flat and schedule nothing.
|
|
||||||
let peak = 0;
|
|
||||||
for (let i = 0; i < values.length; i++) {
|
|
||||||
const a = Math.abs(values[i]);
|
|
||||||
if (a > peak) peak = a;
|
|
||||||
}
|
|
||||||
if (reduceMotion || peak < REST_EPSILON) {
|
|
||||||
values.fill(0);
|
|
||||||
draw(values.length);
|
|
||||||
return cleanup;
|
|
||||||
}
|
|
||||||
const decayTick = (t: number) => {
|
|
||||||
if (!mounted) return;
|
|
||||||
if (drawThreshold > 0 && t - lastDraw < drawThreshold) {
|
|
||||||
raf = requestAnimationFrame(decayTick);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastDraw = t;
|
|
||||||
let max = 0;
|
|
||||||
for (let i = 0; i < values.length; i++) {
|
|
||||||
const v = values[i] * DECAY_PER_FRAME;
|
|
||||||
values[i] = v;
|
|
||||||
const a = Math.abs(v);
|
|
||||||
if (a > max) max = a;
|
|
||||||
}
|
|
||||||
if (max < REST_EPSILON) {
|
|
||||||
values.fill(0);
|
|
||||||
draw(values.length);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
draw(values.length);
|
|
||||||
raf = requestAnimationFrame(decayTick);
|
|
||||||
};
|
|
||||||
raf = requestAnimationFrame(decayTick);
|
|
||||||
return cleanup;
|
|
||||||
}
|
|
||||||
|
|
||||||
values.fill(0);
|
|
||||||
draw(values.length);
|
|
||||||
|
|
||||||
const tick = (t: number) => {
|
|
||||||
if (!mounted) return;
|
|
||||||
raf = requestAnimationFrame(tick);
|
|
||||||
if (drawThreshold > 0 && t - lastDraw < drawThreshold) return;
|
|
||||||
|
|
||||||
const n = AstraScope.getOscilloscopeFrame(values);
|
|
||||||
if (n > 0) {
|
|
||||||
lastDraw = t;
|
|
||||||
draw(n);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
raf = requestAnimationFrame(tick);
|
|
||||||
return cleanup;
|
|
||||||
}, [
|
|
||||||
active,
|
|
||||||
color,
|
|
||||||
edgeFade,
|
|
||||||
edgeFadeWidth,
|
|
||||||
frameMs,
|
|
||||||
glow,
|
|
||||||
height,
|
|
||||||
lineWidth,
|
|
||||||
reduceMotion,
|
|
||||||
width,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (width <= 0 || height <= 0) return null;
|
|
||||||
return <SkiaPictureView ref={viewRef} picture={initialPicture} style={{ width, height }} />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default OscilloscopeWave;
|
export default OscilloscopeWave;
|
||||||
|
|||||||
@@ -1,22 +1,7 @@
|
|||||||
import {
|
import { useMemo } from 'react';
|
||||||
useEffect,
|
import { processColor } from 'react-native';
|
||||||
useLayoutEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef
|
|
||||||
} from 'react';
|
|
||||||
import { useReducedMotion } from 'react-native-reanimated';
|
import { useReducedMotion } from 'react-native-reanimated';
|
||||||
import {
|
import { AstraScopeView } from '../../modules/astra-scope';
|
||||||
BlendMode,
|
|
||||||
PaintStyle,
|
|
||||||
Skia,
|
|
||||||
SkiaPictureView,
|
|
||||||
StrokeCap,
|
|
||||||
StrokeJoin,
|
|
||||||
TileMode,
|
|
||||||
type SkPath,
|
|
||||||
type SkPicture
|
|
||||||
} from '@shopify/react-native-skia';
|
|
||||||
import { AstraScope, SPECTRUM_BINS } from '../../modules/astra-scope';
|
|
||||||
import { useColors } from '@/theme/themed';
|
import { useColors } from '@/theme/themed';
|
||||||
|
|
||||||
interface SpectrumCurveProps {
|
interface SpectrumCurveProps {
|
||||||
@@ -28,11 +13,11 @@ interface SpectrumCurveProps {
|
|||||||
active?: boolean;
|
active?: boolean;
|
||||||
/** Which native tap to pull from. 'post' is the post-EQ ring (EQ screen). */
|
/** Which native tap to pull from. 'post' is the post-EQ ring (EQ screen). */
|
||||||
source?: 'pre' | 'post';
|
source?: 'pre' | 'post';
|
||||||
/** Number of render points when active. Defaults to one point per rendered pixel. */
|
/** Number of log-frequency render points. */
|
||||||
pointCount?: number;
|
pointCount?: number;
|
||||||
/** Active render cadence. 0 means display-sync; 32 keeps the mini-player battery-friendly. */
|
/** Active render cadence. 0 means display-sync; 32 keeps the mini-player battery-friendly. */
|
||||||
frameMs?: number;
|
frameMs?: number;
|
||||||
/** Native pull cadence. Defaults to frameMs; 0 advances analysis every display frame. */
|
/** Native analysis cadence. Defaults to frameMs. */
|
||||||
analysisFrameMs?: number;
|
analysisFrameMs?: number;
|
||||||
/** Previous native spectrum-frame retention in [0, 0.99]. */
|
/** Previous native spectrum-frame retention in [0, 0.99]. */
|
||||||
smoothing?: number;
|
smoothing?: number;
|
||||||
@@ -49,326 +34,16 @@ interface SpectrumCurveProps {
|
|||||||
edgeFadeWidth?: number;
|
edgeFadeWidth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SkiaViewApiShape = {
|
|
||||||
setJsiProperty: <T>(nativeId: number, name: string, value: T) => void;
|
|
||||||
requestRedraw: (nativeId: number) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_POINTS = 120;
|
const DEFAULT_POINTS = 120;
|
||||||
const MINI_FRAME_MS = 32;
|
const MINI_FRAME_MS = 32;
|
||||||
const DEFAULT_SMOOTHING = 0.92;
|
const DEFAULT_SMOOTHING = 0.92;
|
||||||
const DISPLAY_DB_MIN = -90;
|
const DISPLAY_DB_MIN = -90;
|
||||||
const DISPLAY_DB_MAX = -10;
|
const DISPLAY_DB_MAX = -10;
|
||||||
const SPECTRUM_SAMPLE_RATE = 48000;
|
const TILT_DB_PER_OCTAVE = 3.5;
|
||||||
const MIN_FREQUENCY = 20;
|
|
||||||
const MAX_FREQUENCY = 20000;
|
|
||||||
const TILT_DB_PER_OCT = 3.5;
|
|
||||||
const TILT_REFERENCE_HZ = 1000;
|
|
||||||
// Deactivation decay: let the last live curve fall to the floor over ~250ms
|
|
||||||
// instead of freezing mid-song, so pausing reads as powering down.
|
|
||||||
const DECAY_PER_FRAME = 0.72;
|
|
||||||
const REST_EPSILON = 0.004;
|
|
||||||
const spectrumBins = new Float32Array(SPECTRUM_BINS);
|
|
||||||
|
|
||||||
type SkiaDisposable = { dispose: () => void };
|
|
||||||
|
|
||||||
function disposeSkiaResources(resources: readonly (SkiaDisposable | null)[]) {
|
|
||||||
for (let i = resources.length - 1; i >= 0; i--) resources[i]?.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
function skiaViewApi(): SkiaViewApiShape | null {
|
|
||||||
const globalWithSkia = globalThis as typeof globalThis & { SkiaViewApi?: SkiaViewApiShape };
|
|
||||||
return globalWithSkia.SkiaViewApi ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** #rrggbb -> rgba() with the given alpha. */
|
|
||||||
function withAlpha(hex: string, alpha: number): string {
|
|
||||||
const r = parseInt(hex.slice(1, 3), 16);
|
|
||||||
const g = parseInt(hex.slice(3, 5), 16);
|
|
||||||
const b = parseInt(hex.slice(5, 7), 16);
|
|
||||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeStrokePaint(color: string, width: number, alpha = 1) {
|
|
||||||
const paint = Skia.Paint();
|
|
||||||
paint.setAntiAlias(true);
|
|
||||||
paint.setColor(Skia.Color(alpha === 1 ? color : withAlpha(color, alpha)));
|
|
||||||
paint.setStrokeWidth(width);
|
|
||||||
paint.setStyle(PaintStyle.Stroke);
|
|
||||||
paint.setStrokeCap(StrokeCap.Round);
|
|
||||||
paint.setStrokeJoin(StrokeJoin.Round);
|
|
||||||
return paint;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Edge fade baked into the paints: a horizontal alpha ramp so the curve
|
* Thin React wrapper. FFT projection, pause decay, path preparation, and frame
|
||||||
* dissolves at its ends over any background — solid screen or blurred artwork.
|
* scheduling all live in AstraScopeView's serialized Android worker.
|
||||||
*/
|
|
||||||
function makeFadedStrokeShader(
|
|
||||||
color: string,
|
|
||||||
alpha: number,
|
|
||||||
width: number,
|
|
||||||
fadeWidth: number
|
|
||||||
) {
|
|
||||||
const f = Math.min(fadeWidth, width * 0.5);
|
|
||||||
return Skia.Shader.MakeLinearGradient(
|
|
||||||
{ x: 0, y: 0 },
|
|
||||||
{ x: width, y: 0 },
|
|
||||||
[
|
|
||||||
Skia.Color(withAlpha(color, 0)),
|
|
||||||
Skia.Color(withAlpha(color, alpha)),
|
|
||||||
Skia.Color(withAlpha(color, alpha)),
|
|
||||||
Skia.Color(withAlpha(color, 0)),
|
|
||||||
],
|
|
||||||
[0, f / width, 1 - f / width, 1],
|
|
||||||
TileMode.Clamp
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** White-with-alpha horizontal ramp; Modulate-blending it onto another shader
|
|
||||||
* multiplies alphas while leaving color untouched. */
|
|
||||||
function makeFadeMaskShader(width: number, fadeWidth: number) {
|
|
||||||
const f = Math.min(fadeWidth, width * 0.5);
|
|
||||||
return Skia.Shader.MakeLinearGradient(
|
|
||||||
{ x: 0, y: 0 },
|
|
||||||
{ x: width, y: 0 },
|
|
||||||
[
|
|
||||||
Skia.Color('rgba(255, 255, 255, 0)'),
|
|
||||||
Skia.Color('rgba(255, 255, 255, 1)'),
|
|
||||||
Skia.Color('rgba(255, 255, 255, 1)'),
|
|
||||||
Skia.Color('rgba(255, 255, 255, 0)'),
|
|
||||||
],
|
|
||||||
[0, f / width, 1 - f / width, 1],
|
|
||||||
TileMode.Clamp
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeFillPaint(
|
|
||||||
color: string,
|
|
||||||
height: number,
|
|
||||||
opacity: number,
|
|
||||||
fade: { width: number; fadeWidth: number } | null = null
|
|
||||||
) {
|
|
||||||
const paint = Skia.Paint();
|
|
||||||
paint.setAntiAlias(true);
|
|
||||||
paint.setStyle(PaintStyle.Fill);
|
|
||||||
const vertical = Skia.Shader.MakeLinearGradient(
|
|
||||||
{ x: 0, y: 0 },
|
|
||||||
{ x: 0, y: height },
|
|
||||||
[
|
|
||||||
Skia.Color(withAlpha(color, 0.38 * opacity)),
|
|
||||||
Skia.Color(withAlpha(color, 0.08 * opacity)),
|
|
||||||
Skia.Color(withAlpha(color, 0)),
|
|
||||||
],
|
|
||||||
null,
|
|
||||||
TileMode.Clamp
|
|
||||||
);
|
|
||||||
const shaders: SkiaDisposable[] = [vertical];
|
|
||||||
if (fade) {
|
|
||||||
const mask = makeFadeMaskShader(fade.width, fade.fadeWidth);
|
|
||||||
const blended = Skia.Shader.MakeBlend(BlendMode.Modulate, vertical, mask);
|
|
||||||
shaders.push(mask, blended);
|
|
||||||
paint.setShader(blended);
|
|
||||||
} else {
|
|
||||||
paint.setShader(vertical);
|
|
||||||
}
|
|
||||||
return { paint, shaders };
|
|
||||||
}
|
|
||||||
|
|
||||||
function writePaths(
|
|
||||||
values: ArrayLike<number>,
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
pad: number,
|
|
||||||
line: SkPath,
|
|
||||||
fill: SkPath
|
|
||||||
) {
|
|
||||||
line.reset();
|
|
||||||
fill.reset();
|
|
||||||
const n = values.length;
|
|
||||||
if (n < 2 || width <= 0 || height <= 0) return;
|
|
||||||
|
|
||||||
const usableH = height - pad * 2;
|
|
||||||
const xAt = (i: number) => (i / (n - 1)) * width;
|
|
||||||
const yAt = (i: number) => {
|
|
||||||
const v = values[i] < 0 ? 0 : values[i] > 1 ? 1 : values[i];
|
|
||||||
return pad + (1 - v) * usableH;
|
|
||||||
};
|
|
||||||
|
|
||||||
line.moveTo(xAt(0), yAt(0));
|
|
||||||
for (let i = 1; i < n; i++) {
|
|
||||||
const midX = (xAt(i - 1) + xAt(i)) * 0.5;
|
|
||||||
const midY = (yAt(i - 1) + yAt(i)) * 0.5;
|
|
||||||
line.quadTo(xAt(i - 1), yAt(i - 1), midX, midY);
|
|
||||||
}
|
|
||||||
line.lineTo(xAt(n - 1), yAt(n - 1));
|
|
||||||
|
|
||||||
fill.addPath(line);
|
|
||||||
fill.lineTo(width, height);
|
|
||||||
fill.lineTo(0, height);
|
|
||||||
fill.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPaths(values: ArrayLike<number>, width: number, height: number, pad: number) {
|
|
||||||
const line = Skia.Path.Make();
|
|
||||||
const fill = Skia.Path.Make();
|
|
||||||
writePaths(values, width, height, pad, line, fill);
|
|
||||||
return { line, fill };
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPicture(
|
|
||||||
values: ArrayLike<number>,
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
color: string,
|
|
||||||
lineWidth: number,
|
|
||||||
lineOpacity: number,
|
|
||||||
fillOpacity: number,
|
|
||||||
glow: boolean,
|
|
||||||
glowOpacity: number,
|
|
||||||
edgeFade: boolean,
|
|
||||||
edgeFadeWidth: number
|
|
||||||
): SkPicture {
|
|
||||||
const recorder = Skia.PictureRecorder();
|
|
||||||
const canvas = recorder.beginRecording(Skia.XYWHRect(0, 0, width, height));
|
|
||||||
const { line, fill } = buildPaths(values, width, height, lineWidth);
|
|
||||||
const resources: SkiaDisposable[] = [recorder, line, fill];
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (values.length >= 2 && width > 0 && height > 0) {
|
|
||||||
const fade = edgeFade && edgeFadeWidth > 0 ? { width, fadeWidth: edgeFadeWidth } : null;
|
|
||||||
const fillResources = makeFillPaint(color, height, fillOpacity, fade);
|
|
||||||
resources.push(fillResources.paint, ...fillResources.shaders);
|
|
||||||
canvas.drawPath(fill, fillResources.paint);
|
|
||||||
if (glow) {
|
|
||||||
const glowPaint = makeStrokePaint(color, lineWidth * 3, glowOpacity);
|
|
||||||
resources.push(glowPaint);
|
|
||||||
if (fade) {
|
|
||||||
const glowShader = makeFadedStrokeShader(color, glowOpacity, width, edgeFadeWidth);
|
|
||||||
resources.push(glowShader);
|
|
||||||
glowPaint.setShader(glowShader);
|
|
||||||
}
|
|
||||||
canvas.drawPath(line, glowPaint);
|
|
||||||
}
|
|
||||||
const strokePaint = makeStrokePaint(color, lineWidth, lineOpacity);
|
|
||||||
resources.push(strokePaint);
|
|
||||||
if (fade) {
|
|
||||||
const strokeShader = makeFadedStrokeShader(color, lineOpacity, width, edgeFadeWidth);
|
|
||||||
resources.push(strokeShader);
|
|
||||||
strokePaint.setShader(strokeShader);
|
|
||||||
}
|
|
||||||
canvas.drawPath(line, strokePaint);
|
|
||||||
}
|
|
||||||
return recorder.finishRecordingAsPicture();
|
|
||||||
} finally {
|
|
||||||
disposeSkiaResources(resources);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function lerp(a: number, b: number, t: number): number {
|
|
||||||
return a + (b - a) * t;
|
|
||||||
}
|
|
||||||
|
|
||||||
function interpolatedValue(data: Float32Array, index: number): number {
|
|
||||||
const i0 = Math.max(0, Math.min(data.length - 1, Math.floor(index)));
|
|
||||||
const i1 = Math.min(i0 + 1, data.length - 1);
|
|
||||||
return lerp(data[i0], data[i1], index - i0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function frequencyAtPosition(t: number, minFrequency: number, maxFrequency: number): number {
|
|
||||||
const logMin = Math.log10(minFrequency);
|
|
||||||
const logMax = Math.log10(maxFrequency);
|
|
||||||
return 10 ** (logMin + t * (logMax - logMin));
|
|
||||||
}
|
|
||||||
|
|
||||||
function peakInRange(data: Float32Array, startIndex: number, endIndex: number, binWidth: number) {
|
|
||||||
const clampedStart = Math.max(0, Math.min(data.length - 1, startIndex));
|
|
||||||
const clampedEnd = Math.max(0, Math.min(data.length - 1, endIndex));
|
|
||||||
const lo = Math.floor(Math.min(clampedStart, clampedEnd));
|
|
||||||
const hi = Math.ceil(Math.max(clampedStart, clampedEnd));
|
|
||||||
|
|
||||||
if (hi <= lo) {
|
|
||||||
return {
|
|
||||||
rawDb: interpolatedValue(data, clampedStart),
|
|
||||||
frequencyHz: Math.max(0, clampedStart * binWidth),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let peakBin = lo;
|
|
||||||
let peakDb = Number.NEGATIVE_INFINITY;
|
|
||||||
for (let i = lo; i <= hi; i++) {
|
|
||||||
if (data[i] > peakDb) {
|
|
||||||
peakDb = data[i];
|
|
||||||
peakBin = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (peakBin > 0 && peakBin < data.length - 1) {
|
|
||||||
const y1 = data[peakBin - 1];
|
|
||||||
const y2 = data[peakBin];
|
|
||||||
const y3 = data[peakBin + 1];
|
|
||||||
const denominator = y1 - 2 * y2 + y3;
|
|
||||||
if (Math.abs(denominator) > 1e-9) {
|
|
||||||
const offset = Math.max(-0.5, Math.min(0.5, 0.5 * (y1 - y3) / denominator));
|
|
||||||
return {
|
|
||||||
rawDb: y2 - 0.25 * (y1 - y3) * offset,
|
|
||||||
frequencyHz: Math.max(0, (peakBin + offset) * binWidth),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
rawDb: peakDb,
|
|
||||||
frequencyHz: Math.max(0, peakBin * binWidth),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SpectrumPointOptions {
|
|
||||||
dbMin: number;
|
|
||||||
dbMax: number;
|
|
||||||
tiltDbPerOctave: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyTilt(db: number, frequency: number, tiltDbPerOctave: number): number {
|
|
||||||
const safeFreq = Math.max(1, frequency);
|
|
||||||
return db + tiltDbPerOctave * Math.log2(safeFreq / TILT_REFERENCE_HZ);
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeSpectrumPoints(rawBins: Float32Array, out: Float32Array, options: SpectrumPointOptions) {
|
|
||||||
const pointCount = out.length;
|
|
||||||
const bufferLength = rawBins.length;
|
|
||||||
const nyquist = SPECTRUM_SAMPLE_RATE / 2;
|
|
||||||
const minFrequency = Math.max(1, Math.min(MIN_FREQUENCY, nyquist));
|
|
||||||
const maxFrequency = Math.max(minFrequency + 1, Math.min(MAX_FREQUENCY, nyquist));
|
|
||||||
const binWidth = nyquist / bufferLength;
|
|
||||||
const dbRange = Math.max(1, options.dbMax - options.dbMin);
|
|
||||||
|
|
||||||
for (let p = 0; p < pointCount; p++) {
|
|
||||||
const t0 = p / (pointCount - 1);
|
|
||||||
const t1 = Math.min(1, (p + 1) / (pointCount - 1));
|
|
||||||
const frequency0 = frequencyAtPosition(t0, minFrequency, maxFrequency);
|
|
||||||
const frequency1 = frequencyAtPosition(t1, minFrequency, maxFrequency);
|
|
||||||
const centerFrequency = (frequency0 + frequency1) * 0.5;
|
|
||||||
const bin0 = frequency0 / binWidth;
|
|
||||||
const bin1 = frequency1 / binWidth;
|
|
||||||
const centerBin = (bin0 + bin1) * 0.5;
|
|
||||||
const binSpan = Math.abs(bin1 - bin0);
|
|
||||||
const rawDb =
|
|
||||||
binSpan <= 1
|
|
||||||
? interpolatedValue(rawBins, Math.min(centerBin, bufferLength - 1))
|
|
||||||
: peakInRange(rawBins, bin0, bin1, binWidth).rawDb;
|
|
||||||
const db = applyTilt(rawDb, centerFrequency, options.tiltDbPerOctave);
|
|
||||||
|
|
||||||
let norm = (db - options.dbMin) / dbRange;
|
|
||||||
if (norm < 0) norm = 0;
|
|
||||||
else if (norm > 1) norm = 1;
|
|
||||||
out[p] = norm;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filled-line spectrum. When `active` is true this mirrors the oscilloscope hot
|
|
||||||
* path: a frame loop pulls native data and updates the Skia view imperatively.
|
|
||||||
*/
|
*/
|
||||||
export function SpectrumCurve({
|
export function SpectrumCurve({
|
||||||
values,
|
values,
|
||||||
@@ -382,7 +57,7 @@ export function SpectrumCurve({
|
|||||||
smoothing = DEFAULT_SMOOTHING,
|
smoothing = DEFAULT_SMOOTHING,
|
||||||
dbMin = DISPLAY_DB_MIN,
|
dbMin = DISPLAY_DB_MIN,
|
||||||
dbMax = DISPLAY_DB_MAX,
|
dbMax = DISPLAY_DB_MAX,
|
||||||
tiltDbPerOctave = TILT_DB_PER_OCT,
|
tiltDbPerOctave = TILT_DB_PER_OCTAVE,
|
||||||
color: colorProp,
|
color: colorProp,
|
||||||
lineWidth = 2,
|
lineWidth = 2,
|
||||||
lineOpacity = 1,
|
lineOpacity = 1,
|
||||||
@@ -392,226 +67,45 @@ export function SpectrumCurve({
|
|||||||
edgeFade = false,
|
edgeFade = false,
|
||||||
edgeFadeWidth = 28,
|
edgeFadeWidth = 28,
|
||||||
}: SpectrumCurveProps) {
|
}: SpectrumCurveProps) {
|
||||||
const themeColors = useColors();
|
const colors = useColors();
|
||||||
const color = colorProp ?? themeColors.accent;
|
const reducedMotion = useReducedMotion();
|
||||||
const reduceMotion = useReducedMotion();
|
|
||||||
const viewRef = useRef<SkiaPictureView | null>(null);
|
|
||||||
// Last live curve, kept across effect re-runs so deactivation can decay it
|
|
||||||
// to the floor instead of freezing the final frame.
|
|
||||||
const lastLiveValuesRef = useRef<Float32Array | null>(null);
|
|
||||||
// Half a point per pixel, capped: the quadTo midpoint smoothing makes denser
|
|
||||||
// sampling visually indistinguishable while doubling per-frame path cost.
|
|
||||||
const activePointCount = Math.min(160, Math.max(96, Math.floor(width / 2)));
|
const activePointCount = Math.min(160, Math.max(96, Math.floor(width / 2)));
|
||||||
const resolvedPointCount = pointCount ?? values?.length ?? (active ? activePointCount : DEFAULT_POINTS);
|
const resolvedPointCount =
|
||||||
|
pointCount ?? values?.length ?? (active ? activePointCount : DEFAULT_POINTS);
|
||||||
const staticValues = useMemo(
|
const staticValues = useMemo(
|
||||||
() => values ?? new Float32Array(resolvedPointCount),
|
() => (values ? Array.from(values) : undefined),
|
||||||
[resolvedPointCount, values]
|
[values]
|
||||||
);
|
);
|
||||||
const initialPicture = useMemo(
|
const color = processColor(colorProp ?? colors.accent);
|
||||||
() =>
|
|
||||||
buildPicture(
|
if (width <= 0 || height <= 0 || typeof color !== 'number') return null;
|
||||||
staticValues,
|
return (
|
||||||
Math.max(1, width),
|
<AstraScopeView
|
||||||
Math.max(1, height),
|
mode="spectrum"
|
||||||
color,
|
source={source}
|
||||||
lineWidth,
|
active={active && !reducedMotion}
|
||||||
lineOpacity,
|
reducedMotion={reducedMotion}
|
||||||
fillOpacity,
|
frameMs={frameMs}
|
||||||
glow,
|
analysisFrameMs={analysisFrameMs ?? frameMs}
|
||||||
glowOpacity,
|
smoothing={smoothing}
|
||||||
edgeFade,
|
pointCount={resolvedPointCount}
|
||||||
edgeFadeWidth
|
dbMin={dbMin}
|
||||||
),
|
dbMax={dbMax}
|
||||||
[
|
tiltDbPerOctave={tiltDbPerOctave}
|
||||||
color,
|
color={color}
|
||||||
edgeFade,
|
lineWidth={lineWidth}
|
||||||
edgeFadeWidth,
|
lineOpacity={lineOpacity}
|
||||||
fillOpacity,
|
fillOpacity={fillOpacity}
|
||||||
glow,
|
glow={glow}
|
||||||
glowOpacity,
|
glowOpacity={glowOpacity}
|
||||||
height,
|
edgeFade={edgeFade}
|
||||||
lineOpacity,
|
edgeFadeWidth={edgeFadeWidth}
|
||||||
lineWidth,
|
values={staticValues}
|
||||||
staticValues,
|
pointerEvents="none"
|
||||||
width,
|
collapsable={false}
|
||||||
]
|
style={{ width, height }}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => () => initialPicture.dispose(), [initialPicture]);
|
|
||||||
|
|
||||||
useLayoutEffect(
|
|
||||||
() => () => {
|
|
||||||
const view = viewRef.current;
|
|
||||||
const api = skiaViewApi();
|
|
||||||
if (!view || !api) return;
|
|
||||||
api.setJsiProperty(view.nativeId, 'picture', null);
|
|
||||||
api.requestRedraw(view.nativeId);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const view = viewRef.current;
|
|
||||||
const api = skiaViewApi();
|
|
||||||
if (!view || !api || width <= 0 || height <= 0 || resolvedPointCount < 2) return;
|
|
||||||
const priorLive = lastLiveValuesRef.current;
|
|
||||||
// Static usage (values prop, never went live): leave the initial picture.
|
|
||||||
if (!active && !priorLive) return;
|
|
||||||
|
|
||||||
let mounted = true;
|
|
||||||
let raf = 0;
|
|
||||||
let lastAnalysis = 0;
|
|
||||||
let lastDraw = 0;
|
|
||||||
let hasNewFrame = false;
|
|
||||||
const drawThreshold = frameMs > 0 ? Math.max(0, frameMs - 0.5) : 0;
|
|
||||||
const analysisMs = analysisFrameMs ?? frameMs;
|
|
||||||
const analysisThreshold = analysisMs > 0 ? Math.max(0, analysisMs - 0.5) : 0;
|
|
||||||
const renderValues =
|
|
||||||
!active && priorLive && priorLive.length === resolvedPointCount
|
|
||||||
? priorLive
|
|
||||||
: new Float32Array(resolvedPointCount);
|
|
||||||
const pointOptions = { dbMin, dbMax, tiltDbPerOctave };
|
|
||||||
|
|
||||||
// Paints, shaders, and paths live for the whole effect run: allocating them
|
|
||||||
// (and the gradient shaders) per frame was measurable GC/JSI churn at 60fps.
|
|
||||||
const fade = edgeFade && edgeFadeWidth > 0 ? { width, fadeWidth: edgeFadeWidth } : null;
|
|
||||||
const strokePaint = makeStrokePaint(color, lineWidth, lineOpacity);
|
|
||||||
const glowPaint = glow ? makeStrokePaint(color, lineWidth * 3, glowOpacity) : null;
|
|
||||||
const effectResources: SkiaDisposable[] = [strokePaint];
|
|
||||||
if (glowPaint) effectResources.push(glowPaint);
|
|
||||||
if (fade) {
|
|
||||||
const strokeShader = makeFadedStrokeShader(color, lineOpacity, width, edgeFadeWidth);
|
|
||||||
effectResources.push(strokeShader);
|
|
||||||
strokePaint.setShader(strokeShader);
|
|
||||||
if (glowPaint) {
|
|
||||||
const glowShader = makeFadedStrokeShader(color, glowOpacity, width, edgeFadeWidth);
|
|
||||||
effectResources.push(glowShader);
|
|
||||||
glowPaint.setShader(glowShader);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const fillResources = makeFillPaint(color, height, fillOpacity, fade);
|
|
||||||
const fillPaint = fillResources.paint;
|
|
||||||
effectResources.push(fillPaint, ...fillResources.shaders);
|
|
||||||
const bounds = Skia.XYWHRect(0, 0, width, height);
|
|
||||||
const linePath = Skia.Path.Make();
|
|
||||||
const fillPath = Skia.Path.Make();
|
|
||||||
effectResources.push(linePath, fillPath);
|
|
||||||
let currentPicture: SkPicture | null = null;
|
|
||||||
|
|
||||||
const draw = () => {
|
|
||||||
writePaths(renderValues, width, height, lineWidth, linePath, fillPath);
|
|
||||||
const recorder = Skia.PictureRecorder();
|
|
||||||
const canvas = recorder.beginRecording(bounds);
|
|
||||||
canvas.drawPath(fillPath, fillPaint);
|
|
||||||
if (glowPaint) canvas.drawPath(linePath, glowPaint);
|
|
||||||
canvas.drawPath(linePath, strokePaint);
|
|
||||||
const nextPicture = recorder.finishRecordingAsPicture();
|
|
||||||
recorder.dispose();
|
|
||||||
api.setJsiProperty(view.nativeId, 'picture', nextPicture);
|
|
||||||
api.requestRedraw(view.nativeId);
|
|
||||||
currentPicture?.dispose();
|
|
||||||
currentPicture = nextPicture;
|
|
||||||
};
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
mounted = false;
|
|
||||||
cancelAnimationFrame(raf);
|
|
||||||
api.setJsiProperty(view.nativeId, 'picture', null);
|
|
||||||
api.requestRedraw(view.nativeId);
|
|
||||||
currentPicture?.dispose();
|
|
||||||
currentPicture = null;
|
|
||||||
disposeSkiaResources(effectResources);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!active) {
|
|
||||||
// Deactivation: decay the last live curve to the floor, then rest.
|
|
||||||
lastLiveValuesRef.current = null;
|
|
||||||
let peak = 0;
|
|
||||||
for (let i = 0; i < renderValues.length; i++) {
|
|
||||||
if (renderValues[i] > peak) peak = renderValues[i];
|
|
||||||
}
|
|
||||||
if (reduceMotion || peak < REST_EPSILON) {
|
|
||||||
renderValues.fill(0);
|
|
||||||
draw();
|
|
||||||
return cleanup;
|
|
||||||
}
|
|
||||||
const decayTick = (t: number) => {
|
|
||||||
if (!mounted) return;
|
|
||||||
if (drawThreshold > 0 && t - lastDraw < drawThreshold) {
|
|
||||||
raf = requestAnimationFrame(decayTick);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastDraw = t;
|
|
||||||
let max = 0;
|
|
||||||
for (let i = 0; i < renderValues.length; i++) {
|
|
||||||
const v = renderValues[i] * DECAY_PER_FRAME;
|
|
||||||
renderValues[i] = v;
|
|
||||||
if (v > max) max = v;
|
|
||||||
}
|
|
||||||
if (max < REST_EPSILON) {
|
|
||||||
renderValues.fill(0);
|
|
||||||
draw();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
draw();
|
|
||||||
raf = requestAnimationFrame(decayTick);
|
|
||||||
};
|
|
||||||
raf = requestAnimationFrame(decayTick);
|
|
||||||
return cleanup;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastLiveValuesRef.current = renderValues;
|
|
||||||
renderValues.fill(0);
|
|
||||||
draw();
|
|
||||||
|
|
||||||
const tick = (t: number) => {
|
|
||||||
if (!mounted) return;
|
|
||||||
raf = requestAnimationFrame(tick);
|
|
||||||
if (analysisThreshold <= 0 || t - lastAnalysis >= analysisThreshold) {
|
|
||||||
lastAnalysis = t;
|
|
||||||
const got =
|
|
||||||
source === 'post'
|
|
||||||
? AstraScope.getSpectrumFramePostEq(spectrumBins, smoothing)
|
|
||||||
: AstraScope.getSpectrumFrame(spectrumBins, smoothing);
|
|
||||||
if (got > 0) {
|
|
||||||
writeSpectrumPoints(spectrumBins, renderValues, pointOptions);
|
|
||||||
hasNewFrame = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasNewFrame || (drawThreshold > 0 && t - lastDraw < drawThreshold)) return;
|
|
||||||
lastDraw = t;
|
|
||||||
hasNewFrame = false;
|
|
||||||
draw();
|
|
||||||
};
|
|
||||||
|
|
||||||
raf = requestAnimationFrame(tick);
|
|
||||||
return cleanup;
|
|
||||||
}, [
|
|
||||||
active,
|
|
||||||
analysisFrameMs,
|
|
||||||
color,
|
|
||||||
dbMax,
|
|
||||||
dbMin,
|
|
||||||
edgeFade,
|
|
||||||
edgeFadeWidth,
|
|
||||||
fillOpacity,
|
|
||||||
frameMs,
|
|
||||||
glow,
|
|
||||||
glowOpacity,
|
|
||||||
height,
|
|
||||||
lineOpacity,
|
|
||||||
lineWidth,
|
|
||||||
reduceMotion,
|
|
||||||
resolvedPointCount,
|
|
||||||
source,
|
|
||||||
smoothing,
|
|
||||||
tiltDbPerOctave,
|
|
||||||
width,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (width <= 0 || height <= 0) return null;
|
|
||||||
return <SkiaPictureView ref={viewRef} picture={initialPicture} style={{ width, height }} />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SpectrumCurve;
|
export default SpectrumCurve;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState, type ReactNode } from 'react';
|
import {
|
||||||
|
useCallback,
|
||||||
|
type ReactNode
|
||||||
|
} from 'react';
|
||||||
import {
|
import {
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
View,
|
View
|
||||||
type LayoutChangeEvent
|
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import {
|
import {
|
||||||
@@ -24,8 +26,15 @@ type IconName = keyof typeof Ionicons.glyphMap;
|
|||||||
|
|
||||||
const SWIPE_ACTIVE_OFFSET_X = 10;
|
const SWIPE_ACTIVE_OFFSET_X = 10;
|
||||||
// Scroll-slop-sized: at 30 every vertical drag starting on a row had to travel
|
// Scroll-slop-sized: at 30 every vertical drag starting on a row had to travel
|
||||||
// 30px before the pan failed and the surrounding scrollable could win.
|
// 30px before the pan failed and the surrounding scrollable could win. Keep
|
||||||
const SWIPE_FAIL_OFFSET_Y = 12;
|
// this tighter than the horizontal activation threshold so vertical intent
|
||||||
|
// yields immediately, especially inside the queue's BottomSheet scrollable.
|
||||||
|
const SWIPE_FAIL_OFFSET_Y = 6;
|
||||||
|
// A fixed reveal distance avoids an onLayout -> setState -> gesture rebuild for
|
||||||
|
// every recycled list row. It is also more predictable on wide tablet rows than
|
||||||
|
// using half of the full row width.
|
||||||
|
const SWIPE_MAX_TRANSLATION = 168;
|
||||||
|
const SWIPE_ARM_TRANSLATION = 84;
|
||||||
|
|
||||||
export interface SwipeAction {
|
export interface SwipeAction {
|
||||||
icon: IconName;
|
icon: IconName;
|
||||||
@@ -67,32 +76,32 @@ export function SwipeableRow({
|
|||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const tx = useSharedValue(0);
|
const tx = useSharedValue(0);
|
||||||
const armed = useSharedValue(false);
|
const armed = useSharedValue(false);
|
||||||
const [rowWidth, setRowWidth] = useState(0);
|
|
||||||
|
|
||||||
const max = rowWidth / 2;
|
|
||||||
const arm = rowWidth / 4;
|
|
||||||
const hasRight = !!swipeRight;
|
const hasRight = !!swipeRight;
|
||||||
const hasLeft = !!swipeLeft;
|
const hasLeft = !!swipeLeft;
|
||||||
|
const rightCommit = swipeRight?.onCommit;
|
||||||
|
const leftCommit = swipeLeft?.onCommit;
|
||||||
|
|
||||||
const onLayout = (e: LayoutChangeEvent) => setRowWidth(e.nativeEvent.layout.width);
|
const onCommit = useCallback(
|
||||||
|
(direction: 'right' | 'left') => {
|
||||||
const onCommit = (direction: 'right' | 'left') => {
|
if (direction === 'right') rightCommit?.();
|
||||||
if (direction === 'right') swipeRight?.onCommit();
|
else leftCommit?.();
|
||||||
else swipeLeft?.onCommit();
|
playHaptic('confirm');
|
||||||
playHaptic('confirm');
|
},
|
||||||
};
|
[leftCommit, rightCommit]
|
||||||
|
);
|
||||||
|
|
||||||
const pan = Gesture.Pan()
|
const pan = Gesture.Pan()
|
||||||
.enabled(enabled && rowWidth > 0 && (hasRight || hasLeft))
|
.enabled(enabled && (hasRight || hasLeft))
|
||||||
.activeOffsetX([-SWIPE_ACTIVE_OFFSET_X, SWIPE_ACTIVE_OFFSET_X])
|
.activeOffsetX([-SWIPE_ACTIVE_OFFSET_X, SWIPE_ACTIVE_OFFSET_X])
|
||||||
.failOffsetY([-SWIPE_FAIL_OFFSET_Y, SWIPE_FAIL_OFFSET_Y])
|
.failOffsetY([-SWIPE_FAIL_OFFSET_Y, SWIPE_FAIL_OFFSET_Y])
|
||||||
.onUpdate((e) => {
|
.onUpdate((e) => {
|
||||||
let t = e.translationX;
|
let t = e.translationX;
|
||||||
if (t > 0 && !hasRight) t = 0;
|
if (t > 0 && !hasRight) t = 0;
|
||||||
if (t < 0 && !hasLeft) t = 0;
|
if (t < 0 && !hasLeft) t = 0;
|
||||||
t = Math.max(-max, Math.min(max, t));
|
t = Math.max(-SWIPE_MAX_TRANSLATION, Math.min(SWIPE_MAX_TRANSLATION, t));
|
||||||
tx.value = t;
|
tx.value = t;
|
||||||
const nowArmed = Math.abs(t) >= arm;
|
const nowArmed = Math.abs(t) >= SWIPE_ARM_TRANSLATION;
|
||||||
if (nowArmed !== armed.value) {
|
if (nowArmed !== armed.value) {
|
||||||
armed.value = nowArmed;
|
armed.value = nowArmed;
|
||||||
runOnJS(playHaptic)('threshold');
|
runOnJS(playHaptic)('threshold');
|
||||||
@@ -100,8 +109,13 @@ export function SwipeableRow({
|
|||||||
})
|
})
|
||||||
.onEnd(() => {
|
.onEnd(() => {
|
||||||
const t = tx.value;
|
const t = tx.value;
|
||||||
if (t >= arm && hasRight) runOnJS(onCommit)('right');
|
if (t >= SWIPE_ARM_TRANSLATION && hasRight) runOnJS(onCommit)('right');
|
||||||
else if (t <= -arm && hasLeft) runOnJS(onCommit)('left');
|
else if (t <= -SWIPE_ARM_TRANSLATION && hasLeft) runOnJS(onCommit)('left');
|
||||||
|
armed.value = false;
|
||||||
|
tx.value = withTiming(0, motion.quick);
|
||||||
|
})
|
||||||
|
.onFinalize((_event, success) => {
|
||||||
|
if (success) return;
|
||||||
armed.value = false;
|
armed.value = false;
|
||||||
tx.value = withTiming(0, motion.quick);
|
tx.value = withTiming(0, motion.quick);
|
||||||
});
|
});
|
||||||
@@ -111,26 +125,24 @@ export function SwipeableRow({
|
|||||||
const gesture = dragGesture ? Gesture.Race(dragGesture, pan) : pan;
|
const gesture = dragGesture ? Gesture.Race(dragGesture, pan) : pan;
|
||||||
|
|
||||||
const contentStyle = useAnimatedStyle(() => ({ transform: [{ translateX: tx.value }] }));
|
const contentStyle = useAnimatedStyle(() => ({ transform: [{ translateX: tx.value }] }));
|
||||||
const leftLaneStyle = useAnimatedStyle(() => ({ opacity: tx.value > 1 ? 1 : 0 }));
|
|
||||||
const rightLaneStyle = useAnimatedStyle(() => ({ opacity: tx.value < -1 ? 1 : 0 }));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.wrap} onLayout={onLayout}>
|
<View style={styles.wrap}>
|
||||||
{swipeRight ? (
|
{swipeRight ? (
|
||||||
<Animated.View
|
<View
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
style={[styles.lane, styles.laneLeft, { backgroundColor: swipeRight.color }, leftLaneStyle]}
|
style={[styles.lane, styles.laneLeft, { backgroundColor: swipeRight.color }]}
|
||||||
>
|
>
|
||||||
<Ionicons name={swipeRight.icon} size={22} color={swipeRight.iconColor ?? colors.bgPrimary} />
|
<Ionicons name={swipeRight.icon} size={22} color={swipeRight.iconColor ?? colors.bgPrimary} />
|
||||||
</Animated.View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
{swipeLeft ? (
|
{swipeLeft ? (
|
||||||
<Animated.View
|
<View
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
style={[styles.lane, styles.laneRight, { backgroundColor: swipeLeft.color }, rightLaneStyle]}
|
style={[styles.lane, styles.laneRight, { backgroundColor: swipeLeft.color }]}
|
||||||
>
|
>
|
||||||
<Ionicons name={swipeLeft.icon} size={22} color={swipeLeft.iconColor ?? colors.bgPrimary} />
|
<Ionicons name={swipeLeft.icon} size={22} color={swipeLeft.iconColor ?? colors.bgPrimary} />
|
||||||
</Animated.View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
<GestureDetector gesture={gesture}>
|
<GestureDetector gesture={gesture}>
|
||||||
<Animated.View style={contentStyle}>{children}</Animated.View>
|
<Animated.View style={contentStyle}>{children}</Animated.View>
|
||||||
@@ -146,18 +158,21 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
lane: {
|
lane: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
// Keep the always-mounted action colors out from under translucent row
|
||||||
left: 0,
|
// separators; otherwise each half of the lane tints the resting hairline.
|
||||||
right: 0,
|
top: StyleSheet.hairlineWidth,
|
||||||
bottom: 0,
|
bottom: StyleSheet.hairlineWidth,
|
||||||
|
width: '50%',
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: 24,
|
paddingHorizontal: 24,
|
||||||
},
|
},
|
||||||
laneLeft: {
|
laneLeft: {
|
||||||
|
left: 0,
|
||||||
justifyContent: 'flex-start',
|
justifyContent: 'flex-start',
|
||||||
},
|
},
|
||||||
laneRight: {
|
laneRight: {
|
||||||
|
right: 0,
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Skia,
|
Skia,
|
||||||
rect
|
rect
|
||||||
} from '@shopify/react-native-skia';
|
} from '@shopify/react-native-skia';
|
||||||
|
import { useDerivedValue } from 'react-native-reanimated';
|
||||||
import { Text } from './Text';
|
import { Text } from './Text';
|
||||||
import { spacing } from '@/theme';
|
import { spacing } from '@/theme';
|
||||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||||
@@ -27,7 +28,7 @@ import {
|
|||||||
mergeProgressiveWaveform,
|
mergeProgressiveWaveform,
|
||||||
subscribeWaveformProgress,
|
subscribeWaveformProgress,
|
||||||
} from '@/scope/waveform';
|
} from '@/scope/waveform';
|
||||||
import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime';
|
import { useAnimatedPlaybackProgress } from '@/audio/useAnimatedPlaybackProgress';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
import { playHaptic } from '@/lib/haptics';
|
import { playHaptic } from '@/lib/haptics';
|
||||||
import {
|
import {
|
||||||
@@ -95,7 +96,15 @@ export function WaveformSeekBar({
|
|||||||
const scrubRef = useRef<number | null>(null);
|
const scrubRef = useRef<number | null>(null);
|
||||||
const grantRef = useRef({ fraction: 0, pageX: 0 });
|
const grantRef = useRef({ fraction: 0, pageX: 0 });
|
||||||
const detentRef = useRef<ScrubDetentState | null>(null);
|
const detentRef = useRef<ScrubDetentState | null>(null);
|
||||||
const smoothTime = useSmoothPlaybackTime(currentTime, duration, isPlaying);
|
const heldFraction = pendingSeek && duration > 0 ? clamp(pendingSeek.target / duration) : null;
|
||||||
|
const progress = useAnimatedPlaybackProgress({
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
isPlaying,
|
||||||
|
active,
|
||||||
|
trackKey: trackPath,
|
||||||
|
overrideFraction: scrubFraction ?? heldFraction,
|
||||||
|
});
|
||||||
// The coarse preview is kept aside as well as rendered: it's the amplitude reference the
|
// The coarse preview is kept aside as well as rendered: it's the amplitude reference the
|
||||||
// partially-decoded prefix is scaled against, and it supplies the not-yet-decoded tail.
|
// partially-decoded prefix is scaled against, and it supplies the not-yet-decoded tail.
|
||||||
const previewRef = useRef<{ path: string; peaks: Float32Array } | null>(null);
|
const previewRef = useRef<{ path: string; peaks: Float32Array } | null>(null);
|
||||||
@@ -209,8 +218,7 @@ export function WaveformSeekBar({
|
|||||||
// Displayed position: scrub > pending seek target > live progress. The player
|
// Displayed position: scrub > pending seek target > live progress. The player
|
||||||
// store clears pendingSeek only after native progress acknowledges the target
|
// store clears pendingSeek only after native progress acknowledges the target
|
||||||
// or the guard times out, so stale RNTP progress cannot bounce the UI back.
|
// or the guard times out, so stale RNTP progress cannot bounce the UI back.
|
||||||
const liveFraction = duration > 0 ? Math.min(1, smoothTime / duration) : 0;
|
const liveFraction = duration > 0 ? Math.min(1, currentTime / duration) : 0;
|
||||||
const heldFraction = pendingSeek && duration > 0 ? clamp(pendingSeek.target / duration) : null;
|
|
||||||
const fraction = scrubFraction ?? heldFraction ?? liveFraction;
|
const fraction = scrubFraction ?? heldFraction ?? liveFraction;
|
||||||
const shownTime = fraction * duration;
|
const shownTime = fraction * duration;
|
||||||
|
|
||||||
@@ -235,11 +243,21 @@ export function WaveformSeekBar({
|
|||||||
return path;
|
return path;
|
||||||
}, [source, barCount, barWidth, height]);
|
}, [source, barCount, barWidth, height]);
|
||||||
|
|
||||||
const splitX = fraction * barWidth;
|
const playedClip = useDerivedValue(
|
||||||
const playheadX = Math.min(
|
() => rect(0, 0, progress.value * barWidth, height),
|
||||||
Math.max(0, barWidth - PLAYHEAD_WIDTH),
|
[barWidth, height]
|
||||||
Math.max(0, splitX - PLAYHEAD_WIDTH / 2)
|
|
||||||
);
|
);
|
||||||
|
const unplayedClip = useDerivedValue(() => {
|
||||||
|
const splitX = progress.value * barWidth;
|
||||||
|
return rect(splitX, 0, Math.max(0, barWidth - splitX), height);
|
||||||
|
}, [barWidth, height]);
|
||||||
|
const playheadX = useDerivedValue(() => {
|
||||||
|
const splitX = progress.value * barWidth;
|
||||||
|
return Math.min(
|
||||||
|
Math.max(0, barWidth - PLAYHEAD_WIDTH),
|
||||||
|
Math.max(0, splitX - PLAYHEAD_WIDTH / 2)
|
||||||
|
);
|
||||||
|
}, [barWidth]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
@@ -258,10 +276,10 @@ export function WaveformSeekBar({
|
|||||||
accessibilityValue={{ min: 0, max: Math.round(duration), now: Math.round(shownTime) }}
|
accessibilityValue={{ min: 0, max: Math.round(duration), now: Math.round(shownTime) }}
|
||||||
>
|
>
|
||||||
<Canvas style={{ width: '100%', height }}>
|
<Canvas style={{ width: '100%', height }}>
|
||||||
<Group clip={rect(0, 0, splitX, height)}>
|
<Group clip={playedClip}>
|
||||||
<Path path={barsPath} color={colors.accent} />
|
<Path path={barsPath} color={colors.accent} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group clip={rect(splitX, 0, Math.max(0, barWidth - splitX), height)}>
|
<Group clip={unplayedClip}>
|
||||||
<Path path={barsPath} color={colors.glassBorder} />
|
<Path path={barsPath} color={colors.glassBorder} />
|
||||||
</Group>
|
</Group>
|
||||||
{barWidth > 0 ? (
|
{barWidth > 0 ? (
|
||||||
|
|||||||
@@ -425,7 +425,10 @@ export function NowPlayingOverlay() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
stageProgress.value = withTiming(effectiveScopeStageVisible ? 1 : 0, motion.snap);
|
stageProgress.value = withTiming(effectiveScopeStageVisible ? 1 : 0, motion.snap);
|
||||||
}, [effectiveScopeStageVisible, stageProgress]);
|
}, [effectiveScopeStageVisible, stageProgress]);
|
||||||
const commitClosed = () => usePlayerUiStore.getState().commitClosed();
|
const commitClosed = useCallback(
|
||||||
|
() => usePlayerUiStore.getState().commitClosed(),
|
||||||
|
[]
|
||||||
|
);
|
||||||
/**
|
/**
|
||||||
* Enter the closing phase and drop the inner layers. Split out from
|
* Enter the closing phase and drop the inner layers. Split out from
|
||||||
* `dismissSheet` so the pan gesture can commit the phase without handing the
|
* `dismissSheet` so the pan gesture can commit the phase without handing the
|
||||||
@@ -433,13 +436,13 @@ export function NowPlayingOverlay() {
|
|||||||
* Clearing the layers here rather than at the end also unpins the menu card,
|
* Clearing the layers here rather than at the end also unpins the menu card,
|
||||||
* which renders outside the translating content.
|
* which renders outside the translating content.
|
||||||
*/
|
*/
|
||||||
const beginDismiss = () => {
|
const beginDismiss = useCallback(() => {
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
setQueueOpen(false);
|
setQueueOpen(false);
|
||||||
// `true`: this path drives the sheet away itself, so the effect below must
|
// `true`: this path drives the sheet away itself, so the effect below must
|
||||||
// not overwrite the offset with a competing generic slide-out.
|
// not overwrite the offset with a competing generic slide-out.
|
||||||
usePlayerUiStore.getState().closePlayer(true);
|
usePlayerUiStore.getState().closePlayer(true);
|
||||||
};
|
}, []);
|
||||||
const finishCloseMenu = () => setMenuOpen(false);
|
const finishCloseMenu = () => setMenuOpen(false);
|
||||||
|
|
||||||
function openMenu() {
|
function openMenu() {
|
||||||
@@ -478,6 +481,10 @@ export function NowPlayingOverlay() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const pan = Gesture.Pan()
|
const pan = Gesture.Pan()
|
||||||
|
// A child sheet owns vertical gestures while it is visible. Replacing this
|
||||||
|
// gesture during the queue-button touch used to cancel a partially active
|
||||||
|
// pan and leave translateY off-screen while phase still said "open".
|
||||||
|
.enabled(playerOpen && !queueOpen)
|
||||||
.activeOffsetY(14) // engage only on a downward drag
|
.activeOffsetY(14) // engage only on a downward drag
|
||||||
.failOffsetY(-14)
|
.failOffsetY(-14)
|
||||||
.failOffsetX([-24, 24]) // let the horizontal seek drag through
|
.failOffsetX([-24, 24]) // let the horizontal seek drag through
|
||||||
@@ -509,15 +516,21 @@ export function NowPlayingOverlay() {
|
|||||||
} else {
|
} else {
|
||||||
translateY.value = withTiming(0, motion.snap);
|
translateY.value = withTiming(0, motion.snap);
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.onFinalize((_event, success) => {
|
||||||
|
// RNGH does not call onEnd for a cancelled gesture. Never leave the
|
||||||
|
// overlay at its last partial translation in that path.
|
||||||
|
if (!success) translateY.value = withTiming(0, motion.snap);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Enter animation. Keyed on `openRequest` as well as the phase, so asking for
|
// Enter animation. Keyed on `openRequest` as well as the phase, so asking for
|
||||||
// a player that already believes it is open still re-runs the slide-in — that
|
// a player that already believes it is open still re-runs the slide-in — that
|
||||||
// is the recovery path for a sheet stranded off-screen by an interrupted
|
// is the recovery path for a sheet stranded off-screen by an interrupted
|
||||||
// close. `windowHeight` is deliberately NOT a dependency: a dimension change
|
// close. `queueOpen` also re-anchors the player before its modal BottomSheet
|
||||||
// (rotation, or an RN Modal like the output picker) would re-run this effect
|
// appears. `windowHeight` is deliberately NOT a dependency: a dimension
|
||||||
// and cancel an in-flight exit spring. NOTE: this effect must stay BELOW
|
// change (rotation, or an RN Modal like the output picker) would re-run this
|
||||||
// every direct `translateY.value` write — the react compiler forbids
|
// effect and cancel an in-flight exit spring. NOTE: this effect must stay
|
||||||
|
// BELOW every direct `translateY.value` write — the react compiler forbids
|
||||||
// mutations after an effect that depends on the value.
|
// mutations after an effect that depends on the value.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (phase === 'closing') {
|
if (phase === 'closing') {
|
||||||
@@ -531,7 +544,7 @@ export function NowPlayingOverlay() {
|
|||||||
}
|
}
|
||||||
translateY.value = withTiming(0, { duration: 240 });
|
translateY.value = withTiming(0, { duration: 240 });
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- windowHeight excluded on purpose (see above)
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- windowHeight excluded on purpose (see above)
|
||||||
}, [phase, openRequest, exitAnimated, translateY]);
|
}, [phase, openRequest, exitAnimated, queueOpen, translateY]);
|
||||||
|
|
||||||
// `closing` → `closed`, and `opening` → `open`. Both are timers rather than
|
// `closing` → `closed`, and `opening` → `open`. Both are timers rather than
|
||||||
// animation callbacks, so a cancelled animation can never strand the phase.
|
// animation callbacks, so a cancelled animation can never strand the phase.
|
||||||
@@ -666,7 +679,7 @@ export function NowPlayingOverlay() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={StyleSheet.absoluteFill} pointerEvents={playerOpen ? 'auto' : 'none'}>
|
<View style={StyleSheet.absoluteFill} pointerEvents={playerOpen ? 'box-none' : 'none'}>
|
||||||
<GestureDetector gesture={pan}>
|
<GestureDetector gesture={pan}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
|
|||||||
@@ -67,8 +67,12 @@ import {
|
|||||||
resolveSelectedQueueAction,
|
resolveSelectedQueueAction,
|
||||||
type QueueIndexByKey,
|
type QueueIndexByKey,
|
||||||
} from './queueActions';
|
} from './queueActions';
|
||||||
|
import {
|
||||||
|
QUEUE_RENDER_DISTANCE,
|
||||||
|
QUEUE_ROW_HEIGHT,
|
||||||
|
queuePreviewRowCount,
|
||||||
|
} from './queuePerformance';
|
||||||
|
|
||||||
const QUEUE_ROW_HEIGHT = 64;
|
|
||||||
const ART = 42;
|
const ART = 42;
|
||||||
const EMPTY_KEY_SET = new Set<string>();
|
const EMPTY_KEY_SET = new Set<string>();
|
||||||
|
|
||||||
@@ -183,7 +187,7 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
|||||||
// fires a frame after first layout completes, so rows are already underneath).
|
// fires a frame after first layout completes, so rows are already underneath).
|
||||||
const [listPainted, setListPainted] = useState(false);
|
const [listPainted, setListPainted] = useState(false);
|
||||||
const onListLoad = useCallback(() => setListPainted(true), []);
|
const onListLoad = useCallback(() => setListPainted(true), []);
|
||||||
const previewCount = Math.ceil(windowHeight / QUEUE_ROW_HEIGHT);
|
const previewCount = queuePreviewRowCount(windowHeight);
|
||||||
// Bottom padding clears the gesture-nav inset so the last row is fully
|
// Bottom padding clears the gesture-nav inset so the last row is fully
|
||||||
// scrollable into view at the 100% snap.
|
// scrollable into view at the 100% snap.
|
||||||
const listContentStyle = useMemo(
|
const listContentStyle = useMemo(
|
||||||
@@ -856,7 +860,7 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
|
|||||||
data={entries}
|
data={entries}
|
||||||
scrollEnabled
|
scrollEnabled
|
||||||
keyExtractor={(item) => item.key}
|
keyExtractor={(item) => item.key}
|
||||||
drawDistance={QUEUE_ROW_HEIGHT * 12}
|
drawDistance={QUEUE_RENDER_DISTANCE}
|
||||||
maintainVisibleContentPosition={{ disabled: true }}
|
maintainVisibleContentPosition={{ disabled: true }}
|
||||||
renderScrollComponent={embedded ? undefined : renderFlashListScrollComponent}
|
renderScrollComponent={embedded ? undefined : renderFlashListScrollComponent}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
QUEUE_RENDER_AHEAD_ROWS,
|
||||||
|
QUEUE_RENDER_DISTANCE,
|
||||||
|
QUEUE_ROW_HEIGHT,
|
||||||
|
queuePreviewRowCount,
|
||||||
|
} from './queuePerformance.ts';
|
||||||
|
|
||||||
|
test('queue render-ahead stays bounded to four rows', () => {
|
||||||
|
assert.equal(QUEUE_RENDER_AHEAD_ROWS, 4);
|
||||||
|
assert.equal(QUEUE_RENDER_DISTANCE, QUEUE_ROW_HEIGHT * 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initial queue preview covers the sheet viewport without duplicating a screen', () => {
|
||||||
|
assert.equal(queuePreviewRowCount(780), 4);
|
||||||
|
assert.equal(queuePreviewRowCount(900), 5);
|
||||||
|
assert.equal(queuePreviewRowCount(1400), 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('queue preview remains safe for unusually short windows', () => {
|
||||||
|
assert.equal(queuePreviewRowCount(0), 1);
|
||||||
|
assert.equal(queuePreviewRowCount(320), 1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export const QUEUE_ROW_HEIGHT = 64;
|
||||||
|
export const QUEUE_RENDER_AHEAD_ROWS = 4;
|
||||||
|
|
||||||
|
const QUEUE_SHEET_INITIAL_FRACTION = 0.58;
|
||||||
|
const QUEUE_PREVIEW_NON_LIST_HEIGHT = 220;
|
||||||
|
const QUEUE_PREVIEW_MAX_ROWS = 6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The preview only fills the list portion of the initial sheet snap. Using the
|
||||||
|
* whole window height used to duplicate far more rows than could be visible
|
||||||
|
* while FlashList was mounting its own render-ahead window underneath.
|
||||||
|
*/
|
||||||
|
export function queuePreviewRowCount(windowHeight: number): number {
|
||||||
|
const initialListHeight = Math.max(
|
||||||
|
QUEUE_ROW_HEIGHT,
|
||||||
|
windowHeight * QUEUE_SHEET_INITIAL_FRACTION - QUEUE_PREVIEW_NON_LIST_HEIGHT
|
||||||
|
);
|
||||||
|
return Math.min(
|
||||||
|
QUEUE_PREVIEW_MAX_ROWS,
|
||||||
|
Math.max(1, Math.ceil(initialListHeight / QUEUE_ROW_HEIGHT))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QUEUE_RENDER_DISTANCE = QUEUE_ROW_HEIGHT * QUEUE_RENDER_AHEAD_ROWS;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
TAB_SCENE_ANIMATION,
|
||||||
|
TAB_TRANSITION_SETTLE_MS,
|
||||||
|
TAB_TRANSITION_SPEC,
|
||||||
|
} from './tabTransition.ts';
|
||||||
|
|
||||||
|
test('tab scenes use a cross-fade and retain the short settle guard', () => {
|
||||||
|
assert.equal(TAB_SCENE_ANIMATION, 'fade');
|
||||||
|
assert.equal(TAB_TRANSITION_SETTLE_MS, 160);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tab fade uses a critically damped native spring with no overshoot', () => {
|
||||||
|
assert.equal(TAB_TRANSITION_SPEC.animation, 'spring');
|
||||||
|
assert.equal(TAB_TRANSITION_SPEC.config.overshootClamping, true);
|
||||||
|
const criticalDamping =
|
||||||
|
2 * Math.sqrt(TAB_TRANSITION_SPEC.config.stiffness * TAB_TRANSITION_SPEC.config.mass);
|
||||||
|
assert.equal(TAB_TRANSITION_SPEC.config.damping, criticalDamping);
|
||||||
|
});
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Bottom tabs use React Native's legacy native Animated driver. On Android,
|
* Bottom tabs use React Native's legacy native Animated driver. On Android,
|
||||||
* timing animations are pre-sampled at 60 fps, so their positions repeat on a
|
* timing animations are pre-sampled at 60 fps. A short, critically damped
|
||||||
* 120 Hz display. A critically damped native spring is evaluated from each
|
* native spring avoids translating two retained full-page scenes and leaves
|
||||||
* display frame instead while preserving the current ~160 ms ease-out feel.
|
* only an intentional cross-fade at the tab boundary.
|
||||||
*/
|
*/
|
||||||
export const TAB_TRANSITION_SETTLE_MS = 160;
|
export const TAB_TRANSITION_SETTLE_MS = 160;
|
||||||
|
export const TAB_SCENE_ANIMATION = 'fade' as const;
|
||||||
|
|
||||||
export const TAB_TRANSITION_SPEC = {
|
export const TAB_TRANSITION_SPEC = {
|
||||||
animation: 'spring',
|
animation: 'spring',
|
||||||
@@ -17,4 +18,3 @@ export const TAB_TRANSITION_SPEC = {
|
|||||||
restSpeedThreshold: 0.15,
|
restSpeedThreshold: 0.15,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user