This commit is contained in:
Boof2015
2026-06-19 13:19:37 -04:00
parent 978d001db9
commit bc687dfe87
145 changed files with 4685 additions and 197 deletions
+6 -1
View File
@@ -20,8 +20,10 @@ export function Badge({ label }: { label: string }) {
*/
export function FormatBadges({
track,
wrap = true,
}: {
track: Pick<Track, 'format' | 'bitDepth' | 'sampleRate'>;
wrap?: boolean;
}) {
const labels: string[] = [];
if (track.format) labels.push(track.format.toUpperCase());
@@ -31,7 +33,7 @@ export function FormatBadges({
if (labels.length === 0) return null;
return (
<View style={styles.row}>
<View style={[styles.row, !wrap && styles.rowNoWrap]}>
{labels.map((label) => (
<Badge key={label} label={label} />
))}
@@ -45,6 +47,9 @@ const styles = StyleSheet.create({
flexWrap: 'wrap',
gap: spacing.xs,
},
rowNoWrap: {
flexWrap: 'nowrap',
},
badge: {
backgroundColor: colors.glassBg,
borderColor: colors.glassBorder,
+137
View File
@@ -0,0 +1,137 @@
import { useEffect, useState } from 'react';
import {
StyleSheet,
View,
type LayoutChangeEvent,
type StyleProp,
type TextStyle,
type ViewStyle,
} from 'react-native';
import type { TextLayoutEvent } from 'react-native/Libraries/Types/CoreEventTypes';
import Animated, {
Easing,
cancelAnimation,
useAnimatedStyle,
useSharedValue,
withDelay,
withRepeat,
withSequence,
withTiming,
} from 'react-native-reanimated';
import { Text } from './Text';
type TextVariant = 'title' | 'heading' | 'body' | 'label' | 'caption' | 'mono';
const DEFAULT_DELAY_MS = 900;
const DEFAULT_HOLD_MS = 900;
const DEFAULT_SPEED_PX_PER_SECOND = 28;
const MIN_DURATION_MS = 1600;
const MEASURE_WIDTH = 10000;
interface MarqueeTextProps {
children: string;
variant?: TextVariant;
color?: string;
style?: StyleProp<TextStyle>;
containerStyle?: StyleProp<ViewStyle>;
delayMs?: number;
holdMs?: number;
speedPxPerSecond?: number;
}
export function MarqueeText({
children,
variant = 'body',
color,
style,
containerStyle,
delayMs = DEFAULT_DELAY_MS,
holdMs = DEFAULT_HOLD_MS,
speedPxPerSecond = DEFAULT_SPEED_PX_PER_SECOND,
}: MarqueeTextProps) {
const [containerWidth, setContainerWidth] = useState(0);
const [textWidth, setTextWidth] = useState(0);
const offset = useSharedValue(0);
const overflowDistance = Math.max(0, Math.ceil(textWidth - containerWidth));
useEffect(() => {
cancelAnimation(offset);
offset.value = 0;
if (overflowDistance <= 1) return;
const duration = Math.max(
MIN_DURATION_MS,
Math.round((overflowDistance / speedPxPerSecond) * 1000)
);
offset.value = withDelay(
delayMs,
withRepeat(
withSequence(
withTiming(-overflowDistance, { duration, easing: Easing.linear }),
withDelay(holdMs, withTiming(-overflowDistance, { duration: 0 })),
withTiming(0, { duration, easing: Easing.linear }),
withDelay(holdMs, withTiming(0, { duration: 0 }))
),
-1,
false
)
);
}, [delayMs, holdMs, offset, overflowDistance, speedPxPerSecond]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: offset.value }],
}));
const handleContainerLayout = (event: LayoutChangeEvent) => {
setContainerWidth(event.nativeEvent.layout.width);
};
const handleTextLayout = (event: TextLayoutEvent) => {
const measuredWidth = Math.ceil(event.nativeEvent.lines[0]?.width ?? 0);
setTextWidth((current) => (Math.abs(current - measuredWidth) > 1 ? measuredWidth : current));
};
return (
<View style={[styles.container, containerStyle]} onLayout={handleContainerLayout}>
<Animated.View
style={[styles.content, textWidth > 0 ? { width: textWidth } : null, animatedStyle]}
>
<Text
variant={variant}
color={color}
numberOfLines={1}
ellipsizeMode="clip"
style={style}
>
{children}
</Text>
</Animated.View>
<Text
variant={variant}
color={color}
numberOfLines={1}
onTextLayout={handleTextLayout}
style={[styles.measure, style]}
>
{children}
</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
overflow: 'hidden',
},
content: {
alignSelf: 'flex-start',
},
measure: {
position: 'absolute',
width: MEASURE_WIDTH,
opacity: 0,
},
});
export default MarqueeText;
+24 -5
View File
@@ -8,6 +8,8 @@ import {
type SkPicture,
} from '@shopify/react-native-skia';
import { AstraScope, OSCILLOSCOPE_POINTS } from '../../modules/astra-scope';
import { useScopeStore } from '@/scope/scopeStore';
import { DEFAULT_OSC_GAIN } from '@/scope/oscilloscopeGain';
import { colors } from '@/theme';
interface OscilloscopeWaveProps {
@@ -25,7 +27,6 @@ type SkiaViewApiShape = {
requestRedraw: (nativeId: number) => void;
};
const VISUAL_GAIN = 1.8;
const values = new Float32Array(OSCILLOSCOPE_POINTS);
function skiaViewApi(): SkiaViewApiShape | null {
@@ -58,7 +59,8 @@ function buildPicture(
height: number,
color: string,
lineWidth: number,
glow: boolean
glow: boolean,
gain: number
): SkPicture {
const recorder = Skia.PictureRecorder();
const canvas = recorder.beginRecording(Skia.XYWHRect(0, 0, width, height));
@@ -70,7 +72,9 @@ function buildPicture(
const amp = mid - lineWidth;
const xAt = (i: number) => (i / (n - 1)) * width;
const yAt = (i: number) => {
let v = samples[i] * VISUAL_GAIN;
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;
@@ -94,6 +98,10 @@ function buildPicture(
* Imperative oscilloscope renderer. This mirrors desktop/prism's hot path:
* a frame loop pulls native scope data and draws directly into a canvas-like
* surface instead of routing each frame through React reconciliation.
*
* Amplitude uses a per-track display gain (scopeStore.oscGain, set once per track by
* useNormalizationSync) — read fresh each frame so it tracks song changes, but held
* constant within a track so the music's own dynamics are preserved.
*/
export function OscilloscopeWave({
active,
@@ -106,7 +114,17 @@ export function OscilloscopeWave({
}: OscilloscopeWaveProps) {
const viewRef = useRef<SkiaPictureView | null>(null);
const initialPicture = useMemo(
() => buildPicture(values, values.length, Math.max(1, width), Math.max(1, height), color, lineWidth, glow),
() =>
buildPicture(
values,
values.length,
Math.max(1, width),
Math.max(1, height),
color,
lineWidth,
glow,
DEFAULT_OSC_GAIN
),
[color, glow, height, lineWidth, width]
);
@@ -119,7 +137,8 @@ export function OscilloscopeWave({
let raf = 0;
const draw = (sampleCount: number) => {
const picture = buildPicture(values, sampleCount, width, height, color, lineWidth, glow);
const gain = useScopeStore.getState().oscGain;
const picture = buildPicture(values, sampleCount, width, height, color, lineWidth, glow, gain);
api.setJsiProperty(view.nativeId, 'picture', picture);
api.requestRedraw(view.nativeId);
};
+9 -1
View File
@@ -18,6 +18,8 @@ interface SpectrumCurveProps {
height: number;
/** Pull native spectrum frames while active, bypassing React per-frame state. */
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. */
pointCount?: number;
/** Active render cadence. 0 means display-sync; 32 keeps the mini-player battery-friendly. */
@@ -292,6 +294,7 @@ export function SpectrumCurve({
width,
height,
active = false,
source = 'pre',
pointCount,
frameMs = MINI_FRAME_MS,
analysisFrameMs,
@@ -391,7 +394,11 @@ export function SpectrumCurve({
raf = requestAnimationFrame(tick);
if (analysisThreshold <= 0 || t - lastAnalysis >= analysisThreshold) {
lastAnalysis = t;
if (AstraScope.getSpectrumFrame(spectrumBins) > 0) {
const got =
source === 'post'
? AstraScope.getSpectrumFramePostEq(spectrumBins)
: AstraScope.getSpectrumFrame(spectrumBins);
if (got > 0) {
writeSpectrumPoints(spectrumBins, renderValues, pointOptions);
hasNewFrame = true;
}
@@ -425,6 +432,7 @@ export function SpectrumCurve({
lineOpacity,
lineWidth,
resolvedPointCount,
source,
tiltDbPerOctave,
width,
]);
+125
View File
@@ -0,0 +1,125 @@
import { Pressable, StyleSheet, Switch, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import type { EQBand } from '@/types/audio';
import { EQ_MAX_FREQUENCY, EQ_MAX_GAIN_DB, EQ_MAX_Q, EQ_MIN_FREQUENCY, EQ_MIN_Q, isPassEQBandType } from '@/audio/eq';
import { EQSlider } from './EQSlider';
import { BAND_TYPE_LABEL, formatFreq, formatGain } from './format';
interface BandDetailPanelProps {
band: EQBand | null;
bandNumber: number;
onUpdate: (updates: Partial<EQBand>) => void;
/** Open the filter-type picker (the sheet lives at the screen root). */
onEditType: () => void;
/** Open the exact value editor (the sheet lives at the screen root). */
onEditValue: (value: EQEditableValue) => void;
}
export type EQEditableValue = 'frequency' | 'gain' | 'Q';
/** "Band N" + type dropdown + On toggle + Frequency / Gain / Q sliders. */
export function BandDetailPanel({ band, bandNumber, onUpdate, onEditType, onEditValue }: BandDetailPanelProps) {
if (!band) {
return (
<View style={styles.card}>
<Text variant="body" color={colors.textSecondary}>
Select a band to edit.
</Text>
</View>
);
}
const isPass = isPassEQBandType(band.type);
return (
<View style={styles.card}>
<View style={styles.header}>
<Text variant="heading">Band {bandNumber}</Text>
<Pressable style={styles.typeButton} onPress={onEditType}>
<Text variant="label" color={colors.textPrimary}>
{BAND_TYPE_LABEL[band.type]}
</Text>
<Ionicons name="chevron-down" size={14} color={colors.textSecondary} />
</Pressable>
<View style={styles.toggle}>
<Text variant="label">{band.enabled ? 'On' : 'Off'}</Text>
<Switch
value={band.enabled}
onValueChange={(enabled) => onUpdate({ enabled })}
trackColor={{ false: colors.glassBorder, true: colors.accent }}
thumbColor={colors.textPrimary}
/>
</View>
</View>
<EQSlider
label="Frequency"
value={band.frequency}
min={EQ_MIN_FREQUENCY}
max={EQ_MAX_FREQUENCY}
log
format={(v) => `${formatFreq(v)} Hz`}
onChange={(v) => onUpdate({ frequency: v })}
onValuePress={() => onEditValue('frequency')}
/>
<EQSlider
label="Gain"
value={isPass ? 0 : band.gain}
min={-EQ_MAX_GAIN_DB}
max={EQ_MAX_GAIN_DB}
format={(v) => `${formatGain(v)} dB`}
onChange={(v) => onUpdate({ gain: v })}
onValuePress={() => onEditValue('gain')}
disabled={isPass}
/>
<EQSlider
label="Q"
value={band.Q}
min={EQ_MIN_Q}
max={EQ_MAX_Q}
log
format={(v) => v.toFixed(2)}
onChange={(v) => onUpdate({ Q: v })}
onValuePress={() => onEditValue('Q')}
/>
</View>
);
}
const styles = StyleSheet.create({
card: {
borderRadius: radius.lg,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
padding: spacing.lg,
gap: spacing.xs,
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
marginBottom: spacing.sm,
},
typeButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
toggle: {
marginLeft: 'auto',
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
});
export default BandDetailPanel;
+87
View File
@@ -0,0 +1,87 @@
import { Pressable, ScrollView, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import type { EQBand } from '@/types/audio';
import { formatFreq, formatGain, gainColor } from './format';
interface BandStripProps {
bands: EQBand[];
activeBandId: string | null;
canAdd: boolean;
onSelect: (id: string) => void;
onAdd: () => void;
}
/** Horizontal strip of per-band cells (freq + gain) + a trailing "+" add cell. */
export function BandStrip({ bands, activeBandId, canAdd, onSelect, onAdd }: BandStripProps) {
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.content}
>
{bands.map((band) => {
const isActive = band.id === activeBandId;
return (
<Pressable
key={band.id}
onPress={() => onSelect(band.id)}
style={[styles.cell, isActive && styles.cellActive]}
>
<Text variant="caption" style={styles.freq}>
{formatFreq(band.frequency)}
</Text>
<Text
variant="label"
style={[styles.gain, { color: band.enabled ? gainColor(band.gain) : colors.textTertiary }]}
>
{formatGain(band.gain)}
</Text>
</Pressable>
);
})}
{canAdd ? (
<Pressable onPress={onAdd} style={[styles.cell, styles.addCell]} accessibilityLabel="Add band">
<Ionicons name="add" size={22} color={colors.accentText} />
</Pressable>
) : null}
</ScrollView>
);
}
const styles = StyleSheet.create({
content: {
gap: spacing.sm,
paddingVertical: spacing.xs,
},
cell: {
minWidth: 66,
alignItems: 'center',
gap: 2,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.md,
backgroundColor: colors.glassBg,
borderWidth: StyleSheet.hairlineWidth,
borderColor: 'transparent',
},
cellActive: {
borderColor: colors.accent,
backgroundColor: colors.glassHighlight,
},
addCell: {
justifyContent: 'center',
borderColor: colors.glassBorder,
borderStyle: 'dashed',
minWidth: 52,
},
freq: {
color: colors.textSecondary,
},
gain: {
fontSize: 15,
},
});
export default BandStrip;
+330
View File
@@ -0,0 +1,330 @@
import { useMemo, useRef, useState } from 'react';
import {
View,
StyleSheet,
type GestureResponderEvent,
type LayoutChangeEvent,
} from 'react-native';
import {
Canvas,
Circle,
DashPathEffect,
Group,
Path,
Skia,
type SkPath,
} from '@shopify/react-native-skia';
import { Text } from '@/components/Text';
import { SpectrumCurve } from '@/components/SpectrumCurve';
import { colors } from '@/theme';
import type { EQBand } from '@/types/audio';
import {
FREQ_TICKS,
buildResponseFill,
buildResponsePath,
freqToX,
gainToY,
xToFreq,
yToGain,
} from './eqGraphMath';
const HIT_RADIUS = 34;
const NODE_R = 13;
interface EQGraphProps {
bands: EQBand[];
activeBandId: string | null;
enabled: boolean;
/** Pull the live post-EQ spectrum behind the curve. */
spectrumActive: boolean;
onSelectBand: (id: string) => void;
onChangeBand: (id: string, updates: { frequency: number; gain: number }) => void;
}
/**
* The EQ response graph: a live post-EQ spectrum behind a draggable response curve
* with one numbered node per band. Skia draws the curve/grid/nodes; a transparent
* RN responder maps touches to the nearest node and drags it (x → frequency, y →
* gain). Q is edited from the detail panel, not the curve.
*/
export function EQGraph({
bands,
activeBandId,
enabled,
spectrumActive,
onSelectBand,
onChangeBand,
}: EQGraphProps) {
const [size, setSize] = useStableSize();
const width = size.width;
const height = size.height;
// Anchor the grabbed node + grant page coords; move by absolute page deltas
// (clamped to the graph) so veering off-bounds can't snap to a corner.
const dragRef = useRef<{
id: string;
pageX: number;
pageY: number;
startX: number;
startY: number;
} | null>(null);
const linePath = useMemo(
() => buildResponsePath(bands, width, height),
[bands, width, height]
);
const fillPath = useMemo(
() => buildResponseFill(linePath, width, height),
[linePath, width, height]
);
const onLayout = (e: LayoutChangeEvent) => {
setSize({ width: e.nativeEvent.layout.width, height: e.nativeEvent.layout.height });
};
const nearestBandId = (x: number, y: number): string | null => {
let best: string | null = null;
let bestDist = HIT_RADIUS * HIT_RADIUS;
for (const band of bands) {
const bx = freqToX(band.frequency, width);
const by = gainToY(band.gain, height);
const d = (bx - x) ** 2 + (by - y) ** 2;
if (d <= bestDist) {
bestDist = d;
best = band.id;
}
}
return best;
};
const handleGrant = (e: GestureResponderEvent) => {
const { locationX, locationY, pageX, pageY } = e.nativeEvent;
const id = nearestBandId(locationX, locationY);
if (!id) {
dragRef.current = null;
return;
}
const band = bands.find((b) => b.id === id);
if (!band) return;
dragRef.current = {
id,
pageX,
pageY,
startX: freqToX(band.frequency, width),
startY: gainToY(band.gain, height),
};
onSelectBand(id);
};
const handleMove = (e: GestureResponderEvent) => {
const d = dragRef.current;
if (!d) return;
const { pageX, pageY } = e.nativeEvent;
const nx = Math.max(0, Math.min(width, d.startX + (pageX - d.pageX)));
const ny = Math.max(0, Math.min(height, d.startY + (pageY - d.pageY)));
onChangeBand(d.id, { frequency: xToFreq(nx, width), gain: yToGain(ny, height) });
};
const endDrag = () => {
dragRef.current = null;
};
const curveColor = enabled ? colors.accent : colors.textTertiary;
const centerY = height / 2;
const yPlus6 = gainToY(6, height);
const yMinus6 = gainToY(-6, height);
return (
<View style={styles.container} onLayout={onLayout}>
{width > 0 && height > 0 ? (
<>
{/* Live post-EQ spectrum behind the curve. */}
<View style={StyleSheet.absoluteFill} pointerEvents="none">
<SpectrumCurve
source="post"
active={spectrumActive}
width={width}
height={height}
frameMs={0}
color={colors.accent}
lineOpacity={0.22}
fillOpacity={0.5}
glow={false}
/>
</View>
<Canvas style={StyleSheet.absoluteFill} pointerEvents="none">
{/* Grid: ±6 dB lines + dashed 0 dB centerline. */}
<Group>
<Path
path={hLine(0, yPlus6, width)}
color={colors.glassBorder}
style="stroke"
strokeWidth={1}
/>
<Path
path={hLine(0, yMinus6, width)}
color={colors.glassBorder}
style="stroke"
strokeWidth={1}
/>
<Path path={hLine(0, centerY, width)} color={colors.glassBorder} style="stroke" strokeWidth={1}>
<DashPathEffect intervals={[3, 5]} />
</Path>
</Group>
{/* Response curve + soft fill. */}
<Path path={fillPath} color={withAlpha(curveColor, 0.1)} style="fill" />
<Path
path={linePath}
color={curveColor}
style="stroke"
strokeWidth={2}
strokeJoin="round"
strokeCap="round"
/>
{/* Band nodes. */}
{bands.map((band) => {
const cx = freqToX(band.frequency, width);
const cy = gainToY(band.gain, height);
const isActive = band.id === activeBandId;
const dim = !band.enabled || !enabled;
return (
<Group key={band.id}>
<Circle
cx={cx}
cy={cy}
r={NODE_R}
color={isActive ? colors.accent : colors.bgTertiary}
opacity={dim ? 0.4 : 1}
/>
<Circle
cx={cx}
cy={cy}
r={NODE_R}
color={isActive ? colors.accent : colors.glassBorder}
style="stroke"
strokeWidth={isActive ? 0 : 1.5}
opacity={dim ? 0.5 : 1}
/>
</Group>
);
})}
</Canvas>
{/* Node numbers (RN text over the canvas). */}
{bands.map((band, i) => {
const cx = freqToX(band.frequency, width);
const cy = gainToY(band.gain, height);
const isActive = band.id === activeBandId;
return (
<Text
key={band.id}
variant="caption"
pointerEvents="none"
style={[
styles.nodeLabel,
{ left: cx - NODE_R, top: cy - 8 },
{ color: isActive ? colors.accentTextStrong : colors.textSecondary },
]}
>
{i + 1}
</Text>
);
})}
{/* dB labels (right edge). */}
<Text variant="caption" pointerEvents="none" style={[styles.dbLabel, { top: yPlus6 - 6 }]}>
+6
</Text>
<Text variant="caption" pointerEvents="none" style={[styles.dbLabel, { top: yMinus6 - 6 }]}>
-6
</Text>
{/* Frequency labels (bottom axis). */}
{FREQ_TICKS.map((tick) => (
<Text
key={tick.label}
variant="caption"
pointerEvents="none"
style={[styles.freqLabel, { left: freqToX(tick.freq, width) - 10 }]}
>
{tick.label}
</Text>
))}
{/* Gesture overlay. */}
<View
style={StyleSheet.absoluteFill}
onStartShouldSetResponder={() => true}
onMoveShouldSetResponder={() => true}
onResponderTerminationRequest={() => false}
onResponderGrant={handleGrant}
onResponderMove={handleMove}
onResponderRelease={endDrag}
onResponderTerminate={endDrag}
/>
</>
) : null}
</View>
);
}
// --- helpers ---------------------------------------------------------------
function hLine(x0: number, y: number, width: number): SkPath {
const p = Skia.Path.Make();
p.moveTo(x0, y);
p.lineTo(width, y);
return p;
}
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 useStableSize(): [
{ width: number; height: number },
(s: { width: number; height: number }) => void,
] {
const [size, setSize] = useState({ width: 0, height: 0 });
const set = (s: { width: number; height: number }) => {
setSize((prev) => (prev.width === s.width && prev.height === s.height ? prev : s));
};
return [size, set];
}
const styles = StyleSheet.create({
container: {
flex: 1,
borderRadius: 16,
overflow: 'hidden',
backgroundColor: colors.bgSecondary,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
},
nodeLabel: {
position: 'absolute',
width: NODE_R * 2,
textAlign: 'center',
fontSize: 12,
},
dbLabel: {
position: 'absolute',
right: 8,
color: colors.textTertiary,
},
freqLabel: {
position: 'absolute',
bottom: 4,
width: 20,
textAlign: 'center',
color: colors.textTertiary,
},
});
export default EQGraph;
+196
View File
@@ -0,0 +1,196 @@
import { useRef, useState } from 'react';
import {
Pressable,
View,
StyleSheet,
type GestureResponderEvent,
type LayoutChangeEvent,
} from 'react-native';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
const THUMB = 16;
interface EQSliderProps {
label: string;
value: number;
min: number;
max: number;
/** Logarithmic mapping (for frequency). */
log?: boolean;
format: (v: number) => string;
onChange: (v: number) => void;
disabled?: boolean;
onValuePress?: () => void;
}
const clamp01 = (f: number) => Math.min(1, Math.max(0, f));
/** Labeled horizontal slider following the SeekBar gesture/derivation pattern. */
export function EQSlider({
label,
value,
min,
max,
log,
format,
onChange,
disabled,
onValuePress,
}: EQSliderProps) {
const [width, setWidth] = useState(0);
const [active, setActive] = useState(false);
const widthRef = useRef(0);
// Anchor on grant, then track absolute pageX deltas so veering off the row
// vertically can't corrupt the value (the SeekBar pattern).
const grantRef = useRef({ fraction: 0, pageX: 0 });
const valueToFraction = (v: number): number => {
if (log) {
const lo = Math.log10(min);
const hi = Math.log10(max);
return clamp01((Math.log10(Math.max(min, v)) - lo) / (hi - lo));
}
return clamp01((v - min) / (max - min));
};
const fractionToValue = (f: number): number => {
if (log) {
const lo = Math.log10(min);
const hi = Math.log10(max);
return 10 ** (lo + clamp01(f) * (hi - lo));
}
return min + clamp01(f) * (max - min);
};
const onLayout = (e: LayoutChangeEvent) => {
widthRef.current = e.nativeEvent.layout.width;
setWidth(e.nativeEvent.layout.width);
};
const handleGrant = (e: GestureResponderEvent) => {
setActive(true);
const f = clamp01(e.nativeEvent.locationX / Math.max(1, widthRef.current));
grantRef.current = { fraction: f, pageX: e.nativeEvent.pageX };
onChange(fractionToValue(f));
};
const handleMove = (e: GestureResponderEvent) => {
const delta = (e.nativeEvent.pageX - grantRef.current.pageX) / Math.max(1, widthRef.current);
onChange(fractionToValue(clamp01(grantRef.current.fraction + delta)));
};
const fraction = valueToFraction(value);
return (
<View style={[styles.row, disabled && styles.disabled]}>
<Text variant="label" style={styles.label}>
{label}
</Text>
<View
style={styles.touch}
onLayout={onLayout}
onStartShouldSetResponder={() => !disabled}
onMoveShouldSetResponder={() => !disabled}
onResponderTerminationRequest={() => false}
onResponderGrant={handleGrant}
onResponderMove={handleMove}
onResponderRelease={() => setActive(false)}
onResponderTerminate={() => setActive(false)}
accessibilityRole="adjustable"
accessibilityLabel={label}
>
<View style={styles.track}>
<View style={[styles.fill, { width: `${fraction * 100}%` }]} />
</View>
<View
pointerEvents="none"
style={[
styles.thumb,
active && styles.thumbActive,
{ left: Math.max(0, fraction * width - THUMB / 2) },
]}
/>
</View>
{onValuePress && !disabled ? (
<Pressable
style={({ pressed }) => [styles.valueButton, pressed && styles.valueButtonPressed]}
onPress={onValuePress}
accessibilityRole="button"
accessibilityLabel={`Edit ${label}`}
>
<Text variant="mono" style={styles.value}>
{format(value)}
</Text>
</Pressable>
) : (
<Text variant="mono" style={styles.value}>
{format(value)}
</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.xs,
},
disabled: {
opacity: 0.4,
},
label: {
width: 78,
},
touch: {
flex: 1,
justifyContent: 'center',
paddingVertical: spacing.sm,
},
track: {
height: 4,
borderRadius: radius.pill,
backgroundColor: colors.glassBorder,
overflow: 'hidden',
},
fill: {
height: 4,
borderRadius: radius.pill,
backgroundColor: colors.accent,
},
thumb: {
position: 'absolute',
width: THUMB,
height: THUMB,
borderRadius: THUMB / 2,
backgroundColor: colors.accent,
},
thumbActive: {
transform: [{ scale: 1.3 }],
backgroundColor: colors.accentHover,
},
valueButton: {
minWidth: 68,
alignItems: 'flex-end',
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: radius.pill,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
valueButtonPressed: {
borderColor: colors.accent,
backgroundColor: colors.glassHighlight,
},
value: {
width: 64,
textAlign: 'right',
color: colors.textPrimary,
},
});
export default EQSlider;
+156
View File
@@ -0,0 +1,156 @@
import { useState } from 'react';
import {
Pressable,
StyleSheet,
View,
type KeyboardTypeOptions,
} from 'react-native';
import { BottomSheetTextInput } from '@gorhom/bottom-sheet';
import { Text } from '@/components/Text';
import { colors, fonts, radius, spacing } from '@/theme';
import { EqSheet } from './EqSheet';
interface EQValueEditSheetProps {
title: string;
initialValue: string;
unit: string;
rangeLabel: string;
placeholder?: string;
keyboardType?: KeyboardTypeOptions;
parseValue: (value: string) => number | null;
onApply: (value: number) => void;
onClose: () => void;
}
/** Focused numeric editor for exact EQ band values. */
export function EQValueEditSheet({
title,
initialValue,
unit,
rangeLabel,
placeholder,
keyboardType = 'numbers-and-punctuation',
parseValue,
onApply,
onClose,
}: EQValueEditSheetProps) {
const [value, setValue] = useState(initialValue);
const trimmed = value.trim();
const parsed = trimmed.length > 0 ? parseValue(trimmed) : null;
const valid = parsed !== null;
const apply = () => {
if (parsed === null) return;
onApply(parsed);
onClose();
};
return (
<EqSheet onClose={onClose}>
<Text variant="heading" style={styles.title}>
{title}
</Text>
<View style={styles.inputRow}>
<BottomSheetTextInput
value={value}
onChangeText={setValue}
placeholder={placeholder}
placeholderTextColor={colors.textTertiary}
keyboardType={keyboardType}
style={[styles.input, trimmed.length > 0 && !valid && styles.inputInvalid]}
autoFocus
selectTextOnFocus
maxLength={16}
returnKeyType="done"
onSubmitEditing={apply}
selectionColor={colors.accent}
/>
<Text variant="label" style={styles.unit}>
{unit}
</Text>
</View>
<Text variant="caption" style={[styles.range, trimmed.length > 0 && !valid && styles.invalidText]}>
{valid || trimmed.length === 0 ? rangeLabel : 'Enter a valid number'}
</Text>
<View style={styles.actions}>
<Pressable style={[styles.btn, styles.cancel]} onPress={onClose}>
<Text variant="label" color={colors.textSecondary}>
Cancel
</Text>
</Pressable>
<Pressable
style={[styles.btn, styles.apply, !valid && styles.applyDisabled]}
disabled={!valid}
onPress={apply}
>
<Text variant="label" color={colors.accentTextStrong}>
Apply
</Text>
</Pressable>
</View>
</EqSheet>
);
}
const styles = StyleSheet.create({
title: {
marginTop: spacing.xs,
marginBottom: spacing.md,
},
inputRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
input: {
flex: 1,
color: colors.textPrimary,
fontFamily: fonts.mono.regular,
fontSize: 18,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
inputInvalid: {
borderColor: colors.warning,
},
unit: {
minWidth: 34,
color: colors.textSecondary,
},
range: {
marginTop: spacing.sm,
color: colors.textTertiary,
},
invalidText: {
color: colors.warning,
},
actions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm,
marginTop: spacing.lg,
},
btn: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.md,
borderRadius: radius.pill,
},
cancel: {
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
},
apply: {
backgroundColor: colors.accentGlow,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.accent,
},
applyDisabled: {
opacity: 0.4,
},
});
export default EQValueEditSheet;
+132
View File
@@ -0,0 +1,132 @@
import { useCallback, type ReactNode } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import BottomSheet, {
BottomSheetBackdrop,
BottomSheetView,
type BottomSheetBackdropProps,
} from '@gorhom/bottom-sheet';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
/**
* Bottom sheet for the EQ screen's menus — same chrome/behaviour as the now-playing
* QueueTray (inline gorhom BottomSheet, dimmed backdrop, grab handle, pan-to-close)
* so trays stay consistent across the app. Dynamically sized to its content; render
* it conditionally ({open && <EqSheet onClose=...>}).
*/
export function EqSheet({ onClose, children }: { onClose: () => void; children: ReactNode }) {
const insets = useSafeAreaInsets();
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
appearsOnIndex={0}
disappearsOnIndex={-1}
pressBehavior="close"
opacity={0.58}
/>
),
[]
);
return (
<BottomSheet
index={0}
enableDynamicSizing
enablePanDownToClose
onClose={onClose}
backdropComponent={renderBackdrop}
backgroundStyle={styles.sheetBg}
handleIndicatorStyle={styles.handle}
>
<BottomSheetView style={[styles.content, { paddingBottom: insets.bottom + spacing.md }]}>
{children}
</BottomSheetView>
</BottomSheet>
);
}
/** Section label inside a sheet. */
export function EqSheetSection({ label }: { label: string }) {
return (
<Text variant="caption" style={styles.section}>
{label}
</Text>
);
}
interface EqSheetItemProps {
label: string;
icon?: keyof typeof Ionicons.glyphMap;
selected?: boolean;
destructive?: boolean;
onPress: () => void;
/** Optional trailing control (e.g. a delete button). */
trailing?: ReactNode;
}
/** One tappable row, styled like the ActionSheet items it replaces. */
export function EqSheetItem({ label, icon, selected, destructive, onPress, trailing }: EqSheetItemProps) {
const tint = destructive ? colors.warning : selected ? colors.accentTextStrong : colors.textPrimary;
return (
<View style={styles.itemRow}>
<Pressable
style={({ pressed }) => [styles.item, pressed && styles.itemPressed]}
onPress={onPress}
accessibilityRole="button"
>
{icon ? (
<Ionicons name={icon} size={20} color={destructive ? colors.warning : colors.textSecondary} />
) : null}
<Text variant="body" numberOfLines={1} style={styles.itemLabel} color={tint}>
{label}
</Text>
{selected ? <Ionicons name="checkmark" size={18} color={colors.accent} /> : null}
</Pressable>
{trailing}
</View>
);
}
const styles = StyleSheet.create({
sheetBg: {
backgroundColor: colors.bgSecondary,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
},
handle: {
backgroundColor: colors.glassBorder,
width: 38,
},
content: {
paddingHorizontal: spacing.lg,
paddingTop: spacing.xs,
},
section: {
color: colors.textTertiary,
letterSpacing: 1,
marginTop: spacing.md,
marginBottom: spacing.xs,
},
itemRow: {
flexDirection: 'row',
alignItems: 'center',
},
item: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.md,
},
itemPressed: {
opacity: 0.6,
},
itemLabel: {
flex: 1,
},
});
export default EqSheet;
+102
View File
@@ -0,0 +1,102 @@
import { Pressable, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { colors, spacing } from '@/theme';
import type { EQPreset } from '@/types/audio';
import { EqSheet, EqSheetItem, EqSheetSection } from './EqSheet';
interface PresetSheetProps {
presets: EQPreset[];
activePresetId: string | null;
onApply: (id: string) => void;
onDelete: (id: string) => void;
onSaveNew: () => void;
onClose: () => void;
}
/** Preset hub: pick a built-in or custom preset, delete custom ones, or save a new one. */
export function PresetSheet({
presets,
activePresetId,
onApply,
onDelete,
onSaveNew,
onClose,
}: PresetSheetProps) {
const builtIn = presets.filter((p) => !p.isCustom);
const custom = presets.filter((p) => p.isCustom);
return (
<EqSheet onClose={onClose}>
<Text variant="heading" style={styles.title}>
Presets
</Text>
<EqSheetSection label="BUILT-IN" />
{builtIn.map((p) => (
<EqSheetItem
key={p.id}
label={p.name}
selected={p.id === activePresetId}
onPress={() => {
onApply(p.id);
onClose();
}}
/>
))}
<EqSheetSection label="CUSTOM" />
{custom.length === 0 ? (
<Text variant="caption" color={colors.textTertiary} style={styles.empty}>
No saved presets yet.
</Text>
) : (
custom.map((p) => (
<EqSheetItem
key={p.id}
label={p.name}
selected={p.id === activePresetId}
onPress={() => {
onApply(p.id);
onClose();
}}
trailing={
<Pressable
hitSlop={10}
onPress={() => onDelete(p.id)}
style={styles.delete}
accessibilityLabel={`Delete preset ${p.name}`}
>
<Ionicons name="trash-outline" size={18} color={colors.textTertiary} />
</Pressable>
}
/>
))
)}
<EqSheetItem
label="Save current as preset…"
icon="bookmark-outline"
onPress={() => {
onClose();
onSaveNew();
}}
/>
</EqSheet>
);
}
const styles = StyleSheet.create({
title: {
marginTop: spacing.xs,
},
empty: {
paddingVertical: spacing.sm,
},
delete: {
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
},
});
export default PresetSheet;
+105
View File
@@ -0,0 +1,105 @@
import { useState } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import { BottomSheetTextInput } from '@gorhom/bottom-sheet';
import { Text } from '@/components/Text';
import { colors, fonts, radius, spacing } from '@/theme';
import { EqSheet } from './EqSheet';
interface SavePresetSheetProps {
defaultName: string;
onSave: (name: string) => void;
onClose: () => void;
}
/** Name + save a custom preset from the current bands/preamp. */
export function SavePresetSheet({ defaultName, onSave, onClose }: SavePresetSheetProps) {
const [name, setName] = useState(defaultName);
const trimmed = name.trim();
return (
<EqSheet onClose={onClose}>
<Text variant="heading" style={styles.title}>
Save preset
</Text>
<BottomSheetTextInput
value={name}
onChangeText={setName}
placeholder="Preset name"
placeholderTextColor={colors.textTertiary}
style={styles.input}
autoFocus
selectTextOnFocus
maxLength={40}
returnKeyType="done"
onSubmitEditing={() => {
if (trimmed) {
onSave(trimmed);
onClose();
}
}}
/>
<View style={styles.actions}>
<Pressable style={[styles.btn, styles.cancel]} onPress={onClose}>
<Text variant="label" color={colors.textSecondary}>
Cancel
</Text>
</Pressable>
<Pressable
style={[styles.btn, styles.save, !trimmed && styles.saveDisabled]}
disabled={!trimmed}
onPress={() => {
onSave(trimmed);
onClose();
}}
>
<Text variant="label" color={colors.accentTextStrong}>
Save
</Text>
</Pressable>
</View>
</EqSheet>
);
}
const styles = StyleSheet.create({
title: {
marginTop: spacing.xs,
marginBottom: spacing.md,
},
input: {
color: colors.textPrimary,
fontFamily: fonts.sans.regular,
fontSize: 16,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
actions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm,
marginTop: spacing.lg,
},
btn: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.md,
borderRadius: radius.pill,
},
cancel: {
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
},
save: {
backgroundColor: colors.accentGlow,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.accent,
},
saveDisabled: {
opacity: 0.4,
},
});
export default SavePresetSheet;
+83
View File
@@ -0,0 +1,83 @@
// Coordinate mapping + response-curve sampling for the EQ graph. Frequency is on a
// log axis (20 Hz20 kHz); gain is linear (±12 dB) centered vertically.
import { Skia, type SkPath } from '@shopify/react-native-skia';
import type { EQBand } from '@/types/audio';
import {
EQ_MAX_FREQUENCY,
EQ_MAX_GAIN_DB,
EQ_MIN_FREQUENCY,
computeCombinedEQMagnitude,
} from '@/audio/eq';
export const GRAPH_SAMPLE_RATE = 48000;
export const GRAPH_PAD_Y = 14; // px headroom so ±12 dB nodes aren't clipped
const LOG_MIN = Math.log10(EQ_MIN_FREQUENCY);
const LOG_MAX = Math.log10(EQ_MAX_FREQUENCY);
const LOG_SPAN = LOG_MAX - LOG_MIN;
function clamp(v: number, min: number, max: number): number {
return Math.max(min, Math.min(max, v));
}
export function freqToX(freq: number, width: number): number {
const f = clamp(freq, EQ_MIN_FREQUENCY, EQ_MAX_FREQUENCY);
return ((Math.log10(f) - LOG_MIN) / LOG_SPAN) * width;
}
export function xToFreq(x: number, width: number): number {
const t = clamp(width > 0 ? x / width : 0, 0, 1);
return 10 ** (LOG_MIN + t * LOG_SPAN);
}
export function gainToY(gainDb: number, height: number): number {
const center = height / 2;
const usable = center - GRAPH_PAD_Y;
return center - (clamp(gainDb, -EQ_MAX_GAIN_DB, EQ_MAX_GAIN_DB) / EQ_MAX_GAIN_DB) * usable;
}
export function yToGain(y: number, height: number): number {
const center = height / 2;
const usable = center - GRAPH_PAD_Y;
if (usable <= 0) return 0;
return clamp(((center - y) / usable) * EQ_MAX_GAIN_DB, -EQ_MAX_GAIN_DB, EQ_MAX_GAIN_DB);
}
/** Combined response curve as a stroked SkPath sampled across the width. */
export function buildResponsePath(
bands: readonly EQBand[],
width: number,
height: number,
samples = 96
): SkPath {
const path = Skia.Path.Make();
if (width <= 0 || height <= 0) return path;
for (let i = 0; i <= samples; i++) {
const x = (i / samples) * width;
const freq = xToFreq(x, width);
const db = computeCombinedEQMagnitude(bands, freq, GRAPH_SAMPLE_RATE);
const y = gainToY(db, height);
if (i === 0) path.moveTo(x, y);
else path.lineTo(x, y);
}
return path;
}
/** Closes a copy of the response path down to the baseline for a soft fill. */
export function buildResponseFill(line: SkPath, width: number, height: number): SkPath {
const fill = line.copy();
fill.lineTo(width, height / 2);
fill.lineTo(0, height / 2);
fill.close();
return fill;
}
/** Frequency gridline positions + labels shown along the bottom axis. */
export const FREQ_TICKS: { freq: number; label: string }[] = [
{ freq: 30, label: '30' },
{ freq: 100, label: '100' },
{ freq: 500, label: '500' },
{ freq: 1000, label: '1k' },
{ freq: 5000, label: '5k' },
{ freq: 15000, label: '15k' },
];
+31
View File
@@ -0,0 +1,31 @@
// Shared EQ value formatting for the band strip + detail panel.
import { colors } from '@/theme';
import type { EQBandType } from '@/types/audio';
export function formatFreq(hz: number): string {
if (hz >= 1000) {
const k = hz / 1000;
return `${Number.isInteger(k) ? k : k.toFixed(1)}k`;
}
return `${Math.round(hz)}`;
}
export function formatGain(db: number): string {
if (Math.abs(db) < 0.05) return '0';
return `${db > 0 ? '+' : ''}${db.toFixed(1)}`;
}
export function gainColor(db: number): string {
if (db > 0.05) return colors.accentText;
if (db < -0.05) return colors.warning;
return colors.textTertiary;
}
export const BAND_TYPE_LABEL: Record<EQBandType, string> = {
lowshelf: 'Low Shelf',
peaking: 'Peaking',
highshelf: 'High Shelf',
highpass: 'High Pass',
lowpass: 'Low Pass',
};
+8 -6
View File
@@ -11,14 +11,16 @@ export function ScanProgress() {
if (!isScanning) return null;
const label =
progress.phase === 'extracting'
? `Scanning ${progress.folderName ?? ''}${progress.processed}/${progress.total}`
: progress.total > 0
? `Found ${progress.total} files in ${progress.folderName ?? ''}`
: `Looking for music${progress.folderName ? ` in ${progress.folderName}` : ''}`;
progress.phase === 'analyzing'
? `Analyzing audio${progress.processed}/${progress.total}`
: progress.phase === 'extracting'
? `Scanning ${progress.folderName ?? ''} ${progress.processed}/${progress.total}`
: progress.total > 0
? `Found ${progress.total} files in ${progress.folderName ?? ''}`
: `Looking for music${progress.folderName ? ` in ${progress.folderName}` : ''}`;
const fraction =
progress.phase === 'extracting' && progress.total > 0
(progress.phase === 'extracting' || progress.phase === 'analyzing') && progress.total > 0
? progress.processed / progress.total
: 0;