mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 12:14:47 +02:00
m4
This commit is contained in:
+355
-50
@@ -1,76 +1,381 @@
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Pressable, StyleSheet, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useFocusEffect } from 'expo-router';
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import { readAsStringAsync } from 'expo-file-system/legacy';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { Text } from '@/components/Text';
|
||||
import { EQGraph } from '@/components/eq/EQGraph';
|
||||
import { BandStrip } from '@/components/eq/BandStrip';
|
||||
import { BandDetailPanel, type EQEditableValue } from '@/components/eq/BandDetailPanel';
|
||||
import { EQSlider } from '@/components/eq/EQSlider';
|
||||
import { EqSheet, EqSheetItem } from '@/components/eq/EqSheet';
|
||||
import { EQValueEditSheet } from '@/components/eq/EQValueEditSheet';
|
||||
import { PresetSheet } from '@/components/eq/PresetSheet';
|
||||
import { SavePresetSheet } from '@/components/eq/SavePresetSheet';
|
||||
import { colors, radius, spacing } from '@/theme';
|
||||
import { useEQStore } from '@/stores/eqStore';
|
||||
import { useScopeActive } from '@/scope/scopeStore';
|
||||
import { setActivePostEqNative } from '@/audio/eqNative';
|
||||
import {
|
||||
EQ_MAX_BANDS,
|
||||
EQ_MAX_FREQUENCY,
|
||||
EQ_MAX_GAIN_DB,
|
||||
EQ_MAX_PREAMP_DB,
|
||||
EQ_MAX_Q,
|
||||
EQ_MIN_FREQUENCY,
|
||||
EQ_MIN_PREAMP_DB,
|
||||
EQ_MIN_Q,
|
||||
isPassEQBandType,
|
||||
} from '@/audio/eq';
|
||||
import { parseAutoEQ } from '@/audio/autoEQParser';
|
||||
import { BAND_TYPE_LABEL, formatGain } from '@/components/eq/format';
|
||||
import type { EQBand, EQBandType } from '@/types/audio';
|
||||
|
||||
function formatFreq(hz: number): string {
|
||||
return hz >= 1000 ? `${hz / 1000}k` : `${hz}`;
|
||||
}
|
||||
type SheetKind = 'none' | 'preset' | 'save' | 'overflow' | 'type';
|
||||
|
||||
const BAND_TYPES: EQBandType[] = ['lowshelf', 'peaking', 'highshelf', 'highpass', 'lowpass'];
|
||||
|
||||
export default function EQScreen() {
|
||||
const bands = useEQStore((s) => s.bands);
|
||||
const eq = useEQStore();
|
||||
const scopeActive = useScopeActive();
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [sheet, setSheet] = useState<SheetKind>('none');
|
||||
const [editingValue, setEditingValue] = useState<EQEditableValue | null>(null);
|
||||
const closeSheet = useCallback(() => setSheet('none'), []);
|
||||
|
||||
// Gate the post-EQ tap to while this screen is visible.
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
setFocused(true);
|
||||
setActivePostEqNative(true);
|
||||
return () => {
|
||||
setFocused(false);
|
||||
setActivePostEqNative(false);
|
||||
};
|
||||
}, [])
|
||||
);
|
||||
|
||||
const activeBand = eq.bands.find((b) => b.id === eq.activeBandId) ?? null;
|
||||
const activeBandNumber = eq.bands.findIndex((b) => b.id === eq.activeBandId) + 1;
|
||||
const presetName = eq.presets.find((p) => p.id === eq.activePresetId)?.name ?? 'Custom';
|
||||
const defaultPresetName = `Preset ${eq.presets.filter((p) => p.isCustom).length + 1}`;
|
||||
const valueEditConfig = activeBand && editingValue ? getValueEditConfig(editingValue, activeBand) : null;
|
||||
|
||||
const handleImportAutoEQ = async () => {
|
||||
closeSheet();
|
||||
try {
|
||||
const res = await DocumentPicker.getDocumentAsync({ type: '*/*', copyToCacheDirectory: true });
|
||||
if (res.canceled || !res.assets?.[0]) return;
|
||||
const asset = res.assets[0];
|
||||
const content = await readAsStringAsync(asset.uri);
|
||||
const preset = parseAutoEQ(content, asset.name);
|
||||
if (preset.bands.length > 0) eq.importPreset(preset);
|
||||
} catch {
|
||||
/* invalid file — ignore */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<Text variant="title" style={styles.heading}>
|
||||
Equalizer
|
||||
</Text>
|
||||
<Text variant="body" color={colors.textSecondary} style={styles.note}>
|
||||
The band model is in place. The Media3 biquad chain that makes these
|
||||
sliders live arrives in M4.
|
||||
</Text>
|
||||
|
||||
<View style={styles.bands}>
|
||||
{bands.map((band) => (
|
||||
<View key={band.id} style={styles.band}>
|
||||
<View style={styles.track}>
|
||||
<View style={styles.knob} />
|
||||
</View>
|
||||
<Text variant="caption" style={styles.freq}>
|
||||
{formatFreq(band.frequency)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
<Screen padded={false}>
|
||||
<View style={styles.header}>
|
||||
<Text variant="heading">Equalizer</Text>
|
||||
<View style={styles.headerActions}>
|
||||
<Pressable style={styles.iconButton} onPress={() => setSheet('save')} hitSlop={8}>
|
||||
<Ionicons name="save-outline" size={20} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<Pressable style={styles.iconButton} onPress={() => setSheet('overflow')} hitSlop={8}>
|
||||
<Ionicons name="ellipsis-vertical" size={20} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Pressable style={styles.presetRow} onPress={() => setSheet('preset')}>
|
||||
<Text variant="body" color={colors.textPrimary}>
|
||||
{presetName}
|
||||
</Text>
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.graphWrap}>
|
||||
<EQGraph
|
||||
bands={eq.bands}
|
||||
activeBandId={eq.activeBandId}
|
||||
enabled={eq.enabled}
|
||||
spectrumActive={scopeActive && focused}
|
||||
onSelectBand={eq.selectBand}
|
||||
onChangeBand={(id, updates) => eq.updateBand(id, updates)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<BandStrip
|
||||
bands={eq.bands}
|
||||
activeBandId={eq.activeBandId}
|
||||
canAdd={eq.bands.length < EQ_MAX_BANDS}
|
||||
onSelect={eq.selectBand}
|
||||
onAdd={() => eq.addBand()}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<BandDetailPanel
|
||||
band={activeBand}
|
||||
bandNumber={activeBandNumber > 0 ? activeBandNumber : 1}
|
||||
onUpdate={(updates) => activeBand && eq.updateBand(activeBand.id, updates)}
|
||||
onEditType={() => setSheet('type')}
|
||||
onEditValue={setEditingValue}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.bottomBar}>
|
||||
<View style={styles.preamp}>
|
||||
<EQSlider
|
||||
label="Preamp"
|
||||
value={eq.preamp}
|
||||
min={EQ_MIN_PREAMP_DB}
|
||||
max={EQ_MAX_PREAMP_DB}
|
||||
format={(v) => `${formatGain(v)} dB`}
|
||||
onChange={eq.setPreamp}
|
||||
/>
|
||||
</View>
|
||||
<Pressable
|
||||
style={[styles.eqToggle, eq.enabled && styles.eqToggleOn]}
|
||||
onPress={eq.toggleEnabled}
|
||||
>
|
||||
<Ionicons
|
||||
name="power"
|
||||
size={16}
|
||||
color={eq.enabled ? colors.accentTextStrong : colors.textSecondary}
|
||||
/>
|
||||
<Text variant="label" color={eq.enabled ? colors.accentTextStrong : colors.textSecondary}>
|
||||
{eq.enabled ? 'EQ on' : 'EQ off'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{sheet === 'preset' ? (
|
||||
<PresetSheet
|
||||
presets={eq.presets}
|
||||
activePresetId={eq.activePresetId}
|
||||
onApply={eq.applyPreset}
|
||||
onDelete={eq.deleteCustomPreset}
|
||||
onSaveNew={() => setSheet('save')}
|
||||
onClose={closeSheet}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{sheet === 'save' ? (
|
||||
<SavePresetSheet
|
||||
defaultName={defaultPresetName}
|
||||
onSave={(name) => eq.saveCustomPreset(name)}
|
||||
onClose={closeSheet}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{sheet === 'overflow' ? (
|
||||
<EqSheet onClose={closeSheet}>
|
||||
<EqSheetItem label="Import AutoEQ…" icon="download-outline" onPress={handleImportAutoEQ} />
|
||||
{eq.bands.length > 1 && activeBand ? (
|
||||
<EqSheetItem
|
||||
label={`Remove band ${activeBandNumber}`}
|
||||
icon="remove-circle-outline"
|
||||
onPress={() => {
|
||||
eq.removeBand(activeBand.id);
|
||||
closeSheet();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<EqSheetItem
|
||||
label="Reset to Flat"
|
||||
icon="refresh-outline"
|
||||
destructive
|
||||
onPress={() => {
|
||||
eq.resetToFlat();
|
||||
closeSheet();
|
||||
}}
|
||||
/>
|
||||
</EqSheet>
|
||||
) : null}
|
||||
|
||||
{sheet === 'type' && activeBand ? (
|
||||
<EqSheet onClose={closeSheet}>
|
||||
<Text variant="heading" style={styles.sheetTitle}>
|
||||
Filter type
|
||||
</Text>
|
||||
{BAND_TYPES.map((type) => (
|
||||
<EqSheetItem
|
||||
key={type}
|
||||
label={BAND_TYPE_LABEL[type]}
|
||||
selected={type === activeBand.type}
|
||||
onPress={() => {
|
||||
eq.updateBand(activeBand.id, { type });
|
||||
closeSheet();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</EqSheet>
|
||||
) : null}
|
||||
|
||||
{valueEditConfig && activeBand && editingValue ? (
|
||||
<EQValueEditSheet
|
||||
title={valueEditConfig.title}
|
||||
initialValue={valueEditConfig.initialValue}
|
||||
unit={valueEditConfig.unit}
|
||||
rangeLabel={valueEditConfig.rangeLabel}
|
||||
placeholder={valueEditConfig.placeholder}
|
||||
keyboardType={valueEditConfig.keyboardType}
|
||||
parseValue={valueEditConfig.parseValue}
|
||||
onApply={(value) => eq.updateBand(activeBand.id, createValueUpdate(editingValue, value))}
|
||||
onClose={() => setEditingValue(null)}
|
||||
/>
|
||||
) : null}
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
function getValueEditConfig(kind: EQEditableValue, band: EQBand) {
|
||||
switch (kind) {
|
||||
case 'frequency':
|
||||
return {
|
||||
title: 'Edit frequency',
|
||||
initialValue: String(Math.round(band.frequency)),
|
||||
unit: 'Hz',
|
||||
rangeLabel: `${EQ_MIN_FREQUENCY}-${EQ_MAX_FREQUENCY} Hz`,
|
||||
placeholder: '1000 or 1k',
|
||||
keyboardType: 'default' as const,
|
||||
parseValue: parseFrequency,
|
||||
};
|
||||
case 'gain':
|
||||
if (isPassEQBandType(band.type)) return null;
|
||||
return {
|
||||
title: 'Edit gain',
|
||||
initialValue: band.gain.toFixed(1),
|
||||
unit: 'dB',
|
||||
rangeLabel: `${-EQ_MAX_GAIN_DB} to +${EQ_MAX_GAIN_DB} dB`,
|
||||
placeholder: '0.0',
|
||||
keyboardType: 'numbers-and-punctuation' as const,
|
||||
parseValue: parseDb,
|
||||
};
|
||||
case 'Q':
|
||||
return {
|
||||
title: 'Edit Q',
|
||||
initialValue: band.Q.toFixed(2),
|
||||
unit: 'Q',
|
||||
rangeLabel: `${EQ_MIN_Q}-${EQ_MAX_Q}`,
|
||||
placeholder: '1.00',
|
||||
keyboardType: 'numbers-and-punctuation' as const,
|
||||
parseValue: parsePlainNumber,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function createValueUpdate(kind: EQEditableValue, value: number): Partial<EQBand> {
|
||||
switch (kind) {
|
||||
case 'frequency':
|
||||
return { frequency: value };
|
||||
case 'gain':
|
||||
return { gain: value };
|
||||
case 'Q':
|
||||
return { Q: value };
|
||||
}
|
||||
}
|
||||
|
||||
function parseFrequency(value: string): number | null {
|
||||
const match = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/,/g, '')
|
||||
.replace(/\s+/g, '')
|
||||
.match(/^([+-]?(?:\d+\.?\d*|\.\d+))(khz|hz|k)?$/);
|
||||
if (!match) return null;
|
||||
const parsed = Number(match[1]);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
return match[2] === 'k' || match[2] === 'khz' ? parsed * 1000 : parsed;
|
||||
}
|
||||
|
||||
function parseDb(value: string): number | null {
|
||||
const normalized = value.trim().toLowerCase().replace(/\s+/g, '');
|
||||
const raw = normalized.endsWith('db') ? normalized.slice(0, -2) : normalized;
|
||||
return parsePlainNumber(raw);
|
||||
}
|
||||
|
||||
function parsePlainNumber(value: string): number | null {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
heading: {
|
||||
marginTop: spacing.xl,
|
||||
},
|
||||
note: {
|
||||
marginTop: spacing.sm,
|
||||
marginBottom: spacing.xxl,
|
||||
lineHeight: 20,
|
||||
},
|
||||
bands: {
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
band: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.sm,
|
||||
},
|
||||
headerActions: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
track: {
|
||||
width: 4,
|
||||
height: 140,
|
||||
borderRadius: radius.pill,
|
||||
backgroundColor: colors.glassBorder,
|
||||
iconButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: radius.md,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.glassBg,
|
||||
},
|
||||
knob: {
|
||||
width: 14,
|
||||
height: 14,
|
||||
presetRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginHorizontal: spacing.lg,
|
||||
marginBottom: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: colors.bgTertiary,
|
||||
},
|
||||
graphWrap: {
|
||||
flex: 1,
|
||||
minHeight: 180,
|
||||
marginHorizontal: spacing.lg,
|
||||
},
|
||||
section: {
|
||||
marginHorizontal: spacing.lg,
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
bottomBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.sm,
|
||||
},
|
||||
preamp: {
|
||||
flex: 1,
|
||||
},
|
||||
eqToggle: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.pill,
|
||||
backgroundColor: colors.accent,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
},
|
||||
freq: {
|
||||
color: colors.textSecondary,
|
||||
eqToggleOn: {
|
||||
borderColor: colors.accent,
|
||||
backgroundColor: colors.accentGlow,
|
||||
},
|
||||
sheetTitle: {
|
||||
marginTop: spacing.xs,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
});
|
||||
|
||||
+185
-41
@@ -1,9 +1,12 @@
|
||||
import { View, Pressable, StyleSheet } from 'react-native';
|
||||
import { View, Pressable, ScrollView, StyleSheet, Switch } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { Text } from '@/components/Text';
|
||||
import { EQSlider } from '@/components/eq/EQSlider';
|
||||
import { colors, radius, spacing } from '@/theme';
|
||||
import { useSettingsStore } from '@/stores/settingsStore';
|
||||
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
|
||||
import type { ReplayGainMode } from '@/audio/normalization';
|
||||
import type { ArtistGroupingMode } from '@/library/artistGrouping';
|
||||
|
||||
const ARTIST_GROUPING_OPTIONS: { mode: ArtistGroupingMode; title: string; description: string }[] = [
|
||||
@@ -19,59 +22,159 @@ const ARTIST_GROUPING_OPTIONS: { mode: ArtistGroupingMode; title: string; descri
|
||||
},
|
||||
];
|
||||
|
||||
const REPLAYGAIN_MODES: { mode: ReplayGainMode; label: string }[] = [
|
||||
{ mode: 'auto', label: 'Auto' },
|
||||
{ mode: 'track', label: 'Track' },
|
||||
{ mode: 'album', label: 'Album' },
|
||||
];
|
||||
|
||||
function ToggleRow({
|
||||
title,
|
||||
description,
|
||||
value,
|
||||
onValueChange,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
value: boolean;
|
||||
onValueChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.toggleRow}>
|
||||
<View style={styles.toggleText}>
|
||||
<Text variant="body">{title}</Text>
|
||||
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
|
||||
{description}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
trackColor={{ false: colors.glassBorder, true: colors.accent }}
|
||||
thumbColor={colors.textPrimary}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const groupingMode = useSettingsStore((s) => s.artistGroupingMode);
|
||||
const setArtistGroupingMode = useSettingsStore((s) => s.setArtistGroupingMode);
|
||||
|
||||
const normalizationEnabled = useAudioSettingsStore((s) => s.normalizationEnabled);
|
||||
const normalizationTargetLufs = useAudioSettingsStore((s) => s.normalizationTargetLufs);
|
||||
const replayGainEnabled = useAudioSettingsStore((s) => s.replayGainEnabled);
|
||||
const replayGainMode = useAudioSettingsStore((s) => s.replayGainMode);
|
||||
const setNormalizationEnabled = useAudioSettingsStore((s) => s.setNormalizationEnabled);
|
||||
const setNormalizationTargetLufs = useAudioSettingsStore((s) => s.setNormalizationTargetLufs);
|
||||
const setReplayGainEnabled = useAudioSettingsStore((s) => s.setReplayGainEnabled);
|
||||
const setReplayGainMode = useAudioSettingsStore((s) => s.setReplayGainMode);
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<Text variant="title" style={styles.heading}>
|
||||
Settings
|
||||
</Text>
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.content}>
|
||||
<Text variant="title" style={styles.heading}>
|
||||
Settings
|
||||
</Text>
|
||||
|
||||
<Text variant="label" color={colors.textTertiary} style={styles.sectionLabel}>
|
||||
LIBRARY
|
||||
</Text>
|
||||
<Text variant="body" style={styles.settingTitle}>
|
||||
Artist grouping
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.textSecondary} style={styles.settingNote}>
|
||||
How tracks are organized into artists in the library.
|
||||
</Text>
|
||||
<Text variant="label" color={colors.textTertiary} style={styles.sectionLabel}>
|
||||
AUDIO
|
||||
</Text>
|
||||
<View style={styles.card}>
|
||||
<ToggleRow
|
||||
title="Loudness normalization"
|
||||
description="Level every track to a target loudness — easier on your ears and keeps the scopes consistent."
|
||||
value={normalizationEnabled}
|
||||
onValueChange={(v) => void setNormalizationEnabled(v)}
|
||||
/>
|
||||
{normalizationEnabled ? (
|
||||
<View style={styles.indent}>
|
||||
<EQSlider
|
||||
label="Target"
|
||||
value={normalizationTargetLufs}
|
||||
min={-30}
|
||||
max={-5}
|
||||
format={(v) => `${Math.round(v)} LUFS`}
|
||||
onChange={(v) => void setNormalizationTargetLufs(Math.round(v))}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.options}>
|
||||
{ARTIST_GROUPING_OPTIONS.map((option) => {
|
||||
const selected = option.mode === groupingMode;
|
||||
return (
|
||||
<Pressable
|
||||
key={option.mode}
|
||||
style={[styles.option, selected && styles.optionSelected]}
|
||||
onPress={() => void setArtistGroupingMode(option.mode)}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{ selected }}
|
||||
>
|
||||
<View style={styles.optionText}>
|
||||
<Text variant="body" color={selected ? colors.accentTextStrong : colors.textPrimary}>
|
||||
{option.title}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
|
||||
{option.description}
|
||||
</Text>
|
||||
</View>
|
||||
{selected ? (
|
||||
<Ionicons name="checkmark-circle" size={20} color={colors.accent} />
|
||||
) : (
|
||||
<Ionicons name="ellipse-outline" size={20} color={colors.textTertiary} />
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<View style={[styles.card, styles.cardSpacing]}>
|
||||
<ToggleRow
|
||||
title="ReplayGain"
|
||||
description="Use ReplayGain tags when present; falls back to the measured loudness above."
|
||||
value={replayGainEnabled}
|
||||
onValueChange={(v) => void setReplayGainEnabled(v)}
|
||||
/>
|
||||
{replayGainEnabled ? (
|
||||
<View style={styles.modeRow}>
|
||||
{REPLAYGAIN_MODES.map((m) => {
|
||||
const selected = m.mode === replayGainMode;
|
||||
return (
|
||||
<Pressable
|
||||
key={m.mode}
|
||||
style={[styles.modePill, selected && styles.modePillSelected]}
|
||||
onPress={() => void setReplayGainMode(m.mode)}
|
||||
>
|
||||
<Text variant="label" color={selected ? colors.accentTextStrong : colors.textSecondary}>
|
||||
{m.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<Text variant="label" color={colors.textTertiary} style={[styles.sectionLabel, styles.sectionSpacing]}>
|
||||
LIBRARY
|
||||
</Text>
|
||||
<Text variant="body" style={styles.settingTitle}>
|
||||
Artist grouping
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.textSecondary} style={styles.settingNote}>
|
||||
How tracks are organized into artists in the library.
|
||||
</Text>
|
||||
|
||||
<View style={styles.options}>
|
||||
{ARTIST_GROUPING_OPTIONS.map((option) => {
|
||||
const selected = option.mode === groupingMode;
|
||||
return (
|
||||
<Pressable
|
||||
key={option.mode}
|
||||
style={[styles.option, selected && styles.optionSelected]}
|
||||
onPress={() => void setArtistGroupingMode(option.mode)}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{ selected }}
|
||||
>
|
||||
<View style={styles.optionText}>
|
||||
<Text variant="body" color={selected ? colors.accentTextStrong : colors.textPrimary}>
|
||||
{option.title}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
|
||||
{option.description}
|
||||
</Text>
|
||||
</View>
|
||||
{selected ? (
|
||||
<Ionicons name="checkmark-circle" size={20} color={colors.accent} />
|
||||
) : (
|
||||
<Ionicons name="ellipse-outline" size={20} color={colors.textTertiary} />
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
content: {
|
||||
paddingBottom: spacing.xxl,
|
||||
},
|
||||
heading: {
|
||||
marginTop: spacing.xl,
|
||||
marginBottom: spacing.xxl,
|
||||
@@ -80,6 +183,47 @@ const styles = StyleSheet.create({
|
||||
letterSpacing: 1,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
sectionSpacing: {
|
||||
marginTop: spacing.xxl,
|
||||
},
|
||||
card: {
|
||||
borderRadius: radius.md,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.glassBg,
|
||||
padding: spacing.lg,
|
||||
},
|
||||
cardSpacing: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
toggleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
},
|
||||
toggleText: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
indent: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
modeRow: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
modePill: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.pill,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
},
|
||||
modePillSelected: {
|
||||
borderColor: colors.accent,
|
||||
backgroundColor: colors.accentGlow,
|
||||
},
|
||||
settingTitle: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
|
||||
+20
-1
@@ -18,6 +18,9 @@ import {
|
||||
import { usePlaybackSync } from '@/audio/usePlaybackSync';
|
||||
import { useScopeLifecycle } from '@/scope/useScopeLifecycle';
|
||||
import { useLibraryStore } from '@/stores/libraryStore';
|
||||
import { useEQStore } from '@/stores/eqStore';
|
||||
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
|
||||
import { useNormalizationSync } from '@/audio/useNormalizationSync';
|
||||
import { colors } from '@/theme';
|
||||
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
@@ -34,6 +37,12 @@ function ScopeLifecycle() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Pushes per-track normalization gain to native on track/settings change. */
|
||||
function NormalizationSync() {
|
||||
useNormalizationSync();
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
const [fontsLoaded] = useFonts({
|
||||
Inter_400Regular,
|
||||
@@ -51,12 +60,21 @@ export default function RootLayout() {
|
||||
}, [fontsLoaded]);
|
||||
|
||||
// Eager library init: SQLite open + initial reads are tens of ms, and the
|
||||
// Library tab + playback adapters get data immediately.
|
||||
// Library tab + playback adapters get data immediately. EQ + audio settings load
|
||||
// alongside so the native EQ/gain reflect persisted prefs from the first play.
|
||||
useEffect(() => {
|
||||
useLibraryStore
|
||||
.getState()
|
||||
.initialize()
|
||||
.catch((err) => console.error('[library] init failed', err));
|
||||
useEQStore
|
||||
.getState()
|
||||
.load()
|
||||
.catch((err) => console.error('[eq] load failed', err));
|
||||
useAudioSettingsStore
|
||||
.getState()
|
||||
.load()
|
||||
.catch((err) => console.error('[audioSettings] load failed', err));
|
||||
}, []);
|
||||
|
||||
if (!fontsLoaded) return null;
|
||||
@@ -67,6 +85,7 @@ export default function RootLayout() {
|
||||
<StatusBar style="light" />
|
||||
<PlaybackSync />
|
||||
<ScopeLifecycle />
|
||||
<NormalizationSync />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
|
||||
+63
-40
@@ -16,12 +16,14 @@ import Animated, {
|
||||
import { Text } from '@/components/Text';
|
||||
import { AstraLogo } from '@/components/AstraLogo';
|
||||
import { FormatBadges } from '@/components/FormatBadge';
|
||||
import { MarqueeText } from '@/components/MarqueeText';
|
||||
import { WaveformSeekBar } from '@/components/WaveformSeekBar';
|
||||
import { Visualizer } from '@/components/Visualizer';
|
||||
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
|
||||
import { QueueTray } from '@/components/queue/QueueTray';
|
||||
import { colors, radius, spacing } from '@/theme';
|
||||
import { motion } from '@/theme/motion';
|
||||
import { resolveCanonicalBrowseArtist, resolveStrictBrowseArtist } from '@/library/artistGrouping';
|
||||
import { useLibraryStore } from '@/stores/libraryStore';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { usePlaylistStore } from '@/stores/playlistStore';
|
||||
@@ -177,6 +179,7 @@ export default function NowPlayingScreen() {
|
||||
const scopeMode = useSettingsStore((s) => s.scopeMode);
|
||||
const scopeStageVisible = useSettingsStore((s) => s.scopeStageVisible);
|
||||
const setScopeStageVisible = useSettingsStore((s) => s.setScopeStageVisible);
|
||||
const artistGroupingMode = useSettingsStore((s) => s.artistGroupingMode);
|
||||
const libraryTracks = useLibraryStore((s) => s.tracks);
|
||||
const track = usePlayerStore((s) => s.currentTrack);
|
||||
const playbackState = usePlayerStore((s) => s.playbackState);
|
||||
@@ -194,11 +197,15 @@ export default function NowPlayingScreen() {
|
||||
const source = track?.album?.trim() ? track.album : 'Library';
|
||||
const shellRight = Math.max(layout.contentPadding, (windowWidth - layout.contentWidth) / 2);
|
||||
const menuTop = insets.top + CONTENT_TOP_PADDING + HEADER_HEIGHT + spacing.xs;
|
||||
const artistName = track?.artist.trim() ?? '';
|
||||
const libraryTrack = useMemo(
|
||||
() => (track ? libraryTracks.find((entry) => entry.path === track.path) ?? null : null),
|
||||
[libraryTracks, track]
|
||||
);
|
||||
const artistName = track
|
||||
? artistGroupingMode === 'fileTags'
|
||||
? resolveStrictBrowseArtist(libraryTrack ?? { artist: track.artist, album_artist: track.albumArtist ?? null })
|
||||
: resolveCanonicalBrowseArtist(libraryTrack ?? { artist: track.artist, album_artist: track.albumArtist ?? null })
|
||||
: '';
|
||||
const albumKey = track?.albumIdentityKey ?? libraryTrack?.album_identity_key;
|
||||
|
||||
const navigateToArtist = () => {
|
||||
@@ -454,9 +461,13 @@ export default function NowPlayingScreen() {
|
||||
<View style={styles.playerControls}>
|
||||
<View style={styles.trackInfo}>
|
||||
<View style={styles.trackTextStack}>
|
||||
<Text variant="heading" numberOfLines={1} style={styles.trackTitle}>
|
||||
<MarqueeText
|
||||
variant="heading"
|
||||
containerStyle={styles.trackTitle}
|
||||
style={styles.trackTitleText}
|
||||
>
|
||||
{track.title}
|
||||
</Text>
|
||||
</MarqueeText>
|
||||
<View style={styles.trackMetaRow}>
|
||||
<Pressable
|
||||
onPress={navigateToArtist}
|
||||
@@ -465,13 +476,10 @@ export default function NowPlayingScreen() {
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={`View artist ${track.artist}`}
|
||||
>
|
||||
<Text variant="body" numberOfLines={1} style={styles.artist}>
|
||||
<MarqueeText variant="body" style={styles.artist}>
|
||||
{track.artist}
|
||||
</Text>
|
||||
</MarqueeText>
|
||||
</Pressable>
|
||||
<View style={styles.badges}>
|
||||
<FormatBadges track={track} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<Pressable
|
||||
@@ -566,31 +574,36 @@ export default function NowPlayingScreen() {
|
||||
</View>
|
||||
|
||||
<View style={styles.subRow}>
|
||||
<Pressable
|
||||
hitSlop={10}
|
||||
style={styles.subBtn}
|
||||
onPress={() => void setScopeStageVisible(!scopeStageVisible)}
|
||||
accessibilityLabel={scopeStageVisible ? 'Hide visualizer' : 'Show visualizer'}
|
||||
accessibilityState={{ selected: scopeStageVisible }}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="sine-wave"
|
||||
size={SUB_ICON_SIZE + 2}
|
||||
color={scopeStageVisible ? colors.accent : colors.textTertiary}
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
hitSlop={10}
|
||||
style={styles.subBtn}
|
||||
onPress={() => setQueueOpen(true)}
|
||||
accessibilityLabel="Queue"
|
||||
>
|
||||
<Ionicons
|
||||
name="list-outline"
|
||||
size={SUB_ICON_SIZE + 2}
|
||||
color={colors.textTertiary}
|
||||
/>
|
||||
</Pressable>
|
||||
<View style={styles.subBadges}>
|
||||
<FormatBadges track={track} wrap={false} />
|
||||
</View>
|
||||
<View style={styles.subActions}>
|
||||
<Pressable
|
||||
hitSlop={10}
|
||||
style={styles.subBtn}
|
||||
onPress={() => void setScopeStageVisible(!scopeStageVisible)}
|
||||
accessibilityLabel={scopeStageVisible ? 'Hide visualizer' : 'Show visualizer'}
|
||||
accessibilityState={{ selected: scopeStageVisible }}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="sine-wave"
|
||||
size={SUB_ICON_SIZE + 2}
|
||||
color={scopeStageVisible ? colors.accent : colors.textTertiary}
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
hitSlop={10}
|
||||
style={styles.subBtn}
|
||||
onPress={() => setQueueOpen(true)}
|
||||
accessibilityLabel="Queue"
|
||||
>
|
||||
<Ionicons
|
||||
name="list-outline"
|
||||
size={SUB_ICON_SIZE + 2}
|
||||
color={colors.textTertiary}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -778,6 +791,8 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
trackTitle: {
|
||||
alignSelf: 'stretch',
|
||||
},
|
||||
trackTitleText: {
|
||||
textAlign: 'left',
|
||||
},
|
||||
inlineActionBtn: {
|
||||
@@ -789,13 +804,13 @@ const styles = StyleSheet.create({
|
||||
trackMetaRow: {
|
||||
alignSelf: 'stretch',
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
flexWrap: 'nowrap',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
artistButton: {
|
||||
flexShrink: 1,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
centered: {
|
||||
@@ -804,9 +819,6 @@ const styles = StyleSheet.create({
|
||||
artist: {
|
||||
color: colors.accentText,
|
||||
},
|
||||
badges: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
spacer: {
|
||||
flex: 1,
|
||||
minHeight: MIN_FLOATING_SPACE,
|
||||
@@ -843,11 +855,22 @@ const styles = StyleSheet.create({
|
||||
subRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
gap: spacing.lg,
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.md,
|
||||
marginTop: SUB_TOP_MARGIN,
|
||||
paddingHorizontal: spacing.sm,
|
||||
},
|
||||
subBadges: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
subActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
gap: spacing.lg,
|
||||
},
|
||||
subBtn: {
|
||||
width: SUB_BUTTON_SIZE,
|
||||
height: SUB_BUTTON_SIZE,
|
||||
|
||||
Reference in New Issue
Block a user