Skip to main content

Player app overview

The player (audiosilo-frontend) is the read side of AudioSilo: one codebase that ships to a web PWA, iOS, and Android. It consumes the server's JSON API (hand-mirrored types, no codegen - see the cross-repo contract), addresses all content by (library_id, rel_path), and owns exactly one hard problem: playback.

Stack

ConcernChoice
FrameworkExpo SDK 56, React Native 0.85 (new architecture), React 19
RoutingExpo Router (file-based, routes live in src/app/)
StylingNativeWind v4 (Tailwind v3.4 engine) - className on core RN components
Server stateTanStack Query (src/api/hooks.ts, provider in src/api/provider.tsx)
Client stateZustand (src/stores/, plus the playback and downloads stores)
AudioA custom native Expo module, modules/audiosilo-player: AVQueuePlayer on iOS, Media3/ExoPlayer on Android; HTML5 Audio + Media Session on web. There is no react-native-track-player dependency - older docs that mention it are stale.
IconsFontAwesome Pro 7 glyphs vendored as raw SVG path data in src/components/ui/icon-data.ts, drawn with react-native-svg. No @fortawesome/* dependency, so no private npm token is needed to build.
Secretsexpo-secure-store (Keychain/Keystore) for session tokens; AsyncStorage for everything else (src/lib/secure-store.ts / src/lib/storage.ts)
i18ni18next + react-i18next + expo-localization (src/i18n/) - see Internationalisation

Source layout

src/app/ Expo Router routes: (app) authenticated shell, connect/ onboarding,
player modal, demo landing, +html.tsx web shell
src/api/ client.ts (typed fetch wrapper), types.ts (wire mirrors),
hooks.ts (React Query), provider.tsx (multi-connection registry),
reachability.ts (online/offline tracking)
src/playback/ PlaybackService interface + per-platform engines, the player store,
book-queue (timeline math), progress-sync (offline-safe saves),
sleep-timer (+ auto-sleep, use-shake-to-extend), rate helpers
src/downloads/ offline downloads: native/web engines + registry store (a sibling
of playback, not inside it)
src/components/ ui/ (design-system primitives - Text, Icon, Button, Card, Sheet,
SegmentedControl, Stepper, Skeleton, EmptyState, SectionHeader,
AnimatedPressable, OverlayHost), layout/ (shell/header/nav),
player/, library/, account/, brand/
src/stores/ Zustand: session (connections + tokens), settings, search
src/i18n/ i18next init, LanguageProvider, locale catalogs (locales/*.json)
src/theme/ ThemeProvider + raw color tokens (tokens.ts)
src/lib/ storage, secure-store, paths, format, hhmm (wall-clock "HH:MM"),
ticker (one start/stop interval), pairing, known-servers, device,
base-url, layout (the one phone->desktop breakpoint),
register-sw, and other pure helpers
modules/audiosilo-player/ the local Expo module (Swift + Kotlin + TS bridge)
public/ sw.js (service worker) + manifest.json (PWA), copied verbatim
into the web export

Two conventions keep this layout healthy:

  • Logic stays out of src/app/** screens. Screens compose components and hooks; anything with behavior worth testing lives in src/lib, src/api, src/playback, src/downloads or src/stores, where it gets a co-located unit test (the coverage config excludes src/app/** entirely - see Testing).
  • Path is identity, scoped by connection. Every content call passes ?path=<rel_path>, never a database id. Because the app can be signed in to several servers at once (and two can share a library id), durable and cached client state keys on (connectionId, library_id, path) - see State & data.

Route map

Expo Router derives routes from the files under src/app/. The three top-level groups are the authenticated shell (app), the connect/ onboarding flow, and a handful of standalone screens.

RouteFilePurpose
- (root layout)src/app/_layout.tsxMounts the provider tree (GestureHandlerRootViewSafeAreaProviderLanguageProviderThemeProviderApiProvider), hydrates the session/settings/downloads stores, imports @/lib/register-sw for its side effect, mounts the headless BookEndedListener (drives the end-of-book flow, see Playback), starts the framework-free startAutoSleep() controller (arms the nightly sleep timer, see Playback), and runs useAppResume (foreground refresh + the Android swipe-from-recents reset). Declares the (app) stack and the player/finished screens as fullScreenModals.
- (web HTML shell)src/app/+html.tsxThe static HTML wrapper for every exported web route: PWA manifest/favicon links (base-prefixed) and a dark backdrop painted before React mounts so there is no white flash.
(app) guardsrc/app/(app)/_layout.tsxThe auth gate: loading → spinner, unauthenticated<Redirect href="/connect" />, otherwise wraps children in AppShell (header + nav, which renders the ReconnectBanner when a connection's token is rejected - see below). Also backfills has_password/has_recovery on sessions persisted before those flags existed.
/(app)/index.tsxHome: continue-listening cards, recently-added shelf, favourites - aggregated across every connected server via the use*All hooks.
/browse?type=recent|finished(app)/browse.tsxThe "see all" grid behind a home shelf.
/search(app)/search.tsxSearch across all connections, de-duplicated; shares its query text with the desktop top bar via useSearchStore.
/library(app)/library/index.tsxAll libraries from all connections, plus a Favourites shelf row.
/library/favourites(app)/library/favourites.tsxThe favourites list (un-heart in place).
/library/[libraryId]?connection=…&path=…(app)/library/[libraryId].tsxLibrary browse, root and nested folders alike - a two-line re-export of src/components/library/browse-screen.tsx. Content routes are flat: the connection id and the library-relative folder path ride as query params, never as nested route segments (an in-app router.push cannot resolve a route nested under a dynamic layout segment - it lands on the group's first child; rationale and helpers in src/lib/paths.ts). The (app) layout republishes ?connection= as the scope the content hooks read via useScopedCid().
/book/[libraryId]?connection=…&path=…(app)/book/[libraryId].tsxBook detail: an overview (breadcrumbs, versions, cover hero, stats, play/resume + download control, and the capability-gated community-metadata About block) above a scrollable pill tab row - Chapters/Files, Recaps, Characters, Bookmarks, History, Notes, Series - whose meta-driven tabs appear only when the data exists (State & data). Same flat query-param addressing as the library routes.
/downloads(app)/downloads.tsxDownloaded books + storage used (Offline).
/settings(app)/settings.tsxApp-level preferences only: playback tunables, the auto sleep timer's window and type, up-next/download behaviour, language, theme, plus the Servers list that opens each connection's account screen.
/account?connection=…(app)/account.tsxPer-connection account screen, reached from the Settings screen's Servers list: set/change the self-service password (the sign-out guard nudges a password-less user here via sign-out-confirm.tsx), personal API keys (capability-gated, demo-hidden), and sign-out.
/playersrc/app/player.tsxThe full player, presented as a full-screen modal above the shell. Accepts libraryId/path (+ optional position/track) params and gates playback start on the chapters query settling.
/finishedsrc/app/finished.tsxThe end-credits screen shown when a book finishes (or from the player's menu). A root modal sibling of the player; renders EndCredits with an "up next" suggestion. See Playback.
/connect layoutsrc/app/connect/_layout.tsxOnboarding stack. An authenticated user is bounced home unless they are adding another server (?add=1, a pairing ?token=, or a sign-in mid-flow via pendingServerUrl) - the app supports multiple simultaneous server connections.
/connectconnect/index.tsxEnter a server URL (or auto-redeem a pairing token arriving via deep link / QR web_url). When signed out of everything, also lists previously-connected servers as one-tap Reconnect shortcuts (src/lib/known-servers.ts), each pre-filling the address so the user only re-enters a code or password.
/connect/scanconnect/scan.tsxCamera QR scanner (expo-camera) for the pairing QR.
/connect/sign-inconnect/sign-in.tsxAuth-code or username/password sign-in against the pending server. Reached fresh, from a Reconnect shortcut, or from the dead-token ReconnectBanner (src/components/layout/reconnect-banner.tsx), all with the address pre-filled via pendingServerUrl. The code field redeems invite codes (legacy recovery codes still redeem server-side, but the app no longer mints them).
/demosrc/app/demo.tsxPublic demo landing: mints a throwaway session on a demo-mode server and shows the pairing QR so the same demo user opens on a phone.

Styling conventions

  • className everywhere. NativeWind v4 styles core RN components directly; design tokens live in tailwind.config.js and the directives in src/global.css.
  • Tokens: primary pink #db2777; custom dark grays gray-750 (#2c3340), gray-840 (#1a2331), gray-860 (#161f2c); Roboto weights as font-roboto-{light,medium,semibold,bold} (plain font-sans is Roboto regular). The app is dark-mode-first.
  • Raw color values for places that need a string instead of a class (status bar, ActivityIndicator, SVG fills, navigation theme) come from src/theme/tokens.ts, which mirrors the Tailwind theme.
  • Never import an icon library. Use <Icon name=… /> from src/components/ui/icon.tsx; the glyphs are vendored SVG paths in icon-data.ts. To add or change an icon, edit scripts/glyphs/manifest.mjs and regenerate via the isolated generator in scripts/glyphs/ (the only place a FontAwesome Pro token is ever needed).
  • Text goes through <Text variant=… /> (src/components/ui/text.tsx) - variants body, muted, heading, title, subtitle, label, caption encode the type scale; add a className for overrides.
  • Compose the shared UI primitives in src/components/ui/ rather than restyling raw RN components ad hoc. Beyond Icon and Text the design system provides Button, Card, Sheet (the bottom sheet behind the speed and sleep-timer controls), SegmentedControl, Stepper, Skeleton (loading placeholders), EmptyState (the "nothing here yet" screens), SectionHeader, and AnimatedPressable - a className-aware Pressable that adds a press-in scale/opacity via reanimated (and honours reduce-motion), used for tappable rows and buttons. OverlayHost mounts sheets and dialogs above the app tree.
  • One layout breakpoint. src/lib/layout.ts WIDE_BREAKPOINT (1024px) is the single phone->desktop switch every screen flips at (bottom nav + full-screen modal player below it; sidebar rail + docked player at or above) - never re-declare a local width constant.
  • Web role="button" workaround. src/lib/rnw-button-fix.web.ts patches react-native-web so role="button" renders a <div role="button"> instead of a real <button> (which nests illegally and trips an older-Safari flex bug); the native rnw-button-fix.ts is a no-op.

Environment gotchas

:::warning Read this before running anything These are the four failure modes that cost the most time on a fresh checkout. :::

  1. Node 24 is required (.nvmrc pins 24.16.0). RN 0.85 needs ≥ 20.19.4, and the Expo CLI's env-file loader uses util.parseEnv (Node ≥ 20.12) - an older system Node crashes as soon as a .env file exists. Run nvm use first.
  2. Native runs need a dev build, not Expo Go. The audiosilo-player module, react-native-svg and expo-secure-store are native code: npx expo prebuild then npx expo run:ios / run:android. Web (npm run web) needs no native build.
  3. Editing native code under modules/audiosilo-player/{ios,android} requires a full rebuild (run:ios / run:android). A Metro/JS reload will not pick it up - this is the single most common "my fix didn't do anything" trap.
  4. Web dev needs CORS. The Metro dev server runs at http://localhost:8081; set the server's cors_origins to that origin (or serve same-origin via the baked export). See the server configuration page.

Also: run tool commands from the repo root - a stray cd into node_modules breaks Expo's config resolution. And before calling any change done, run the full gate (npx tsc --noEmit && npm run lint && npm run format && npm test) - CI enforces all four; see Gates and CI.

The baseUrl: "/web" coupling

The web build of this app is served by the server at /web, not from its own host. That subpath ripples through the build:

  • app.json sets experiments.baseUrl: "/web", so the static export (npx expo export -p web) emits asset URLs that resolve under /web/….
  • At runtime, src/lib/base-url.ts exposes BASE_URL: EXPO_BASE_URL in a production export, but empty in development - the Metro dev server serves everything at the root and ignores baseUrl, so links like /web/manifest.json would 404 in dev. Anything that builds absolute paths (the service-worker registration, the PWA manifest link in +html.tsx, the synthetic offline media URLs) goes through BASE_URL.
  • The server side of the seam is internal/web/web.go (serving web_dir with a per-document CSP) - see the web UI page. Releases bake a pinned web image into the server's Docker image, web image first: release pipeline.

public/ is copied verbatim into the export, which is how sw.js and manifest.json end up at <base>/sw.js and <base>/manifest.json - the service worker's scope is therefore <base>/. Details in Offline & PWA.