App Architecture

How One Song works — every component, service, data flow, and lesson learned from building it. Built with React Native 0.85 + TypeScript. View on GitHub ↗

What is One Song?

One Song is an Android-only music player that does exactly one thing: play a single audio file on infinite loop. No playlists, no libraries, no complexity. Pick one song from your device and the app remembers it forever.

Core features

Instant Auto-Play Song starts immediately on launch — no taps needed
Infinite Loop Uses native RepeatMode.Track for gapless looping
Sleep Timer 5 to 60 minutes, configurable default in Settings
Background Audio Plays while locked, in other apps, even when app is killed
Notification Controls Play/Pause from lock screen and notification shade
Seekable Progress Tap or drag the progress bar to jump anywhere
Persistent Song Remembers your song even if you move or rename the original file
In-App Updates Detects and installs Play Store updates without leaving the app

Tech stack

Layer Technology
Framework React Native 0.85
Language TypeScript
Audio Engine react-native-track-player (v5 alpha)
Navigation @react-navigation/native-stack
State @react-native-async-storage/async-storage
File Picker @react-native-documents/picker
Permissions react-native-permissions
Build Gradle (Android)

Architecture overview

Navigation & screen flow

index.js
App.tsx
AppNavigator
OnboardingScreen
PlayerScreen
SettingsScreen

Component tree

App
 ├─ SafeAreaProvider
      ├─ StatusBar
      └─ NavigationContainer
           └─ Stack.Navigator
                ├─ "Onboarding" → OnboardingScreen(Permission, File Pick, Save Song)
                ├─ "Player" → PlayerScreen
                │    ├─ ProgressBar  (tap/drag to seek)
                │    ├─ PlayPauseButton
                │    └─ SleepTimerButton
                │         └─ TimerPresetPicker (modal chips)
                └─ "Settings" → SettingsScreen
                     (Change Song, Timer Default, Reset)

Navigation flow

  1. App starts → AppNavigator checks hasCompletedOnboarding() from AsyncStorage
  2. First launch? → OnboardingScreen (pick song, grant permission, save)
  3. Returning user? → PlayerScreen (auto-load song, auto-play)
  4. Settings accessible from Player via gear icon. Uses CommonActions.reset() — no back-stack accumulation
  5. Corrupted song? (initError: true) → auto-redirects to Onboarding

Design decisions

No global state library

No Redux, no Context, no Zustand. A simple module-level pub/sub pattern in Playback.ts is sufficient for a single-screen player app. State lives outside React, enabling the 1-second polling loop and audio focus handlers to work independently of component lifecycle.

1-second polling for position

Instead of event-driven position tracking, a simple 1-second setInterval reads position/duration from the native player. Trade-off: ~1% CPU overhead vs much simpler code.

Song copied to app cache

Selected files are copied to cachesDirectory to avoid Android content URI permission expiration on app reinstall. Trade-off: files may be cleared by OS under storage pressure, but simpler than documentsDirectory.

Audio playback system

The audio engine is react-native-track-player, which wraps Android's native MediaSession system. Audio runs in a foreground service, meaning playback continues even when the app is backgrounded or killed.

Audio system flow

AndroidManifest.xml
MusicService
TrackPlayer API
AudioService.ts
Native Audio Engine
Android MediaSession

Audio runs in a foreground service — survives app kill. Notification auto-generated by TrackPlayer.

Track lifecycle

Step API Call What Happens
1. Setup TrackPlayer.setupPlayer() Initializes native audio engine. Configures capabilities (Play, Pause, SeekTo), sets RepeatMode.Track for infinite loop, and StopPlaybackAndRemoveNotification for app-kill behavior
2. Load TrackPlayer.reset() + add() Clears queue, adds single track with { id, url, title, artist, artwork, duration }
3. Play TrackPlayer.play() Starts playback. Android creates a media notification showing track info and Play/Pause controls
4. Poll getProgress() + getPlaybackState() Every 1 second, reads position/duration and play state from native player, updates React state
5. Loop RepeatMode.Track Native looping at the audio engine level — no JS intervention needed, gapless

Audio focus handling

What happens during a phone call?

TrackPlayer integrates with Android's audio focus system. When another app requests focus:

  • Transient focus loss (notification sound, alarm) → auto-pause, auto-resume when focus regained
  • Permanent focus loss (phone call, other music app) → pause and stay paused
  • Focus regained → resume playback automatically (transient only)

Handled via useTrackPlayerEvents([Event.RemoteDuck]) → maps to AudioFocusEvent objects → updates module-level state.

Android native configuration

<!-- AndroidManifest.xml -->
<service
  android:name="com.doublesymmetry.trackplayer.service.MusicService"
  android:foregroundServiceType="mediaPlayback"
  android:exported="true">
  <intent-filter>
    <action android:name="android.intent.action.MEDIA_BUTTON" />
  </intent-filter>
