| author | Clayton Gentry <clayton@podstock.io> | 2026-08-14 02:24:09 -0400 |
|---|---|---|
| committer | Clayton Gentry <clayton@podstock.io> | 2026-08-14 02:24:09 -0400 |
| commit | 007f8616fa067800ecc04406ff8385d48762450d (patch) | |
| tree | acf3ef2bf15c231b569121966a40f8ff98605080 | |
| parent | e161eb5f6882c72ed07d35852747f2ac3bbaf1fa (diff) | |
| download | cribd-007f8616fa.tar.gz zip | |
Add stream status viewer
Serve a dedicated viewer that reports connecting, live, and offline states. Detect frozen frames, retry automatically, and supervise the viewer alongside MediaMTX.
Diffstat
| CHANGELOG.md | +1 | -0 |
| README.md | +6 | -8 |
| bin/cribd | +59 | -3 |
| changelog/2026-08-13-stream-status.md (new) | +9 | -0 |
| web/crib/app.css (new) | +178 | -0 |
| web/crib/app.js (new) | +159 | -0 |
| web/crib/index.html (new) | +34 | -0 |
7 files changed, 446 insertions(+), 11 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -2,5 +2,6 @@ ## 2026 +- **AUG.13** — [Stream Status](changelog/2026-08-13-stream-status.md) - **AUG.13** — [Local Network Viewing](changelog/2026-08-13-local-network-viewing.md) - **AUG.13** — [RTSP to WebRTC](changelog/2026-08-13-rtsp-to-webrtc.md) diff --git a/README.md b/README.md @@ -7,7 +7,7 @@ ## Status -The current prototype runs on an Apple silicon Mac, serves one stream through MediaMTX's built-in viewer, and is loopback-only by default. It can also serve devices on the same local network. Custom viewers, event adapters, remote access, persistence, and Raspberry Pi deployment come later. +The current prototype runs on an Apple silicon Mac and serves one stream through a status-aware WebRTC viewer. It is loopback-only by default and can also serve devices on the same local network. Event adapters, remote access, persistence, and Raspberry Pi deployment come later. ## Run locally @@ -19,7 +19,7 @@ ./bin/cribd ``` -Open `http://localhost:8889/crib`. Use H.264 video with Opus, G722, PCMA, or PCMU audio for WebRTC playback. +Open `http://localhost:8889/crib`. The viewer reports when the camera is connecting, live, or offline, and reconnects after interruptions or frozen video. Use H.264 video with Opus, G722, PCMA, or PCMU audio for WebRTC playback. To view from another device, use the Mac's LAN IPv4 address—not the camera's: @@ -29,7 +29,7 @@ Then open `http://192.168.1.10:8889/crib` from a device on the same network. -The launcher runs MediaMTX 1.20.0 and binds its WebRTC ports to the selected address. Docker and Docker Compose are not required; `docker.io` is only the OCI image registry. +The launcher runs MediaMTX 1.20.0 and a BusyBox 1.37.0 static viewer, with both services bound to the selected address. Docker and Docker Compose are not required; `docker.io` is only the OCI image registry. ## Architecture @@ -67,11 +67,9 @@ ## Roadmap -1. Prove RTSP to MediaMTX to browser WebRTC on a Mac. -2. Add the viewer, health reporting, and explicit offline states. -3. Test a dedicated camera, night vision, audio, and restart recovery. -4. Move the stack to a Raspberry Pi and add automatic startup. -5. Add Tailscale, define the event interface, and build a Reolink adapter. +1. Test a dedicated camera, night vision, audio, and restart recovery. +2. Move the stack to a Raspberry Pi and add automatic startup. +3. Add Tailscale, define the event interface, and build a Reolink adapter. ## License diff --git a/bin/cribd b/bin/cribd @@ -13,6 +13,10 @@ project_dir=$(CDPATH= cd -- "${0%/*}/.." && pwd) host=${CRIBD_HOST:-127.0.0.1} +media_name=cribd-media +viewer_name=cribd-viewer +started= +log_pid= if [ "${CRIBD_RTSP_URL+x}" != x ] && [ -f "$project_dir/.env" ]; then . "$project_dir/.env" @@ -28,9 +32,61 @@ container system start -exec container run --rm --name cribd-media \ +for name in "$media_name" "$viewer_name"; do + if container inspect "$name" >/dev/null 2>&1; then + error "container '$name' already exists. Stop or remove it manually." + fi +done + +cleanup() { + status=$? + trap - 0 1 2 15 + + if [ -n "$log_pid" ]; then + kill "$log_pid" >/dev/null 2>&1 || true + fi + for name in $started; do + container stop "$name" >/dev/null 2>&1 || true + done + if [ -n "$log_pid" ]; then + wait "$log_pid" 2>/dev/null || true + fi + + exit "$status" +} + +container_running() { + container inspect "$1" 2>/dev/null | + grep -Eq '"state"[[:space:]]*:[[:space:]]*"running"' +} + +trap cleanup 0 +trap 'exit 129' 1 +trap 'exit 130' 2 +trap 'exit 143' 15 + +container run --detach --rm --name "$media_name" \ --env "MTX_PATHS_CRIB_SOURCE=$rtsp_url" \ --env "MTX_WEBRTCADDITIONALHOSTS=$host" \ - --publish "$host:8889:8889/tcp" \ + --publish "$host:8890:8889/tcp" \ --publish "$host:8189:8189/udp" \ - docker.io/bluenviron/mediamtx:1.20.0 + docker.io/bluenviron/mediamtx:1.20.0 >/dev/null +started=$media_name + +container run --detach --rm --read-only --name "$viewer_name" \ + --mount "type=bind,source=$project_dir/web,target=/www,readonly" \ + --publish "$host:8889:8080/tcp" \ + docker.io/library/busybox:1.37.0 \ + httpd -f -p 8080 -h /www >/dev/null +started="$viewer_name $started" + +container logs --follow "$media_name" & +log_pid=$! + +while :; do + for name in "$media_name" "$viewer_name"; do + container_running "$name" || + error "${name#cribd-} container stopped unexpectedly." + done + sleep 1 +done diff --git a/changelog/2026-08-13-stream-status.md b/changelog/2026-08-13-stream-status.md @@ -0,0 +1,9 @@ +# Stream Status + +Date: **AUG.13.2026** + +`cribd` shows whether the camera stream is connecting, live, or offline. + +The viewer clears stale video when the stream disconnects or stops producing frames and retries until it returns. Playback resumes without reloading the page. + +This version does not include events, alerts, history, or remote access. diff --git a/web/crib/app.css b/web/crib/app.css @@ -0,0 +1,178 @@ +:root { + color-scheme: dark; + font-family: ui-rounded, "SF Pro Rounded", "Avenir Next", sans-serif; + --night-deep: #080c0d; + --ink: #f2ece1; + --muted: #aaa79d; + --panel: rgb(25 31 30 / 78%); + --line: rgb(242 236 225 / 14%); + --connecting: #d2b779; + --live: #91b69d; + --offline: #d27f70; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + overflow: hidden; + background: var(--night-deep); +} + +body { + color: var(--ink); + -webkit-font-smoothing: antialiased; + --state-color: var(--connecting); +} + +body[data-state="live"] { + --state-color: var(--live); +} + +body[data-state="offline"] { + --state-color: var(--offline); +} + +.monitor { + position: relative; + height: 100vh; + height: 100dvh; + background: radial-gradient( + circle at 50% 44%, + #26302d 0, + #151d1c 42%, + #0a0f10 78% + ); +} + +.monitor::after { + position: absolute; + z-index: 2; + inset: 0; + border: 1px solid rgb(242 236 225 / 6%); + box-shadow: + inset 0 0 120px rgb(0 0 0 / 58%), + inset 0 -48px 90px rgb(0 0 0 / 18%); + content: ""; + pointer-events: none; +} + +video { + position: absolute; + z-index: 1; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + background: var(--night-deep); +} + +.status { + position: absolute; + z-index: 4; + right: max(16px, env(safe-area-inset-right)); + bottom: max(16px, env(safe-area-inset-bottom)); + display: flex; + align-items: center; + gap: 10px; + min-height: 38px; + padding: 0 15px; + border: 1px solid var(--line); + border-radius: 999px; + background: + linear-gradient(145deg, rgb(46 54 51 / 38%), transparent 52%), + var(--panel); + box-shadow: + 0 12px 36px rgb(0 0 0 / 30%), + inset 0 1px rgb(255 255 255 / 5%); + -webkit-backdrop-filter: blur(18px) saturate(115%); + backdrop-filter: blur(18px) saturate(115%); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.015em; + text-transform: capitalize; + pointer-events: none; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--state-color); + box-shadow: 0 0 0 4px + color-mix(in srgb, var(--state-color) 11%, transparent); +} + +body[data-state="connecting"] .status-dot { + animation: breathe 2.8s ease-in-out infinite; +} + +.empty-state { + position: absolute; + z-index: 3; + inset: 0; + display: grid; + place-content: center; + padding: 24px; + background: + radial-gradient( + circle at 50% 48%, + color-mix(in srgb, var(--state-color) 8%, transparent) 0, + transparent 34% + ), + linear-gradient(180deg, rgb(255 255 255 / 1.5%), transparent 30%); + text-align: center; + pointer-events: none; + transition: opacity 260ms ease; +} + +body[data-state="live"] .empty-state { + opacity: 0; +} + +.eyebrow { + margin: 0 0 17px; + color: var(--muted); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.075em; +} + +h1 { + margin: 0; + font-family: "Iowan Old Style", "Palatino Linotype", Georgia, serif; + font-size: clamp(44px, 8vw, 76px); + font-weight: 400; + letter-spacing: -0.042em; + line-height: 0.96; + text-shadow: 0 12px 38px rgb(0 0 0 / 25%); +} + +.detail { + margin: 20px 0 0; + color: var(--muted); + font-size: 13px; + font-weight: 500; + letter-spacing: 0.01em; + line-height: 1.5; +} + +@keyframes breathe { + 50% { + opacity: 0.55; + transform: scale(0.84); + } +} + +@media (prefers-reduced-motion: reduce) { + body[data-state="connecting"] .status-dot { + animation: none; + } + + .empty-state { + transition: none; + } +} diff --git a/web/crib/app.js b/web/crib/app.js @@ -0,0 +1,159 @@ +"use strict"; + +const mediaPort = "8890"; +const retryDelay = 1000; +const freezeAfter = 5000; +const watchdogEvery = 1000; + +const video = document.getElementById("video"); +const statusLabel = document.getElementById("status-label"); +const stateTitle = document.getElementById("state-title"); +const stateDetail = document.getElementById("state-detail"); + +const stateCopy = { + connecting: ["Connecting", "Waiting for the camera stream"], + offline: ["Camera offline", "Retrying automatically…"], +}; + +const mediaBase = new URL(window.location.href); +mediaBase.port = mediaPort; +mediaBase.pathname = "/crib/"; +mediaBase.search = ""; +mediaBase.hash = ""; + +let reader = null; +let reconnectTimer = null; +let frameCallback = null; +let lastFrameAt = 0; +let stopped = false; + +function setState(next) { + const copy = stateCopy[next]; + document.body.dataset.state = next; + video.controls = next === "live"; + statusLabel.textContent = next; + if (copy) { + [stateTitle.textContent, stateDetail.textContent] = copy; + } +} + +function clearVideo() { + video.pause(); + video.srcObject = null; +} + +function scheduleReconnect(reason) { + if (stopped) { + return; + } + + console.warn(`[cribd] ${reason}`); + reader?.close(); + reader = null; + clearVideo(); + setState("offline"); + clearTimeout(reconnectTimer); + reconnectTimer = window.setTimeout(connect, retryDelay); +} + +function loadReader() { + if (window.MediaMTXWebRTCReader !== undefined) { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = new URL("reader.js", mediaBase).toString(); + script.onload = () => { + if (window.MediaMTXWebRTCReader === undefined) { + reject(new Error("MediaMTX reader did not load")); + return; + } + resolve(); + }; + script.onerror = () => { + script.remove(); + reject(new Error("MediaMTX reader is unavailable")); + }; + document.head.append(script); + }); +} + +async function connect() { + reconnectTimer = null; + + try { + await loadReader(); + if (stopped) { + return; + } + + reader = new window.MediaMTXWebRTCReader({ + url: new URL("whep", mediaBase).toString(), + onError: scheduleReconnect, + onTrack: (event) => { + if (stopped || event.streams.length === 0) { + return; + } + + video.srcObject = event.streams[0]; + lastFrameAt = performance.now(); + video.play().catch(() => undefined); + }, + }); + } catch (error) { + scheduleReconnect(error); + } +} + +function markFrame() { + if (video.srcObject === null) { + return; + } + lastFrameAt = performance.now(); + if (document.body.dataset.state !== "live") { + setState("live"); + } +} + +if (typeof video.requestVideoFrameCallback === "function") { + const onFrame = () => { + markFrame(); + frameCallback = video.requestVideoFrameCallback(onFrame); + }; + frameCallback = video.requestVideoFrameCallback(onFrame); +} else { + video.addEventListener("timeupdate", markFrame); +} + +document.addEventListener("visibilitychange", () => { + if (!document.hidden) { + lastFrameAt = performance.now(); + } +}); + +const watchdog = window.setInterval(() => { + if ( + !document.hidden && + video.srcObject !== null && + !video.paused && + performance.now() - lastFrameAt >= freezeAfter + ) { + scheduleReconnect("video stopped producing frames"); + } +}, watchdogEvery); + +window.addEventListener("beforeunload", () => { + stopped = true; + clearTimeout(reconnectTimer); + clearInterval(watchdog); + if ( + frameCallback !== null && + typeof video.cancelVideoFrameCallback === "function" + ) { + video.cancelVideoFrameCallback(frameCallback); + } + reader?.close(); +}); + +connect(); diff --git a/web/crib/index.html b/web/crib/index.html @@ -0,0 +1,34 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <meta name="theme-color" content="#101617" /> + <title>cribd</title> + <link rel="stylesheet" href="app.css" /> + </head> + <body data-state="connecting"> + <main class="monitor"> + <video + id="video" + aria-label="Crib camera live stream" + autoplay + muted + playsinline + ></video> + + <div class="status" role="status" aria-live="polite" aria-atomic="true"> + <span class="status-dot" aria-hidden="true"></span> + <span id="status-label">Connecting</span> + </div> + + <section class="empty-state" aria-hidden="true"> + <p class="eyebrow">Crib camera</p> + <h1 id="state-title">Connecting</h1> + <p class="detail" id="state-detail">Waiting for the camera stream</p> + </section> + </main> + + <script src="app.js"></script> + </body> +</html> |