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