mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-21 21:16:09 +02:00
initial scaffold
This commit is contained in:
+43
@@ -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
|
||||||
@@ -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 (M1–M7 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`).
|
||||||
|
|||||||
@@ -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 |
@@ -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);
|
||||||
Generated
+8627
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user