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.
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.
Goodme Frontend Team
Regularly sharing the team's insights and expertise in the frontend field
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.