Offline Video Downloads & Background Audio in React Native with Expo

June 22, 2026

How we built offline video downloads and lock-screen audio controls in the Kolel iOS/Android app — using Expo FileSystem, react-native-track-player, and a typed service factory that keeps UI components out of the storage and audio logic.

The Problem

The Kolel app serves long-form Torah lessons — shiurim that run anywhere from 20 minutes to several hours. Many users listen while commuting, in synagogue, or in areas with unreliable connectivity. Two things were non-negotiable: users needed to download videos for offline playback, and audio needed to keep playing when the screen locked — with working lock-screen controls.

On React Native, both features touch platform-specific APIs that sit outside the standard React component model. The challenge was implementing them cleanly so that UI components stay unaware of where the file came from or how audio focus is managed.

What We Built

App Store — Kolel iOS app (4.9★ · 100K+ downloads)
Google Play — Kolel Android app (4.9★ · 100K+ downloads)

We implemented a DownloadService that handles the full offline asset pipeline — JSON metadata, thumbnail, and video file — stored in Expo’s document directory. For background audio, we use react-native-track-player: the video player registers a silent track with the OS audio session so that lock-screen controls (play, pause, stop) and audio interruption events (phone call, other app) are handled correctly. Both are accessed through a typed service factory so components call ServiceFactory.createDownloadService() and never import platform APIs directly.

How It Works

Download flow (left) and background audio registration flow (right) in the Kolel React Native app.

Key Technical Decisions

1. DownloadService — 3-file download sequence

Each downloaded video stores three assets: a JSON file with metadata, a thumbnail, and the video itself. The service creates directory structure on first run and uses Expo’s downloadAsync with authenticated headers:

// DownloadService.ts
async downloadVideo(video: Video) {
  const store = StoreService.getStore();
  await store.dispatch(apologize('Added to Downloads'));

  // 1. Write a pending record to AsyncStorage immediately
  const request: StoredData = {
    type: 'video',
    id: video.id,
    durationInSeconds: video.durationInSeconds,
    title: video.title,
    startTimeInSeconds: 0,
    done: false,
  };
  await this.addToStorage(request);

  // 2. Download all 3 assets in sequence
  await this.downloadJsonFile(video.id);
  const thumb = await this.downloadThumbFile(video.id, video.thumbnail);
  const videoFile = await this.downloadVideoFile(video.id, video.videoUrl);

  // 3. Mark complete with local file URIs
  await this.updateInStorage(video.id, {
    done: true,
    thumbnail: thumb.uri,
    videoUrl: videoFile.uri,
  });

  await AnalyticsService.event('Video', 'Download', video.id.toString());
}

private async downloadVideoFile(videoId: number, videoUrl: string) {
  const videos = new Directory(`${this.baseDir}videos`);
  if (!videos.exists) videos.create();

  return await downloadAsync(
    videoUrl,
    `${this.baseDir}videos/${videoId}.${DownloadService.getFileType(videoUrl)}`,
    { headers: await this.getAuthHeaders() },
  );
}

2. TrackPlayer initialised only when the app is foregrounded

TrackPlayer.setupPlayer() must run in the foreground. VideoScreenWrapper listens to AppState and defers initialisation until the app is active — preventing crashes on cold start in the background:

// VideoScreenWrapper.tsx
const systemService = ServiceFactory.createSystemService();

useEffect(() => {
  const setupWhenForeground = async () => {
    if (AppState.currentState === 'active') {
      await systemService.initTrackPlayer();
      setHasInitTrackPlayer(true);
    } else {
      const sub = AppState.addEventListener('change', async (state) => {
        if (state === 'active') {
          await systemService.initTrackPlayer();
          setHasInitTrackPlayer(true);
          sub.remove();
        }
      });
    }
  };
  setupWhenForeground();
}, [hasInitTrackPlayer]);

// SystemService.ts
async initTrackPlayer(): Promise<void> {
  await TrackPlayer.setupPlayer();
  await TrackPlayer.updateOptions({
    capabilities: [Capability.Play, Capability.Pause],
    compactCapabilities: [Capability.Play, Capability.Pause],
    android: {
      appKilledPlaybackBehavior:
        AppKilledPlaybackBehavior.StopPlaybackAndRemoveNotification,
    },
  });
}

3. Video player registers a silent TrackPlayer track for OS audio session

The video player adds a track to TrackPlayer at volume 0. This registers the app with the OS audio session so lock-screen controls appear and RemotePlay / RemotePause / RemoteDuck events fire correctly — even though the actual audio comes from the video renderer:

// VideoPlayer.tsx
async setupBackgroundPlayer() {
  await TrackPlayer.reset();
  await TrackPlayer.add({
    id: this.props.videoId,
    url: this.props.videoURL,
    title: this.props.videoTitle,
    artwork: this.props.videoThumbnail,
    artist: this.props.channelName,
    duration: this.props.videoDurationMillis,
  });
  await TrackPlayer.seekTo(this.props.startVideoPos);
  await TrackPlayer.setVolume(0); // silent — real audio from the video player
  await TrackPlayer.pause();
}

async componentDidMount() {
  await this.setupBackgroundPlayer();

  // sync lock-screen controls → video player state
  this.remotePlayRef.current  = TrackPlayer.addEventListener(Event.RemotePlay,  () => this.play());
  this.remotePauseRef.current = TrackPlayer.addEventListener(Event.RemotePause, () => this.pause());
  this.remoteStopRef.current  = TrackPlayer.addEventListener(Event.RemoteStop,  () => this.pause());

  // handle audio interruptions (phone calls, other apps)
  this.remoteDuckRef.current  = TrackPlayer.addEventListener(Event.RemoteDuck, (event) => {
    if (!this.state.isVideoLoaded) return;
    event.paused ? this.pause() : this.play();
  });
}

4. Hook-based player keeps play/pause/seek decoupled

The functional video player component uses useCallback hooks that call both the native video ref and TrackPlayer in lockstep, ensuring the OS audio session state always matches what’s on screen:

// ui/video-player/index.tsx
const pause = useCallback(async () => {
  setIsVideoPaused(true);
  deactivateKeepAwake();
  TrackPlayer.pause();
}, [deactivateKeepAwake]);

const play = useCallback(async (reload?: boolean) => {
  setIsVideoPaused(false);
  activateKeepAwake(); // keep screen on during playback
  TrackPlayer.play();
}, [isVideoFinished, activateKeepAwake]);

const seek = useCallback(async (seekTo: number) => {
  await TrackPlayer.seekTo(seekTo);
  videoRef.current?.seek(seekTo, 100); // native video seek
  if (isVideoPaused) play();
}, [isVideoPaused, play]);

Results / What Changed

  • Users can download full lessons and watch them on flights, in synagogue, or on the subway — no connection required
  • Lock-screen controls (play, pause) work correctly on both iOS and Android
  • Audio resumes after a phone call interruption without user action
  • The service factory pattern means no component imports expo-file-system or react-native-track-player directly — swap the implementation without touching UI
  • App Store rating: 4.9★ with 100K+ downloads across iOS and Android

Stack

React Native · Expo 53 · TypeScript · react-native-track-player · Expo FileSystem (downloadAsync) · Redux Thunk · AsyncStorage · ServiceFactory pattern · Rails 7 API · Firebase FCM

Building a React Native video or audio app? Get in touch →