Native iOS + Android player for SUB/WAVE — built with Expo + react-native-track-player. Full feature parity with the web player (../web), multi-station: enter any SUB/WAVE station's URL, switch between them, with one featured default.
- Expo SDK 56 (RN 0.85, React 19, New Architecture) · expo-router (file-based)
- react-native-track-player — background audio + lock-screen / CarPlay / Android Auto controls
- NativeWind 4 (Tailwind for RN) — reuses the web's class names + the 7 theme tokens
- @shopify/react-native-skia — the 120-bar waveform · @gorhom/bottom-sheet — drawers
- react-native-image-colors — cover-art ambient wash · lucide-react-native — icons
Native modules (track-player, skia, image-colors, bottom-sheet) ship native code, so Expo Go won't work — you must build a dev client.
cd app
npm install # .npmrc pins legacy-peer-deps (SDK 56 react/react-dom quirk)
# One-time: build a dev client (needs EAS account; Apple Developer for iOS device)
npm i -g eas-cli && eas login
eas build --profile development --platform ios # or android (--profile development builds an installable .apk)
# Install the dev client on a device/simulator, then:
npm start # expo start --dev-clientnpm run typecheck mirrors the repo lint gate (tsc --noEmit). npx expo-doctor runs 21 project-health checks.
Two ways to ship, decided by what a change touches:
- JS/TS only (components, hooks, styles, copy, logic) → OTA via
expo-updates:eas update --channel <preview|production>. No store round-trip. - Native (a dep add/upgrade,
patches/, a config plugin,app.jsonnative sections) → new store build:eas build --profile production --platform <ios|android>theneas submit.
runtimeVersion.policy = "fingerprint" hashes the native inputs (incl. patches/), so an OTA can only ever reach a binary with matching native code — editing the RNTP patch automatically rolls the runtime version. fallbackToCacheTimeout: 0 keeps OTA off the launch critical path (it applies on the next cold start), which matters for a radio app opened in dead zones.
Channels map to build profiles in eas.json (preview / production). Store-submit configs live there too (iOS ascAppId; Android secrets/play-service-account.json, gitignored). Full runbook + the OTA-vs-binary table: docs/RELEASE.md. The manual pre-release device gate: docs/QA-CHECKLIST.md. Step-by-step store flows are the subwave-app-ios-release / subwave-app-android-release skills.
The default/featured station is one line in app.json:
"extra": { "featuredStation": { "url": "https://radio.getsubwave.com", "name": "SUB/WAVE" } }Listeners can still enter any station URL on first launch and switch between them (stored locally in AsyncStorage). The app talks to {stationURL}/api/* and streams {stationURL}/stream.mp3 (MP3 only — same universal-floor reasoning as the web player; Opus/Ogg is skipped).
The structural difference from the web player: the web bakes its base URL in at build time (NEXT_PUBLIC_API_URL). Here the base is resolved at runtime from StationContext and threaded through createApi(base) (src/lib/api.ts). Every hook/screen reads api/base from context.
src/config/StationContext.tsx— active station + recents + switch/forget (the runtime base)src/theme/ThemeContext.tsx— station theme via NativeWindvars(); per-listener overridesrc/audio/player.ts+src/hooks/usePlayer.ts+service.ts— the RNTP audio layer (isolated so it's swappable)src/hooks/*— ports of the web hooks (useStationFeed,useSignal,useNowPlayingInfo,useCoverColors,useSpectrum)src/player/*— the player UI (TopBar, CenterStage, Waveform, TransportBar, DotRail, drawers)src/lib/{types,format,sessionFeed,tagline}.ts— copied verbatim from../web/lib(source of truth noted in each file header)
- react-native-track-player + New Architecture. RN 0.85 mandates the New Architecture on both platforms (the
newArchEnabled=falseflags ingradle.properties/app.jsonare ignored no-ops since RN 0.82). Reanimated 4.3.1 requires it. RNTP 4.1.2 isn't natively new-arch-compatible, sopatches/react-native-track-player+4.1.2.patchcarries the fix (2 source files: a Unit-returninglaunchhelper inMusicModule.kt, andcurrentReactContextCompat()inMusicService.kt— without it Android crashes on the first playback event). Validated: iOS live playback on the simulator + AndroidBUILD SUCCESSFUL. Seedocs/TESTING.md→ "Architecture-critical facts". The audio layer is still isolated behindsrc/audio/player.ts+usePlayer+service.tsshould a swap toexpo-audioever be needed. (The doctor warning for this package is intentionally excluded inpackage.json.) - HTTP-only stations. iOS App Transport Security blocks plain HTTP. Stations should be HTTPS;
NSAllowsLocalNetworkingis on for LAN dev against a local controller. For a non-HTTPS dev controller over Wi-Fi, add a temporary ATS exception inapp.json→ios.infoPlist. - Background-audio review. iOS declares
UIBackgroundModes: ["audio"](legitimate — live radio). Android uses RNTP's foreground service (FOREGROUND_SERVICE_MEDIA_PLAYBACK).
tsc --noEmitclean ·expo-doctor21/21 ·expo exportbundles cleanly for both iOS and Android.- iOS simulator (2026-06):
expo run:iosbuilds clean (new arch ON), installs, bundles 4075 modules, and runs the full flow — onboarding → health check (all 4 probes OK vs live getsubwave.com) → player with live audio (NOW PLAYING timer advances), runtime theming, cover art, Skia spectrum. RNTP works under new arch on iOS. - Android emulator (2026-06):
expo run:android(JDK 17, new arch OFF)BUILD SUCCESSFUL, debug APK installs. Physical-device run is proven via thesubwave-app-androidskill. - Still device-only / not yet validated: background audio + lock-screen metadata + persona-avatar swap (needs a physical device).
- How to run/test it all: see
docs/TESTING.md(local sim/emulator, physical devices, EAS cloud builds, known gotchas).