Audio System Refactor: Playback Stability, Background Service, Sync Optimisation, UI enhancement & Testing Improvements#376
Conversation
…s, audio progress reset
There was a problem hiding this comment.
Need some higher level document on the implementation plan, from a quick look, I found that:
src/services/audioApi.js and src/ReaderScreen/components/AudioPlayer/hooks/useAudioManifest/index.js both contain near-identical hand-coded manifests with hard-coded URLs, durations, and sizes for all banis × 3 artists.
Any time an audio file is replaced or a length changes, three places need to stay in sync:
backend, audioApi.js, useAudioManifest.
The "emergency manifest" already exists in useAudioManifest; the duplicate in audioApi.js should be removed or both must be sourced from one constant.
There was a problem hiding this comment.
This was implemented by previous developers and we didn't modify it. But it will corrected when Get-Audio-Data API will be introduced (probably in upcoming commits)
There was a problem hiding this comment.
@manjotkaur27 Can you setup a github issue for this, so it's not lost in discussion.
| * For these banis the emergency manifest is ALWAYS used as the source of | ||
| * truth for URLs, regardless of what the API returns. | ||
| */ | ||
| const BANI_IDS_WITH_LENGTH_VARIANTS = new Set([9, 21, 23]); |
There was a problem hiding this comment.
["9", "21", "23"] appears as a literal in:
src/common/reducer.js:202 (defaultAudio clear)
src/common/reducer.js:251 (audioProgress clear)
src/ReaderScreen/components/AudioPlayer/hooks/useAudioManifest/index.js (BANI_IDS_WITH_LENGTH_VARIANTS)
src/ReaderScreen/components/AudioPlayer/components/AudioControlBar/index.jsx (LENGTH_VARIANT_BANI_IDS = new Set([...]) — recreated on every render)
Promote to a single exported constant in src/common/constant.js. Today, adding bani 30 to the length-variant set means hunting 5 files.
…efore playback, fast-seek, and manifest merge; purge stale entries atomically
- Remove temp_head.jsx. A stray duplicate of AudioTrackDialog/index.jsx - Remove package-lock.json as this project is on yarn. maintaining consistency. - APNs were dropped as no trace of background handling found in code. restored. - Consolidate length-variant bani IDs into a single source of truth - Remove dead firebase/perf JS surface (phase 1): delete the no-op performance.js
Tie the BottomNavigation to the reader's existing isHeader state so it
hides and shows together with the header on the same tap and scroll
signals (hidden on entry, both toggle together).
- BottomNavigation: add an optional `visible` prop (defaults true, so
other screens are unaffected) that slides the bar down out of view,
with an unmount-safe stop guard to avoid the "Animated node does not
exist" native crash.
- ReaderScreen: wrap the nav in an animated-height view keyed off
isHeader so the freed space collapses (no blank gap), and pass
visible={isHeader}.
Point the audio asset host at the CDN (origin: banidb.blob.core.windows.net) instead of the raw blob, centralizing it in constant.AUDIO_BASE_URL. - constant.AUDIO_BASE_URL: new CDN base (database REMOTE_DB_URL stays on blob). - audioApi.js / useAudioManifest: BLOB_BASE/_BLOB now reference AUDIO_BASE_URL, so all track_url/lyrics_url template literals resolve to the CDN. - bundledLyrics: re-key the bundled lyrics from blob to CDN URLs, since lookup is an exact lyrics_url key match — keeping the zero-network bundled fast path working now that the manifest emits CDN URLs.
- Update Trimmed version of Bhai Jarnail Singh rehras sahib & Kirtan Sohila - Restore highlight alignment - Reduce preview to 15 seconds
Fixes in Existing Workflow
This PR addresses longstanding issues across the audio player, background service, sync-scroll, crash resilience, and analytics — things that were either broken, silently failing, or causing poor user experience.
1. Startup crash on stale APK / hot-reload mismatch
Before:
react-native-track-playerwas imported at module level. If the native module was null, the IIFE inCapability.jsthrew beforeAppRegistry.registerComponentran — crashing the entire app before it started.After: RNTP is loaded lazily via
loadRNTP(). The native module is only resolved wheninitialize()is called, by which point the Android runtime has registered it.registerPlaybackServiceis also guarded with a null-check onNativeModules.TrackPlayerModule.2. Silent playback stall (audio playing with no sound)
Before:
waitForBuffer: falselet ExoPlayer/AVPlayer advance the position counter while the stream buffer was empty — the player reportedPlayingbut produced no audio output.After:
waitForBuffer: trueforces the player intoState.Bufferingwhen the stream runs dry, enabling the auto-resume watchdog to recover correctly.3. ForegroundServiceStartNotAllowedException crash on Android 12+ (Production)
Root cause: On Android 12+,
START_STICKYcauses the OS to reviveMusicServicewith a null intent when the app is killed — but by that point the process is background-restricted, sostartForeground()throws a fatalForegroundServiceStartNotAllowedException.Three-layer fix:
appKilledPlaybackBehaviorchanged toStopPlaybackAndRemoveNotification→ service returnsSTART_NOT_STICKYon app kill → OS never schedules a null-intent revivalapp.js→TrackPlayerSetup()is deferred until the app is in the foreground, blocking background cold-start initialization entirelyMusicService.ktpatched viapatch-package: null intent →stopSelf()+START_NOT_STICKY;startAndStopEmptyNotificationToAvoidANR()wrapped in try/catch → any remainingForegroundServiceStartNotAllowedExceptionbecomes a graceful no-op instead of a fatal crash. Patch auto-applied on everynpm installviapostinstallhook.4. Audio ducking — notification/call interruptions broke playback
Before:
alwaysPauseOnInterruption: truecaused the player to fully pause on any OS audio interruption (notification ping, incoming text). The player never auto-resumed regardless of autoplay setting.After: Setting removed entirely. Android's default duck-and-resume behavior is restored — audio lowers briefly for notification sounds and continues automatically.
5. 10–30 second delays on seek
Before: Every seek on a remote HTTP stream triggered a full network rebuffer.
After: Multi-tier local-first seeking:
getFullLocalTrackPath)getFullPrefetchTrackPath)reset()→addTrack()with local file →seekTo()— instant disk seek, zero network6. Partial/corrupt downloads causing silent playback failure after network switch
Root cause: Switching networks mid-download (e.g. WiFi → mobile data) leaves a partial M4A on disk. The old
mergeDownloadedTracksonly checkedexists()— it trusted any file on disk and handed the corrupt path to ExoPlayer, which failed silently. The fast-seek path had the same flaw: it would swap ExoPlayer to an in-progress partial download, play up to the downloaded bytes (e.g. 4:39 into a 21-minute track), then stop with no fallback to the remote stream. Because ExoPlayer held the file open,File.delete()on Android failed — so the partial file survived restarts and only clearing app storage fixed it.Four-point fix, all using
trackSizeMBfrom the manifest (file must be ≥ 90% of expected size):mergeDownloadedTracks: validates each local file before using it. Corrupt → deleted, manifest entry purged in a single atomic dispatch (all corrupt IDs collected across thePromise.allfirst to avoid stale-closure multi-track race), falls back to remote streamdownloadAudioOnlyalreadyExists path: validates before returning early. Corrupt leftover from a previous failed download → deleted, fresh download startsseekTofast-seek: validates local file before swapping ExoPlayer to it. Partial in-progress file → skipped, ExoPlayer stays on remote stream, file never opened so OS-level cleanup succeedsaddAndPlayTrackprefetch path: validates cached prefetch copy before using it as the playback URL7. Background service was a non-functional stub
Before:
TrackPlayerService.jswas literallymodule.exports = async function () {}— remote controls, audio ducking, and notification interactions did nothing.After: Fully implemented with
RemotePlay,RemotePause,RemoteStop,RemoteSeek,RemoteNext,RemotePrevious, audio ducking with intelligent auto-resume (readsisAudioAutoPlayfromAsyncStorage), and proper queue-end handling.8. Sync-scroll was O(n) on every playback tick
Before:
baniLRC.find()did a linear scan through all lyric lines on every progress event.After: Binary search O(log n) with fallback to the nearest previous line for gap timestamps. A seek guard (
seekGuardRef) ignores stale pre-seek progress ticks until position comes within 1.25s of the seek target or a 1.8s timeout expires, preventing the scroll from jumping backward after a seek.9. Auto-scroll was jittery and inconsistent
Before:
setTimeoutloop callingwindow.scrollBy({ top: 1 })— frame-rate dependent, inconsistent, noticeably jittery.After:
requestAnimationFrameloop with delta-time accumulation and sub-pixel precision. Stops at bottom automatically. Pauses on touch, resumes on release.10. Audio killed on screen navigation
Before:
stopTrack()was called on component unmount inuseTrackPlayer, killing audio whenever the user navigated between screens.After: Unmount cleanup removed. Audio pause on screen exit is handled exclusively by
ReaderScreenviapauseTrack()— single source of truth.11. Slider and time display showed garbage values
Before: Raw
progress.positionandprogress.durationrendered directly — could show NaN, Infinity, or values from a previously loaded track.After:
sanitizeDuration()andsanitizePosition()guard all display values.durationTrackIdprevents showing a previous track's duration during load transitions.12.
webviewDebuggingEnabledalways on in productionBefore: Debugging unconditionally enabled.
After: Gated to
__DEV__— disabled in all production builds.13. Android sync-scroll drift on seeking
Before: All audio served as MP3. ExoPlayer estimates byte offsets via bitrate calculation — drift worsened progressively through the track (~1 word off at 5 min, ~half a line at 10 min, ~1.5 lines near the end). Confirmed known ExoPlayer limitation.
After: All audio assets converted to M4A (AAC 128kbps,
-movflags +faststart). M4A's container index gives ExoPlayer an exact byte map sogetPosition()is accurate after any seek regardless of position in the file. Themoovatom is placed at the front of every file — streaming begins with a single HTTP request. All API URLs normalized to.m4a.Improvements and Additions
1. Firebase Analytics — complete overhaul
Replaced a single
audioumbrella event (which produced(not set)for most params in Firebase) with 7 dedicated events, each with fully populated params:bani_openbani_id,bani_title,artistbani_listenbani_id,bani_title,artist,duration_sec,duration_min,track_length_secbani_listen_completionposition_sec,percent_complete,completion_tierbani_artist_defaultbani_id,artisttrack_downloadbani_id,artist,bani_titleaudio_link_requestbani_title,bani_id,bani_lengthscroll_progressbani_id,bani_title,scroll_percent,scroll_tier,sync_scrollEvery param passes through
safeStr()/safeInt()— Firebase will never receivenull,undefined, or(not set). Canonical artist names enforced viaCANONICAL_ARTIST_NAMESmap so API variants like"Indermohan Kaur UK"never appear in events.2. Auto-resume watchdog
New
wasPlayingBeforeBufferReftracks whether the user had an active play session before a buffer stall. When state transitions toState.Readyafter buffering, immediately callsTrackPlayer.play(). A 5-second hard fallback kicks play directly for OEM edge cases whereState.Readyis never emitted. Resets correctly onState.Paused,State.Stopped, andState.None— never incorrectly auto-resumes after an intentional user pause.3. Background prefetch system
New
audio_prefetch/directory with LRU eviction via.prefetch-index.json. When playing a remote track, a background prefetch starts after playback begins. Keeps the 5 most recently accessed tracks cached on disk — future seeks on these tracks are instant. All fast-seek and playback paths validate prefetch file size againsttrackSizeMBbefore use.4. 30-second audio preview system
Tapping an artist in the track picker starts a 30-second preview instantly. Visual progress bar + countdown timer per track row. Session tokens (
previewSessionRef) prevent concurrent preview collisions — each new preview gets a unique session ID and stale callbacks check their ID before acting. Notification controls stripped during preview and restored after.5. Seek coalescing
seekInFlightRef+pendingSeekRefpair prevents overlapping seek operations. While a seek is in flight, subsequent seeks queue inpendingSeekRefand execute in the next iteration — no deadlocks, no race conditions.6. Optimistic seek UI
Displayed position updates instantly when the user drags the slider without waiting for the native player to catch up. Auto-clears when native position comes within 1.2s of the optimistic value, or after a 2.2s timeout.
7. Buffering indicator on play button
isBufferingprop renders anActivityIndicatorspinner directly on the play button duringState.Buffering. Play button also disabled during loading and while a player action is in flight.8. Giani Gurdev Singh — new artist
5 banis added: Japji Sahib, Jaap Sahib, Tav Prasad Swayiye, Chaupai Sahib, Anand Sahib — with lyrics JSON files and Azure Blob URLs integrated into the emergency manifest.
9. Bundled lyrics (zero-network sync-scroll)
bundledLyrics.jsmaps 19 Azure Blob JSON URLs torequire()calls — Metro inlines all lyrics at build time. Sync-scroll for all 3 artists works fully offline with zero network or disk I/O. Priority chain: bundled → local JSON → remote fetch.10. Android immersive edge-to-edge mode
WindowCompat.setDecorFitsSystemWindows(window, false)inMainActivity.kt.hideSystemBars()re-applied inonWindowFocusChanged— survives notification shade pull-down and lock/unlock. Bars reappear transiently on edge swipe.11. Settings bottom-sheet modals
Translation and Transliteration menus are now BlurView-overlaid modal bottom-sheets. Translation supports multi-select with an "Off" option; Transliteration is single-select. Orientation-aware sizing: 90% width in landscape, full width in portrait.
12. New Redux state slices
isAudioFeatureEnabled— global toggle for the entire audio feature.audioProgress— stores{ [baniID]: { trackId, position, sequence } }for cross-session resume so playback position is preserved when the user returns to a bani.13. Track load deduplication
getActiveTrack()is queried before loading — if the same track is already active in the player, the reload is skipped entirely and playback optionally resumes. Prevents unnecessary reloads on re-renders.14. New AppBar component
New 92-line
AppBar/index.jsxfor consistent header rendering across all screens and platforms, replacing ad-hoc header implementations.15. Scroll progress bar (new)
Fixed-position div at the bottom of the WebView. GPU-accelerated via
transform: scaleX()withwill-change: transform. 60fps updates via direct DOM manipulation on every scroll event — zero bridge overhead. Replaces the old React Native view-based bar that usedpostMessage→ state update (~10fps effective).Test Suite Overhaul — dev → HEAD
Complete overhaul of the unit test layer to align with the current architecture.
301 tests passing across 14 suites.
1. Global Native Mock Restoration (
setupTests.js)On dev, the test runner attempted to load the
react-native-track-playerbinary natively during Jest evaluation, consistently crashing headless CI environments. A 70-line native mock boundary was introduced intosetupTests.jscovering all TrackPlayer constants and methods. Missing mocks for@react-native-async-storage/async-storageandreact-native-safe-area-contextwere also added.2. New Background Service Test File (
TrackPlayerService.test.js)No tests existed for the background audio runtime service on dev. This 102-line file was created from scratch, validating audio focus ducking via
Event.RemoteDuckacross three cases:3. Metadata Fallback Corrections and
act()Leak Fixes (AudioTrackDialog,AudioControlBar,AudioPlayer)addAndPlayTrackassertions were coupled to an outdated parameter signature assuming theartistfield fell back todisplayName. All assertions now reflect the current requirement whereprops.titleis used as the artist string, applied consistently acrossAudioTrackDialogandAudioPlayer. EveryfireEvent.presscall triggering async state updates is wrapped inact(async () => { }), eliminating all React lifecycle leak warnings.4. Navigation and AutoScroll Logic Decoupling (
BottomNavigation/index.test.jsx)BottomNavigationtests on dev incorrectly asserted againstTOGGLE_AUTO_SCROLLdispatch actions. Since AutoScroll has been migrated into the audio feature layer, those assertions were replaced with correctstopTrack,resetPlayer, andTOGGLE_AUDIOverifications. A new Audio Preview Re-entry test was also added.5. Firebase Analytics Test Suite (new)
Full test coverage for all 7 dedicated events:
undefined,null, or(not set)values across all 7 events"Bhai Jarnail Singh","Bibi Indermohan Kaur","Giani Gurdev Singh"may appear in anyartistparam — API variants are rejected6. Corrupt Download Validation Tests (
useAudioManifest/index.test.js)react-native-fsmock extended withstat(returns{ size: 5000000 }) andunlinkto cover the new corrupt-file detection paths inmergeDownloadedTracks. Existing manifest merge tests continue to pass with valid file sizes.7. New Player UX Regression Tests
The following states are now validated against regressions:
currentPlayingexistsTest Case Report:

Code Coverage:

Attached: detailed documentation of all fixes, improvements, and test cases
changelog_walkthrough.pdf