onboarding + eq fixes

This commit is contained in:
Boof2015
2026-07-07 20:21:09 -04:00
parent c072a1df16
commit 6aa4b544fc
20 changed files with 1349 additions and 131 deletions
+3 -1
View File
@@ -51,7 +51,8 @@ import {
EQ_MIN_FREQUENCY,
EQ_MIN_PREAMP_DB,
EQ_MIN_Q,
isPassEQBandType
isPassEQBandType,
isShelfEQBandType
} from '@/audio/eq';
import { parseAutoEQ } from '@/audio/autoEQParser';
import { buildGraphicBands } from '@/audio/graphicEq';
@@ -516,6 +517,7 @@ function getValueEditConfig(kind: EQEditableValue, band: EQBand) {
parseValue: parseDb,
};
case 'Q':
if (isShelfEQBandType(band.type)) return null;
return {
title: 'Edit Q',
initialValue: band.Q.toFixed(2),
+42 -12
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { AppState } from 'react-native';
import { AppState, StyleSheet, View } from 'react-native';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
@@ -41,6 +41,8 @@ import { fetchDesktopRemoteIdentity } from '@/services/desktopRemoteClient';
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
import { SyncConflictPrompt } from '@/components/sync/SyncConflictPrompt';
import { useThemeStore } from '@/stores/themeStore';
import { useOnboardingStore } from '@/stores/onboardingStore';
import { OnboardingFlow } from '@/components/onboarding/OnboardingFlow';
import { useTheme } from '@/theme/themed';
// Anchor the root stack at the tabs so a deep link straight to a top-level route (the
@@ -280,8 +282,10 @@ export default function RootLayout() {
// persisted theme (no flash). The failsafe path paints the default theme
// and snaps once the SQLite read lands — accepted degradation.
const themeLoaded = useThemeStore((s) => s.loaded);
const onboardingLoaded = useOnboardingStore((s) => s.loaded);
const onboardingComplete = useOnboardingStore((s) => s.onboardingComplete);
const theme = useTheme();
const ready = (fontsLoaded && themeLoaded) || splashTimedOut;
const ready = (fontsLoaded && themeLoaded && onboardingLoaded) || splashTimedOut;
useEffect(() => {
if (ready) {
@@ -297,6 +301,10 @@ export default function RootLayout() {
.getState()
.load()
.catch((err) => console.error('[theme] load failed', err));
useOnboardingStore
.getState()
.load()
.catch((err) => console.error('[onboarding] load failed', err));
useLibraryStore
.getState()
.initialize()
@@ -326,14 +334,22 @@ export default function RootLayout() {
<GestureHandlerRootView style={{ flex: 1, backgroundColor: theme.colors.bgPrimary }}>
<SafeAreaProvider>
<StatusBar style={theme.statusBarStyle} />
<ThemeSystemSync />
<PlaybackSync />
<ScopeLifecycle />
<NormalizationSync />
<LastFmScrobbler />
<PlaybackTargetSync />
<DesktopRemoteMediaSessionSync />
<DesktopSyncAutoTrigger />
{/* The navigator stays mounted whatever the onboarding state (expo-router
needs a root navigator), but the playback/sync/desktop side-effects and
overlays are gated off during the wizard — no LAN-discovery bursts or
scrobbler running mid-onboarding. */}
{onboardingComplete ? (
<>
<ThemeSystemSync />
<PlaybackSync />
<ScopeLifecycle />
<NormalizationSync />
<LastFmScrobbler />
<PlaybackTargetSync />
<DesktopRemoteMediaSessionSync />
<DesktopSyncAutoTrigger />
</>
) : null}
<Stack
screenOptions={{
headerShown: false,
@@ -351,8 +367,22 @@ export default function RootLayout() {
}}
/>
</Stack>
<QuickSearchOverlay />
<SyncConflictPrompt />
{onboardingComplete ? (
<>
<QuickSearchOverlay />
<SyncConflictPrompt />
</>
) : (
// First-run gate: opaque full-screen wizard over the (hidden) navigator.
// markComplete flips the flag → this unmounts, revealing the app.
<View style={StyleSheet.absoluteFill}>
<OnboardingFlow
onDone={() => {
void useOnboardingStore.getState().markComplete();
}}
/>
</View>
)}
</SafeAreaProvider>
</GestureHandlerRootView>
);
+21 -1
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { InteractionManager } from 'react-native';
import { Alert, InteractionManager } from 'react-native';
import { useRouter } from 'expo-router';
import {
SettingsNavRow,
@@ -10,6 +10,7 @@ import { formatRelativeTime } from '@/lib/format';
import { useColors } from '@/theme/themed';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
import { useOnboardingStore } from '@/stores/onboardingStore';
export default function ExperimentalSettingsScreen() {
const colors = useColors();
@@ -31,6 +32,17 @@ export default function ExperimentalSettingsScreen() {
const desktopRemoteSubtitle = desktopRemoteConnection
? `${desktopRemoteConnection.desktopName ?? 'Astra Desktop'}: ${desktopRemoteState === 'connected' ? 'connected' : desktopRemoteState}`
: 'Pair with Astra Desktop to control playback from this phone.';
const replayOnboarding = () => {
Alert.alert(
'Replay onboarding?',
'The first-run setup wizard will show again the next time you return to the home screen. Your library and settings are kept.',
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Replay', onPress: () => void useOnboardingStore.getState().reset() },
]
);
};
const desktopSyncSubtitle = !desktopRemoteConnection
? 'Sync favorites and playlists with Astra Desktop.'
: desktopSyncConflictCount > 0
@@ -57,6 +69,14 @@ export default function ExperimentalSettingsScreen() {
subtitleColor={desktopSyncConflictCount > 0 ? colors.warning : undefined}
onPress={() => router.push('/desktop-sync' as never)}
/>
<SettingsSectionLabel>DEVELOPER</SettingsSectionLabel>
<SettingsNavRow
icon="refresh-outline"
title="Replay onboarding"
subtitle="Show the first-run setup wizard again."
onPress={replayOnboarding}
/>
</SettingsSectionScreen>
);
}
+102
View File
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { EQBand } from '../types/audio.ts';
import {
computeCombinedEQMagnitude,
computeEQFilterCoefficients,
computeEQFilterMagnitude,
type EQFilterCoefficients,
} from './eq.ts';
function band(overrides: Partial<EQBand> = {}): EQBand {
return {
id: overrides.id ?? 'band-1',
type: overrides.type ?? 'peaking',
frequency: overrides.frequency ?? 1000,
gain: overrides.gain ?? 0,
Q: overrides.Q ?? 1,
enabled: overrides.enabled ?? true,
};
}
function assertClose(actual: number, expected: number, tolerance = 1e-6): void {
assert.ok(
Math.abs(actual - expected) <= tolerance,
`expected ${actual} to be within ${tolerance} of ${expected}`
);
}
function assertCoefficientsClose(
actual: EQFilterCoefficients,
expected: EQFilterCoefficients,
tolerance = 1e-9
): void {
assertClose(actual.b0, expected.b0, tolerance);
assertClose(actual.b1, expected.b1, tolerance);
assertClose(actual.b2, expected.b2, tolerance);
assertClose(actual.a1, expected.a1, tolerance);
assertClose(actual.a2, expected.a2, tolerance);
}
test('peaking filter reaches requested gain at center frequency', () => {
const boost = band({ type: 'peaking', frequency: 1200, gain: 5.5, Q: 1.25 });
assertClose(computeEQFilterMagnitude(boost, 1200, 48000), 5.5, 1e-6);
});
test('shelf filters ignore Q like Web Audio BiquadFilterNode', () => {
const lowLoose = band({ type: 'lowshelf', frequency: 100, gain: 6, Q: 0.1 });
const lowTight = band({ type: 'lowshelf', frequency: 100, gain: 6, Q: 18 });
const highLoose = band({ type: 'highshelf', frequency: 8000, gain: -4, Q: 0.1 });
const highTight = band({ type: 'highshelf', frequency: 8000, gain: -4, Q: 18 });
assertCoefficientsClose(
computeEQFilterCoefficients(lowLoose, 48000),
computeEQFilterCoefficients(lowTight, 48000)
);
assertCoefficientsClose(
computeEQFilterCoefficients(highLoose, 48000),
computeEQFilterCoefficients(highTight, 48000)
);
assertClose(
computeEQFilterMagnitude(lowLoose, 40, 48000),
computeEQFilterMagnitude(lowTight, 40, 48000)
);
assertClose(
computeEQFilterMagnitude(highLoose, 12000, 48000),
computeEQFilterMagnitude(highTight, 12000, 48000)
);
});
test('lowpass and highpass coefficients use Web Audio Q-in-dB semantics', () => {
assertCoefficientsClose(
computeEQFilterCoefficients(band({ type: 'lowpass', frequency: 1000, Q: 6 }), 48000),
{
b0: 0.004142085705,
b1: 0.00828417141,
b2: 0.004142085705,
a1: -1.920085584611,
a2: 0.936653927431,
}
);
assertCoefficientsClose(
computeEQFilterCoefficients(band({ type: 'highpass', frequency: 1000, Q: 6 }), 48000),
{
b0: 0.964184878011,
b1: -1.928369756021,
b2: 0.964184878011,
a1: -1.920085584611,
a2: 0.936653927431,
}
);
});
test('combined response skips disabled bands', () => {
const enabled = band({ id: 'enabled', frequency: 1000, gain: 3, Q: 1, enabled: true });
const disabled = band({ id: 'disabled', frequency: 1000, gain: 9, Q: 1, enabled: false });
assertClose(
computeCombinedEQMagnitude([enabled, disabled], 1000, 48000),
computeEQFilterMagnitude(enabled, 1000, 48000)
);
});
+71 -28
View File
@@ -1,7 +1,7 @@
// Parametric EQ math + helpers — ported from desktop `src/renderer/utils/eq.ts`.
// The biquad cookbook (Audio EQ Cookbook) magnitude math drives the response curve
// in the EQ screen. Coefficients themselves are computed natively (Kotlin) at the
// real stream sample rate — here we only flatten band params for the native bridge.
// Web Audio BiquadFilterNode-compatible math drives the response curve in the EQ
// screen. Native playback computes matching coefficients in Kotlin at the real
// stream sample rate — here we also flatten band params for the native bridge.
import type { EQBand, EQBandType, EQMode, EQPreset } from '../types/audio';
@@ -88,6 +88,10 @@ export function isPassEQBandType(type: EQBandType): boolean {
return type === 'highpass' || type === 'lowpass';
}
export function isShelfEQBandType(type: EQBandType): boolean {
return type === 'lowshelf' || type === 'highshelf';
}
/** Pass filters carry no gain — force it to 0. */
export function normalizeEQBand<T extends EQBand>(band: T): T {
if (!isPassEQBandType(band.type) || band.gain === 0) {
@@ -181,18 +185,49 @@ export function serializeEQPresetData(
}
// ---------------------------------------------------------------------------
// Response curve magnitude (Audio EQ Cookbook) — for the Skia response curve.
// Response curve magnitude (Web Audio BiquadFilterNode) — for the Skia response curve.
// ---------------------------------------------------------------------------
export function computeEQFilterMagnitude(band: EQBand, testFreq: number, sampleRate: number): number {
if (sampleRate <= 0) return 0;
export interface EQFilterCoefficients {
b0: number;
b1: number;
b2: number;
a1: number;
a2: number;
}
const MIN_FILTER_Q = 0.0001;
function normalizeCoefficientSet(
b0: number,
b1: number,
b2: number,
a0: number,
a1: number,
a2: number
): EQFilterCoefficients {
const invA0 = 1 / a0;
return {
b0: b0 * invA0,
b1: b1 * invA0,
b2: b2 * invA0,
a1: a1 * invA0,
a2: a2 * invA0,
};
}
export function computeEQFilterCoefficients(band: EQBand, sampleRate: number): EQFilterCoefficients {
if (sampleRate <= 0) {
return { b0: 1, b1: 0, b2: 0, a1: 0, a2: 0 };
}
const w0 = (2 * Math.PI * band.frequency) / sampleRate;
const w = (2 * Math.PI * testFreq) / sampleRate;
const A = Math.pow(10, band.gain / 40);
const sinW0 = Math.sin(w0);
const cosW0 = Math.cos(w0);
const alpha = sinW0 / (2 * band.Q);
const alphaQ = sinW0 / (2 * Math.max(band.Q, MIN_FILTER_Q));
const alphaQDb = sinW0 / (2 * Math.pow(10, band.Q / 20));
const alphaShelf = (sinW0 / 2) * Math.SQRT2;
let b0 = 1;
let b1 = 0;
@@ -203,60 +238,68 @@ export function computeEQFilterMagnitude(band: EQBand, testFreq: number, sampleR
switch (band.type) {
case 'peaking':
b0 = 1 + alpha * A;
b0 = 1 + alphaQ * A;
b1 = -2 * cosW0;
b2 = 1 - alpha * A;
a0 = 1 + alpha / A;
b2 = 1 - alphaQ * A;
a0 = 1 + alphaQ / A;
a1 = -2 * cosW0;
a2 = 1 - alpha / A;
a2 = 1 - alphaQ / A;
break;
case 'lowshelf': {
const sqrtA = Math.sqrt(A);
b0 = A * (A + 1 - (A - 1) * cosW0 + 2 * sqrtA * alpha);
b0 = A * (A + 1 - (A - 1) * cosW0 + 2 * sqrtA * alphaShelf);
b1 = 2 * A * (A - 1 - (A + 1) * cosW0);
b2 = A * (A + 1 - (A - 1) * cosW0 - 2 * sqrtA * alpha);
a0 = A + 1 + (A - 1) * cosW0 + 2 * sqrtA * alpha;
b2 = A * (A + 1 - (A - 1) * cosW0 - 2 * sqrtA * alphaShelf);
a0 = A + 1 + (A - 1) * cosW0 + 2 * sqrtA * alphaShelf;
a1 = -2 * (A - 1 + (A + 1) * cosW0);
a2 = A + 1 + (A - 1) * cosW0 - 2 * sqrtA * alpha;
a2 = A + 1 + (A - 1) * cosW0 - 2 * sqrtA * alphaShelf;
break;
}
case 'highshelf': {
const sqrtA = Math.sqrt(A);
b0 = A * (A + 1 + (A - 1) * cosW0 + 2 * sqrtA * alpha);
b0 = A * (A + 1 + (A - 1) * cosW0 + 2 * sqrtA * alphaShelf);
b1 = -2 * A * (A - 1 + (A + 1) * cosW0);
b2 = A * (A + 1 + (A - 1) * cosW0 - 2 * sqrtA * alpha);
a0 = A + 1 - (A - 1) * cosW0 + 2 * sqrtA * alpha;
b2 = A * (A + 1 + (A - 1) * cosW0 - 2 * sqrtA * alphaShelf);
a0 = A + 1 - (A - 1) * cosW0 + 2 * sqrtA * alphaShelf;
a1 = 2 * (A - 1 - (A + 1) * cosW0);
a2 = A + 1 - (A - 1) * cosW0 - 2 * sqrtA * alpha;
a2 = A + 1 - (A - 1) * cosW0 - 2 * sqrtA * alphaShelf;
break;
}
case 'lowpass':
b0 = (1 - cosW0) / 2;
b1 = 1 - cosW0;
b2 = (1 - cosW0) / 2;
a0 = 1 + alpha;
a0 = 1 + alphaQDb;
a1 = -2 * cosW0;
a2 = 1 - alpha;
a2 = 1 - alphaQDb;
break;
case 'highpass':
b0 = (1 + cosW0) / 2;
b1 = -(1 + cosW0);
b2 = (1 + cosW0) / 2;
a0 = 1 + alpha;
a0 = 1 + alphaQDb;
a1 = -2 * cosW0;
a2 = 1 - alpha;
a2 = 1 - alphaQDb;
break;
}
return normalizeCoefficientSet(b0, b1, b2, a0, a1, a2);
}
export function computeEQFilterMagnitude(band: EQBand, testFreq: number, sampleRate: number): number {
if (sampleRate <= 0) return 0;
const w = (2 * Math.PI * testFreq) / sampleRate;
const { b0, b1, b2, a1, a2 } = computeEQFilterCoefficients(band, sampleRate);
const cosW = Math.cos(w);
const sinW = Math.sin(w);
const cos2W = Math.cos(2 * w);
const sin2W = Math.sin(2 * w);
const numReal = b0 / a0 + (b1 / a0) * cosW + (b2 / a0) * cos2W;
const numImag = -(b1 / a0) * sinW - (b2 / a0) * sin2W;
const denReal = 1 + (a1 / a0) * cosW + (a2 / a0) * cos2W;
const denImag = -(a1 / a0) * sinW - (a2 / a0) * sin2W;
const numReal = b0 + b1 * cosW + b2 * cos2W;
const numImag = -b1 * sinW - b2 * sin2W;
const denReal = 1 + a1 * cosW + a2 * cos2W;
const denImag = -a1 * sinW - a2 * sin2W;
const numMag = Math.sqrt(numReal * numReal + numImag * numImag);
const denMag = Math.sqrt(denReal * denReal + denImag * denImag);
+16 -12
View File
@@ -18,7 +18,8 @@ import {
EQ_MAX_Q,
EQ_MIN_FREQUENCY,
EQ_MIN_Q,
isPassEQBandType
isPassEQBandType,
isShelfEQBandType
} from '@/audio/eq';
import { EQSlider } from './EQSlider';
import {
@@ -39,7 +40,7 @@ interface BandDetailPanelProps {
export type EQEditableValue = 'frequency' | 'gain' | 'Q';
/** "Band N" + type dropdown + On toggle + Frequency / Gain / Q sliders. */
/** "Band N" + type dropdown + On toggle + audible parameter sliders. */
export function BandDetailPanel({ band, bandNumber, onUpdate, onEditType, onEditValue }: BandDetailPanelProps) {
const styles = useStyles();
const colors = useColors();
@@ -54,6 +55,7 @@ export function BandDetailPanel({ band, bandNumber, onUpdate, onEditType, onEdit
}
const isPass = isPassEQBandType(band.type);
const isShelf = isShelfEQBandType(band.type);
return (
<View style={styles.card}>
@@ -96,16 +98,18 @@ export function BandDetailPanel({ band, bandNumber, onUpdate, onEditType, onEdit
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')}
/>
{!isShelf ? (
<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')}
/>
) : null}
</View>
);
}
@@ -0,0 +1,544 @@
import { useEffect, useState, type ComponentProps } from 'react';
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
View,
useWindowDimensions,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Animated, {
FadeIn,
FadeInDown,
FadeOutUp,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { Canvas, LinearGradient, Rect, vec } from '@shopify/react-native-skia';
import { Ionicons } from '@expo/vector-icons';
import { AstraLogo } from '@/components/AstraLogo';
import { Text } from '@/components/Text';
import { ScanProgress } from '@/components/library/ScanProgress';
import { AccentSwatchRow } from '@/components/settings/AccentSwatchRow';
import { formatFolderCount, formatTrackCount } from '@/components/settings/SettingsPanels';
import { radius, spacing } from '@/theme';
import { motion } from '@/theme/motion';
import { createThemedStyles, useColors } from '@/theme/themed';
import type { BaseThemeId } from '@/theme/resolve';
import { useLibraryStore } from '@/stores/libraryStore';
import { useThemeStore } from '@/stores/themeStore';
type IoniconName = ComponentProps<typeof Ionicons>['name'];
type StepId = 'welcome' | 'library' | 'theme' | 'done';
const STEP_ORDER: StepId[] = ['welcome', 'library', 'theme', 'done'];
const WIZARD_THEME_OPTIONS: { id: BaseThemeId; title: string }[] = [
{ id: 'system', title: 'System' },
{ id: 'midnight', title: 'Midnight' },
{ id: 'dark', title: 'Dark' },
{ id: 'amoled', title: 'AMOLED' },
{ id: 'light', title: 'Light' },
{ id: 'materialYou', title: 'Material You' },
];
/**
* First-run wizard. Rendered by the root layout instead of the app tree while
* `onboarding_complete` is unset. Purely presentational — it drives the existing
* library + theme stores and calls `onDone` (→ markComplete) at the end.
*/
export function OnboardingFlow({ onDone }: { onDone: () => void }) {
const styles = useStyles();
const colors = useColors();
const insets = useSafeAreaInsets();
const { width, height } = useWindowDimensions();
const [stepIndex, setStepIndex] = useState(0);
const step = STEP_ORDER[stepIndex];
const foldersCount = useLibraryStore((s) => s.folders.length);
const isScanning = useLibraryStore((s) => s.isScanning);
const goNext = () => {
if (stepIndex < STEP_ORDER.length - 1) setStepIndex((i) => i + 1);
else onDone();
};
const goBack = () => setStepIndex((i) => Math.max(0, i - 1));
const canGoBack = step === 'library' || step === 'theme';
const primaryLabel =
step === 'welcome'
? 'Get started'
: step === 'library'
? // A scan is orchestrated by the store (not this component), so it keeps
// running after "Continue" — never call the forward action "Skip" while
// it is actively working.
foldersCount > 0 || isScanning
? 'Continue'
: 'Skip for now'
: step === 'theme'
? 'Continue'
: 'Start listening';
return (
<View style={styles.root}>
<Canvas style={StyleSheet.absoluteFill}>
<Rect x={0} y={0} width={width} height={height}>
<LinearGradient
start={vec(0, 0)}
end={vec(width * 0.5, height)}
colors={[colors.accentGlow, colors.bgPrimary, colors.bgPrimary]}
positions={[0, 0.55, 1]}
/>
</Rect>
</Canvas>
<View
style={[
styles.content,
{ paddingTop: insets.top + spacing.lg, paddingBottom: insets.bottom + spacing.lg },
]}
>
{/* Once past the library step, a subtle banner keeps reminding the user the
scan they started is still running in the background (it was not stopped
by continuing). */}
{step !== 'library' ? <ScanBanner /> : null}
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<Animated.View key={step} entering={FadeIn.duration(220)} style={styles.stepWrap}>
{step === 'welcome' ? <WelcomeStep /> : null}
{step === 'library' ? <LibraryStep /> : null}
{step === 'theme' ? <ThemeStep /> : null}
{step === 'done' ? <DoneStep /> : null}
</Animated.View>
</ScrollView>
<View style={styles.footer}>
<View style={styles.dots}>
{STEP_ORDER.map((id, i) => (
<Dot key={id} active={i === stepIndex} />
))}
</View>
<View style={styles.navRow}>
{canGoBack ? (
<Pressable
onPress={goBack}
style={styles.secondaryButton}
accessibilityRole="button"
accessibilityLabel="Go back"
>
<Text variant="label" color={colors.textSecondary}>
Back
</Text>
</Pressable>
) : null}
<Pressable
onPress={goNext}
style={styles.primaryButton}
accessibilityRole="button"
accessibilityLabel={primaryLabel}
>
<Text variant="label" color={colors.bgPrimary} style={styles.primaryButtonText}>
{primaryLabel}
</Text>
</Pressable>
</View>
</View>
</View>
</View>
);
}
function WelcomeStep() {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.centered}>
<Animated.View entering={FadeInDown.duration(500)}>
<AstraLogo size={96} />
</Animated.View>
<Animated.View entering={FadeInDown.delay(120).duration(500)} style={styles.centeredText}>
<Text variant="title" style={styles.centeredTitle}>
Welcome to Astra
</Text>
<Text variant="body" color={colors.textSecondary} style={styles.centeredSubtitle}>
Your music, beautifully played. Set up your library in a few taps.
</Text>
</Animated.View>
</View>
);
}
function LibraryStep() {
const styles = useStyles();
const colors = useColors();
const folders = useLibraryStore((s) => s.folders);
const totalTrackCount = useLibraryStore((s) => s.totalTrackCount);
const isScanning = useLibraryStore((s) => s.isScanning);
const addFolder = useLibraryStore((s) => s.addFolder);
return (
<View style={styles.stepBody}>
<StepHeader
icon="musical-notes-outline"
title="Add your music"
subtitle="Point Astra at the folders where your music lives. It scans them into your library — files on disk are never modified."
/>
<Pressable
style={[styles.choiceButton, isScanning && styles.disabled]}
disabled={isScanning}
onPress={() => void addFolder()}
accessibilityRole="button"
>
<Ionicons name="folder-open-outline" size={20} color={colors.accent} />
<Text variant="body" color={colors.textPrimary}>
{folders.length > 0 ? 'Add another folder' : 'Choose music folder'}
</Text>
</Pressable>
<ScanProgress />
{isScanning ? (
<Text variant="caption" color={colors.textTertiary} style={styles.hint}>
Scanning keeps running in the background continue whenever you like, or add
more folders.
</Text>
) : folders.length > 0 ? (
<View style={styles.summaryCard}>
<Ionicons name="checkmark-circle" size={20} color={colors.accent} />
<Text variant="body" color={colors.textPrimary}>
{formatFolderCount(folders.length)} · {formatTrackCount(totalTrackCount)}
</Text>
</View>
) : (
<Text variant="caption" color={colors.textTertiary} style={styles.hint}>
You can skip this and add folders later from Settings Library.
</Text>
)}
</View>
);
}
function ThemeStep() {
const styles = useStyles();
const colors = useColors();
const baseTheme = useThemeStore((s) => s.baseTheme);
const materialYouAvailable = useThemeStore((s) => s.materialYouAvailable);
const resolvedId = useThemeStore((s) => s.theme.id);
const accentId = useThemeStore((s) => s.accentId);
const setBaseTheme = useThemeStore((s) => s.setBaseTheme);
const setAccent = useThemeStore((s) => s.setAccent);
const options = WIZARD_THEME_OPTIONS.filter(
(option) => option.id !== 'materialYou' || materialYouAvailable
);
const accentApplies = !resolvedId.startsWith('materialYou');
return (
<View style={styles.stepBody}>
<StepHeader
icon="color-palette-outline"
title="Make it yours"
subtitle="Pick a theme. You can change it anytime in Settings."
/>
<View style={styles.themeGrid}>
{options.map((option) => {
const selected = option.id === baseTheme;
return (
<Pressable
key={option.id}
onPress={() => void setBaseTheme(option.id)}
style={[styles.themePill, selected && styles.themePillSelected]}
accessibilityRole="radio"
accessibilityState={{ selected }}
>
<Text
variant="label"
color={selected ? colors.accentTextStrong : colors.textSecondary}
>
{option.title}
</Text>
</Pressable>
);
})}
</View>
{accentApplies ? (
<View style={styles.accentBlock}>
<AccentSwatchRow value={accentId} onChange={(id) => void setAccent(id)} />
</View>
) : null}
</View>
);
}
function DoneStep() {
const styles = useStyles();
const colors = useColors();
const totalTrackCount = useLibraryStore((s) => s.totalTrackCount);
return (
<View style={styles.centered}>
<Animated.View entering={FadeInDown.duration(400)} style={styles.doneBadge}>
<Ionicons name="checkmark" size={44} color={colors.bgPrimary} />
</Animated.View>
<Animated.View entering={FadeInDown.delay(100).duration(400)} style={styles.centeredText}>
<Text variant="title" style={styles.centeredTitle}>
All set
</Text>
<Text variant="body" color={colors.textSecondary} style={styles.centeredSubtitle}>
{totalTrackCount > 0
? `${formatTrackCount(totalTrackCount)} ready to play.`
: 'Add music anytime from Settings Library.'}
</Text>
</Animated.View>
</View>
);
}
function StepHeader({
icon,
title,
subtitle,
}: {
icon: IoniconName;
title: string;
subtitle: string;
}) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.stepHeader}>
<View style={styles.stepIconWrap}>
<Ionicons name={icon} size={26} color={colors.accent} />
</View>
<Text variant="heading" style={styles.centeredTitle}>
{title}
</Text>
<Text variant="body" color={colors.textSecondary} style={styles.centeredSubtitle}>
{subtitle}
</Text>
</View>
);
}
/** Subtle "still scanning" pill shown at the top of steps after the library step. */
function ScanBanner() {
const styles = useStyles();
const colors = useColors();
const isScanning = useLibraryStore((s) => s.isScanning);
const progress = useLibraryStore((s) => s.scanProgress);
if (!isScanning) return null;
const detail =
(progress.phase === 'extracting' || progress.phase === 'analyzing') && progress.total > 0
? `${progress.processed}/${progress.total}`
: null;
return (
<Animated.View
entering={FadeInDown.duration(220)}
exiting={FadeOutUp.duration(160)}
style={styles.scanBanner}
>
<ActivityIndicator size="small" color={colors.accent} />
<Text
variant="caption"
color={colors.textSecondary}
numberOfLines={1}
style={styles.scanBannerText}
>
Scanning your library{detail ? ` · ${detail}` : '…'}
</Text>
</Animated.View>
);
}
/** Page indicator dot — widens + brightens when active. Animated View, not an icon. */
function Dot({ active }: { active: boolean }) {
const styles = useStyles();
const progress = useSharedValue(active ? 1 : 0);
useEffect(() => {
progress.value = withTiming(active ? 1 : 0, motion.snap);
}, [active, progress]);
const animatedStyle = useAnimatedStyle(() => ({
width: 8 + progress.value * 14,
opacity: 0.3 + progress.value * 0.7,
}));
return <Animated.View style={[styles.dot, animatedStyle]} />;
}
const useStyles = createThemedStyles((colors) => ({
root: {
flex: 1,
backgroundColor: colors.bgPrimary,
},
content: {
flex: 1,
paddingHorizontal: spacing.lg,
},
scanBanner: {
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'center',
gap: spacing.sm,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
marginBottom: spacing.sm,
borderRadius: radius.pill,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
scanBannerText: {
maxWidth: 240,
},
scroll: {
flex: 1,
},
scrollContent: {
flexGrow: 1,
justifyContent: 'center',
paddingVertical: spacing.xl,
},
stepWrap: {
width: '100%',
},
centered: {
alignItems: 'center',
gap: spacing.xl,
},
centeredText: {
alignItems: 'center',
gap: spacing.sm,
},
centeredTitle: {
textAlign: 'center',
},
centeredSubtitle: {
textAlign: 'center',
maxWidth: 340,
},
doneBadge: {
width: 88,
height: 88,
borderRadius: 44,
backgroundColor: colors.accent,
alignItems: 'center',
justifyContent: 'center',
},
stepBody: {
width: '100%',
gap: spacing.lg,
},
stepHeader: {
alignItems: 'center',
gap: spacing.sm,
marginBottom: spacing.xs,
},
stepIconWrap: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: colors.glassBg,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.xs,
},
choiceButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
minHeight: 52,
paddingVertical: spacing.md + 2,
paddingHorizontal: spacing.lg,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
disabled: {
opacity: 0.5,
},
summaryCard: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
paddingVertical: spacing.md,
paddingHorizontal: spacing.lg,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
hint: {
textAlign: 'center',
},
themeGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm,
justifyContent: 'center',
},
themePill: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.pill,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
themePillSelected: {
borderColor: colors.accent,
backgroundColor: colors.accentGlow,
},
accentBlock: {
alignItems: 'center',
marginTop: spacing.sm,
},
footer: {
gap: spacing.lg,
paddingTop: spacing.md,
},
dots: {
flexDirection: 'row',
gap: spacing.sm,
justifyContent: 'center',
alignItems: 'center',
},
dot: {
height: 8,
borderRadius: 4,
backgroundColor: colors.accent,
},
navRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
},
secondaryButton: {
minHeight: 52,
paddingHorizontal: spacing.lg,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
},
primaryButton: {
flex: 1,
minHeight: 52,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.md,
backgroundColor: colors.accent,
},
primaryButtonText: {
fontSize: 15,
},
}));
export default OnboardingFlow;
+98
View File
@@ -0,0 +1,98 @@
// Foreground-service keepalive around a library scan. The scan loop runs on the JS
// thread (see scanner.ts), so without a foreground service + wakelock Android throttles
// it the moment the app is backgrounded or the screen sleeps — a big scan would stall.
// This starts the FGS on the first progress tick and tears it down when the scan ends.
// All no-ops on non-Android and on native binaries built before the FGS methods existed.
import { PermissionsAndroid, Platform } from 'react-native';
import { AstraLibraryScanner } from '../../modules/astra-library-scanner';
import type { ScanProgress } from './scanner';
const supported =
Platform.OS === 'android' &&
typeof (AstraLibraryScanner as { startScanService?: unknown }).startScanService === 'function';
// runScan guarantees scans never overlap, so single-scan module state is safe.
let active = false;
let notifPermRequested = false;
/**
* POST_NOTIFICATIONS is Android 13+ (API 33); PermissionsAndroid resolves it granted
* automatically below that. Requested contextually on the first scan. The FGS +
* wakelock still keep the scan alive without it — only the visible notification needs it.
*/
async function ensureNotificationPermission(): Promise<void> {
if (notifPermRequested) return;
notifPermRequested = true;
const permission = (PermissionsAndroid.PERMISSIONS as Record<string, string | undefined>)
.POST_NOTIFICATIONS;
if (!permission) return;
try {
await PermissionsAndroid.request(permission as Parameters<typeof PermissionsAndroid.request>[0]);
} catch {
// Denied/unavailable — the scan still runs, the notification just won't show.
}
}
interface ScanNotification {
title: string;
text: string;
subText: string | null;
current: number;
total: number;
indeterminate: boolean;
}
const n = (value: number) => value.toLocaleString();
function notificationFor(progress: ScanProgress): ScanNotification {
const folder = progress.folderName?.trim() || null;
if (progress.phase === 'extracting') {
return {
title: 'Scanning your library',
text: progress.total > 0 ? `${n(progress.processed)} of ${n(progress.total)} files` : 'Reading files…',
subText: folder,
current: progress.processed,
total: progress.total,
indeterminate: progress.total <= 0,
};
}
if (progress.phase === 'analyzing') {
return {
title: 'Analyzing audio',
text: progress.total > 0 ? `${n(progress.processed)} of ${n(progress.total)} tracks` : 'Analyzing…',
subText: folder,
current: progress.processed,
total: progress.total,
indeterminate: progress.total <= 0,
};
}
return {
title: 'Finding your music',
text: progress.total > 0 ? `${n(progress.total)} files found so far…` : 'Looking through your folders…',
subText: folder,
current: 0,
total: 0,
indeterminate: true,
};
}
/** Report a scan progress tick — starts the FGS on the first call, updates it after. */
export async function reportScanProgress(progress: ScanProgress): Promise<void> {
if (!supported) return;
const { title, text, subText, current, total, indeterminate } = notificationFor(progress);
if (!active) {
active = true;
await ensureNotificationPermission();
if (!active) return; // scan ended while we awaited the permission dialog
AstraLibraryScanner.startScanService(title, text);
}
AstraLibraryScanner.updateScanNotification(title, text, subText, current, total, indeterminate);
}
/** Tear down the scan foreground service when a scan finishes (or errors). */
export function endScanService(): void {
if (!supported || !active) return;
active = false;
AstraLibraryScanner.stopScanService();
}
+8 -1
View File
@@ -21,6 +21,7 @@ import {
type ScanProgress,
type ScanResult,
} from '@/library/scanner';
import { endScanService, reportScanProgress } from '@/library/scanService';
import { ALBUM_SORT_LABELS, type AlbumSort } from '@/lib/albumSort';
import { ARTIST_SORT_LABELS, type ArtistSort } from '@/lib/artistSort';
import { TRACK_SORT_LABELS, type TrackSort } from '@/lib/trackSort';
@@ -114,7 +115,12 @@ interface LibraryStore {
let initPromise: Promise<void> | null = null;
export const useLibraryStore = create<LibraryStore>((set, get) => {
const onProgress = (progress: ScanProgress) => set({ scanProgress: progress });
const onProgress = (progress: ScanProgress) => {
set({ scanProgress: progress });
// Mirror progress into the foreground-service notification (starts it on the
// first tick) so a big scan keeps running + stays visible when backgrounded.
void reportScanProgress(progress);
};
/** Shared scan wrapper: progress/error state + refresh, scans never overlap. */
const runScan = async (scan: () => Promise<ScanResult | null>) => {
@@ -127,6 +133,7 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
} finally {
await get().refresh();
set({ isScanning: false, scanProgress: { ...IDLE_PROGRESS } });
endScanService();
}
};
+53
View File
@@ -0,0 +1,53 @@
import { create } from 'zustand';
import { openLibraryDb } from '@/db/database';
import { getFolders, getSetting, setSetting } from '@/db/queries';
/**
* First-run wizard gate. SQLite (settings table) is the source of truth, mirrored
* in memory like the other pref stores. The wizard shows once on a fresh install
* and never again once `onboardingComplete` is persisted.
*/
const ONBOARDING_COMPLETE_KEY = 'onboarding_complete';
interface OnboardingStore {
onboardingComplete: boolean;
loaded: boolean;
load: () => Promise<void>;
markComplete: () => Promise<void>;
/** Dev affordance: re-arm the wizard (see Experimental settings). */
reset: () => Promise<void>;
}
export const useOnboardingStore = create<OnboardingStore>((set, get) => ({
onboardingComplete: false,
loaded: false,
load: async () => {
if (get().loaded) return;
const db = await openLibraryDb();
const value = await getSetting(db, ONBOARDING_COMPLETE_KEY);
if (value !== null) {
set({ onboardingComplete: value === 'true', loaded: true });
return;
}
// Flag never set: an install that already has library folders predates this
// wizard — treat it as onboarded (and persist) so the wizard never ambushes
// an upgrading user. A genuinely fresh install has no folders → show it.
const folders = await getFolders(db);
const complete = folders.length > 0;
if (complete) await setSetting(db, ONBOARDING_COMPLETE_KEY, 'true');
set({ onboardingComplete: complete, loaded: true });
},
markComplete: async () => {
set({ onboardingComplete: true });
const db = await openLibraryDb();
await setSetting(db, ONBOARDING_COMPLETE_KEY, 'true');
},
reset: async () => {
set({ onboardingComplete: false });
const db = await openLibraryDb();
await setSetting(db, ONBOARDING_COMPLETE_KEY, 'false');
},
}));