mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 04:06:43 +02:00
performance improvements + ui/ux fixes
This commit is contained in:
@@ -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 (
|
||||
<View style={styles.progressTrack}>
|
||||
<View style={[styles.progressFill, { width: `${progress * 100}%` }]} />
|
||||
<Animated.View style={[styles.progressFill, progressStyle]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 <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}
|
||||
duration={presentation.duration}
|
||||
isPlaying={isPlaying}
|
||||
active={!playerOpen}
|
||||
trackKey={presentation.trackKey}
|
||||
/>
|
||||
) : (
|
||||
<PhoneMiniProgress isPlaying={isPlaying} />
|
||||
<PhoneMiniProgress isPlaying={isPlaying} active={!playerOpen} />
|
||||
)
|
||||
) : null}
|
||||
</Pressable>
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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: <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;
|
||||
|
||||
/**
|
||||
* 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<SkiaPictureView | null>(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 (
|
||||
<AstraScopeView
|
||||
mode="oscilloscope"
|
||||
source="pre"
|
||||
active={active && !reducedMotion}
|
||||
reducedMotion={reducedMotion}
|
||||
frameMs={frameMs}
|
||||
analysisFrameMs={frameMs}
|
||||
color={color}
|
||||
lineWidth={lineWidth}
|
||||
lineOpacity={1}
|
||||
fillOpacity={0}
|
||||
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;
|
||||
|
||||
@@ -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: <T>(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<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.
|
||||
* 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<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 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 (
|
||||
<AstraScopeView
|
||||
mode="spectrum"
|
||||
source={source}
|
||||
active={active && !reducedMotion}
|
||||
reducedMotion={reducedMotion}
|
||||
frameMs={frameMs}
|
||||
analysisFrameMs={analysisFrameMs ?? frameMs}
|
||||
smoothing={smoothing}
|
||||
pointCount={resolvedPointCount}
|
||||
dbMin={dbMin}
|
||||
dbMax={dbMax}
|
||||
tiltDbPerOctave={tiltDbPerOctave}
|
||||
color={color}
|
||||
lineWidth={lineWidth}
|
||||
lineOpacity={lineOpacity}
|
||||
fillOpacity={fillOpacity}
|
||||
glow={glow}
|
||||
glowOpacity={glowOpacity}
|
||||
edgeFade={edgeFade}
|
||||
edgeFadeWidth={edgeFadeWidth}
|
||||
values={staticValues}
|
||||
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 || 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;
|
||||
|
||||
@@ -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 (
|
||||
<View style={styles.wrap} onLayout={onLayout}>
|
||||
<View style={styles.wrap}>
|
||||
{swipeRight ? (
|
||||
<Animated.View
|
||||
<View
|
||||
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} />
|
||||
</Animated.View>
|
||||
</View>
|
||||
) : null}
|
||||
{swipeLeft ? (
|
||||
<Animated.View
|
||||
<View
|
||||
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} />
|
||||
</Animated.View>
|
||||
</View>
|
||||
) : null}
|
||||
<GestureDetector gesture={gesture}>
|
||||
<Animated.View style={contentStyle}>{children}</Animated.View>
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const grantRef = useRef({ fraction: 0, pageX: 0 });
|
||||
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
|
||||
// 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 (
|
||||
<View>
|
||||
@@ -258,10 +276,10 @@ export function WaveformSeekBar({
|
||||
accessibilityValue={{ min: 0, max: Math.round(duration), now: Math.round(shownTime) }}
|
||||
>
|
||||
<Canvas style={{ width: '100%', height }}>
|
||||
<Group clip={rect(0, 0, splitX, height)}>
|
||||
<Group clip={playedClip}>
|
||||
<Path path={barsPath} color={colors.accent} />
|
||||
</Group>
|
||||
<Group clip={rect(splitX, 0, Math.max(0, barWidth - splitX), height)}>
|
||||
<Group clip={unplayedClip}>
|
||||
<Path path={barsPath} color={colors.glassBorder} />
|
||||
</Group>
|
||||
{barWidth > 0 ? (
|
||||
|
||||
@@ -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 (
|
||||
<View style={StyleSheet.absoluteFill} pointerEvents={playerOpen ? 'auto' : 'none'}>
|
||||
<View style={StyleSheet.absoluteFill} pointerEvents={playerOpen ? 'box-none' : 'none'}>
|
||||
<GestureDetector gesture={pan}>
|
||||
<Animated.View
|
||||
style={[
|
||||
|
||||
@@ -67,8 +67,12 @@ import {
|
||||
resolveSelectedQueueAction,
|
||||
type QueueIndexByKey,
|
||||
} from './queueActions';
|
||||
import {
|
||||
QUEUE_RENDER_DISTANCE,
|
||||
QUEUE_ROW_HEIGHT,
|
||||
queuePreviewRowCount,
|
||||
} from './queuePerformance';
|
||||
|
||||
const QUEUE_ROW_HEIGHT = 64;
|
||||
const ART = 42;
|
||||
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).
|
||||
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}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
QUEUE_RENDER_AHEAD_ROWS,
|
||||
QUEUE_RENDER_DISTANCE,
|
||||
QUEUE_ROW_HEIGHT,
|
||||
queuePreviewRowCount,
|
||||
} from './queuePerformance.ts';
|
||||
|
||||
test('queue render-ahead stays bounded to four rows', () => {
|
||||
assert.equal(QUEUE_RENDER_AHEAD_ROWS, 4);
|
||||
assert.equal(QUEUE_RENDER_DISTANCE, QUEUE_ROW_HEIGHT * 4);
|
||||
});
|
||||
|
||||
test('initial queue preview covers the sheet viewport without duplicating a screen', () => {
|
||||
assert.equal(queuePreviewRowCount(780), 4);
|
||||
assert.equal(queuePreviewRowCount(900), 5);
|
||||
assert.equal(queuePreviewRowCount(1400), 6);
|
||||
});
|
||||
|
||||
test('queue preview remains safe for unusually short windows', () => {
|
||||
assert.equal(queuePreviewRowCount(0), 1);
|
||||
assert.equal(queuePreviewRowCount(320), 1);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
export const QUEUE_ROW_HEIGHT = 64;
|
||||
export const QUEUE_RENDER_AHEAD_ROWS = 4;
|
||||
|
||||
const QUEUE_SHEET_INITIAL_FRACTION = 0.58;
|
||||
const QUEUE_PREVIEW_NON_LIST_HEIGHT = 220;
|
||||
const QUEUE_PREVIEW_MAX_ROWS = 6;
|
||||
|
||||
/**
|
||||
* The preview only fills the list portion of the initial sheet snap. Using the
|
||||
* whole window height used to duplicate far more rows than could be visible
|
||||
* while FlashList was mounting its own render-ahead window underneath.
|
||||
*/
|
||||
export function queuePreviewRowCount(windowHeight: number): number {
|
||||
const initialListHeight = Math.max(
|
||||
QUEUE_ROW_HEIGHT,
|
||||
windowHeight * QUEUE_SHEET_INITIAL_FRACTION - QUEUE_PREVIEW_NON_LIST_HEIGHT
|
||||
);
|
||||
return Math.min(
|
||||
QUEUE_PREVIEW_MAX_ROWS,
|
||||
Math.max(1, Math.ceil(initialListHeight / QUEUE_ROW_HEIGHT))
|
||||
);
|
||||
}
|
||||
|
||||
export const QUEUE_RENDER_DISTANCE = QUEUE_ROW_HEIGHT * QUEUE_RENDER_AHEAD_ROWS;
|
||||
Reference in New Issue
Block a user