You have a live video stream in your homelab. Maybe it’s a security camera feed from Frigate, a local channel from an HDHomeRun tuner, or a curated IPTV playlist. You want to watch it on a custom dashboard, not in a clunky app. The standard advice is to spin up a heavy media server like Jellyfin or write a custom media proxy in Node.js to handle the stream.
That’s usually overkill.
If your source already outputs a standard HLS (HTTP Live Streaming) stream, you can play it directly in any modern browser with a few lines of JavaScript. By building your dashboard with Astro, you can create a fast, static front end that serves the video player, which then connects directly to your stream source. No media proxy, no backend server, no unnecessary resource drain.
The “No Media Proxy” Philosophy
A media proxy sits between your video source and your browser. It fetches the video stream and “re-serves” it. Proxies can be useful for transcoding, authentication, or bypassing network restrictions. They also add a significant point of failure and complexity.
This setup skips that layer entirely. Here’s the data flow:
- Your browser loads a static HTML page from your Astro site.
- The page contains a JavaScript video player.
- The player connects directly to the HLS stream URL on your local network (e.g.,
http://192.168.1.50:8080/stream.m3u8). - The browser handles fetching the HLS manifest and video segments.
Your Astro server’s job is done after serving the initial HTML and JavaScript. It isn’t involved in the video streaming itself. This is brutally efficient. The only things that matter are the client’s network connection to the stream source and the source’s ability to serve the stream.
Prerequisite: A Working HLS Source
This whole idea depends on you having a device or service on your network that provides an HLS stream. You can’t just point it at a raw video file. HLS breaks video into small .ts chunks and provides a .m3u8 playlist file that tells the player where to find them.
Many homelab tools can do this:
- go2rtc: Excellent for re-streaming RTSP camera feeds as HLS.
- xTeVe: A popular choice for proxying HDHomeRun tuners and IPTV playlists.
- IPTV-Screener: A simple Docker container that can take an IPTV playlist and generate clean HLS streams.
I used IPTV-Screener for testing because it’s so simple to deploy. Here’s a docker-compose.yml to get it running. You’ll need to provide your own playlist.m3u file.
# docker-compose.yml
version: "3.7"
services:
iptv-screener:
image: maltejur/iptv-screener:latest
container_name: iptv-screener
ports:
- "4044:4044"
volumes:
- ./playlist.m3u:/app/playlist.m3u:ro
- ./config.json:/app/config.json:ro
restart: unless-stopped
The Critical Part: CORS Headers
Your browser, for security reasons, will block a script on dashboard.your.domain from fetching a video stream from 192.168.1.50:4044 unless the server at that IP explicitly allows it. This is Cross-Origin Resource Sharing (CORS).
Your HLS source must be configured to send the Access-Control-Allow-Origin header in its HTTP responses. For IPTV-Screener, this is handled in its config.json. For other tools, you might need to configure a reverse proxy like Nginx or Caddy to add the header. A lazy but effective header is Access-Control-Allow-Origin: *, which allows any domain. If you don’t configure this, your browser’s developer console will scream about CORS errors, and nothing will work.
Building the Astro Component
Now for the Astro part. This is surprisingly simple. We’ll use the popular hls.js library because browser-native HLS support can be inconsistent, especially with error handling and recovery.
Create a new component at src/components/HlsPlayer.astro.
---
// src/components/HlsPlayer.astro
export interface Props {
streamUrl: string;
}
const { streamUrl } = Astro.props;
---
<!-- hls.js is tiny, loading from a CDN is fine for this. -->
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<div class="video-container">
<video id="hls-video" controls muted autoplay></video>
</div>
<script define:vars={{ streamUrl }}>
document.addEventListener('DOMContentLoaded', () => {
const video = document.getElementById('hls-video');
const hls = new Hls();
if (Hls.isSupported()) {
console.log(`Attaching HLS stream: ${streamUrl}`);
hls.loadSource(streamUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, function () {
video.play().catch(() => {
console.log("Autoplay was blocked by the browser. User interaction is needed.");
});
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Fallback for browsers with native HLS support (like Safari)
video.src = streamUrl;
video.addEventListener('loadedmetadata', function () {
video.play().catch(() => {
console.log("Autoplay was blocked by the browser. User interaction is needed.");
});
});
}
// Basic error handling
hls.on(Hls.Events.ERROR, function (event, data) {
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
console.error('Fatal network error encountered, trying to recover');
hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
console.error('Fatal media error encountered, trying to recover');
hls.recoverMediaError();
break;
default:
// Cannot recover
console.error('Unrecoverable HLS error. Destroying HLS instance.');
hls.destroy();
break;
}
}
});
});
</script>
<style>
.video-container {
width: 100%;
aspect-ratio: 16 / 9;
background-color: #000;
}
#hls-video {
width: 100%;
height: 100%;
}
</style>
Here’s what this component does:
- It accepts a
streamUrlprop. - It pulls in the
hls.jslibrary from a CDN. - It defines a standard HTML
<video>element. Theautoplayandmutedattributes are important; most browsers block un-muted autoplay. - The client-side
<script>tag (not processed by Astro) initializeshls.js, attaches it to the<video>element, and points it at your stream URL. - It includes a fallback for Safari, which has good native HLS support.
- It has some basic error handling to try and recover from network hiccups.
Now, you can use this component anywhere on your Astro site.
---
// src/pages/dashboard.astro
import HlsPlayer from '../components/HlsPlayer.astro';
const securityCamFeed = "http://192.168.1.110:8083/stream.m3u8";
const localNewsFeed = "http://192.168.1.50:4044/stream/1.m3u8";
---
<html lang="en">
<head>
<title>Homelab Dashboard</title>
</head>
<body>
<h1>Live Streams</h1>
<div class="grid">
<div>
<h2>Security Camera</h2>
<HlsPlayer streamUrl={securityCamFeed} />
</div>
<div>
<h2>Local News</h2>
<HlsPlayer streamUrl={localNewsFeed} />
</div>
</div>
</body>
</html>
Build your site (npm run build), and the resulting static files in the dist/ directory contain everything needed. Serve them with any static web server.
Real-World Gotchas
This setup works incredibly well, but you have to respect the architecture.
- Network Path is King: The browser playing the video must have a direct network route to the
streamUrl. If you access your Astro dashboard from outside your home network, you need a VPN like Tailscale or WireGuard to access the local stream IP. The Astro site itself can be hosted anywhere, but the stream source must be reachable by the client. - No Transcoding: This is a raw feed. If your camera outputs a 20 Mbps 4K stream, that’s what the browser will try to download. There’s no server in the middle to downscale it for a slow connection. You get what the source gives you.
- Authentication: The stream URL is sitting in plain text in the HTML/JS source. This method is not suitable for streams that require robust authentication. It’s designed for trusted networks.
I was surprised by how little code it took. The hardest part wasn’t Astro or HLS, it was digging through the documentation for my camera’s firmware to figure out how to get a clean HLS stream out of it in the first place. The Astro side of things was trivial once the source was solid.
What’s Next
Once you have a direct HLS pipeline, you can build all sorts of interesting things on top of it.
[discussion]
Comments are powered by Giscus — backed by GitHub Discussions. Sign in with GitHub to join the conversation.