Rádio Alvorada TV · Platform 3 of 3 · Featured · 2025–2026

Featuredvideo.radioalvoradatv.com.br

When the broadcast goes live, viewers shouldn't think about codecs

Custom live TV end-to-end: OBS publishes RTMP, nginx writes one-second HLS segments, a Next.js viewer handles browser playback policy, and YouTube becomes an invisible fallback when the studio goes quiet.

Role

Product Designer & Design EngineerOBS → viewer, solo owner

Latency

~3–6 seconds1s keyframes + 2s playlist

Origin

nginx-rtmp VMOracle Always Free

Viewer

hls.js + Safari nativeYouTube off-air fallback

  • ~3–6s

    HLS latency

    720p30, 1s GOP

  • $0

    Origin cost

    No streaming SaaS

  • ~15–25s

    Off-air detection

    Was ~80s before fix

Mobile live TV viewer showing chromeless 16:9 letterbox with Ativar som unmute button

Production viewer at video.radioalvoradatv.com.br — no site chrome, no YouTube UI, one explicit unmute affordance.

Same geometry for live HLS and YouTube fallback so mode switches never jump layout.

Executive summary

Live TV is a policy problem disguised as a video problem

Viewers expect television. Browsers expect user gestures, muted autoplay, and strict cross-origin rules. Mobile OSes suspend iframes on lock and cheerfully redraw YouTube's native chrome on wake.

This project ships a single black 16:9 frame, a labeled Ativar som button, and infrastructure so boring an operator only configures OBS once. The engineering depth lives beneath intentional silence — that is the cognitive distance collapse.

My role and brief

Own the whole path from studio action to viewer trust

I owned the live-video product end to end: product strategy, UX state model, Next.js viewer, health API, hls.js integration, YouTube fallback behavior, mobile wake handling, nginx-rtmp VM setup, TLS, OBS settings, operator page, and runbook. The assignment was not “embed a video.” It was to make a small broadcaster feel operationally credible with almost no recurring infrastructure cost.

The audience never asked for RTMP, HLS, MSE, native Safari playback, stale segment detection, iframe postMessage, or autoplay policy. They asked for television. The product challenge was to preserve that simple mental model while acknowledging every browser constraint underneath.

Viewer promise

A black 16:9 frame, no site chrome, no YouTube controls, and one clear action when sound requires consent.

Operator promise

OBS configuration should be stable enough that volunteers do not need to understand the server.

Infrastructure promise

Oracle Always Free VM with nginx + certbot only; no hidden bridge process required for video.

Product promise

If live is not actually fresh, fail into intentional fallback content instead of freezing or lying.

Plain language primer

What HLS is and why we use it

HLS (HTTP Live Streaming) splits video into small files — typically one second each — and serves a playlist (.m3u8) that tells the player which file to fetch next. Your browser downloads segments over ordinary HTTPS, buffers a few seconds, and plays.

Why not WebRTC? WebRTC is lower latency but needs specialized media servers and billing. For a $0 Oracle VM and volunteer operators, HLS via nginx-rtmp is the right trade-off: ~3–6 seconds behind live, infinitely maintainable.

Decision log

The important part is what I removed

The final architecture is small because earlier versions were deliberately simplified. A hiring manager should care about this: senior product engineering is often the discipline of deleting plausible solutions that make operations worse.

Video.js → removed

It solved a generic player problem, not this product problem. The project needed a chromeless TV frame, not controls. hls.js + native Safari HLS gave better control over failure states.

Node/ffmpeg bridge → removed

Video already has OBS as the encoder. Adding a bridge duplicated responsibility, increased VM memory pressure, and created another process to restart.

Manifest-only health → rejected

A playlist can exist long after the studio stopped publishing. The product needed freshness semantics, not existence semantics.

Full-screen invisible unmute → rejected

It maximized tap area but weakened intent. The shipped “Ativar som” button makes browser policy visible and keeps the action deliberate.

Architecture

OBS → RTMP → nginx → Vercel viewer

