diff --git a/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeModule.kt b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeModule.kt index 8406786..ae0db57 100644 --- a/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeModule.kt +++ b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeModule.kt @@ -90,5 +90,34 @@ class AstraScopeModule : Module() { Function("setFallbackGain") { linear: Double -> 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? -> + view.staticValues = value?.let { values -> + FloatArray(values.size) { index -> values[index].toFloat() } + } + } + OnViewDidUpdateProps { view -> view.commitProps() } + } } } diff --git a/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeProjection.kt b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeProjection.kt new file mode 100644 index 0000000..e0df21e --- /dev/null +++ b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeProjection.kt @@ -0,0 +1,165 @@ +package expo.modules.astrascope + +import kotlin.math.ln +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.roundToInt + +internal enum class ScopeMode { + SPECTRUM, + OSCILLOSCOPE; + + companion object { + fun from(value: String): ScopeMode = + if (value == "oscilloscope") OSCILLOSCOPE else SPECTRUM + } +} + +internal enum class ScopeSource { + PRE, + POST; + + companion object { + fun from(value: String): ScopeSource = if (value == "post") POST else PRE + } +} + +/** + * Pure scope math kept separate from the Android view so cadence, projection, + * clamping, decay, and lifecycle generation changes have inexpensive JVM tests. + */ +internal object AstraScopeProjection { + const val SPECTRUM_BINS = 1024 + const val OSCILLOSCOPE_POINTS = 256 + const val DECAY_PER_FRAME = 0.72f + const val REST_EPSILON = 0.004f + + private const val MIN_FREQUENCY = 20.0 + private const val MAX_FREQUENCY = 20_000.0 + private const val TILT_REFERENCE_HZ = 1_000.0 + private const val LN_2 = 0.6931471805599453 + + fun cadenceMs(requestedMs: Double, refreshRate: Float): Long { + if (requestedMs > 0.0) return max(1L, requestedMs.roundToInt().toLong()) + val safeRate = if (refreshRate.isFinite() && refreshRate >= 30f) refreshRate else 60f + return max(1L, (1_000.0 / safeRate).roundToInt().toLong()) + } + + fun clamp01(value: Float): Float = when { + !value.isFinite() || value <= 0f -> 0f + value >= 1f -> 1f + else -> value + } + + /** + * Projects linear FFT bins into log-frequency display points, matching the + * former React/Skia renderer's 20 Hz–20 kHz presentation and tilt. + */ + fun writeSpectrum( + raw: java.nio.FloatBuffer, + rawCount: Int, + out: FloatArray, + pointCount: Int, + dbMin: Float, + dbMax: Float, + tiltDbPerOctave: Float, + sampleRate: Float = 48_000f + ) { + val bins = min(rawCount, raw.capacity()) + val points = min(pointCount, out.size) + if (bins <= 0 || points < 2) { + out.fill(0f, 0, max(0, points)) + return + } + + val nyquist = max(1.0, sampleRate.toDouble() / 2.0) + val minFrequency = min(MIN_FREQUENCY, nyquist) + val maxFrequency = max(minFrequency + 1.0, min(MAX_FREQUENCY, nyquist)) + val binWidth = nyquist / bins.toDouble() + val range = max(1f, dbMax - dbMin) + + for (point in 0 until points) { + val t0 = point.toDouble() / (points - 1).toDouble() + val t1 = min(1.0, (point + 1).toDouble() / (points - 1).toDouble()) + val frequency0 = frequencyAt(t0, minFrequency, maxFrequency) + val frequency1 = frequencyAt(t1, minFrequency, maxFrequency) + val centerFrequency = (frequency0 + frequency1) * 0.5 + val bin0 = frequency0 / binWidth + val bin1 = frequency1 / binWidth + val centerBin = (bin0 + bin1) * 0.5 + + val rawDb = if (kotlin.math.abs(bin1 - bin0) <= 1.0) { + interpolated(raw, bins, min(centerBin, (bins - 1).toDouble())) + } else { + peak(raw, bins, bin0, bin1) + } + val tiltedDb = + rawDb + tiltDbPerOctave * (ln(max(1.0, centerFrequency) / TILT_REFERENCE_HZ) / LN_2).toFloat() + out[point] = clamp01((tiltedDb - dbMin) / range) + } + } + + /** Returns the largest absolute value left after one decay step. */ + fun decay(values: FloatArray, count: Int, factor: Float = DECAY_PER_FRAME): Float { + var peak = 0f + val n = min(count, values.size) + for (index in 0 until n) { + val next = values[index] * factor + values[index] = next + peak = max(peak, kotlin.math.abs(next)) + } + return peak + } + + private fun frequencyAt(t: Double, minFrequency: Double, maxFrequency: Double): Double = + minFrequency * (maxFrequency / minFrequency).pow(t) + + private fun interpolated( + values: java.nio.FloatBuffer, + count: Int, + position: Double + ): Float { + val lower = position.toInt().coerceIn(0, count - 1) + val upper = min(count - 1, lower + 1) + val mix = (position - lower.toDouble()).toFloat() + return values.get(lower) + (values.get(upper) - values.get(lower)) * mix + } + + private fun peak( + values: java.nio.FloatBuffer, + count: Int, + startPosition: Double, + endPosition: Double + ): Float { + val start = startPosition.toInt().coerceIn(0, count - 1) + val end = kotlin.math.ceil(endPosition).toInt().coerceIn(start, count - 1) + var result = values.get(start) + for (index in (start + 1)..end) result = max(result, values.get(index)) + return result + } +} + +/** + * A monotonically increasing token makes queued/running work self-cancelling + * after detach, backgrounding, pause, size loss, or a prop-generation change. + */ +internal class ScopeRenderGate { + @Volatile + private var generation = 0 + + @Volatile + var eligible: Boolean = false + private set + + @Synchronized + fun update(nextEligible: Boolean): Int { + eligible = nextEligible + generation += 1 + return generation + } + + fun isCurrent(token: Int): Boolean = eligible && generation == token + + fun currentGeneration(): Int = generation +} diff --git a/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeView.kt b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeView.kt new file mode 100644 index 0000000..2ef5b2d --- /dev/null +++ b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeView.kt @@ -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 + } +} diff --git a/modules/astra-scope/android/src/test/java/expo/modules/astrascope/AstraScopeProjectionTest.kt b/modules/astra-scope/android/src/test/java/expo/modules/astrascope/AstraScopeProjectionTest.kt new file mode 100644 index 0000000..194dc0d --- /dev/null +++ b/modules/astra-scope/android/src/test/java/expo/modules/astrascope/AstraScopeProjectionTest.kt @@ -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) + } +} diff --git a/modules/astra-scope/cpp/scope_ring.h b/modules/astra-scope/cpp/scope_ring.h index 4e821d7..fb3c672 100644 --- a/modules/astra-scope/cpp/scope_ring.h +++ b/modules/astra-scope/cpp/scope_ring.h @@ -2,16 +2,16 @@ // Process-wide scope driver: a single-producer / single-consumer bridge between // 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: // - pushInterleaved() + configure() run on the AUDIO thread. They are // allocation-free and lock-free: they only touch the ring (atomic write // position) and an atomic pending-sample-rate. They NEVER touch the // analyzer (no FFT on the audio callback). -// - fillSpectrum() runs on the single JS/render thread. It owns the analyzer -// and all consumer-only state. It snapshots the most recent fftSize mono -// samples from the ring and runs Visualizer::Spectrum::process there. +// - fill methods own analyzer/consumer state. A consumer mutex serializes the +// native renderer with legacy synchronous JS getters without ever touching +// the audio-thread producer path. // // 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 @@ -27,6 +27,7 @@ #include #include #include +#include #include namespace astra { @@ -69,6 +70,7 @@ class ScopeDriver { // samples, run the FFT, copy up to `cap` dB magnitudes into `out`. // Returns the number of bins written. size_t fillSpectrum(float* out, size_t cap, float smoothing) { + std::lock_guard lock(consumerMutex_); if (out == nullptr || cap == 0) { return 0; } @@ -110,6 +112,7 @@ class ScopeDriver { // pitch-locked trigger. We drain a bounded recent slice into its internal // circular buffer, then return render-ready points from the triggered window. size_t fillOscilloscope(float* out, size_t cap) { + std::lock_guard lock(consumerMutex_); if (out == nullptr || cap == 0) { return 0; } @@ -212,6 +215,7 @@ class ScopeDriver { // Render thread. Latest post-EQ spectrum window -> `out` (dB magnitudes). size_t fillSpectrumPostEq(float* out, size_t cap, float smoothing) { + std::lock_guard lock(consumerMutex_); if (out == nullptr || cap == 0) { return 0; } @@ -250,6 +254,7 @@ class ScopeDriver { size_t binCount() const { return spectrum_.getFFTSize() / 2; } void reset() { + std::lock_guard lock(consumerMutex_); spectrum_.reset(); postEqSpectrum_.reset(); osc_.reset(); @@ -339,6 +344,7 @@ class ScopeDriver { std::atomic pendingSampleRate_{44100}; // Consumer-only state. + std::mutex consumerMutex_; std::vector scratch_; Visualizer::Spectrum spectrum_; int appliedSampleRate_{0}; diff --git a/modules/astra-scope/index.ts b/modules/astra-scope/index.ts index 0999091..62f323f 100644 --- a/modules/astra-scope/index.ts +++ b/modules/astra-scope/index.ts @@ -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). */ export const SPECTRUM_BINS = 1024; @@ -70,3 +75,32 @@ declare class AstraScopeModuleType extends NativeModule { } export const AstraScope = requireNativeModule('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('AstraScope'); diff --git a/package.json b/package.json index 841e8f3..6b2a1ac 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "ios": "expo run:ios", "web": "expo start --web", "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: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", @@ -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: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: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: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: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: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: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", diff --git a/src/app/(tabs)/_layout.tsx b/src/app/(tabs)/_layout.tsx index ebefffe..ea9ebb6 100644 --- a/src/app/(tabs)/_layout.tsx +++ b/src/app/(tabs)/_layout.tsx @@ -2,6 +2,7 @@ import { useMemo, useRef } from 'react'; import { Tabs } from 'expo-router'; import { TabBar, type TabItem } from '@/components/TabBar'; import { + TAB_SCENE_ANIMATION, TAB_TRANSITION_SETTLE_MS, TAB_TRANSITION_SPEC, } from '@/navigation/tabTransition'; @@ -19,8 +20,8 @@ export default function TabsLayout() { headerShown: false, freezeOnBlur: false, sceneStyle: { backgroundColor: colors.bgPrimary }, - // Directional slide + cross-fade between tabs, following tab order. - animation: 'shift' as const, + // Retained scenes cross-fade without translating two full pages. + animation: TAB_SCENE_ANIMATION, transitionSpec: TAB_TRANSITION_SPEC, }), [colors.bgPrimary] @@ -37,7 +38,7 @@ export default function TabsLayout() { })); 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 // taps until the current transition has finished. const now = Date.now(); diff --git a/src/audio/playbackProgressProjection.test.mts b/src/audio/playbackProgressProjection.test.mts new file mode 100644 index 0000000..a7f7f31 --- /dev/null +++ b/src/audio/playbackProgressProjection.test.mts @@ -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); +}); diff --git a/src/audio/playbackProgressProjection.ts b/src/audio/playbackProgressProjection.ts new file mode 100644 index 0000000..1bc01ac --- /dev/null +++ b/src/audio/playbackProgressProjection.ts @@ -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, + }; +} diff --git a/src/audio/useAnimatedPlaybackProgress.ts b/src/audio/useAnimatedPlaybackProgress.ts new file mode 100644 index 0000000..b742d61 --- /dev/null +++ b/src/audio/useAnimatedPlaybackProgress.ts @@ -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 { + 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; +} diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index c19667a..02e906e 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -33,7 +33,7 @@ import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; import { skipToNext, skipToPrevious, togglePlay } from '@/audio/playbackController'; import { useScopeActive } from '@/scope/scopeStore'; import { artworkThumbFromSource } from '@/library/artwork'; -import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime'; +import { useAnimatedPlaybackProgress } from '@/audio/useAnimatedPlaybackProgress'; import { useAppForeground } from '@/lib/useAppForeground'; import { playHaptic } from '@/lib/haptics'; import { PlaybackTargetPicker } from './PlaybackTargetPicker'; @@ -87,26 +87,47 @@ function MiniProgress({ currentTime, duration, isPlaying, + active, + trackKey, }: { currentTime: number; duration: number; isPlaying: boolean; + active: boolean; + trackKey: string | null; }) { const styles = useStyles(); - const smoothTime = useSmoothPlaybackTime(currentTime, duration, isPlaying); - const progress = duration > 0 ? Math.min(1, smoothTime / duration) : 0; + const progress = useAnimatedPlaybackProgress({ + currentTime, + duration, + isPlaying, + active, + trackKey, + }); + const progressStyle = useAnimatedStyle(() => ({ + transform: [{ scaleX: progress.value }], + })); return ( - + ); } /** 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 duration = usePlayerStore((s) => s.duration); - return ; + const trackKey = usePlayerStore((s) => s.currentTrack?.path ?? null); + return ( + + ); } /** @@ -520,9 +541,11 @@ export function MiniPlayer() { currentTime={presentation.currentTime} duration={presentation.duration} isPlaying={isPlaying} + active={!playerOpen} + trackKey={presentation.trackKey} /> ) : ( - + ) ) : null} @@ -632,6 +655,11 @@ const useStyles = createThemedStyles((colors) => ({ backgroundColor: colors.glassBorder, }, progressFill: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + transformOrigin: 'left center', height: 2, backgroundColor: colors.accent, }, diff --git a/src/components/OscilloscopeWave.tsx b/src/components/OscilloscopeWave.tsx index f0fd3d7..d5545a3 100644 --- a/src/components/OscilloscopeWave.tsx +++ b/src/components/OscilloscopeWave.tsx @@ -1,23 +1,7 @@ -import { - useEffect, - useLayoutEffect, - useMemo, - useRef -} from 'react'; +import { processColor } from 'react-native'; import { useReducedMotion } from 'react-native-reanimated'; -import { - PaintStyle, - Skia, - SkiaPictureView, - StrokeCap, - StrokeJoin, - TileMode, - type SkPath, - type SkPicture -} from '@shopify/react-native-skia'; -import { AstraScope, OSCILLOSCOPE_POINTS } from '../../modules/astra-scope'; +import { AstraScopeView } from '../../modules/astra-scope'; import { useScopeStore } from '@/scope/scopeStore'; -import { DEFAULT_OSC_GAIN } from '@/scope/oscilloscopeGain'; import { useColors } from '@/theme/themed'; interface OscilloscopeWaveProps { @@ -33,157 +17,11 @@ interface OscilloscopeWaveProps { edgeFadeWidth?: number; } -type SkiaViewApiShape = { - setJsiProperty: (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; /** - * Edge fade baked into the stroke paint: a horizontal gradient shader whose - * alpha ramps in from transparent at both ends, so the trace dissolves at its - * 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. + * Thin React wrapper for the native oscilloscope. Gain changes happen only at + * track boundaries; audio frames and drawing never cross React or the JS thread. */ export function OscilloscopeWave({ active, @@ -196,167 +34,34 @@ export function OscilloscopeWave({ edgeFade = false, edgeFadeWidth = EDGE_FADE_WIDTH, }: OscilloscopeWaveProps) { - const themeColors = useColors(); - const color = colorProp ?? themeColors.accent; - const reduceMotion = useReducedMotion(); - const viewRef = useRef(null); - const initialPicture = useMemo( - () => - buildPicture( - values, - values.length, - Math.max(1, width), - Math.max(1, height), - color, - lineWidth, - glow, - DEFAULT_OSC_GAIN, - edgeFade, - edgeFadeWidth - ), - [color, edgeFade, edgeFadeWidth, glow, height, lineWidth, width] + const colors = useColors(); + const reducedMotion = useReducedMotion(); + const gain = useScopeStore((state) => state.oscGain); + const color = processColor(colorProp ?? colors.accent); + + if (width <= 0 || height <= 0 || typeof color !== 'number') return null; + return ( + ); - - 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 ; } export default OscilloscopeWave; diff --git a/src/components/SpectrumCurve.tsx b/src/components/SpectrumCurve.tsx index 4e79383..7d46a1d 100644 --- a/src/components/SpectrumCurve.tsx +++ b/src/components/SpectrumCurve.tsx @@ -1,22 +1,7 @@ -import { - useEffect, - useLayoutEffect, - useMemo, - useRef -} from 'react'; +import { useMemo } from 'react'; +import { processColor } from 'react-native'; import { useReducedMotion } from 'react-native-reanimated'; -import { - 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 { AstraScopeView } from '../../modules/astra-scope'; import { useColors } from '@/theme/themed'; interface SpectrumCurveProps { @@ -28,11 +13,11 @@ interface SpectrumCurveProps { active?: boolean; /** Which native tap to pull from. 'post' is the post-EQ ring (EQ screen). */ source?: 'pre' | 'post'; - /** Number of render points when active. Defaults to one point per rendered pixel. */ + /** Number of log-frequency render points. */ pointCount?: number; /** Active render cadence. 0 means display-sync; 32 keeps the mini-player battery-friendly. */ frameMs?: number; - /** Native pull cadence. Defaults to frameMs; 0 advances analysis every display frame. */ + /** Native analysis cadence. Defaults to frameMs. */ analysisFrameMs?: number; /** Previous native spectrum-frame retention in [0, 0.99]. */ smoothing?: number; @@ -49,326 +34,16 @@ interface SpectrumCurveProps { edgeFadeWidth?: number; } -type SkiaViewApiShape = { - setJsiProperty: (nativeId: number, name: string, value: T) => void; - requestRedraw: (nativeId: number) => void; -}; - const DEFAULT_POINTS = 120; const MINI_FRAME_MS = 32; const DEFAULT_SMOOTHING = 0.92; const DISPLAY_DB_MIN = -90; const DISPLAY_DB_MAX = -10; -const SPECTRUM_SAMPLE_RATE = 48000; -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; -} +const TILT_DB_PER_OCTAVE = 3.5; /** - * Edge fade baked into the paints: a horizontal alpha ramp so the curve - * dissolves at its ends 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 - ); -} - -/** 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, - 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, 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, - 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. + * Thin React wrapper. FFT projection, pause decay, path preparation, and frame + * scheduling all live in AstraScopeView's serialized Android worker. */ export function SpectrumCurve({ values, @@ -382,7 +57,7 @@ export function SpectrumCurve({ smoothing = DEFAULT_SMOOTHING, dbMin = DISPLAY_DB_MIN, dbMax = DISPLAY_DB_MAX, - tiltDbPerOctave = TILT_DB_PER_OCT, + tiltDbPerOctave = TILT_DB_PER_OCTAVE, color: colorProp, lineWidth = 2, lineOpacity = 1, @@ -392,226 +67,45 @@ export function SpectrumCurve({ edgeFade = false, edgeFadeWidth = 28, }: SpectrumCurveProps) { - const themeColors = useColors(); - const color = colorProp ?? themeColors.accent; - const reduceMotion = useReducedMotion(); - const viewRef = useRef(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(null); - // Half a point per pixel, capped: the quadTo midpoint smoothing makes denser - // sampling visually indistinguishable while doubling per-frame path cost. + const colors = useColors(); + const reducedMotion = useReducedMotion(); 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( - () => values ?? new Float32Array(resolvedPointCount), - [resolvedPointCount, values] + () => (values ? Array.from(values) : undefined), + [values] ); - const initialPicture = useMemo( - () => - buildPicture( - staticValues, - Math.max(1, width), - Math.max(1, height), - color, - lineWidth, - lineOpacity, - fillOpacity, - glow, - glowOpacity, - edgeFade, - edgeFadeWidth - ), - [ - color, - edgeFade, - edgeFadeWidth, - fillOpacity, - glow, - glowOpacity, - height, - lineOpacity, - lineWidth, - staticValues, - width, - ] + const color = processColor(colorProp ?? colors.accent); + + if (width <= 0 || height <= 0 || typeof color !== 'number') return null; + return ( + ); - - 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 ; } export default SpectrumCurve; diff --git a/src/components/SwipeableRow.tsx b/src/components/SwipeableRow.tsx index 473848c..9b0bad3 100644 --- a/src/components/SwipeableRow.tsx +++ b/src/components/SwipeableRow.tsx @@ -1,8 +1,10 @@ -import { useState, type ReactNode } from 'react'; +import { + useCallback, + type ReactNode +} from 'react'; import { StyleSheet, - View, - type LayoutChangeEvent + View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { @@ -24,8 +26,15 @@ type IconName = keyof typeof Ionicons.glyphMap; const SWIPE_ACTIVE_OFFSET_X = 10; // 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. -const SWIPE_FAIL_OFFSET_Y = 12; +// 30px before the pan failed and the surrounding scrollable could win. Keep +// 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 { icon: IconName; @@ -67,32 +76,32 @@ export function SwipeableRow({ const colors = useColors(); const tx = useSharedValue(0); const armed = useSharedValue(false); - const [rowWidth, setRowWidth] = useState(0); - const max = rowWidth / 2; - const arm = rowWidth / 4; const hasRight = !!swipeRight; const hasLeft = !!swipeLeft; + const rightCommit = swipeRight?.onCommit; + const leftCommit = swipeLeft?.onCommit; - const onLayout = (e: LayoutChangeEvent) => setRowWidth(e.nativeEvent.layout.width); - - const onCommit = (direction: 'right' | 'left') => { - if (direction === 'right') swipeRight?.onCommit(); - else swipeLeft?.onCommit(); - playHaptic('confirm'); - }; + const onCommit = useCallback( + (direction: 'right' | 'left') => { + if (direction === 'right') rightCommit?.(); + else leftCommit?.(); + playHaptic('confirm'); + }, + [leftCommit, rightCommit] + ); const pan = Gesture.Pan() - .enabled(enabled && rowWidth > 0 && (hasRight || hasLeft)) + .enabled(enabled && (hasRight || hasLeft)) .activeOffsetX([-SWIPE_ACTIVE_OFFSET_X, SWIPE_ACTIVE_OFFSET_X]) .failOffsetY([-SWIPE_FAIL_OFFSET_Y, SWIPE_FAIL_OFFSET_Y]) .onUpdate((e) => { let t = e.translationX; if (t > 0 && !hasRight) 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; - const nowArmed = Math.abs(t) >= arm; + const nowArmed = Math.abs(t) >= SWIPE_ARM_TRANSLATION; if (nowArmed !== armed.value) { armed.value = nowArmed; runOnJS(playHaptic)('threshold'); @@ -100,8 +109,13 @@ export function SwipeableRow({ }) .onEnd(() => { const t = tx.value; - if (t >= arm && hasRight) runOnJS(onCommit)('right'); - else if (t <= -arm && hasLeft) runOnJS(onCommit)('left'); + if (t >= SWIPE_ARM_TRANSLATION && hasRight) runOnJS(onCommit)('right'); + 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; tx.value = withTiming(0, motion.quick); }); @@ -111,26 +125,24 @@ export function SwipeableRow({ const gesture = dragGesture ? Gesture.Race(dragGesture, pan) : pan; 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 ( - + {swipeRight ? ( - - + ) : null} {swipeLeft ? ( - - + ) : null} {children} @@ -146,18 +158,21 @@ const styles = StyleSheet.create({ }, lane: { position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, + // Keep the always-mounted action colors out from under translucent row + // separators; otherwise each half of the lane tints the resting hairline. + top: StyleSheet.hairlineWidth, + bottom: StyleSheet.hairlineWidth, + width: '50%', flexDirection: 'row', alignItems: 'center', paddingHorizontal: 24, }, laneLeft: { + left: 0, justifyContent: 'flex-start', }, laneRight: { + right: 0, justifyContent: 'flex-end', }, }); diff --git a/src/components/WaveformSeekBar.tsx b/src/components/WaveformSeekBar.tsx index e26ef17..231255f 100644 --- a/src/components/WaveformSeekBar.tsx +++ b/src/components/WaveformSeekBar.tsx @@ -17,6 +17,7 @@ import { Skia, rect } from '@shopify/react-native-skia'; +import { useDerivedValue } from 'react-native-reanimated'; import { Text } from './Text'; import { spacing } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; @@ -27,7 +28,7 @@ import { mergeProgressiveWaveform, subscribeWaveformProgress, } from '@/scope/waveform'; -import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime'; +import { useAnimatedPlaybackProgress } from '@/audio/useAnimatedPlaybackProgress'; import { usePlayerStore } from '@/stores/playerStore'; import { playHaptic } from '@/lib/haptics'; import { @@ -95,7 +96,15 @@ export function WaveformSeekBar({ const scrubRef = useRef(null); const grantRef = useRef({ fraction: 0, pageX: 0 }); const detentRef = useRef(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 // partially-decoded prefix is scaled against, and it supplies the not-yet-decoded tail. 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 // store clears pendingSeek only after native progress acknowledges the target // 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 heldFraction = pendingSeek && duration > 0 ? clamp(pendingSeek.target / duration) : null; + const liveFraction = duration > 0 ? Math.min(1, currentTime / duration) : 0; const fraction = scrubFraction ?? heldFraction ?? liveFraction; const shownTime = fraction * duration; @@ -235,11 +243,21 @@ export function WaveformSeekBar({ return path; }, [source, barCount, barWidth, height]); - const splitX = fraction * barWidth; - const playheadX = Math.min( - Math.max(0, barWidth - PLAYHEAD_WIDTH), - Math.max(0, splitX - PLAYHEAD_WIDTH / 2) + const playedClip = useDerivedValue( + () => rect(0, 0, progress.value * barWidth, height), + [barWidth, height] ); + 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 ( @@ -258,10 +276,10 @@ export function WaveformSeekBar({ accessibilityValue={{ min: 0, max: Math.round(duration), now: Math.round(shownTime) }} > - + - + {barWidth > 0 ? ( diff --git a/src/components/player/NowPlayingOverlay.tsx b/src/components/player/NowPlayingOverlay.tsx index 5870286..8d47f94 100644 --- a/src/components/player/NowPlayingOverlay.tsx +++ b/src/components/player/NowPlayingOverlay.tsx @@ -425,7 +425,10 @@ export function NowPlayingOverlay() { useEffect(() => { stageProgress.value = withTiming(effectiveScopeStageVisible ? 1 : 0, motion.snap); }, [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 * `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, * which renders outside the translating content. */ - const beginDismiss = () => { + const beginDismiss = useCallback(() => { setMenuOpen(false); setQueueOpen(false); // `true`: this path drives the sheet away itself, so the effect below must // not overwrite the offset with a competing generic slide-out. usePlayerUiStore.getState().closePlayer(true); - }; + }, []); const finishCloseMenu = () => setMenuOpen(false); function openMenu() { @@ -478,6 +481,10 @@ export function NowPlayingOverlay() { }; 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 .failOffsetY(-14) .failOffsetX([-24, 24]) // let the horizontal seek drag through @@ -509,15 +516,21 @@ export function NowPlayingOverlay() { } else { 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 // 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 - // close. `windowHeight` is deliberately NOT a dependency: a dimension change - // (rotation, or an RN Modal like the output picker) would re-run this effect - // and cancel an in-flight exit spring. NOTE: this effect must stay BELOW - // every direct `translateY.value` write — the react compiler forbids + // close. `queueOpen` also re-anchors the player before its modal BottomSheet + // appears. `windowHeight` is deliberately NOT a dependency: a dimension + // change (rotation, or an RN Modal like the output picker) would re-run this + // 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. useEffect(() => { if (phase === 'closing') { @@ -531,7 +544,7 @@ export function NowPlayingOverlay() { } translateY.value = withTiming(0, { duration: 240 }); // 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 // animation callbacks, so a cancelled animation can never strand the phase. @@ -666,7 +679,7 @@ export function NowPlayingOverlay() { ); return ( - + (); @@ -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). const [listPainted, setListPainted] = useState(false); 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 // scrollable into view at the 100% snap. const listContentStyle = useMemo( @@ -856,7 +860,7 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }: data={entries} scrollEnabled keyExtractor={(item) => item.key} - drawDistance={QUEUE_ROW_HEIGHT * 12} + drawDistance={QUEUE_RENDER_DISTANCE} maintainVisibleContentPosition={{ disabled: true }} renderScrollComponent={embedded ? undefined : renderFlashListScrollComponent} renderItem={renderItem} diff --git a/src/components/queue/queuePerformance.test.mts b/src/components/queue/queuePerformance.test.mts new file mode 100644 index 0000000..7999e81 --- /dev/null +++ b/src/components/queue/queuePerformance.test.mts @@ -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); +}); diff --git a/src/components/queue/queuePerformance.ts b/src/components/queue/queuePerformance.ts new file mode 100644 index 0000000..b7bf1c0 --- /dev/null +++ b/src/components/queue/queuePerformance.ts @@ -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; diff --git a/src/navigation/tabTransition.test.mts b/src/navigation/tabTransition.test.mts new file mode 100644 index 0000000..2991b74 --- /dev/null +++ b/src/navigation/tabTransition.test.mts @@ -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); +}); diff --git a/src/navigation/tabTransition.ts b/src/navigation/tabTransition.ts index 5b20c50..7f02b7a 100644 --- a/src/navigation/tabTransition.ts +++ b/src/navigation/tabTransition.ts @@ -1,10 +1,11 @@ /** * 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 - * 120 Hz display. A critically damped native spring is evaluated from each - * display frame instead while preserving the current ~160 ms ease-out feel. + * timing animations are pre-sampled at 60 fps. A short, critically damped + * native spring avoids translating two retained full-page scenes and leaves + * only an intentional cross-fade at the tab boundary. */ export const TAB_TRANSITION_SETTLE_MS = 160; +export const TAB_SCENE_ANIMATION = 'fade' as const; export const TAB_TRANSITION_SPEC = { animation: 'spring', @@ -17,4 +18,3 @@ export const TAB_TRANSITION_SPEC = { restSpeedThreshold: 0.15, }, } as const; -