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
Settings accessible from Player via gear icon.
Uses CommonActions.reset() — no back-stack
accumulation
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
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.
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
addStatusUpdateListenerbeforestartUpdate(), 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.