OBS RTMP ingest, nginx HLS segments, Vercel viewer with health checkFIG 1 — LIVE VIDEO PIPELINEOBS Mac720p30 · 1800kkeyframe 1sRTMP :1935/live · key livenginx-rtmp VMhls_fragment 1splaylist 2s · Oracle FreeHLS fileslive.m3u8live-N.tsViewerhls.js / Safari~3–6s delayOFF-AIR BRANCH/api/health → hasFreshLiveHls() · segment Last-Modified < 8sfalse → cropped YouTube iframe · mute=1 · postMessage unmute · 6s load maskhysteresis: 2 OK polls to go live · 2–3 failures to leave (~15–25s off-air)Chrome / Brave / Firefoxhls.js → Media Source ExtensionslowLatencyMode · fatal-only error recoverySafari / iOSnative HLS on <video src>no Video.js · no UA sniffing

FIG 1 — OBS sends RTMP once. nginx slices HLS on disk. The viewer never exposes infrastructure — only the black frame.

Off-air branch uses the same health API as the operator panel so green means green for everyone.

Video operator panel showing RTMP URL, OBS settings, and live health badge

Operator panel mirrors the runbook — RTMP URL, keyframe interval, and the same health semantics as the viewer.

OBS (Mac, x264, keyframe = 1s)
  rtmp://video-origin…:1935/live  (stream key: live)
    → nginx-rtmp module
        hls_fragment 1s
        hls_playlist_length 2s
        writes /var/www/hls/live.m3u8 + live-N.ts
    → HTTPS + CORS on video-origin…/hls/

Vercel Next.js (video.radioalvoradatv.com.br)
  GET /api/health → hasFreshLiveHls()
  Viewer:
    if Hls.isSupported() → hls.js MSE pipeline
    else → <video src="live.m3u8"> (Safari native)
  Off-air → cropped YouTube iframe + IFrame API postMessage

Technical deep dive

The ~80 second bug — and the fix

Early health logic asked: “Does live.m3u8 exist?” After OBS stopped, old .ts files remained listed. Health stayed green. Viewers stared at a frozen picture for ~80 seconds.

Five-step health check comparing manifest and segment freshnessFIG 2 — THE ~80s BUG FIX · hasFreshLiveHls()Before · naive checklive.m3u8 exists → ok: truestale .ts segments → frozen picture ~80sAfter · freshness checklast .ts Last-Modified < 8 secondsoff-air → YouTube in ~15–25s1. Fetch live.m3u82. Validate #EXTM3U3. Find last .ts4. HEAD segment5. Age < 8s?

FIG 2 — Live is a time-based truth. A playlist file on disk is not proof anyone is broadcasting.

Five-step hasFreshLiveHls() reduced off-air detection from ~80s to ~15–25s with hysteresis polling.

hasFreshLiveHls() — step by step

  1. Fetch the manifest with cache busting
  2. Reject if missing #EXTM3U or if #EXT-X-ENDLIST (VOD end)
  3. Parse the last .ts filename from the playlist
  4. HEAD request that segment URL — read Last-Modified
  5. Live only if segment age < 8 seconds

In plain terms: “Live” means a segment was written in the last eight seconds — not that a playlist file exists on disk. Off-air → YouTube now lands in ~15–25s with hysteresis polling (2 consecutive OK to enter live, 2–3 failures to leave).

Playback

hls.js vs Safari — one video tag, two paths

Chrome, Brave, Firefox

hls.js demuxes HLS in JavaScript and feeds Media Source Extensions. Config tuned for live: low latency mode, 2-segment sync target, capped buffer (12s max). Fatal errors recover network/media once; only then fall back through health polling.

Safari (and iOS)

Native HLS on <video src="live.m3u8"> — no hls.js weight. Brave was incorrectly treated as Safari early on; fixed by gating on Hls.isSupported() instead of user-agent sniffing.

Mode switching

How the viewer decides live vs fallback

