- TypeScript 29.8%
- CSS 24.5%
- Svelte 22%
- Nix 9.7%
- JavaScript 8.5%
- Other 5.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Svelte 5 runes + Vite static SPA; each series becomes a channel on a fake 30-min program grid with a global broadcast clock (join mid-show, sliding EPG, tape-out roll-forward). Nix flake: build, dev shell, static serve, NixOS module that wires the site into nginx with sops-injected config. Credentials enter via gitignored .env.local (dev) or runtime injection (deploy); the repo carries placeholders only. |
||
| .vscode | ||
| nix | ||
| src | ||
| static | ||
| tools | ||
| .env.example | ||
| .envrc | ||
| .gitignore | ||
| flake.lock | ||
| flake.nix | ||
| index.html | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
| vite.config.ts | ||
Haze Television
A retro-TV web client for your Jellyfin server. Every series becomes a "channel" with a fake 30-minute program grid, like a small-broadcast UHF network from 1962 — in the clothes of a 2000s set-top-box guide.
Stack: Svelte 5 (runes) + Vite, pure static SPA — no server, no SSR. ~56 KB JS (21 KB gzipped). Built and developed entirely with Nix.
Design language: lifted from ~/dev/clips — the Oxide design system as
used on the RFD reader (blue-charcoal neutrals at hue 260, one Oxide-green
accent, 2px corners, hairline strokes, mono uppercase micro-labels, weight
from color tier not boldness). Dark only; the light mirror lives in clips.
Appliance, not product
The set has no settings panel and no runtime config — the dial is the UI.
Credentials arrive by one of two seams (see src/lib/config.ts):
- dev:
.env.local(gitignored, copy from.env.example) — Vite sources it automatically fornpm run devandnpm run build - deploy: the NixOS module injects
window.HTV_CONFIGat runtime from a sops-managed secret; the key never enters git or the nix store
The repo itself carries only public placeholders and is safe to push.
Quick start
cp .env.example .env.local # fill in your Jellyfin server, key, and user id
nix run # turnkey: serve the station on http://localhost:8080 (PORT= to change)
nix build # static site in ./result (index.html + assets)
nix flake check
packages.htv is the vite build output — copy result/ to any static
host, nginx, or even serve it from Jellyfin itself.
Server quirks (Jellyfin 12.2, verified)
- API calls need the full
Authorization: MediaBrowser … Token="…"header; theapi_keyquery param andX-Emby-Tokenheader are rejected (401). - Item queries need an explicit
UserId(no implicit current user). - Images moved to
/Items/{id}/Images/Primary(old/Items/{id}/Primaryis gone). - Images and
/Videos/{id}/stream?Static=trueare served anonymously — that's what makes plain<img>/<video>tags work without headers. - Direct play is the rule. Exception: ~80% of this library carries AC3/E-AC3/DTS
audio, which Chrome can't decode (silent video, greyed volume). For exactly
those items HTV probes
PlaybackInfoonce per program and switches to Jellyfin's audio-transcoded stream (Static=false&AudioCodec=aac, video still stream-copied). The permanent fix istools/fix-audio.sh— a one-time server pass that rewrites audio to AAC in place (video bit-exact), after which the fallback never fires.
Controls
| Key | Action |
|---|---|
G |
toggle the guide (TV + listing on top, schedule pinned to the bottom) |
↑ / ↓ |
channel up / down (zap) |
0–9 |
direct tune |
M |
mute / unmute (the TV's own mute key) |
N |
new channel (in guide mode) |
Esc |
close panel / back to full-screen TV |
| click a guide cell | jump to that program |
Channels
The dial is designed, not accidental. src/lib/stations.json defines
the station lineup — themed channels built from the actual library, mixing
series episodes with movies so the grid breathes:
KIDDO CORNER · TOON TUNES · LAUGH IN · TRUTH HOUR · THE PARLOR ·
GALAXY 12 · SCREAM THEATER · OTAKU O'CLOCK — then a couple of random
series as dial-in-the-dark filler.
Edit the JSON, then rebuild the playlists:
node tools/make-stations.mjs # idempotent: deletes the HTV station playlists and recreates them
The tool expands each station's series round-robin (no single show hogs
the night) and weaves a feature in every 6 episodes, then posts them as
real Jellyfin playlists (query-param POST /Playlists — the JSON body
form 400s on 12.2). The app orders playlists by the stations.json dial;
playlists created by the N studio panel slot in after them. Both kinds
schedule against the wall clock (30-min slots), so everyone tuning in at
the same time sees the same broadcast — and a two-hour feature is a
four-slot cell, breaking up the half-hour mosaic.
Deploying (NixOS module)
The flake ships a NixOS module that plugs the set straight into nginx:
# your host's configuration.nix / flake
{
inputs.htv.url = "github:hbooth/htv"; # or a path/local flake
outputs = { self, nixpkgs, htv, ... }: {
systems.x86_64-linux = nixpkgs.lib.evalModules {
modules = [
./configuration.nix
htv.nixosModules.htv
({ config, ... }: {
services.htv = {
enable = true;
hostName = "htv.home";
serverUrl = "https://jellyfin.example";
# sops-managed: the key is read from /run at activation,
# never a build input, never in the nix store
apiKeyFile = config.sops.secrets.jellyfin_htv_key.path;
forceSSL = true; # certs via your existing nginx/ACME setup
};
sops.secrets.jellyfin_htv_key = { sopsFile = ./secrets.yaml; };
}
]
};
};
}
What services.htv.enable does:
- enables
services.nginxand registers a vhost forhostNameserving the built site (packages.htvvia the flake's overlay) - generates
/var/lib/htv/htv-config.jsvia a oneshot systemd service (htv-config, served no-store) containingwindow.HTV_CONFIG = { serverUrl, userId, apiKey }— the site reads it at boot; the baked defaults insrc/lib/config.tsapply when it's absent (dev mode, plainnix run) - when
apiKeyFileis set, the oneshot orders itself aftersops-nix.service
Cache policy: index.html and htv-config.js are never cached; hashed
assets under /assets/ are immutable for a year.
How it works
- Modes: full-screen TV by default;
Gshrinks the one persistent<video>into a top-left mini and opens the guide grid (sticky channel rail, half-hour columns, green slab + progress tape on the airing you're watching). The video never remounts between modes. - Playback:
<video>pointed straight at Jellyfin's/Videos/{id}/stream?Static=true— direct play, original file, zero transcoding (Range/seek verified).
Development
nix develop
npm install
npm run dev # http://localhost:5173
npm run check
After changing
package.json/package-lock.json, refresh the hash:nix run nixpkgs#prefetch-npm-deps -- package-lock.json→npmDepsHashinflake.nix.
TODO ideas
- Report playback progress to Jellyfin (
/Sessions/Playing) so watch state syncs - "Previously on…" between episodes / intermission cards & fake station IDs
- Movie channels (fill dead air with films), news-bumper interstitials
- EPG-accurate scheduling from
SeasonUserStatus/ unwatched-first rotation