Frontend Development 8 min read

Enable Offline HLS Playback on Android TV with Service Workers

This article details a solution for playing newly added drink videos on Android TV devices by converting large files into HLS streams, using service workers to cache each fragment in IndexedDB, and employing hls.js to enable seamless offline playback while managing memory constraints.

Goodme Frontend Team
Goodme Frontend Team
Goodme Frontend Team
Enable Offline HLS Playback on Android TV with Service Workers

Background

Store TVs need to display various images (including current product prices) and videos when new drinks are launched. All screens must finish loading before playback and support offline playback. Devices: Android TV (Android 5/9) with X5 browser kernel. Cache design uses a Service Worker as a request interceptor.

Story

One morning the product manager reported that two stores saw a white screen when playing a video. The backend showed a 200 MB video uploaded the previous day. The devices were set‑top boxes attached to regular TVs, which have very limited memory.

Solution

Testing with a 1 GB video reproduced the issue: the browser crashed when trying to download the whole file before playback. The team switched to streaming video using the HLS protocol (m3u8).

m3u8 files contain a simple index linking to multiple TS/MP4 fragments, each of which can be played independently. The frontend uses the hls.js library for playback.

<code>if (videoElement.src.indexOf('blob') !== -1) {
  videoElement.currentTime = 0;
  if (videoElement.paused) {
    videoElement.play();
  }
  return;
}
const hls = new Hls();
hls.loadSource(key);
hls.attachMedia(videoElement);
// hls error reporting
hls.on(Hls.Events.ERROR, function(event, data) {
  console.log(event, data);
});
</code>

To increase the buffer size for offline playback:

<code>const hls = new Hls({
  maxBufferLength: 9999999,
});
</code>

When the video still showed a white screen, the team realized that even with HLS the entire video could not be cached at once. They returned to the Service Worker interceptor to cache each fragment individually in IndexedDB.

By pre‑fetching every fragment and storing it, only a small piece resides in memory at any time. After all fragments are cached, the video is marked as ready for offline playback.

The following logic fetches each fragment sequentially, verifies completion, and stores it in the cache:

<code>const fetchFinish = async (response) => {
  if (!response.ok) return false;
  const reader = response.body?.getReader();
  const contentLength = Number(response.headers.get('Content-Length'));
  const getFinished = async (reader, contentLength, receivedLength) => {
    const { done, value } = await reader.read();
    if (done) return true;
    receivedLength += value.length;
    if (contentLength && receivedLength === contentLength) return true;
    return getFinished(reader, contentLength, receivedLength);
  };
  const res = await getFinished(reader, contentLength, 0);
  return res;
};

const hlsLoad = () => {
  return new Promise((resolve) => {
    const hls = new Hls({ maxBufferLength: 0 });
    hls.loadSource(url);
    const video = document.createElement('video');
    hls.attachMedia(video);
    let totalFragments = 0;
    hls.on(Hls.Events.LEVEL_LOADED, async (_, data) => {
      totalFragments = data.details.fragments.length;
      for (let i = 0; i < totalFragments; i++) {
        const fragment = data.details.fragments[i];
        const response = await fetch(fragment.url);
        const res = await fetchFinish(response);
        if (!res) { resolve(false); return; }
      }
      video.remove();
      resolve(true);
    });
    hls.on(Hls.Events.ERROR, () => { video.remove(); resolve(false); });
  });
};
</code>

The Service Worker intercepts fetch requests, serves cached fragments, and stores new ones in the "main" cache:

<code>self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request).then(function(response) {
      if (response) return response;
      return fetch(event.request).then(function(response) {
        const responseToCache = response.clone();
        caches.open('main').then(function(cache) {
          cache.put(event.request, responseToCache);
        });
        return response;
      });
    })
  );
});
</code>

On the playback side, only a few lines are needed to start the process on any TV:

<code>const hls = new Hls();
hls.loadSource(key);
hls.attachMedia(videoElement);
</code>

Summary

By adopting the HLS protocol, the video is split into a lightweight m3u8 index and multiple 2‑10 second TS/MP4 fragments. Using hls.js, fragments are loaded on demand, reducing peak memory usage from hundreds of megabytes to a few megabytes. Service Workers combined with IndexedDB cache each fragment, ensuring complete offline playback once all fragments are stored.

Pre‑load and offline guarantee : Service Worker intercepts all fragment requests and caches them in IndexedDB; the video is marked offline only after every fragment is cached.

Dynamic memory control : The player retains only the currently playing fragment and releases memory immediately after playback.

Custom pre‑loading logic requests fragments sequentially, validates integrity, and monitors errors per fragment, allowing precise fault detection such as network interruptions or corrupted segments.

frontend developmentHLSAndroid TVService Workerhls.jsOffline Playback
Goodme Frontend Team
Written by

Goodme Frontend Team

Regularly sharing the team's insights and expertise in the frontend field

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.