</service>

// index.js (JS-side registration)
TrackPlayer.registerPlaybackService(() => async () => {});

Data & state management

One Song uses a module-level pub/sub pattern instead of Redux, Context, or Zustand. This is deliberate — the app is a single-screen player with simple state.

The pub/sub pattern

Playback.ts
State + Listeners
PlaybackController.ts
PlayerScreen.tsx
// Playback.ts — simplified
let state: PlaybackState = { isPlaying: false, position: 0, /* ... */ };
const listeners = new Set<(s: PlaybackState) => void>();

export function subscribe(cb) { listeners.add(cb); return () => listeners.delete(cb); }
export function getState() { return state; }
function notify() { listeners.forEach(cb => cb(state)); }

// Usage in React hook:
export function usePlaybackController() {
  const [s, setS] = useState(getState());
  useEffect(() => subscribe(setS), []);
}

State shape

Field Type Description
isPlaying boolean Current playback state
position number Current position in seconds
duration number Total track duration in seconds
isReady boolean True after init() completes
hasSong boolean Whether a song is saved
song Song | null The persisted song object
initError boolean Triggers redirect back to Onboarding

AsyncStorage persistence

Key Value Purpose
@onesong:onboarding_complete "true" / "false" Gates onboarding vs player
@onesong:selected_song JSON.stringify(Song) Full song metadata
@onesong:sleep_timer String(minutes) Default timer duration
@onesong:autoplay_enabled String(boolean) Auto-play toggle

Init sequence (on Player mount)

  1. setupPlayer() — Initialize TrackPlayer's native audio session (slowest step)
  2. getSong() — Read saved Song from AsyncStorage
  3. loadSong() — Add the track to TrackPlayer's queue
  4. restoreTimer() — Re-arm the sleep timer if one was active
  5. play() — Auto-play if enabled
  6. startPolling() — Begin 1-second position/duration polling

Onboarding flow

Song selection pipeline

Permission
File Picker
Copy to Cache
Extract Metadata
Save to Storage
  1. Request permission Android 13+ → READ_MEDIA_AUDIO · Android 12- → READ_EXTERNAL_STORAGE · If permanently denied → Alert with "Open Settings" → Linking.openSettings()
  2. Pick audio file Uses @react-native-documents/pickerpick({ type: ['audio/*'] }) → system file picker dialog
  3. Copy to app cache Copies to cachesDirectory → creates persistent file:// URI. Solves Android's SecurityException when content URI permissions expire.
  4. Extract metadata MP3: id3-parser reads ID3 tags (first 256KB) · M4A/MP4: Custom atom parser walks moov→udta→meta→ilst©nam, ©ART, covr atoms · Fallback: Regex parse artist - title.mp3 filename · Artwork saved as file:// URI in cache
  5. Save & navigate complete(song) → AsyncStorage → CommonActions.reset to Player screen

Dual metadata extraction: ID3 + MP4

id3-parser only handles MP3. M4A/MP4 files store metadata in MP4 atoms — a completely different binary structure. The custom parser walks the atom tree to find moov/udta/meta/ilst, then reads child atoms for title (©nam), artist (©ART), and cover art (covr).

Service layer

AudioService.ts

Thin wrapper around react-native-track-player. Provides setupPlayer(), loadSong(), play(), pause(), seekTo(), getProgress(), getPlaybackState(). Also exposes React hooks: useRemotePlayPause() for notification controls and useAudioFocus() for inter-app audio handling.

Playback.ts + PlaybackController.ts

Playback.ts is the core state machine — NOT a React hook. Module-level state + listeners. Orchestrates init(), togglePlay(), seek(), startPolling(), and handleAudioFocus().

PlaybackController.ts is the React bridge — usePlaybackController() hook that subscribes to state changes, runs init on mount, starts/stops polling, and registers remote/audio focus handlers.

SongIntake.ts

The file-picking pipeline: intake() orchestrates permission → file pick → copy to cache → metadata extraction → returns Song. complete() persists to AsyncStorage. getSong(), hasCompletedOnboarding(), clearSongData() manage the onboarding gate.

SleepTimer.ts

Simple setTimeout-based timer. setTimer(minutes, onExpire) sets a timeout. restoreTimer() re-arms on app launch so it survives restarts. saveDefaultTimer() / loadDefaultTimer() persist the default preference.

StorageService.ts

Typed AsyncStorage wrapper: getItem(key), setItem(key, value), removeItem(key), multiRemove(keys). All keys prefixed with @onesong:.

PermissionService.ts

Wraps react-native-permissions. Requests READ_MEDIA_AUDIO (Android 13+) or READ_EXTERNAL_STORAGE (Android 12-). Detects permanently denied state and offers an "Open Settings" path.

InAppUpdateService.ts

