Skip to content

Audio System Refactor: Playback Stability, Background Service, Sync Optimisation, UI enhancement & Testing Improvements#376

Open
manjotkaur27 wants to merge 117 commits into
KhalisFoundation:devfrom
manjotkaur27:fix-trackplayer-interactions
Open

Audio System Refactor: Playback Stability, Background Service, Sync Optimisation, UI enhancement & Testing Improvements#376
manjotkaur27 wants to merge 117 commits into
KhalisFoundation:devfrom
manjotkaur27:fix-trackplayer-interactions

Conversation

@manjotkaur27

@manjotkaur27 manjotkaur27 commented Mar 28, 2026

Copy link
Copy Markdown

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-player was imported at module level. If the native module was null, the IIFE in Capability.js threw before AppRegistry.registerComponent ran — crashing the entire app before it started.

After: RNTP is loaded lazily via loadRNTP(). The native module is only resolved when initialize() is called, by which point the Android runtime has registered it. registerPlaybackService is also guarded with a null-check on NativeModules.TrackPlayerModule.


2. Silent playback stall (audio playing with no sound)

Before: waitForBuffer: false let ExoPlayer/AVPlayer advance the position counter while the stream buffer was empty — the player reported Playing but produced no audio output.

After: waitForBuffer: true forces the player into State.Buffering when 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_STICKY causes the OS to revive MusicService with a null intent when the app is killed — but by that point the process is background-restricted, so startForeground() throws a fatal ForegroundServiceStartNotAllowedException.

Three-layer fix:

  • appKilledPlaybackBehavior changed to StopPlaybackAndRemoveNotification → service returns START_NOT_STICKY on app kill → OS never schedules a null-intent revival
  • AppState guard added in app.jsTrackPlayerSetup() is deferred until the app is in the foreground, blocking background cold-start initialization entirely
  • MusicService.kt patched via patch-package: null intent → stopSelf() + START_NOT_STICKY; startAndStopEmptyNotificationToAvoidANR() wrapped in try/catch → any remaining ForegroundServiceStartNotAllowedException becomes a graceful no-op instead of a fatal crash. Patch auto-applied on every npm install via postinstall hook.

4. Audio ducking — notification/call interruptions broke playback

Before: alwaysPauseOnInterruption: true caused 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:

  1. Priority 1: Check for user-explicitly-downloaded copy (getFullLocalTrackPath)
  2. Priority 2: Check for background-prefetch copy (getFullPrefetchTrackPath)
  3. If a valid local copy exists: reset()addTrack() with local file → seekTo() — instant disk seek, zero network
  4. Fallback: remote seek + kick background prefetch so future seeks are instant

6. 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 mergeDownloadedTracks only checked exists() — 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 trackSizeMB from 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 the Promise.all first to avoid stale-closure multi-track race), falls back to remote stream
  • downloadAudioOnly alreadyExists path: validates before returning early. Corrupt leftover from a previous failed download → deleted, fresh download starts
  • seekTo fast-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 succeeds
  • addAndPlayTrack prefetch path: validates cached prefetch copy before using it as the playback URL

7. Background service was a non-functional stub

Before: TrackPlayerService.js was literally module.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 (reads isAudioAutoPlay from AsyncStorage), 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: setTimeout loop calling window.scrollBy({ top: 1 }) — frame-rate dependent, inconsistent, noticeably jittery.

After: requestAnimationFrame loop 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 in useTrackPlayer, killing audio whenever the user navigated between screens.

After: Unmount cleanup removed. Audio pause on screen exit is handled exclusively by ReaderScreen via pauseTrack() — single source of truth.


11. Slider and time display showed garbage values

Before: Raw progress.position and progress.duration rendered directly — could show NaN, Infinity, or values from a previously loaded track.

After: sanitizeDuration() and sanitizePosition() guard all display values. durationTrackId prevents showing a previous track's duration during load transitions.


12. webviewDebuggingEnabled always on in production

Before: 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 so getPosition() is accurate after any seek regardless of position in the file. The moov atom 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 audio umbrella event (which produced (not set) for most params in Firebase) with 7 dedicated events, each with fully populated params:

Event Key Params
bani_open bani_id, bani_title, artist
bani_listen bani_id, bani_title, artist, duration_sec, duration_min, track_length_sec
bani_listen_completion above + position_sec, percent_complete, completion_tier
bani_artist_default bani_id, artist
track_download bani_id, artist, bani_title
audio_link_request bani_title, bani_id, bani_length
scroll_progress bani_id, bani_title, scroll_percent, scroll_tier, sync_scroll

Every param passes through safeStr()/safeInt() — Firebase will never receive null, undefined, or (not set). Canonical artist names enforced via CANONICAL_ARTIST_NAMES map so API variants like "Indermohan Kaur UK" never appear in events.


2. Auto-resume watchdog

