No description
  • TypeScript 29.8%
  • CSS 24.5%
  • Svelte 22%
  • Nix 9.7%
  • JavaScript 8.5%
  • Other 5.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Haze Booth 0871df728f Haze Television: retro-TV web client for Jellyfin
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.
2026-10-23 14:36:18 -04:00
.vscode Haze Television: retro-TV web client for Jellyfin 2026-10-23 14:36:18 -04:00
nix Haze Television: retro-TV web client for Jellyfin 2026-10-23 14:36:18 -04:00
src Haze Television: retro-TV web client for Jellyfin 2026-10-23 14:36:18 -04:00
static Haze Television: retro-TV web client for Jellyfin 2026-10-23 14:36:18 -04:00
tools Haze Television: retro-TV web client for Jellyfin 2026-10-23 14:36:18 -04:00
.env.example Haze Television: retro-TV web client for Jellyfin 2026-10-23 14:36:18 -04:00
.envrc Haze Television: retro-TV web client for Jellyfin 2026-10-23 14:36:18 -04:00
.gitignore Haze Television: retro-TV web client for Jellyfin 2026-10-23 14:36:18 -04:00
flake.lock Haze Television: retro-TV web client for Jellyfin 2026-10-23 14:36:18 -04:00
flake.nix Haze Television: retro-TV web client for Jellyfin 2026-10-23 14:36:18 -04:00
index.html Haze Television: retro-TV web client for Jellyfin 2026-10-23 14:36:18 -04:00
package-lock.json Haze Television: retro-TV web client for Jellyfin 2026-10-23 14:36:18 -04:00
package.json Haze Television: retro-TV web client for Jellyfin 2026-10-23 14:36:18 -04:00
README.md Haze Television: retro-TV web client for Jellyfin 2026-10-23 14:36:18 -04:00
tsconfig.json Haze Television: retro-TV web client for Jellyfin 2026-10-23 14:36:18 -04:00
vite.config.ts Haze Television: retro-TV web client for Jellyfin 2026-10-23 14:36:18 -04:00

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 for npm run dev and npm run build
  • deploy: the NixOS module injects window.HTV_CONFIG at 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; the api_key query param and X-Emby-Token header are rejected (401).
  • Item queries need an explicit UserId (no implicit current user).
  • Images moved to /Items/{id}/Images/Primary (old /Items/{id}/Primary is gone).
  • Images and /Videos/{id}/stream?Static=true are 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 PlaybackInfo once per program and switches to Jellyfin's audio-transcoded stream (Static=false&AudioCodec=aac, video still stream-copied). The permanent fix is tools/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.nginx and registers a vhost for hostName serving the built site (packages.htv via the flake's overlay)
  • generates /var/lib/htv/htv-config.js via a oneshot systemd service (htv-config, served no-store) containing window.HTV_CONFIG = { serverUrl, userId, apiKey } — the site reads it at boot; the baked defaults in src/lib/config.ts apply when it's absent (dev mode, plain nix run)
  • when apiKeyFile is set, the oneshot orders itself after sops-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; G shrinks 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 → npmDepsHash in flake.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