The viewer does not switch modes on a single request. It uses a small hysteresis model: two positive health checks before entering live, and two to three failures before leaving live depending on whether playback had already started. This avoids flapping when the origin or mobile network hiccups.

In plain language: the product is slightly cautious when claiming “we are live,” and slightly patient before interrupting a live viewer. That patience is a UX decision implemented as counters and polling intervals.

YouTube fallback

Hide the platform, not the content

Off-air plays a looped YouTube VOD (not a live stream ID — loop=1 on live IDs shows “recording not available”). Embed params strip chrome: controls=0, modestbranding=1, iv_load_policy=3, enablejsapi=1, mute=1.

CSS scales the iframe 1.48× centered — cropping title bar and logo inside the same 16:9 box as live HLS. Unmute uses IFrame API postMessage (unMute, setVolume, playVideo) — never iframe src changes, which caused reload flashes.

Temporal UI

The black mask is a product feature, not a loading hack

YouTube's embed parameters can reduce chrome, but they cannot guarantee that the center play glyph, title bar, or post-lock native controls never flash. The black mask is the visual contract: the user should either see the station frame or an intentional loading state, never raw platform UI.

Initial fallback load keeps the mask for at least six seconds and waits for YouTube to report playing or buffering. Screen unlock re-applies a shorter mask, sends playVideo and mutecommands repeatedly, and then reveals the frame only after the player is active or the maximum wait is reached.

Mobile UX

Masks, unmute, and screen-lock lifecycle

Three mobile states: YouTube load mask, muted playback with Ativar som, resume after lockFIG 3 — MOBILE VIEWER STATE MACHINEloadingInitial loadBlack mask min 6sSpinner hides YouTube play glyphAtivar somMuted playbackAtivar som visibleAfter playing / mask clearloadingAfter screen lockMask min 3s + remutepostMessage playVideo + mute

FIG 3 — Each mobile state has designed timing: 6s initial mask, labeled unmute, 3s resume mask with remute.

visibilitychange, freeze/resume, and pageshow are layered because iOS Safari is inconsistent alone.

Black mask + spinner

Initial YouTube load: minimum 6s cover before reveal — kills center play glyph flash. Resume after lock: 3s minimum (8s max) with spinner while playVideo + mute postMessages retry at 150ms, 400ms, 900ms, 1.5s.

Ativar som pill

Live: show after playing while muted. YouTube: show after mask clears. Hidden during resume cover. After unlock, both paths remute — wake is a new consent moment (autoplay policy + user expectation).

Wake listeners

visibilitychange, Page Lifecycle freeze/resume, and bfcache pageshow — layered because iOS Safari is inconsistent with any single event.

Rejected paths

Complexity budget spent elsewhere

Node/ffmpeg bridge on VM — removed. Video path stays OBS → nginx only (audio accepts a bridge; different operator UX).

Full-screen invisible unmute — accidental taps and iframe reload bugs. Labeled lower-center pill won.

Playlist < 2s — tested fragility; buffer stalls beat marginal latency gains.

Outcome

Production evidence and hiring signal

This is the featured case study because it shows the full range of work: product strategy, interaction design, browser policy research, frontend state management, streaming infrastructure, deployment, and operational documentation. It is also honest about the trade-off: HLS is not the lowest-latency technology possible, but it is the best fit for a $0, volunteer-operated broadcast stack.

  • Live TV on Oracle Always Free — nginx + certbot only on origin
  • Stale segment false-live eliminated
  • Mobile YouTube chrome after unlock — auto-covered without user tap
  • Operator runbook + single setup script + /operator panel sharing health semantics
  • 15+ iterative production commits refining health, hysteresis, and mobile lifecycle

Searchable expertise

What I want this work to be found for

This page is written to rank for the actual work I want to do again: HLS live streaming product design, OBS RTMP nginx architecture, mobile autoplay UX, hls.js implementation, Safari native HLS, YouTube fallback UX, low-cost broadcast infrastructure, design engineering for media platforms, and end-to-end product design for streaming tools.

FAQ

Technical questions