mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-18 19:54:26 +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',
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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() }),
|
||||
}));
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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; // 0–1
|
||||
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 }),
|
||||
}));
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user