A minimal, hackable HLS streaming stack. One command, any network. ~600 lines of code between a capture device and a browser. Fork it, bend it, ship it.
A deliberately small HLS streaming stack: FFmpeg captures, nginx serves, a React player plays. That's the whole product. It does one thing β push HLS to a browser β and leaves everything else (auth, chat, DRM, multi-bitrate, DVR) to whoever forks it.
- β‘ Minimal by design β no media server, no cloud, no accounts, no framework lock-in.
- π₯ Direct capture β browser β plug in a USB/HDMI capture card, get a web stream.
- πΊ Drop-in HLS player β auto-hide controls, live badge, error recovery.
- π§βπ» Built to be forked β small enough to read end-to-end in an evening.
- π· Turn a USB/HDMI capture card into a web stream
- π₯οΈ Build internal video monitoring or dashboarding tools
- π§ͺ Test streaming pipelines locally (ships with a test-loop script)
- βͺ Run simple live streams β churches, events, schools, community radio
- π Replace IP cameras with a self-hosted, privacy-respecting solution
- π§° Use as a base for custom streaming apps β fork and add only what you need
Video Source βββΊ FFmpeg βββΊ HLS (.m3u8 + .ts) βββΊ nginx βββΊ React Player
- FFmpeg captures from a V4L2 device (or file) and encodes H.264 + AAC
- HLS segments are written to a directory as 2-second chunks
- nginx serves the playlist and segments with sane cache headers + CORS
- React + hls.js plays it in the browser with a clean, accessible UI
That's the whole thing.
Most streaming tools are either:
- ποΈ Too heavy β full media servers (Ant Media, Wowza, OvenMediaEngine) you don't need
- π§ Too manual β OBS + custom scripts you rewire every time
- π§© Too fragmented β DIY blog-post configs that rot in a year
Stream TV focuses on:
- β Simplicity β works from a folder of HLS files
- β Minimal setup β Docker or PM2, pick one
- β Modern UI β built-in React player, no iframe hacks
- β Dev-first workflow β Vite, HMR, TypeScript, pnpm, ESLint
- β Self-hosted β no cloud, no accounts, no egress fees, no telemetry
- β Hackable β small codebase, one Dockerfile, one nginx config, MIT licensed
- Auto-hiding controls β reveal on mouse/touch, hide after 3.5s
- Animated LIVE badge so viewers know they're on the live edge
- Play/pause, volume slider with mute + last-volume restore
- Fullscreen toggle (desktop + mobile)
- Graceful error recovery β manifest/stream fatal errors show a static-noise fallback with "Reload Stream"
- Mobile-friendly layout and touch targets
- Service worker caches the app shell but never caches live media
- FFmpeg with
libx264-preset veryfast-tune zerolatency - 2-second HLS segments, 6-segment rolling playlist (low-latency friendly)
- Configurable bitrate, framerate, resolution, audio source via env vars
- nginx with open CORS on
/hls/, micro-cached TS segments, uncached playlists - Works with V4L2 capture devices (USB / HDMI grabbers) and PulseAudio
- Node 22 pinned via
.nvmrc(nvm useand you're aligned) - Vite 7 + React Compiler + HMR
- TypeScript + ESLint (Airbnb base) + React 19 hooks lint
pnpm stream:testβ loop a local MP4 into HLS for offline dev, no capture device needed- One-command Docker (
docker compose up --build) or host-native PM2
| You are⦠| Stream TV fits if⦠|
|---|---|
| A homelab / self-hoster | You want a clean web player for an IP camera or capture card without cloud |
| A developer building streaming features | You want a working end-to-end reference to fork |
| A church / school / small org | You want to stream a service or event without paying a SaaS |
| A tinkerer | You want ~1 evening from clone to working stream |
The following will not be added to this repo. They're all legitimate features, just not this project's job:
- Multi-bitrate ABR ladders
- DRM
- WebRTC / sub-second latency
- Recording / DVR / archival
- Built-in auth (use a reverse proxy: Caddy, Traefik, nginx)
- Chat, reactions, social overlays
- Admin UI for runtime config
- Multi-stream / multi-tenant
If you need any of these, fork the repo and add them. The codebase is intentionally small enough (~600 LOC of actual product code) that you can do that without fighting a framework. For a more batteries-included alternative, look at Owncast or Restreamer.
Fastest path to a running stream using the bundled test video:
pnpm install
pnpm stream:test # terminal 1 β loops test-video.mp4 into public/hls
pnpm start:dev # terminal 2 β starts the player at http://localhost:5173Open http://localhost:5173/ and you'll see the player on a live test stream. That's it.
- Node 22 (pinned via
.nvmrcβ runnvm usein the repo root) - pnpm (lockfile v9). Install once:
npm install -g pnpmorcorepack enable pnpm - FFmpeg on your PATH (for the non-Docker flow)
- PM2 (optional) if you use the PM2 flow for FFmpeg
pnpm install
pnpm start:dev # Vite dev server with HMR
pnpm build # Production build to dist/
pnpm preview # Serve the built app locally
pnpm lint # ESLint check
pnpm lint:fix # ESLint autofix
pnpm stream:test # Loop test-video.mp4 into public/hls (run pnpm start:dev separately)Run the FFmpeg pipeline under PM2 so the player always sees /hls/live.m3u8 during development:
pnpm install
pm2 start ecosystem.config.cjs # FFmpeg β public/hls
pnpm start:dev # React app with HMR
# Player: http://localhost:5173/
# HLS: http://localhost:5173/hls/live.m3u8Ensure PulseAudio and the video device are accessible to the user running PM2.
# terminal 1 β stop the PM2 stream and loop the test file into public/hls
pnpm stream:test
# terminal 2 β run the UI with HMR
pnpm start:devstream:test is equivalent to:
pm2 stop FFMPEG-HLS-STREAM
mkdir -p public/hls
ffmpeg -re -stream_loop -1 -i "./test-video.mp4" \
-c:v libx264 -preset veryfast -tune zerolatency -b:v 3000k -g 60 -keyint_min 60 \
-c:a aac -b:a 128k \
-f hls -hls_time 2 -hls_list_size 6 \
-hls_flags delete_segments+append_list+independent_segments \
-hls_segment_filename "public/hls/live_%03d.ts" public/hls/live.m3u8docker compose up --buildDockerfilebuilds the React app with pnpm, then runs nginx + FFmpeg.docker-compose.ymlmaps/dev/video2and Pulse audio in, writes segments to./hls(bind-mounted to/usr/share/nginx/html/hls).- Player: http://localhost:5000/ β HLS: http://localhost:5000/hls/live.m3u8
All runtime knobs are plain env vars:
| Name | Default | Purpose |
|---|---|---|
VIDEO_DEVICE |
/dev/video2 |
Video input device (V4L2) |
PULSE_SOURCE |
alsa_input.usb-MACROSILICON_USB_Video-02.iec958-stereo |
PulseAudio source for audio |
VIDEO_WIDTH |
1920 |
Capture width |
VIDEO_HEIGHT |
1080 |
Capture height |
FRAMERATE |
30 (compose) / 40 (PM2) |
Capture FPS; GOP is FRAMERATE Γ 2 |
VIDEO_BITRATE |
3000k (compose) / 6000k (PM2) |
Video bitrate |
AUDIO_BITRATE |
128k |
Audio bitrate |
HLS_DIR |
/usr/share/nginx/html/hls (compose) / ./public/hls (PM2) |
HLS output directory |
FFMPEG_THREADS |
0 |
0 = auto (all cores) |
PULSE_SERVER |
unix:/run/user/1000/pulse/native |
Pulse socket for audio in compose |
VITE_HLS_URL |
(unset) | Build-time player override. If unset, the player fetches ${BASE_URL}hls/live.m3u8. Set this to point the player at a different host, path, or CDN. |
- nginx CORS is open for
/hls/so the SPA can play back from any origin. - HLS playlists are uncached; TS segments are micro-cached (10s) in nginx.
- Service worker caches app shell/static assets but skips
/hls/*entirely to keep live content fresh. - Works behind a reverse proxy. If mounting at a sub-path, build with
vite build --base=/your-path/so both the app and the default HLS URL are prefixed correctly. For anything more custom (different host, CDN), setVITE_HLS_URLat build time.
The whole point of this project is that it's small enough to fork and bend. The two files you'll touch most:
Edit entrypoint.sh (Docker flow) or ecosystem.config.cjs (PM2 flow). Both run the same FFmpeg command with three sections:
- Input β
-f v4l2for the capture card,-f pulsefor audio. Swap to-i rtsp://β¦for an IP camera,-i file.mp4for a file loop,-f x11grabfor screen capture, etc. - Encode β
libx264preset/tune/bitrate/GOP. Swap toh264_nvenc/h264_vaapi/h264_v4l2m2mfor hardware encoding. Change codecs, bitrates, resolution here. - Output β HLS settings (segment length, list size, flags). Usually the last thing you'd change.
If you want pnpm stream:test to reflect your changes too, mirror the command in package.json.
Edit src/components/player/.
index.tsxβ main player, hls.js config, error handling.LiveBadge.tsx,PlayPause.tsx,VolumeControls.tsx,ErrorState.tsxβ individual UI pieces, each ~50 LOC.src/theme.tsβ colours in one place. Rebrand here.
If you're removing features: the player is flat, not abstracted β just delete what you don't need. No framework will fight you.
- Frontend: React 19, Vite 7, TypeScript, MUI, hls.js, React Compiler
- Pipeline: FFmpeg (libx264 + AAC), HLS v3
- Serving: nginx (CORS, micro-cache)
- Runtime orchestration: PM2 (dev) or Docker Compose (prod)
- Tooling: pnpm 9, ESLint (Airbnb), Node 22
Works anywhere hls.js works β Chrome, Firefox, Edge on desktop; Safari plays HLS natively. Tested on modern mobile browsers (iOS Safari, Android Chrome).
These are the lenses we use when evaluating changes. Align with them and PRs move fast:
- Self-hostable by default. No cloud, no accounts, no telemetry.
- Small on purpose. If a feature needs a library bigger than the feature, we think twice.
- One command should just work. If onboarding grows past three steps, that's a bug.
- Readable beats clever. New contributors should understand any file in 5 minutes.
- Boring tech where it matters. nginx, FFmpeg, React, Vite. Nothing exotic in the hot path.
This project is actively looking for contributors. If you've ever wanted to:
- π‘ Ship something used by self-hosters, small orgs, and devs in the wild
- π Learn FFmpeg, HLS, nginx, or streaming internals with a friendly codebase
- πͺ Sharpen a small codebase β contributions that reduce LOC are welcome
- π·οΈ Get your name on a growing open-source project
β¦you're in the right place. We pair well with: frontend devs (React/TS player polish), backend/infra folks (FFmpeg, nginx, Docker), designers (UI, icons, a demo GIF), and writers (docs, tutorials).
Heads up on scope. PRs that add features from the Out of scope list will be declined with thanks β not because the idea is bad, but because keeping this repo small is the feature. Fork freely.
- Real users, real impact β churches, homelabbers, and devs are using this
- Responsive maintainers β we aim to review PRs within a few days
- Low review friction β small focused PRs get merged fast
- No CLA, no gatekeeping β MIT license, send the PR
- Recognition β every contributor is credited below and in release notes
- Mentorship available β stuck on something? Open a draft PR and ask, we'll help
Ranked by difficulty. Pick one, open an issue saying you're taking it, and go.
All of these serve the core goal (simple, hackable, reliable HLS-to-browser on any network) without bloating scope.
| Difficulty | Task | Area |
|---|---|---|
| π’ Easy | Add a screenshot or demo GIF of the player to this README | Docs |
| π’ Easy | Write a deploy guide (Raspberry Pi, Synology, TrueNAS, VPS, behind Caddy/Traefik) | Docs |
| π’ Easy | Add GitHub Actions CI: install β lint β build | DevOps |
| π’ Easy | Add keyboard shortcuts to the player (space, m, f, β/β) | Frontend |
| π’ Easy | Picture-in-Picture support in the player | Frontend |
| π’ Easy | Fix bufsize hardcoded at 2M β derive from VIDEO_BITRATE |
Pipeline |
| π‘ Medium | Dockerized PulseAudio alternative for headless servers | Infra |
| π‘ Medium | Auto-discover the V4L2 device instead of hardcoding /dev/video2 |
Infra |
| π‘ Medium | Container healthcheck that verifies a fresh .m3u8 is being written |
Infra |
| π‘ Medium | Reduce LOC in the player without losing features | Frontend |
| π‘ Medium | Multi-arch Docker build (x86_64 + aarch64 for Raspberry Pi) | Infra |
| π‘ Medium | Tested reverse-proxy recipe (Caddy/Traefik/nginx) with HTTPS | Infra/Docs |
Don't see your idea? Open an issue β but check the Out of scope list first.
# one-time
git clone <your-fork>
cd stream-tv
nvm use # Node 22
pnpm install
# work loop
pnpm stream:test # terminal 1 β fake a live stream from test-video.mp4
pnpm start:dev # terminal 2 β React app with HMR
pnpm lint # before you pushThat's the whole dev environment. No database, no secrets, no external services to mock.
- Fork the repo and branch from
main. - Keep PRs small and focused β one concern per PR. If you're unsure, open a draft early.
- Run
pnpm lintandpnpm buildlocally β both should pass. - Reference the issue you're fixing in the PR description. Screenshots/GIFs welcome for UI changes.
- Be patient and kind in review β we are too.
Anything you send that improves docs, UX, or reliability is almost certainly welcome. Don't overthink the first PR.
Be decent. Assume good faith. No harassment, no discrimination, no gatekeeping. If something feels off, open an issue or email the maintainer β it'll be handled.
A huge thanks to everyone who has contributed, filed issues, or starred the project. Your name here.
- Package manager: pnpm only.
package-lock.jsonis intentionally removed to avoid tool conflicts. - Linting: Airbnb base + TS + React hooks. Run
pnpm lintbefore commits. - Node: pinned at 22 via
.nvmrc. - Commit style: anything readable. Conventional commits are nice but not required.
- Comments: prefer self-explanatory code. Add a comment only when why isn't obvious.
Released under the MIT License β free to use, modify, fork, ship, commercialize. Attribution appreciated but not required.
Built with β€οΈ for the self-hosted community. If Stream TV is useful to you, a β on GitHub goes a long way.