New wasPlayingBeforeBufferRef tracks whether the user had an active play session before a buffer stall. When state transitions to State.Ready after buffering, immediately calls TrackPlayer.play(). A 5-second hard fallback kicks play directly for OEM edge cases where State.Ready is never emitted. Resets correctly on State.Paused, State.Stopped, and State.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 against trackSizeMB before 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 + pendingSeekRef pair prevents overlapping seek operations. While a seek is in flight, subsequent seeks queue in pendingSeekRef and 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

isBuffering prop renders an ActivityIndicator spinner directly on the play button during State.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.js maps 19 Azure Blob JSON URLs to require() 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) in MainActivity.kt. hideSystemBars() re-applied in onWindowFocusChanged — 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.jsx for 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() with will-change: transform. 60fps updates via direct DOM manipulation on every scroll event — zero bridge overhead. Replaces the old React Native view-based bar that used postMessage → 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-player binary natively during Jest evaluation, consistently crashing headless CI environments. A 70-line native mock boundary was introduced into setupTests.js covering all TrackPlayer constants and methods. Missing mocks for @react-native-async-storage/async-storage and react-native-safe-area-context were also added.

jest.mock("react-native-track-player", () => ({
  __esModule: true,
  default: {
    setupPlayer: jest.fn(() => Promise.resolve()),
    setRepeatMode: jest.fn(() => Promise.resolve()),
    getPlaybackState: jest.fn(() => Promise.resolve({ state: "paused" })),
    // ...
  },
  Event: { RemoteDuck: "remote-duck", ... },
  State: { Playing: "playing", Paused: "paused" },
}));

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.RemoteDuck across three cases:

  • Auto-resumes after transient duck when playback was active
  • Does not auto-resume after transient duck when playback was inactive
  • Stops and resets on permanent duck and never auto-resumes

3. Metadata Fallback Corrections and act() Leak Fixes (AudioTrackDialog, AudioControlBar, AudioPlayer)

addAndPlayTrack assertions were coupled to an outdated parameter signature assuming the artist field fell back to displayName. All assertions now reflect the current requirement where props.title is used as the artist string, applied consistently across AudioTrackDialog and AudioPlayer. Every fireEvent.press call triggering async state updates is wrapped in act(async () => { }), eliminating all React lifecycle leak warnings.

expect(props.addAndPlayTrack).toHaveBeenCalledWith(
  defaultTracks[0].id,
  defaultTracks[0].audioUrl,
- defaultTracks[0].displayName,
+ "Gutka",
  defaultTracks[0].displayName,
  // ...
);

4. Navigation and AutoScroll Logic Decoupling (BottomNavigation/index.test.jsx)

BottomNavigation tests on dev incorrectly asserted against TOGGLE_AUTO_SCROLL dispatch actions. Since AutoScroll has been migrated into the audio feature layer, those assertions were replaced with correct stopTrack, resetPlayer, and TOGGLE_AUDIO verifications. A new Audio Preview Re-entry test was also added.

- expect(mockDispatch).toHaveBeenCalledWith({ type: "TOGGLE_AUTO_SCROLL", payload: false });
+ await waitFor(() => {
+   expect(mockStopTrack).toHaveBeenCalled();
+   expect(mockResetPlayer).toHaveBeenCalled();
+   expect(mockDispatch).toHaveBeenCalledWith({ type: "TOGGLE_AUDIO", payload: false });
+ });

5. Firebase Analytics Test Suite (new)

Full test coverage for all 7 dedicated events:

  • Per-event param validation (correct event name, correct param values)
  • Null-safety suite: all-null inputs produce no undefined, null, or (not set) values across all 7 events
  • Canonical artist name enforcement: only "Bhai Jarnail Singh", "Bibi Indermohan Kaur", "Giani Gurdev Singh" may appear in any artist param — API variants are rejected

6. Corrupt Download Validation Tests (useAudioManifest/index.test.js)

react-native-fs mock extended with stat (returns { size: 5000000 }) and unlink to cover the new corrupt-file detection paths in mergeDownloadedTracks. 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:

  • Disables sync scroll in preview modal and enables it in full player
  • Shows row loading signal while preview is starting
  • Shows countdown label while preview is playing
  • Shows loading state on Next while opening full player
  • Keeps preview modal open on entry even when currentPlaying exists
  • Hides Music tab when audio feature setting is disabled

Test Case Report:
image

Code Coverage:
image


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

MySmilingTurban and others added 30 commits March 28, 2026 15:22
Comment thread temp_head.jsx Outdated
Comment thread package-lock.json Outdated
Comment thread ios/SundarGutka/SundarGutka.entitlements
Comment thread src/services/audioApi.js

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

["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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 1be9cd9

Comment thread src/common/components/BottomNavigation/index.jsx
Comment thread src/common/firebase/performance.js Outdated
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants