initial scaffold

This commit is contained in:
Boof2015
2026-05-25 21:54:43 -04:00
parent 40c2302d97
commit 9761975af7
40 changed files with 10797 additions and 1 deletions
+34
View File
@@ -0,0 +1,34 @@
import { Tabs } from 'expo-router';
import { TabBar, type TabItem } from '@/components/TabBar';
export default function TabsLayout() {
return (
<Tabs
screenOptions={{ headerShown: false }}
tabBar={({ state, navigation }) => {
const items: TabItem[] = state.routes.map((route, index) => ({
key: route.key,
name: route.name,
focused: state.index === index,
}));
const handlePress = (item: TabItem) => {
const event = navigation.emit({
type: 'tabPress',
target: item.key,
canPreventDefault: true,
});
if (!item.focused && !event.defaultPrevented) {
navigation.navigate(item.name);
}
};
return <TabBar items={items} onPress={handlePress} />;
}}
>
<Tabs.Screen name="index" />
<Tabs.Screen name="library" />
<Tabs.Screen name="eq" />
</Tabs>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { View, StyleSheet } from 'react-native';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import { useEQStore } from '@/stores/eqStore';
function formatFreq(hz: number): string {
return hz >= 1000 ? `${hz / 1000}k` : `${hz}`;
}
export default function EQScreen() {
const bands = useEQStore((s) => s.bands);
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>
))}
</View>
</Screen>
);
}
const styles = StyleSheet.create({
heading: {
marginTop: spacing.xl,
},
note: {
marginTop: spacing.sm,
marginBottom: spacing.xxl,
lineHeight: 20,
},
bands: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
},
band: {
flex: 1,
alignItems: 'center',
gap: spacing.sm,
},
track: {
width: 4,
height: 140,
borderRadius: radius.pill,
backgroundColor: colors.glassBorder,
alignItems: 'center',
justifyContent: 'center',
},
knob: {
width: 14,
height: 14,
borderRadius: radius.pill,
backgroundColor: colors.accent,
},
freq: {
color: colors.textSecondary,
},
});
+97
View File
@@ -0,0 +1,97 @@
import { View, Pressable, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
import { FormatBadges } from '@/components/FormatBadge';
import { colors, fonts, radius, spacing } from '@/theme';
import { playSample } from '@/audio/playbackController';
import { SAMPLE_TRACKS } from '@/audio/sampleTracks';
export default function HomeScreen() {
return (
<Screen>
<View style={styles.header}>
<AstraLogo size={36} />
<Text style={styles.wordmark}>ASTRA</Text>
</View>
<Text variant="label" style={styles.tagline}>
Audiophile player
</Text>
<View style={styles.card}>
<Text variant="heading">Quick start</Text>
<Text variant="body" color={colors.textSecondary} style={styles.cardBody}>
On-device library scanning lands next. For now, stream a test track to
verify playback, background audio, and lock-screen controls.
</Text>
<View style={styles.badges}>
<FormatBadges track={SAMPLE_TRACKS[0]} />
</View>
<Pressable
style={({ pressed }) => [styles.cta, pressed && styles.ctaPressed]}
onPress={() => {
void playSample();
}}
>
<Ionicons name="play" size={20} color={colors.bgPrimary} />
<Text style={styles.ctaText}>Play sample track</Text>
</Pressable>
</View>
</Screen>
);
}
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginTop: spacing.xl,
},
wordmark: {
fontFamily: fonts.sans.bold,
fontSize: 30,
letterSpacing: 6,
color: colors.textPrimary,
},
tagline: {
marginTop: spacing.xs,
letterSpacing: 1,
},
card: {
marginTop: spacing.xxl,
padding: spacing.lg,
borderRadius: radius.lg,
backgroundColor: colors.glassBg,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
},
cardBody: {
marginTop: spacing.sm,
lineHeight: 20,
},
badges: {
marginTop: spacing.md,
},
cta: {
marginTop: spacing.lg,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
backgroundColor: colors.accent,
paddingVertical: spacing.md,
borderRadius: radius.md,
},
ctaPressed: {
backgroundColor: colors.accentHover,
},
ctaText: {
fontFamily: fonts.sans.semibold,
fontSize: 15,
color: colors.bgPrimary,
},
});
+46
View File
@@ -0,0 +1,46 @@
import { View, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { colors, spacing } from '@/theme';
export default function LibraryScreen() {
return (
<Screen>
<Text variant="title" style={styles.heading}>
Library
</Text>
<View style={styles.empty}>
<Ionicons name="musical-notes-outline" size={48} color={colors.textTertiary} />
<Text variant="heading" style={styles.emptyTitle}>
No music yet
</Text>
<Text variant="body" color={colors.textSecondary} style={styles.emptyBody}>
On-device file scanning, metadata, and the SQLite library arrive in M1.
</Text>
</View>
</Screen>
);
}
const styles = StyleSheet.create({
heading: {
marginTop: spacing.xl,
marginBottom: spacing.lg,
},
empty: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
paddingBottom: spacing.xxl,
},
emptyTitle: {
marginTop: spacing.sm,
},
emptyBody: {
textAlign: 'center',
maxWidth: 280,
},
});
+71
View File
@@ -0,0 +1,71 @@
import { useEffect } from 'react';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import * as SplashScreen from 'expo-splash-screen';
import { useFonts } from 'expo-font';
import {
Inter_400Regular,
Inter_500Medium,
Inter_600SemiBold,
Inter_700Bold,
} from '@expo-google-fonts/inter';
import {
JetBrainsMono_400Regular,
JetBrainsMono_500Medium,
} from '@expo-google-fonts/jetbrains-mono';
import { usePlaybackSync } from '@/audio/usePlaybackSync';
import { colors } from '@/theme';
SplashScreen.preventAutoHideAsync();
/** Mirrors RNTP state into the player store. Renders nothing. */
function PlaybackSync() {
usePlaybackSync();
return null;
}
export default function RootLayout() {
const [fontsLoaded] = useFonts({
Inter_400Regular,
Inter_500Medium,
Inter_600SemiBold,
Inter_700Bold,
JetBrainsMono_400Regular,
JetBrainsMono_500Medium,
});
useEffect(() => {
if (fontsLoaded) {
void SplashScreen.hideAsync();
}
}, [fontsLoaded]);
if (!fontsLoaded) return null;
return (
<GestureHandlerRootView style={styles.root}>
<SafeAreaProvider>
<StatusBar style="light" />
<PlaybackSync />
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.bgPrimary },
}}
>
<Stack.Screen name="(tabs)" />
<Stack.Screen
name="now-playing"
options={{ presentation: 'modal', animation: 'slide_from_bottom' }}
/>
</Stack>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
const styles = {
root: { flex: 1, backgroundColor: colors.bgPrimary },
} as const;
+194
View File
@@ -0,0 +1,194 @@
import { View, Pressable, StyleSheet } from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
import { FormatBadges } from '@/components/FormatBadge';
import { colors, fonts, radius, spacing } from '@/theme';
import { usePlayerStore } from '@/stores/playerStore';
import { skipToNext, skipToPrevious, togglePlay } from '@/audio/playbackController';
function formatTime(seconds: number): string {
const safe = Number.isFinite(seconds) && seconds > 0 ? seconds : 0;
const m = Math.floor(safe / 60);
const s = Math.floor(safe % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
export default function NowPlayingScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const track = usePlayerStore((s) => s.currentTrack);
const playbackState = usePlayerStore((s) => s.playbackState);
const currentTime = usePlayerStore((s) => s.currentTime);
const duration = usePlayerStore((s) => s.duration);
const isPlaying = playbackState === 'playing';
const isLoading = playbackState === 'loading';
const progress = duration > 0 ? Math.min(1, currentTime / duration) : 0;
return (
<View
style={[
styles.root,
{ paddingTop: insets.top + spacing.sm, paddingBottom: insets.bottom + spacing.xl },
]}
>
<Pressable style={styles.close} onPress={() => router.back()} hitSlop={12}>
<Ionicons name="chevron-down" size={28} color={colors.textSecondary} />
</Pressable>
{track ? (
<>
<View style={styles.artWrap}>
<View style={styles.art}>
{track.artworkData ? (
<Image source={{ uri: track.artworkData }} style={styles.artImage} contentFit="cover" />
) : (
<AstraLogo size={104} />
)}
</View>
</View>
<View style={styles.meta}>
<Text variant="heading" numberOfLines={2}>
{track.title}
</Text>
<Text
variant="body"
color={colors.textSecondary}
numberOfLines={1}
style={styles.subtitle}
>
{track.artist}
{track.album ? ` · ${track.album}` : ''}
</Text>
<View style={styles.badges}>
<FormatBadges track={track} />
</View>
</View>
<View style={styles.progressBlock}>
<View style={styles.progressTrack}>
<View style={[styles.progressFill, { width: `${progress * 100}%` }]} />
</View>
<View style={styles.times}>
<Text variant="mono" style={styles.time}>
{formatTime(currentTime)}
</Text>
<Text variant="mono" style={styles.time}>
{formatTime(duration)}
</Text>
</View>
</View>
<View style={styles.transport}>
<Pressable onPress={skipToPrevious} hitSlop={12}>
<Ionicons name="play-skip-back" size={34} color={colors.textPrimary} />
</Pressable>
<Pressable onPress={togglePlay} hitSlop={12} style={styles.playButton}>
<Ionicons
name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
size={36}
color={colors.bgPrimary}
/>
</Pressable>
<Pressable onPress={skipToNext} hitSlop={12}>
<Ionicons name="play-skip-forward" size={34} color={colors.textPrimary} />
</Pressable>
</View>
</>
) : (
<View style={styles.empty}>
<Text variant="heading">Nothing playing</Text>
<Text variant="body" color={colors.textSecondary} style={styles.subtitle}>
Start a track from Home.
</Text>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: colors.bgPrimary,
paddingHorizontal: spacing.xl,
},
close: {
alignSelf: 'flex-start',
padding: spacing.xs,
},
artWrap: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
art: {
width: 260,
height: 260,
borderRadius: radius.lg,
backgroundColor: colors.bgTertiary,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
artImage: {
width: '100%',
height: '100%',
},
meta: {
marginTop: spacing.xl,
},
subtitle: {
marginTop: spacing.xs,
},
badges: {
marginTop: spacing.md,
},
progressBlock: {
marginTop: spacing.xl,
},
progressTrack: {
height: 4,
borderRadius: radius.pill,
backgroundColor: colors.glassBorder,
overflow: 'hidden',
},
progressFill: {
height: 4,
borderRadius: radius.pill,
backgroundColor: colors.accent,
},
times: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: spacing.xs,
},
time: {
color: colors.textTertiary,
},
transport: {
marginTop: spacing.xl,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xxl,
},
playButton: {
width: 72,
height: 72,
borderRadius: radius.pill,
backgroundColor: colors.accent,
alignItems: 'center',
justifyContent: 'center',
},
empty: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
});