Building a channel directory is easy. Keeping people on the page when they actually want to watch something is not.
We run FreeIPTV as an Astro static site: build-time channel data, search, favorites, the usual directory stuff. For a long time the watch path was basically "copy this M3U URL and open VLC." That works. It also dumps every curious click off-site.
So we added a small watch page that plays streams in <video>, with failover across multiple URLs for the same channel. No backend transcoder. No CORS proxy. Just the browser, hls.js when needed, and a JSON index baked at build time.
Here's what actually mattered once we stopped theorizing and shipped it.
The constraint that shaped everything
Public IPTV URLs are messy:
- lots of
.m3u8, some progressive - Safari can play HLS natively; Chrome usually cannot
- streams die constantly
- many hosts send bad CORS headers
- autoplay is blocked half the time
We did not want to pull media through our origin. Proxying thousands of third-party streams is a legal/ops nightmare, and it turns a static site into something you have to babysit.
So the rule was simple: the browser talks to the stream URL directly. If the host blocks it, we fail honestly and offer the next URL (or "open in VLC").
Split the data: directory vs watch index
The homepage doesn't need every stream URL in the HTML. The watch page does.
At build time we write a compact watch-index.json:
type WatchIndexStream = {
url: string;
quality: string | null;
title?: string;
};
type WatchIndexChannel = {
id: string;
name: string;
logoUrl: string | null;
country: string;
categories: string[];
streams: WatchIndexStream[];
};
Watch routes stay thin Astro pages. A client script loads the index, resolves channelId, then attaches media. That keeps SSG fast and avoids shipping player logic to every page.
One transport at a time
The easy bug with HLS players is attaching a second instance before destroying the first — especially when the user mashes "next source" or failover kicks in.
We keep a generation counter. Every attachStream bumps it. Async callbacks check they're still current before touching the DOM:
let attachmentVersion = 0;
export function detachStream(video: HTMLVideoElement): void {
attachmentVersion += 1;
if (activeHls) {
activeHls.destroy();
activeHls = null;
}
video.pause();
video.removeAttribute('src');
video.load();
}
export async function attachStream(
video: HTMLVideoElement,
url: string,
callbacks: StreamCallbacks = {},
): Promise<void> {
detachStream(video);
const version = attachmentVersion;
const isCurrent = () => attachmentVersion === version;
// ... load hls.js or set video.src
}
If a slow import('hls.js') finishes after the user already switched channels, the stale attach bails out. Without that, you get audio from channel A on top of video from channel B. Ask me how I know.
Native HLS vs hls.js
Don't always force hls.js. Safari (and some iOS WebViews) already understand HLS:
function hasNativeHls(video: HTMLVideoElement): boolean {
return (
video.canPlayType('application/vnd.apple.mpegurl') !== '' ||
video.canPlayType('application/x-mpegURL') !== ''
);
}
function shouldUseHlsJs(url: string, nativeHlsAvailable: boolean): boolean {
const lower = url.toLowerCase();
const isHls = lower.includes('.m3u8') || lower.includes('mpegurl');
return isHls && !nativeHlsAvailable;
}
On Safari we just set video.src. On Chromium we dynamic-import hls.js so the directory pages don't pay for the player bundle.
Also: detect HLS from the URL, not from a content-type probe. You often don't get a clean HEAD response from these hosts, and waiting for one just burns timeout budget.
Failover needs a timeout, not only error
A dead stream doesn't always fire a clean fatal error. Sometimes it hangs on the manifest. Sometimes the playlist loads and segments never arrive.
We treat "no ready signal in 10 seconds" as failure:
export const STREAM_ATTEMPT_TIMEOUT_MS = 10_000;
activeAttemptTimeout = setTimeout(() => {
if (attemptResolved || !isCurrent()) return;
callbacks.onTimeout?.();
}, STREAM_ATTEMPT_TIMEOUT_MS);
Ready means MANIFEST_PARSED (hls.js) or canplay (native/src). Fatal HLS errors and <video> error also count. Then the page UI advances:
const next = nextStreamIndex(current, streams.length);
if (next === null) {
showFailurePanel(); // copy URL, retry, open externally
} else {
attachStream(video, streams[next].url, callbacks);
}
Bounded failover matters. Infinite retry loops feel "smart" until someone's laptop fan takes off on a broken geo-blocked feed.
Manual source switching
Channels often have multiple URLs (different CDNs / qualities). Auto-failover walks the list. Manual select should not feel like a second product.
When the user picks source N, we reorder the list to [N, ...rest] for display, but keep the original index for labels and "currently playing." Small UX detail, saves confusion when quality tags are missing and everything is just "Source 2."
Autoplay will betray you
Even with muted + playsInline, video.play() rejects often enough that you need a path for it:
void video.play().catch(() => callbacks.onAutoplayBlocked?.());
We show a tap-to-play affordance instead of pretending playback started. Silent failure here looks like "the site is broken" when it's really browser policy.
What we deliberately didn't build
- No media proxy. If CORS or the host blocks the browser, we surface that and keep the external-player escape hatch.
- No DRM / paid Xtream panels. Out of scope. This is a directory + preview for public streams.
- No giant SPA player shell. Watch UI is one route with a sidebar (related / favorites / recent). Directory pages stay light.
- No server-side "is this stream alive?" checks at request time. That belongs in build/data pipelines if anywhere, not in the critical path of every click.
Practical gotchas
- CORS is the real boss. Plenty of streams play in VLC and fail in Chrome. Don't market in-browser watch as universal.
- Destroy HLS on every switch. Leaked workers and buffer appends will haunt you.
-
Lazy-load
hls.js. Directory traffic shouldn't download a player. - Persist "recent" on session start, not on every timeupdate. We write localStorage once per watch session so the homepage continue bar stays useful without hammering storage.
- Error copy should be boring and specific. "This stream didn't start in the browser — try another source or VLC" beats "Unexpected error occurred."
Result
Users can browse a channel on the static site and preview it without leaving the tab. When the browser can't play it — which happens — failover and the external-player path still exist. The architecture stays mostly SSG: JSON index at build time, a small client controller at runtime.
If you want to poke at the live behavior: freeiptv.app. The interesting bits are all on /watch/[id].
Top comments (0)