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
+43
View File
@@ -0,0 +1,43 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
example
# generated native folders
/ios
/android
+42 -1
View File
@@ -1 +1,42 @@
# astra-mobile # Astra Mobile
A standalone, Android-first audiophile music player — the mobile counterpart to desktop [Astra](../astra), built as its own independent app (no pairing, no desktop dependency). See [DESIGN.md](DESIGN.md) for the full direction and roadmap.
## Status: M0 — Skeleton ✅
The foundational app runs end-to-end on Android:
- **Design system** ported from desktop Astra — black base, cyan accent (`#38bdf8`), Inter + JetBrains Mono, the `ASTRA` wordmark, and `FLAC`/`24-bit`/`48.0 kHz` format badges (`src/theme`, `src/components`).
- **Navigation shell** — bottom tabs (Home / Library / EQ) with a persistent **mini-player** glued above the bar, plus a swipe-up **now-playing** modal (`src/app`).
- **Playback** via [`react-native-track-player`](https://rntp.dev) (Media3/ExoPlayer) — verified playing on an emulator with a live MediaSession (lock-screen / notification / Bluetooth / Android Auto controls), background playback, and live progress (`src/audio`).
- **State** — Zustand stores mirroring desktop shapes (`src/stores`), typed with ported core models (`src/types`).
Library scanning, scopes, EQ DSP, remote sources, and Last.fm are later milestones (M1M7 in DESIGN.md).
## Stack
Expo SDK 56 · React Native 0.85 (New Architecture / bridgeless) · expo-router · TypeScript · Zustand · react-native-track-player · react-native-svg.
## Prerequisites
- Node 20.19+/22.13+/24.3+ (Node 23 works but emits engine warnings)
- JDK 17, Android SDK + an emulator/device (`ANDROID_HOME` exported)
## Run
```bash
npm install # also applies patches (see below) via postinstall
npm run android # = expo run:android — builds a dev client, installs, launches
```
The first native build downloads Gradle + the NDK/CMake and takes several minutes; subsequent builds are seconds. `npm run typecheck` runs `tsc --noEmit`.
## Patches — RNTP on the New Architecture
`react-native-track-player@4.1.2` predates RN 0.85's bridgeless-only requirement, so it needs three source fixes, captured in `patches/react-native-track-player+4.1.2.patch` and applied automatically by `patch-package` on install:
1. **Compile**`Arguments.fromBundle()` now requires a non-null `Bundle` → null-safe calls.
2. **TurboModule registration**`@ReactMethod` functions written as `= scope.launch { … }` return `Job`, which the new-arch interop rejects (non-void ⇒ treated as synchronous) → converted to block bodies returning `Unit`.
3. **Event emit (bridgeless)**`MusicService.emit/emitList` used the legacy `reactNativeHost.reactInstanceManager`, which throws under the New Architecture → switched to `reactHost.currentReactContext`.
Additionally, player setup is deferred to the first play action (a foreground gesture) rather than app launch, because RNTP starts a foreground MediaSession service on setup and Android only permits that while the app is foregrounded (`src/audio/playbackController.ts`).
+49
View File
@@ -0,0 +1,49 @@
{
"expo": {
"name": "Astra",
"slug": "astra-mobile",
"version": "0.1.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "astra",
"userInterfaceStyle": "dark",
"backgroundColor": "#000000",
"ios": {
"supportsTablet": true
},
"android": {
"package": "com.astra.mobile",
"backgroundColor": "#000000",
"adaptiveIcon": {
"backgroundColor": "#000000",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": false
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
"expo-asset",
[
"expo-splash-screen",
{
"backgroundColor": "#000000",
"android": {
"image": "./assets/images/splash-icon.png",
"imageWidth": 96,
"backgroundColor": "#000000"
}
}
]
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 780 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

+8
View File
@@ -0,0 +1,8 @@
// App entry. Loads expo-router, then registers the RNTP playback service so
// MediaSession / lock-screen / Bluetooth remote controls work even when the
// JS UI isn't mounted (headless).
import 'expo-router/entry';
import TrackPlayer from 'react-native-track-player';
import { PlaybackService } from './src/audio/playbackService';
TrackPlayer.registerPlaybackService(() => PlaybackService);
+8627
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
{
"name": "astra-mobile",
"main": "index.js",
"version": "0.1.0",
"dependencies": {
"@expo-google-fonts/inter": "^0.4.2",
"@expo-google-fonts/jetbrains-mono": "^0.4.1",
"@expo/ui": "~56.0.13",
"@expo/vector-icons": "^15.0.2",
"expo": "~56.0.4",
"expo-asset": "~56.0.14",
"expo-constants": "~56.0.15",
"expo-device": "~56.0.4",
"expo-font": "~56.0.5",
"expo-glass-effect": "~56.0.4",
"expo-image": "~56.0.9",
"expo-linking": "~56.0.11",
"expo-router": "~56.2.6",
"expo-splash-screen": "~56.0.10",
"expo-status-bar": "~56.0.4",
"expo-symbols": "~56.0.5",
"expo-system-ui": "~56.0.5",
"expo-web-browser": "~56.0.5",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.85.3",
"react-native-gesture-handler": "~2.31.1",
"react-native-reanimated": "4.3.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
"react-native-svg": "15.15.4",
"react-native-track-player": "^4.1.2",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.8.3",
"zustand": "^5.0.13"
},
"devDependencies": {
"@types/react": "~19.2.2",
"patch-package": "^8.0.1",
"typescript": "~6.0.3"
},
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"lint": "expo lint",
"typecheck": "tsc --noEmit",
"postinstall": "patch-package"
},
"private": true
}
@@ -0,0 +1,423 @@
diff --git a/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt b/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt
index b2409a0..fb5d5a5 100644
--- a/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt
+++ b/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt
@@ -251,7 +251,7 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
}
@ReactMethod
- fun updateOptions(data: ReadableMap?, callback: Promise) = scope.launch {
+ fun updateOptions(data: ReadableMap?, callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
val options = Arguments.toBundle(data)
@@ -262,9 +262,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
callback.resolve(null)
}
+ }
@ReactMethod
- fun add(data: ReadableArray?, insertBeforeIndex: Int, callback: Promise) = scope.launch {
+ fun add(data: ReadableArray?, insertBeforeIndex: Int, callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
try {
@@ -283,9 +284,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
rejectWithException(callback, exception)
}
}
+ }
@ReactMethod
- fun load(data: ReadableMap?, callback: Promise) = scope.launch {
+ fun load(data: ReadableMap?, callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
if (data == null) {
callback.resolve(null)
@@ -299,16 +301,18 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
callback.reject("invalid_track_object", "Track was not a dictionary type")
}
}
+ }
@ReactMethod
- fun move(fromIndex: Int, toIndex: Int, callback: Promise) = scope.launch {
+ fun move(fromIndex: Int, toIndex: Int, callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
musicService.move(fromIndex, toIndex)
callback.resolve(null)
}
+ }
@ReactMethod
- fun remove(data: ReadableArray?, callback: Promise) = scope.launch {
+ fun remove(data: ReadableArray?, callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
val inputIndexes = Arguments.toList(data)
if (inputIndexes != null) {
@@ -329,9 +333,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
}
callback.resolve(null)
}
+ }
@ReactMethod
- fun updateMetadataForTrack(index: Int, map: ReadableMap?, callback: Promise) =
+ fun updateMetadataForTrack(index: Int, map: ReadableMap?, callback: Promise) {
scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
@@ -346,9 +351,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
callback.resolve(null)
}
}
+ }
@ReactMethod
- fun updateNowPlayingMetadata(map: ReadableMap?, callback: Promise) = scope.launch {
+ fun updateNowPlayingMetadata(map: ReadableMap?, callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
if (musicService.tracks.isEmpty())
@@ -362,9 +368,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
callback.resolve(null)
}
+ }
@ReactMethod
- fun clearNowPlayingMetadata(callback: Promise) = scope.launch {
+ fun clearNowPlayingMetadata(callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
if (musicService.tracks.isEmpty())
@@ -373,17 +380,19 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
musicService.clearNotificationMetadata()
callback.resolve(null)
}
+ }
@ReactMethod
- fun removeUpcomingTracks(callback: Promise) = scope.launch {
+ fun removeUpcomingTracks(callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
musicService.removeUpcomingTracks()
callback.resolve(null)
}
+ }
@ReactMethod
- fun skip(index: Int, initialTime: Float, callback: Promise) = scope.launch {
+ fun skip(index: Int, initialTime: Float, callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
musicService.skip(index)
@@ -394,9 +403,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
callback.resolve(null)
}
+ }
@ReactMethod
- fun skipToNext(initialTime: Float, callback: Promise) = scope.launch {
+ fun skipToNext(initialTime: Float, callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
musicService.skipToNext()
@@ -407,9 +417,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
callback.resolve(null)
}
+ }
@ReactMethod
- fun skipToPrevious(initialTime: Float, callback: Promise) = scope.launch {
+ fun skipToPrevious(initialTime: Float, callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
musicService.skipToPrevious()
@@ -420,9 +431,10 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
callback.resolve(null)
}
+ }
@ReactMethod
- fun reset(callback: Promise) = scope.launch {
+ fun reset(callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
musicService.stop()
@@ -431,135 +443,152 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
callback.resolve(null)
}
+ }
@ReactMethod
- fun play(callback: Promise) = scope.launch {
+ fun play(callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
musicService.play()
callback.resolve(null)
}
+ }
@ReactMethod
- fun pause(callback: Promise) = scope.launch {
+ fun pause(callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
musicService.pause()
callback.resolve(null)
}
+ }
@ReactMethod
- fun stop(callback: Promise) = scope.launch {
+ fun stop(callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
musicService.stop()
callback.resolve(null)
}
+ }
@ReactMethod
- fun seekTo(seconds: Float, callback: Promise) = scope.launch {
+ fun seekTo(seconds: Float, callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
musicService.seekTo(seconds)
callback.resolve(null)
}
+ }
@ReactMethod
- fun seekBy(offset: Float, callback: Promise) = scope.launch {
+ fun seekBy(offset: Float, callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
musicService.seekBy(offset)
callback.resolve(null)
}
+ }
@ReactMethod
- fun retry(callback: Promise) = scope.launch {
+ fun retry(callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
musicService.retry()
callback.resolve(null)
}
+ }
@ReactMethod
- fun setVolume(volume: Float, callback: Promise) = scope.launch {
+ fun setVolume(volume: Float, callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
musicService.setVolume(volume)
callback.resolve(null)
}
+ }
@ReactMethod
- fun getVolume(callback: Promise) = scope.launch {
+ fun getVolume(callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
callback.resolve(musicService.getVolume())
}
+ }
@ReactMethod
- fun setRate(rate: Float, callback: Promise) = scope.launch {
+ fun setRate(rate: Float, callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
musicService.setRate(rate)
callback.resolve(null)
}
+ }
@ReactMethod
- fun getRate(callback: Promise) = scope.launch {
+ fun getRate(callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
callback.resolve(musicService.getRate())
}
+ }
@ReactMethod
- fun setRepeatMode(mode: Int, callback: Promise) = scope.launch {
+ fun setRepeatMode(mode: Int, callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
musicService.setRepeatMode(RepeatMode.fromOrdinal(mode))
callback.resolve(null)
}
+ }
@ReactMethod
- fun getRepeatMode(callback: Promise) = scope.launch {
+ fun getRepeatMode(callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
callback.resolve(musicService.getRepeatMode().ordinal)
}
+ }
@ReactMethod
- fun setPlayWhenReady(playWhenReady: Boolean, callback: Promise) = scope.launch {
+ fun setPlayWhenReady(playWhenReady: Boolean, callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
musicService.playWhenReady = playWhenReady
callback.resolve(null)
}
+ }
@ReactMethod
- fun getPlayWhenReady(callback: Promise) = scope.launch {
+ fun getPlayWhenReady(callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
callback.resolve(musicService.playWhenReady)
}
+ }
@ReactMethod
- fun getTrack(index: Int, callback: Promise) = scope.launch {
+ fun getTrack(index: Int, callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
if (index >= 0 && index < musicService.tracks.size) {
- callback.resolve(Arguments.fromBundle(musicService.tracks[index].originalItem))
+ callback.resolve(musicService.tracks[index].originalItem?.let { Arguments.fromBundle(it) })
} else {
callback.resolve(null)
}
}
+ }
@ReactMethod
- fun getQueue(callback: Promise) = scope.launch {
+ fun getQueue(callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
callback.resolve(Arguments.fromList(musicService.tracks.map { it.originalItem }))
}
+ }
@ReactMethod
- fun setQueue(data: ReadableArray?, callback: Promise) = scope.launch {
+ fun setQueue(data: ReadableArray?, callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
try {
@@ -570,49 +599,54 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
rejectWithException(callback, exception)
}
}
+ }
@ReactMethod
- fun getActiveTrackIndex(callback: Promise) = scope.launch {
+ fun getActiveTrackIndex(callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
callback.resolve(
if (musicService.tracks.isEmpty()) null else musicService.getCurrentTrackIndex()
)
}
+ }
@ReactMethod
- fun getActiveTrack(callback: Promise) = scope.launch {
+ fun getActiveTrack(callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
callback.resolve(
if (musicService.tracks.isEmpty()) null
- else Arguments.fromBundle(
- musicService.tracks[musicService.getCurrentTrackIndex()].originalItem
- )
+ else musicService.tracks[musicService.getCurrentTrackIndex()].originalItem
+ ?.let { Arguments.fromBundle(it) }
)
}
+ }
@ReactMethod
- fun getDuration(callback: Promise) = scope.launch {
+ fun getDuration(callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
callback.resolve(musicService.getDurationInSeconds())
}
+ }
@ReactMethod
- fun getBufferedPosition(callback: Promise) = scope.launch {
+ fun getBufferedPosition(callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
callback.resolve(musicService.getBufferedPositionInSeconds())
}
+ }
@ReactMethod
- fun getPosition(callback: Promise) = scope.launch {
+ fun getPosition(callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
callback.resolve(musicService.getPositionInSeconds())
}
+ }
@ReactMethod
- fun getProgress(callback: Promise) = scope.launch {
+ fun getProgress(callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
var bundle = Bundle()
bundle.putDouble("duration", musicService.getDurationInSeconds());
@@ -620,10 +654,12 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
bundle.putDouble("buffered", musicService.getBufferedPositionInSeconds());
callback.resolve(Arguments.fromBundle(bundle))
}
+ }
@ReactMethod
- fun getPlaybackState(callback: Promise) = scope.launch {
+ fun getPlaybackState(callback: Promise) { scope.launch {
if (verifyServiceBoundOrReject(callback)) return@launch
callback.resolve(Arguments.fromBundle(musicService.getPlayerStateBundle(musicService.state)))
}
+ }
}
diff --git a/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt b/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt
index afa6b0f..c6f01d5 100644
--- a/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt
+++ b/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt
@@ -741,7 +741,7 @@ class MusicService : HeadlessJsTaskService() {
@MainThread
private fun emit(event: String, data: Bundle? = null) {
- reactNativeHost.reactInstanceManager.currentReactContext
+ (applicationContext as? com.facebook.react.ReactApplication)?.reactHost?.currentReactContext
?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
?.emit(event, data?.let { Arguments.fromBundle(it) })
}
@@ -751,7 +751,7 @@ class MusicService : HeadlessJsTaskService() {
val payload = Arguments.createArray()
data.forEach { payload.pushMap(Arguments.fromBundle(it)) }
- reactNativeHost.reactInstanceManager.currentReactContext
+ (applicationContext as? com.facebook.react.ReactApplication)?.reactHost?.currentReactContext
?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
?.emit(event, payload)
}
+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;
}
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./src/*"
],
"@/assets/*": [
"./assets/*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}