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',
},
});
+59
View File
@@ -0,0 +1,59 @@
import TrackPlayer, { isPlaying } from 'react-native-track-player';
import { setupPlayer } from './trackPlayer';
import { SAMPLE_TRACKS, toRntpTrack } from './sampleTracks';
/**
* Transport actions screens call. Thin wrappers over RNTP so the UI never
* imports the engine directly — at M3/M4 this is where a custom Media3 module
* would slot in behind the same function signatures.
*/
/**
* Set up the player and load the demo queue. Setup is deferred to here (a
* user-initiated play) rather than app launch: RNTP starts a foreground
* MediaSession service on setup, and Android only permits starting a foreground
* service while the app is in the foreground.
*/
async function ensurePlayerReady(): Promise<void> {
await setupPlayer();
const queue = await TrackPlayer.getQueue();
if (queue.length === 0) {
await TrackPlayer.add(SAMPLE_TRACKS.map(toRntpTrack));
}
}
/** M0 entry point: ensure the player is ready, then start playback. */
export async function playSample(): Promise<void> {
await ensurePlayerReady();
await TrackPlayer.play();
}
export const play = (): Promise<void> => TrackPlayer.play();
export const pause = (): Promise<void> => TrackPlayer.pause();
export const seekTo = (seconds: number): Promise<void> => TrackPlayer.seekTo(seconds);
export async function togglePlay(): Promise<void> {
const { playing } = await isPlaying();
if (playing) {
await TrackPlayer.pause();
} else {
await ensurePlayerReady();
await TrackPlayer.play();
}
}
export async function skipToNext(): Promise<void> {
try {
await TrackPlayer.skipToNext();
} catch {
// no next track — ignore
}
}
export async function skipToPrevious(): Promise<void> {
try {
await TrackPlayer.skipToPrevious();
} catch {
// no previous track — ignore
}
}
+21
View File
@@ -0,0 +1,21 @@
import TrackPlayer, { Event } from 'react-native-track-player';
/**
* RNTP playback service — registered in `index.js`. Runs in a headless context
* and wires MediaSession / lock-screen / notification / Bluetooth remote
* controls to the player. Must not depend on React or the JS UI tree.
*/
export async function PlaybackService(): Promise<void> {
TrackPlayer.addEventListener(Event.RemotePlay, () => TrackPlayer.play());
TrackPlayer.addEventListener(Event.RemotePause, () => TrackPlayer.pause());
TrackPlayer.addEventListener(Event.RemoteStop, () => TrackPlayer.stop());
TrackPlayer.addEventListener(Event.RemoteNext, () =>
TrackPlayer.skipToNext().catch(() => {}),
);
TrackPlayer.addEventListener(Event.RemotePrevious, () =>
TrackPlayer.skipToPrevious().catch(() => {}),
);
TrackPlayer.addEventListener(Event.RemoteSeek, ({ position }) =>
TrackPlayer.seekTo(position),
);
}
+71
View File
@@ -0,0 +1,71 @@
import type { Track as RntpTrack } from 'react-native-track-player';
import type { Track } from '@/types/audio';
/**
* M0 verification tracks. Streamed from a public royalty-free source so playback
* works on a fresh emulator before on-device file scanning (M1) exists. These
* also exercise the streaming/queue path we reuse for Subsonic/Jellyfin (M5).
*/
export const SAMPLE_TRACKS: Track[] = [
{
id: 'sample-1',
path: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
title: 'SoundHelix Song 1',
artist: 'T. Schürger',
album: 'Astra Test Tones',
duration: 372,
format: 'MP3',
sampleRate: 44100,
bitrate: 320000,
channels: 2,
sourceType: 'local',
},
{
id: 'sample-2',
path: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3',
title: 'SoundHelix Song 2',
artist: 'T. Schürger',
album: 'Astra Test Tones',
duration: 426,
format: 'MP3',
sampleRate: 44100,
bitrate: 320000,
channels: 2,
sourceType: 'local',
},
];
/** Map an Astra Track to an RNTP track, carrying audiophile metadata as custom fields. */
export function toRntpTrack(track: Track): RntpTrack {
return {
id: track.id,
url: track.path,
title: track.title,
artist: track.artist,
album: track.album,
artwork: track.artworkData,
duration: track.duration,
// Custom fields preserved by RNTP and read back in `rntpToTrack`.
format: track.format,
sampleRate: track.sampleRate,
bitDepth: track.bitDepth,
bitrate: track.bitrate,
};
}
/** Reconstruct an Astra Track from the active RNTP track (for the player store). */
export function rntpToTrack(rt: RntpTrack): Track {
return {
id: String(rt.id ?? rt.url),
path: String(rt.url),
title: rt.title ?? 'Unknown title',
artist: rt.artist ?? 'Unknown artist',
album: rt.album ?? '',
duration: typeof rt.duration === 'number' ? rt.duration : 0,
artworkData: typeof rt.artwork === 'string' ? rt.artwork : undefined,
format: (rt.format as string) ?? 'PCM',
sampleRate: rt.sampleRate as number | undefined,
bitDepth: rt.bitDepth as number | undefined,
bitrate: rt.bitrate as number | undefined,
};
}
+50
View File
@@ -0,0 +1,50 @@
import TrackPlayer, {
AppKilledPlaybackBehavior,
Capability,
} from 'react-native-track-player';
/**
* Idempotent RNTP setup. RNTP runs on Media3/ExoPlayer under the hood, which
* gives us MediaSession (lock screen / notification / Bluetooth / Android Auto)
* and background playback. The custom Media3 AudioProcessor chain (EQ + PCM
* scope tap) lands at M3/M4 behind this same module.
*/
let setupPromise: Promise<void> | null = null;
export function setupPlayer(): Promise<void> {
if (!setupPromise) {
setupPromise = doSetup().catch((err) => {
setupPromise = null; // allow a retry on a genuine failure
throw err;
});
}
return setupPromise;
}
async function doSetup(): Promise<void> {
try {
await TrackPlayer.setupPlayer({ autoHandleInterruptions: true });
} catch (err) {
// setupPlayer rejects if the player was already initialized (e.g. across a
// Fast Refresh). That case is safe to ignore; anything else should surface.
const message = err instanceof Error ? err.message : String(err);
if (!/already.*initialized/i.test(message)) throw err;
}
await TrackPlayer.updateOptions({
android: {
appKilledPlaybackBehavior:
AppKilledPlaybackBehavior.StopPlaybackAndRemoveNotification,
},
capabilities: [
Capability.Play,
Capability.Pause,
Capability.Stop,
Capability.SeekTo,
Capability.SkipToNext,
Capability.SkipToPrevious,
],
compactCapabilities: [Capability.Play, Capability.Pause, Capability.SkipToNext],
progressUpdateEventInterval: 1,
});
}
+51
View File
@@ -0,0 +1,51 @@
import { useEffect } from 'react';
import {
State,
useActiveTrack,
usePlaybackState,
useProgress,
} from 'react-native-track-player';
import { usePlayerStore } from '@/stores/playerStore';
import type { PlaybackState } from '@/types/audio';
import { rntpToTrack } from './sampleTracks';
function mapState(state?: State): PlaybackState {
switch (state) {
case State.Playing:
return 'playing';
case State.Buffering:
case State.Loading:
return 'loading';
case State.Paused:
case State.Ready:
return 'paused';
default:
return 'stopped'; // None, Stopped, Ended, Error
}
}
/**
* Mirrors RNTP's playback state into `playerStore` so the whole UI reads from
* one Zustand source (matching the desktop pattern). Mount once, near the root.
*/
export function usePlaybackSync(): void {
const activeTrack = useActiveTrack();
const progress = useProgress(500);
const playbackState = usePlaybackState();
const setCurrentTrack = usePlayerStore((s) => s.setCurrentTrack);
const setProgress = usePlayerStore((s) => s.setProgress);
const setPlaybackState = usePlayerStore((s) => s.setPlaybackState);
useEffect(() => {
setCurrentTrack(activeTrack ? rntpToTrack(activeTrack) : null);
}, [activeTrack, setCurrentTrack]);
useEffect(() => {
setProgress(progress.position, progress.duration);
}, [progress.position, progress.duration, setProgress]);
useEffect(() => {
setPlaybackState(mapState(playbackState.state));
}, [playbackState.state, setPlaybackState]);
}
+57
View File
@@ -0,0 +1,57 @@
import Svg, { G, Path } from 'react-native-svg';
import { colors } from '@/theme';
/**
* Astra mark — ported from desktop `astraLogoShared.ts` (same viewBox, paths,
* transforms). The desktop CSS fills `hsl(198 100% 50%)` / `hsl(198 40% 14%)`
* resolve to the hex tokens in the theme.
*/
const VIEWBOX = '0 0 1024 1024';
const BG_TRANSFORM = 'matrix(0.784074,0,0,0.973384,-34.499234,-27.254753)';
const SHADOW_TRANSFORM = 'matrix(1.726813,0,0,1.726813,-608.701518,-379.851382)';
const SHADOW_LEFT_TRANSFORM = 'matrix(1,0,0,1,-10,3)';
const SHADOW_RIGHT_TRANSFORM = 'matrix(1,0,0,1,0,3)';
const MAIN_TRANSFORM = 'matrix(1.726813,0,0,1.726813,-660.505902,-397.11951)';
const BG_PATH =
'M1286.23,28C1321.449,28 1350,50.998 1350,79.367L1350,1028.633C1350,1057.002 1321.449,1080 1286.23,1080L107.77,1080C72.551,1080 44,1057.002 44,1028.633L44,79.367C44,50.998 72.551,28 107.77,28L1286.23,28Z';
const LEFT_PATH =
'M526.083,500.65C529.86,496.662 535.112,494.402 540.605,494.402C553.071,494.402 576.056,494.402 588.831,494.402C594.652,494.402 600.185,496.939 603.984,501.35C610.054,508.396 619.61,519.49 627.207,528.31C633.905,536.085 633.631,547.668 626.573,555.117C603.295,579.689 553.937,631.788 536.916,649.755C533.139,653.742 527.889,656 522.397,656L452,656C440.954,656 432,647.046 432,636C432,626.32 432,615.247 432,607.967C432,602.851 433.96,597.93 437.478,594.215C454.783,575.942 508.184,519.551 526.083,500.65Z';
const RIGHT_PATH =
'M580,389.237C580,378.578 588.641,369.937 599.3,369.937C625.097,369.937 669.782,369.937 688.899,369.937C694.682,369.937 700.183,372.436 703.987,376.792C736.676,414.222 893.163,593.401 921.571,625.929C924.427,629.198 926,633.392 926,637.733C926,637.733 926,637.734 926,637.734C926,648.379 917.371,657.008 906.726,657.008L817.1,657.008C811.318,657.008 805.817,654.51 802.013,650.155C769.332,612.742 612.909,433.673 584.448,401.092C581.58,397.809 580,393.598 580,389.239C580,389.238 580,389.237 580,389.237Z';
interface AstraLogoProps {
size?: number;
color?: string;
includeBackground?: boolean;
}
export function AstraLogo({
size = 28,
color = colors.logoMain,
includeBackground = false,
}: AstraLogoProps) {
return (
<Svg width={size} height={size} viewBox={VIEWBOX} fill="none">
{includeBackground && (
<G transform={BG_TRANSFORM}>
<Path d={BG_PATH} fill={colors.logoBackdrop} />
</G>
)}
<G transform={SHADOW_TRANSFORM}>
<G transform={SHADOW_LEFT_TRANSFORM}>
<Path d={LEFT_PATH} fill={colors.logoShadow} />
</G>
<G transform={SHADOW_RIGHT_TRANSFORM}>
<Path d={RIGHT_PATH} fill={colors.logoShadow} />
</G>
</G>
<G transform={MAIN_TRANSFORM}>
<Path d={LEFT_PATH} fill={color} />
<Path d={RIGHT_PATH} fill={color} />
</G>
</Svg>
);
}
export default AstraLogo;
+63
View File
@@ -0,0 +1,63 @@
import { View, StyleSheet } from 'react-native';
import { Text } from './Text';
import { colors, radius, spacing } from '@/theme';
import type { Track } from '@/types/audio';
/** A single mono pill (e.g. "FLAC", "24-BIT", "48.0 kHz"). */
export function Badge({ label }: { label: string }) {
return (
<View style={styles.badge}>
<Text variant="mono" style={styles.text}>
{label}
</Text>
</View>
);
}
/**
* Format badge row for a track. Mirrors desktop `TrackList.tsx`:
* `format.toUpperCase()` and `${(sampleRate / 1000).toFixed(1)} kHz`.
*/
export function FormatBadges({
track,
}: {
track: Pick<Track, 'format' | 'bitDepth' | 'sampleRate'>;
}) {
const labels: string[] = [];
if (track.format) labels.push(track.format.toUpperCase());
if (track.bitDepth) labels.push(`${track.bitDepth}-BIT`);
if (track.sampleRate) labels.push(`${(track.sampleRate / 1000).toFixed(1)} kHz`);
if (labels.length === 0) return null;
return (
<View style={styles.row}>
{labels.map((label) => (
<Badge key={label} label={label} />
))}
</View>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.xs,
},
badge: {
backgroundColor: colors.glassBg,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.sm,
paddingHorizontal: spacing.sm,
paddingVertical: 3,
},
text: {
color: colors.accentText,
fontSize: 10,
letterSpacing: 0.5,
},
});
export default FormatBadges;
+114
View File
@@ -0,0 +1,114 @@
import { View, Pressable, StyleSheet } from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { Text } from './Text';
import { AstraLogo } from './AstraLogo';
import { colors, layout, radius, spacing } from '@/theme';
import { usePlayerStore } from '@/stores/playerStore';
import { togglePlay } from '@/audio/playbackController';
/**
* Persistent mini-player, rendered above the tab bar. Tapping the bar opens the
* full now-playing screen. The artwork box is where the spectrum "pulse"
* is-playing indicator will live at M3.
*/
export function MiniPlayer() {
const router = useRouter();
const track = usePlayerStore((s) => s.currentTrack);
const playbackState = usePlayerStore((s) => s.playbackState);
const currentTime = usePlayerStore((s) => s.currentTime);
const duration = usePlayerStore((s) => s.duration);
if (!track) return null;
const isPlaying = playbackState === 'playing';
const isLoading = playbackState === 'loading';
const progress = duration > 0 ? Math.min(1, currentTime / duration) : 0;
return (
<View style={styles.container}>
<View style={styles.progressTrack}>
<View style={[styles.progressFill, { width: `${progress * 100}%` }]} />
</View>
<Pressable style={styles.row} onPress={() => router.push('/now-playing')}>
<View style={styles.art}>
{track.artworkData ? (
<Image source={{ uri: track.artworkData }} style={styles.artImage} contentFit="cover" />
) : (
<AstraLogo size={22} />
)}
</View>
<View style={styles.meta}>
<Text variant="body" numberOfLines={1} style={styles.title}>
{track.title}
</Text>
<Text variant="label" numberOfLines={1}>
{track.artist}
</Text>
</View>
<Pressable hitSlop={12} onPress={togglePlay} style={styles.playButton}>
<Ionicons
name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
size={26}
color={colors.accent}
/>
</Pressable>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: {
height: layout.miniPlayerHeight,
backgroundColor: colors.bgSecondary,
borderTopColor: colors.glassBorder,
borderTopWidth: StyleSheet.hairlineWidth,
},
progressTrack: {
height: 2,
backgroundColor: colors.glassBorder,
},
progressFill: {
height: 2,
backgroundColor: colors.accent,
},
row: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
gap: spacing.md,
},
art: {
width: 44,
height: 44,
borderRadius: radius.sm,
backgroundColor: colors.bgTertiary,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
artImage: {
width: '100%',
height: '100%',
},
meta: {
flex: 1,
},
title: {
fontSize: 15,
},
playButton: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
});
export default MiniPlayer;
+33
View File
@@ -0,0 +1,33 @@
import { View, StyleSheet, type ViewProps } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { colors, spacing } from '@/theme';
interface ScreenProps extends ViewProps {
/** Apply default horizontal padding. */
padded?: boolean;
}
/** Base screen container: black background + top safe-area inset. */
export function Screen({ children, style, padded = true, ...rest }: ScreenProps) {
const insets = useSafeAreaInsets();
return (
<View style={[styles.root, { paddingTop: insets.top }, style]} {...rest}>
<View style={[styles.inner, padded && styles.padded]}>{children}</View>
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: colors.bgPrimary,
},
inner: {
flex: 1,
},
padded: {
paddingHorizontal: spacing.lg,
},
});
export default Screen;
+90
View File
@@ -0,0 +1,90 @@
import { View, Pressable, StyleSheet } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { Text } from './Text';
import { MiniPlayer } from './MiniPlayer';
import { colors, layout, spacing } from '@/theme';
type IconName = keyof typeof Ionicons.glyphMap;
export const TAB_META: Record<string, { label: string; icon: IconName }> = {
index: { label: 'Home', icon: 'home' },
library: { label: 'Library', icon: 'musical-notes' },
eq: { label: 'EQ', icon: 'options' },
};
export interface TabItem {
key: string;
name: string;
focused: boolean;
}
interface TabBarProps {
items: TabItem[];
onPress: (item: TabItem) => void;
}
/**
* Astra bottom tab bar with the persistent mini-player glued above it.
* Receives plain props (no react-navigation types) so the typed navigation
* logic stays in the layout's `tabBar` callback.
*/
export function TabBar({ items, onPress }: TabBarProps) {
const insets = useSafeAreaInsets();
return (
<View style={styles.wrap}>
<MiniPlayer />
<View
style={[
styles.bar,
{ paddingBottom: insets.bottom, height: layout.tabBarHeight + insets.bottom },
]}
>
{items.map((item) => {
const meta = TAB_META[item.name];
if (!meta) return null;
const color = item.focused ? colors.accent : colors.textTertiary;
return (
<Pressable
key={item.key}
style={styles.tab}
onPress={() => onPress(item)}
hitSlop={8}
accessibilityRole="tab"
accessibilityState={{ selected: item.focused }}
>
<Ionicons name={meta.icon} size={22} color={color} />
<Text variant="caption" style={[styles.label, { color }]}>
{meta.label}
</Text>
</Pressable>
);
})}
</View>
</View>
);
}
const styles = StyleSheet.create({
wrap: {
backgroundColor: colors.bgSecondary,
},
bar: {
flexDirection: 'row',
backgroundColor: colors.bgSecondary,
borderTopColor: colors.glassBorder,
borderTopWidth: StyleSheet.hairlineWidth,
},
tab: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingTop: spacing.sm,
},
label: {
marginTop: 2,
fontSize: 10,
},
});
export default TabBar;
+54
View File
@@ -0,0 +1,54 @@
import { Text as RNText, type TextProps as RNTextProps, StyleSheet } from 'react-native';
import { colors, fonts, fontSize } from '@/theme';
type Variant = 'title' | 'heading' | 'body' | 'label' | 'caption' | 'mono';
interface TextProps extends RNTextProps {
variant?: Variant;
color?: string;
}
/** Themed Text — applies Astra fonts/colors. Import this instead of RN's Text. */
export function Text({ variant = 'body', color, style, ...rest }: TextProps) {
return (
<RNText
style={[styles[variant], color ? { color } : null, style]}
{...rest}
/>
);
}
const styles = StyleSheet.create({
title: {
fontFamily: fonts.sans.bold,
fontSize: fontSize.xxl,
color: colors.textPrimary,
},
heading: {
fontFamily: fonts.sans.semibold,
fontSize: fontSize.lg,
color: colors.textPrimary,
},
body: {
fontFamily: fonts.sans.regular,
fontSize: fontSize.base,
color: colors.textPrimary,
},
label: {
fontFamily: fonts.sans.medium,
fontSize: fontSize.sm,
color: colors.textSecondary,
},
caption: {
fontFamily: fonts.sans.regular,
fontSize: fontSize.xs,
color: colors.textTertiary,
},
mono: {
fontFamily: fonts.mono.regular,
fontSize: fontSize.sm,
color: colors.textSecondary,
},
});
export default Text;
+44
View File
@@ -0,0 +1,44 @@
import { create } from 'zustand';
import type { EQBand } from '@/types/audio';
/**
* EQ state — M0 stub. The biquad chain is implemented as a Media3 AudioProcessor
* at M4; for now this just holds the band model (ported `EQBand`) so the EQ
* screen and later DSP wiring share one shape.
*/
const DEFAULT_FREQUENCIES = [32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
function makeDefaultBands(): EQBand[] {
return DEFAULT_FREQUENCIES.map((frequency) => ({
id: `band-${frequency}`,
type: 'peaking',
frequency,
gain: 0,
Q: 1.0,
}));
}
interface EQStore {
enabled: boolean;
preamp: number; // dB
bands: EQBand[];
setEnabled: (enabled: boolean) => void;
setPreamp: (preamp: number) => void;
setBandGain: (id: string, gain: number) => void;
reset: () => void;
}
export const useEQStore = create<EQStore>((set) => ({
enabled: false,
preamp: 0,
bands: makeDefaultBands(),
setEnabled: (enabled) => set({ enabled }),
setPreamp: (preamp) => set({ preamp }),
setBandGain: (id, gain) =>
set((state) => ({
bands: state.bands.map((b) => (b.id === id ? { ...b, gain } : b)),
})),
reset: () => set({ enabled: false, preamp: 0, bands: makeDefaultBands() }),
}));
+30
View File
@@ -0,0 +1,30 @@
import { create } from 'zustand';
import type { Album, Artist, DbTrack } from '@/types/library';
/**
* Library state — minimal M0 stub. Real on-device scanning + SQLite (op-sqlite)
* land at M1; the shapes here mirror desktop `libraryStore` so that work slots in.
*/
type ViewMode = 'tracks' | 'albums' | 'artists' | 'folders';
interface LibraryStore {
tracks: DbTrack[];
albums: Album[];
artists: Artist[];
totalTrackCount: number;
viewMode: ViewMode;
isScanning: boolean;
setViewMode: (mode: ViewMode) => void;
}
export const useLibraryStore = create<LibraryStore>((set) => ({
tracks: [],
albums: [],
artists: [],
totalTrackCount: 0,
viewMode: 'albums',
isScanning: false,
setViewMode: (viewMode) => set({ viewMode }),
}));
+41
View File
@@ -0,0 +1,41 @@
import { create } from 'zustand';
import type { PlaybackState, Track } from '@/types/audio';
/**
* Player state — the UI's single source of truth, mirrored from the playback
* engine (RNTP at M0) by `usePlaybackSync`. Field names match desktop
* `playerStore` so queue/transport logic ports cleanly. Setters are called by
* the sync layer and the playback controller, not directly by screens.
*/
interface PlayerStore {
currentTrack: Track | null;
playbackState: PlaybackState;
currentTime: number;
duration: number;
volume: number; // 01
isMuted: boolean;
setCurrentTrack: (track: Track | null) => void;
setPlaybackState: (state: PlaybackState) => void;
setProgress: (currentTime: number, duration: number) => void;
setVolume: (volume: number) => void;
setMuted: (isMuted: boolean) => void;
reset: () => void;
}
export const usePlayerStore = create<PlayerStore>((set) => ({
currentTrack: null,
playbackState: 'stopped',
currentTime: 0,
duration: 0,
volume: 1,
isMuted: false,
setCurrentTrack: (currentTrack) => set({ currentTrack }),
setPlaybackState: (playbackState) => set({ playbackState }),
setProgress: (currentTime, duration) => set({ currentTime, duration }),
setVolume: (volume) => set({ volume }),
setMuted: (isMuted) => set({ isMuted }),
reset: () =>
set({ currentTrack: null, playbackState: 'stopped', currentTime: 0, duration: 0 }),
}));
+34
View File
@@ -0,0 +1,34 @@
/**
* Astra color tokens — ported from desktop `src/renderer/styles/globals.css`.
* Dark-only on mobile (the desktop app is dark-only too).
*/
export const colors = {
// Base backgrounds
bgPrimary: '#000000',
bgSecondary: '#050505',
bgTertiary: '#0a0a0a',
// Glass / surface overlays (white alphas)
glassBg: 'rgba(255, 255, 255, 0.03)',
glassBorder: 'rgba(255, 255, 255, 0.08)',
glassHighlight: 'rgba(255, 255, 255, 0.05)',
// Text (white alphas)
textPrimary: 'rgba(255, 255, 255, 0.95)',
textSecondary: 'rgba(255, 255, 255, 0.6)',
textTertiary: 'rgba(255, 255, 255, 0.4)',
// Cyan accent
accent: '#38bdf8',
accentHover: '#7dd3fc',
accentGlow: 'rgba(56, 189, 248, 0.3)',
accentText: '#bae6fd',
accentTextStrong: '#e0f2fe',
// Astra mark fills (hsl(198 …) from the desktop logo)
logoMain: '#00b3ff', // hsl(198 100% 50%)
logoShadow: '#152932', // hsl(198 40% 14%)
logoBackdrop: '#05070a',
} as const;
export type ColorToken = keyof typeof colors;
+18
View File
@@ -0,0 +1,18 @@
import { colors } from './colors';
import { fonts, fontSize, lineHeight } from './typography';
import { spacing, radius, layout, durations } from './spacing';
export const theme = {
colors,
fonts,
fontSize,
lineHeight,
spacing,
radius,
layout,
durations,
} as const;
export type Theme = typeof theme;
export { colors, fonts, fontSize, lineHeight, spacing, radius, layout, durations };
+31
View File
@@ -0,0 +1,31 @@
/**
* Spacing, radius, and layout tokens.
* Radii match desktop (`--radius-sm/md/lg`); layout dims are adapted for mobile.
*/
export const spacing = {
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 24,
xxl: 32,
} as const;
export const radius = {
sm: 6,
md: 10,
lg: 16,
pill: 999,
} as const;
export const layout = {
/** Persistent mini-player bar height (desktop now-playing bar is 112px; trimmed for phone). */
miniPlayerHeight: 64,
/** Bottom tab bar content height (excludes safe-area inset, which is added on top). */
tabBarHeight: 56,
} as const;
export const durations = {
fast: 150,
normal: 250,
} as const;
+34
View File
@@ -0,0 +1,34 @@
/**
* Astra typography tokens.
* Inter (UI) + JetBrains Mono (numerics / format badges), mirroring desktop.
* Family strings match the names exposed by @expo-google-fonts packages and
* loaded via `useFonts` in the root layout.
*/
export const fonts = {
sans: {
regular: 'Inter_400Regular',
medium: 'Inter_500Medium',
semibold: 'Inter_600SemiBold',
bold: 'Inter_700Bold',
},
mono: {
regular: 'JetBrainsMono_400Regular',
medium: 'JetBrainsMono_500Medium',
},
} as const;
/** Font sizes — base 14 like desktop, scaled up where mobile reading distance needs it. */
export const fontSize = {
xs: 11,
sm: 12,
base: 14,
md: 16,
lg: 20,
xl: 26,
xxl: 34,
} as const;
export const lineHeight = {
tight: 1.2,
normal: 1.5,
} as const;
+78
View File
@@ -0,0 +1,78 @@
// Core audio types — ported from desktop `src/renderer/types/audio.ts`.
// Kept field-for-field so desktop logic (queue, EQ, library) ports cleanly later.
// Track metadata
export interface Track {
id: string;
path: string;
origin?: 'library' | 'associated-external';
title: string;
artist: string;
artistNames?: string[];
album: string;
albumArtist?: string;
albumArtistNames?: string[];
albumIdentityKey?: string;
duration: number;
trackNumber?: number;
discNumber?: number;
year?: number;
genre?: string;
artworkData?: string; // Base64 data URL (for files opened directly)
artworkHash?: string; // Hash for cached artwork (for library tracks)
format: string;
sampleRate?: number;
bitDepth?: number;
bitrate?: number;
channels?: number;
codec?: string;
codecProfile?: string;
isAtmosJoc?: boolean;
replayGainTrackDb?: number;
replayGainAlbumDb?: number;
sourceType?: 'local' | 'subsonic' | 'jellyfin';
sourceId?: number;
sourceTrackId?: string;
sourcePath?: string;
isAvailable?: boolean;
availabilityReason?: string;
}
// Playback state
export type PlaybackState = 'stopped' | 'playing' | 'paused' | 'loading';
// Player store state (subset realized at M0; expands toward desktop PlayerStore)
export interface PlayerState {
currentTrack: Track | null;
playbackState: PlaybackState;
currentTime: number;
duration: number;
volume: number;
isMuted: boolean;
}
// EQ Band
export interface EQBand {
id: string;
type: 'lowshelf' | 'peaking' | 'highshelf' | 'highpass' | 'lowpass';
frequency: number;
gain: number;
Q: number;
}
// EQ Preset
export interface EQPreset {
id: string;
name: string;
bands: EQBand[];
preamp: number;
isCustom?: boolean;
}
// Visualizer config (scopes land at M3)
export interface VisualizerConfig {
type: 'oscilloscope' | 'spectrum' | 'spectrogram' | 'vu' | 'loudness' | 'stereo';
fftSize: 1024 | 2048 | 4096 | 8192 | 16384;
pitchLock?: boolean;
scale?: 'linear' | 'log' | 'mel';
}
+42
View File
@@ -0,0 +1,42 @@
// Library row types — a mobile subset of desktop `DbTrack` (libraryStore.ts).
// SQLite-backed scanning lands at M1; this shape lets the store/UI be typed now.
export type TrackSourceType = 'local' | 'subsonic' | 'jellyfin';
export interface DbTrack {
id: number;
path: string;
title: string;
artist: string;
album: string;
album_artist: string | null;
duration: number;
track_number: number | null;
disc_number: number | null;
year: number | null;
genre: string | null;
artwork_hash: string | null;
format: string;
sample_rate: number | null;
bit_depth: number | null;
bitrate: number | null;
channels: number | null;
codec: string | null;
source_type: TrackSourceType;
added_at: number;
}
export interface Album {
identity_key: string;
album: string;
artist: string;
year: number | null;
artwork_hash: string | null;
track_count: number;
}
export interface Artist {
artist: string;
track_count: number;
artwork_hash: string | null;
}