Wraps sp-react-native-in-app-updates (patched). Only active in non-dev builds. Checks for updates on app launch, handles FLEXIBLE (background download + install) and IMMEDIATE (blocking) update types. Multiple bugs fixed via patch-package.

React Native basics

What is React Native?

React Native lets you write mobile apps using React-style JavaScript/TypeScript that renders to real native UI components, not web views. A <Text> becomes an Android TextView. A <View> becomes an Android ViewGroup. This gives you native performance with a React development experience.

JS thread vs native thread

React Native runs JavaScript on a separate thread from the native UI. This is why audio can keep playing even when the JS thread is frozen — the native audio service runs independently. TrackPlayer operates entirely on the native side, with JS only sending commands and receiving status updates.

Native modules

Libraries like react-native-track-player have both JavaScript code (the API you call) and native Java code (the actual implementation). React Native "autolinks" these at build time. If ProGuard/R8 obfuscates the Java classes, the JS side gets null — a common source of release-only crashes.

Hermes JS engine

React Native 0.85 defaults to Hermes, a lightweight engine optimized for mobile. Unlike browsers, Hermes doesn't define window as a global. Accessing window.document throws ReferenceError — use typeof window === 'undefined' guards.

Path aliases (@/)

One Song uses @/ as an alias for src/. Requires three separate configs: tsconfig.json (paths for type-checking), metro.config.js (resolveRequest for bundling), jest.config.js (moduleNameMapper for tests).

AsyncStorage

React Native's equivalent of localStorage. Key-value store backed by native SQLite. All values are strings — JSON.stringify before storing, JSON.parse when reading. Used for song, onboarding state, timer default, and auto-play preference.

ProGuard / R8

Release builds run R8, which removes unused code and renames Java classes. This breaks autolinking unless you add keep rules in proguard-rules.pro for every native module library.

ABI splitting

Building for all 4 CPU architectures creates a 56 MB APK. ABI splitting targets only arm64-v8a, reducing size to ~14 MB. Google Play's AAB format does this per-device automatically.

versionCode vs versionName

versionCode (integer) is Google Play's internal identifier — must be strictly increasing. versionName (string like "1.0.1") is what users see. Both must be bumped for every release.

Key lessons

Every bug, fix, and architectural insight from building One Song. The full log is in TIL.md — these are the most impactful entries.

Bug

Android 12+ double splash screen

Android 12+ mandates a system splash screen that shows the app icon before onCreate() runs. Having both a system splash and a custom SplashActivity caused a double flash. Fix: removed SplashActivity, made MainActivity the launcher, customized the system splash via values-v31/styles.xml.

Bug

ProGuard/R8 crashes on release builds

R8 obfuscates Java classes, renaming native module package classes and stripping @ReactModule annotations. Fix: add -keep rules in proguard-rules.pro for every native module library. Debug builds don't run ProGuard, so these crashes only appear in production.

Fix

In-app update stuck in DEVELOPER_TRIGGERED

The library had two bugs: checkNeedsUpdate() returned shouldUpdate: false for DEVELOPER_TRIGGERED state, and startUpdate() rejected it. Patched via pnpm patch at both JS and Java levels. DEVELOPER_TRIGGERED is a legitimate update state, not an error.

Fix

Flexible update downloaded but never installed

Must call installUpdate() after download completes. Old code was fire-and-forget. Fix: register addStatusUpdateListener before startUpdate(), call installUpdate() on DOWNLOADED status. The listener must come first — callbacks can fire synchronously for already-completed states.

Fix

Release APK signed with debug certificate

React Native's starter template defaults release builds to signingConfigs.debug. Must change to signingConfigs.release with env-var-based credential injection.

Arch

Metadata: ID3 + MP4 dual path

id3-parser only handles MP3. M4A/MP4 files store metadata in MP4 atoms. Custom parser walks moov→udta→meta→ilst. Key gotcha: meta is a "full box" with a 4-byte header before children; udta is not. MP4 data atom type indicator is the first byte, not a uint32. Artwork saved as file:// URI.

Arch

Hermes: no window global

Hermes doesn't define window. Accessing an undeclared variable throws ReferenceError. RN 0.85's DevTools setup hit this. Fix: typeof window === 'undefined' guard, persisted via pnpm patch.

Fix

APK size: 56 MB → 14 MB

Building for all 4 CPU architectures packs 4 copies of every native library. ABI splitting targeting only arm64-v8a reduces APK size by 75%. Modern phones (2015+) all use arm64.

Bug

versionCode must be unique

Google Play rejects uploads with duplicate versionCode. Only versionName was bumped; versionCode stayed at 1. Both must be bumped for every upload. Automated via scripts/bump-android-version.sh.

Arch

Java 17 required for RN 0.85

React Native's New Architecture is sensitive to Java version. Java 25 throws restricted method errors. Pin Gradle to OpenJDK 17 via org.gradle.java.home in gradle.properties. System default Java can stay at 25 — only the Android build needs 17.