mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 12:14:47 +02:00
initial scaffold
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user