performance improvements + ui/ux fixes

This commit is contained in:
Boof2015
2026-07-25 19:42:42 -04:00
parent aaadff107c
commit 6dd82420c8
22 changed files with 1269 additions and 953 deletions
@@ -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() }
}
} }
} }
@@ -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 Hz20 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
}
}
@@ -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)
}
}
+10 -4
View File
@@ -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};
+35 -1
View File
@@ -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
View File
@@ -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",
+4 -3
View File
@@ -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);
});
+60
View File
@@ -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,
};
}
+68
View File
@@ -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;
}
+35 -7
View File
@@ -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,
}, },
+31 -326
View File
@@ -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;
+44 -550
View File
@@ -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;
+48 -33
View File
@@ -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',
}, },
}); });
+28 -10
View File
@@ -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 ? (
+22 -9
View File
@@ -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={[
+7 -3
View File
@@ -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);
});
+24
View File
@@ -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;
+20
View File
@@ -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);
});
+4 -4
View File
@@ -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;