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
+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;