mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 12:14:47 +02:00
m4
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,83 @@
|
||||
// Coordinate mapping + response-curve sampling for the EQ graph. Frequency is on a
|
||||
// log axis (20 Hz–20 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' },
|
||||
];
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user