Office Window
DeployedWhat it is
The TV in the basement shows an outdoor camera full-screen, like a window. A clock sits in the top-right corner. The lower-left corner shows a circular radar about 50 miles across, with home marked by a red dot. The lower right shows the weather. The jukebox sits at the bottom center: a new track shows its artwork and details for a moment, then shrinks to a single line until it ends. When nothing is playing, the jukebox shows nothing. Weather alerts slide down from the top center, and one shortcut button changes the camera view to a three-hour radar loop.
Built with:
- Python
- mpv
- Raspberry Pi 4
- MediaMTX
- ffmpeg
- Jellyfin
A Roku remote controls the TV. Home opens the library, OK plays, and the arrows change the volume or move through the lists; a Search row at the top of the library opens an on-screen keyboard. Since 2026-10-06, the box behind the TV has been a Raspberry Pi 4, which runs the display and controls the camera and jukebox through the bridge. Before that, the Roku handled the display, the camera, and the jukebox. The old path ran Roku remote to Roku channel to bridge. The new path runs Roku remote to Pi to bridge to camera and jukebox.
The Roku line
We started with a Roku channel. MediaMTX on the bridge host takes the camera feed and serves it to the Roku. The bridge also creates the radar image. The first camera was a eufy indoor cam aimed outdoors; its stream dropped every few minutes, and there was no local way to restart it. We replaced it with an Amcrest PoE camera.
The jukebox came next, and it hit the Roku's hard limit: a channel can have only one media player, so we had to put the music into the camera stream. A service on the bridge fetches each track from Jellyfin, decodes it with ffmpeg, and mixes it into the camera stream before publishing a second MediaMTX path. When no music is playing, it writes silence so the stream doesn't stall. Each key press lagged three to four seconds because the Roku buffers HLS; we had got that down from about 5. MediaMTX's low-latency HLS was not an option: the Roku Express cannot play it, and the channel reconnected every 5 seconds until we reversed the change. The service delivers audio from the most recent segment within one second of a command; the rest of the delay is the Roku's own buffering.
The worst part was the progress bar; it started moving as soon as you pressed Play, about seven seconds before the music started. We used playback status from five seconds earlier and adjusted the elapsed time, which brought the bar closer to what we were hearing. For Pause, we muted the player instead of waiting for the stream to catch up. The delay stayed, but at least Pause stopped the sound.
By that evening, we had given up on the Roku. We left the channel in its own repository and connected the Pi to the TV.
The Pi set-top
We're using a Raspberry Pi 4 Model B with 8 GB in an Argon ONE V2 case, running Raspberry Pi OS Lite (64-bit), booting from a 128 GB M.2 SATA SSD with no SD card in the slot. We put it on the IoT Wi-Fi, and the picture stuttered. On 2.4 GHz, it got about 0.8 Mbit/s from the bridge, compared with the camera's 4.5, and no buffer fixes that. It has been Ethernet since, with the radio off.
The bridge host is the same unit that fed the Roku. It runs the camera and jukebox services and holds every credential; the Pi has none, reaches Jellyfin only through the jukebox service, and does the display.
One Python program, window.py, does all of it. It starts two mpv players as child processes and controls them over JSON IPC sockets. The video player takes the camera feed from MediaMTX over RTSP and draws it through DRM/KMS with no desktop under it. The audio player plays one song at a time from the bridge's jukebox service; pause, seek, and skip are local commands, so each key press takes effect at once. The card follows the player's position and pause state, so it matches what you hear instead of estimating from five seconds ago.
The video player draws everything on top of the picture. Text goes in as ASS subtitle events on a design grid scaled to the TV's 1920x1080 mode. The radar circle and the album art go in as raw image files through mpv's overlay command. The panel copies the Roku channel's coordinates, fonts, and colors, so the two lines look the same on the TV.
MediaMTX draws one stream from the camera and gives the Pi read-only access to it. The jukebox service handles the Jellyfin login, the catalog, and the album art; it also proxies the tracks and forwards Range requests so mpv can seek.
The radar compositor also runs on the bridge. It rebuilds the circle every two minutes and the loop every five minutes. Both versions use the same bridge, so switching to the Pi didn't move any of that work.
The radar loop
We took the idea from The Weather Channel's 'Local on the 8s'. The loop is 37 frames five minutes apart, three hours of radar on a darkened topographic map centered on home, with each frame's time and a countdown from T-2:55 to NOW along the bottom. The compositor pulls frames from a time-enabled NEXRAD mosaic in Web Mercator format so the map isn't stretched. It keeps each frame on disk so a refresh is quick, then encodes the sequence with ffmpeg and serves it with an ETag. We first tried NOAA's time-enabled mosaic, but its window runs anywhere from two to four hours, so it can't promise a three-hour loop, and we dropped it.
The Pi keeps its own copy of the file, so the view opens at once, and you can fast-forward and rewind through the frames locally. We play the loop with software decoding, because stepping back a frame is unreliable with the hardware decoder. When you leave the view, the camera returns to hardware decoding. The music keeps playing. Only the clock and any weather alert stay on screen, over the map.
Weather alerts and the sound problem
Window.py checks every two minutes for active alerts from the National Weather Service at the home location; the service is free, needs no API key, and takes the coordinates as the query.
Each alert appears in a toast that slides down from the top of the screen in 0.3 seconds, and no timer dismisses it. The toast remains until you press OK or the alert itself ends, and no other key acknowledges it. The stripe color follows the severity: red for Extreme, orange for Severe, yellow for Moderate, and blue for Minor.
When there's more than one alert, the most severe comes first. Pressing OK acknowledges it and brings up the next one. Alerts you've acknowledged stay visible as badges until they end; those you haven't acknowledged sound again every five minutes.
The toast is brief because the details are in the radar view, so there is no separate alerts button. Press OK there, and a panel opens on the right, listing every active alert, most severe first, with its times, areas, instructions, and event name; Up and Down scroll it, and OK or Back closes it. Entering the radar view while a toast is up opens the panel. The map shows each alert's area too: polygon warnings by their geometry, county and zone alerts by zone boundaries fetched once from the NWS and kept, filled with the severity color at 25 percent and the event name at the centroid. The bridge sends the map's bounding box with the loop, so the Pi projects the outlines onto the same map the loop was drawn on and the two stay aligned.
We thought more about the identity than the toast. Whenever the NWS issues an update, it sends a new message with a new ID, and the previous one vanishes from the feed, so keying on the ID would sound the alarm at every update. Instead, window.py identifies each alert by the VTEC string, the NWS's identifier for an event across all updates and cancellations; if that fails, it uses the reference chain, then the ID. An update that raises severity or urgency counts as an escalation and sounds again; any other update is a revision and only refreshes the text.
A thread closes when its end time passes, when a cancellation refers to it, or when it is missing from two polls in a row. We keep the record for six more hours so a late update doesn't sound the same alarm again.
Window.py saves the threads and their acknowledgments to disk, so a restart doesn't replay alerts you've acknowledged.
Each severity has its own sound. We picked four tones from E2S's library, from low to high: a chime, an intermittent 745 Hz sound, the 420 Hz Australian alert, and the 1 kHz general alarm for Extreme. We don't reproduce the EAS attention signal. The Pi's HDMI audio device accepts only the IEC958 subframe format, which ALSA's dmix cannot mix, so two players can never use the device at the same time. So a tone stops the music, notes where it left off, and plays through a temporary third mpv with 500 ms of silence in front so the TV's HDMI lock-in doesn't distort its start; then it reloads the track at the same position, paused, so the room stays quiet until someone presses Play. Press Play while the tone is sounding and the music resumes instead. Tones play one at a time, 20 seconds apart, or 3 seconds when a more severe one is waiting.
It's a convenience for the house, not an emergency broadcast channel. The Pi can be off, and the feed can lag.
The remote
The Roku Express's infrared remote works with the receiver built into
the Argon case. window.py reads the Linux input devices itself, with no
library, and rescans every 30 seconds so you can use a USB keyboard for
testing and a virtual remote on /dev/uinput for automated
checks. The Roku remote uses NEC
extended, and a kernel keymap maps its buttons to normal keys. This took
one hour on 2026-10-07 for two reasons: the keymap tool is a separate
package on Debian Trixie, and the receiver only enables RC-6 decoding by
default, so the NEC frames arrived undecoded until the keymap loaded a
protocol named exactly nec. Name it
necx and the tool looks for a plugin and switches NEC off.
We meant to send volume to the TV over HDMI-CEC, but while the TV answers CEC commands, it ignores volume and mute from the active source. So the arrow buttons adjust the music player's software volume in steps of 3, show a "Volume N" bar at the top for 1.5 seconds, and keep the setting across a restart. The tones have their own volume. CEC can also carry the TV's own remote, but that TV has no remote, so we haven't tested it.
Coming back without hands
A random power failure in the house tested this for us: the Pi, the camera, and the network restarted. The bridge host did not and stayed dark. Dave pressed the power button; the BIOS now powers the machine on after an AC power loss. The Pi's case has a jumper set to always-on, so it boots as soon as power returns.
The TV showed a console login prompt because the video player, with nothing to play, had given up the screen. Window.py now keeps the player window open with nothing loaded, so during a bridge outage the screen shows black with the clock and the message 'camera unavailable / waiting for the bridge'; everything else stays on screen, and the camera retries every five seconds. A player that exits restarts within two seconds; if window.py exits, systemd restarts it after five seconds.
The music catalog catches up within a minute of the bridge. While the jukebox doesn't respond, the Pi works from its saved copy and asks again every minute, then every hour once the bridge is back. It used to wait 20 minutes between attempts, which left search and shuffle on the saved list for that long after the bridge returned. The library pages ask the bridge directly, so they come back the moment it does.
Small things that took a day
The music dimmed in and out, and nothing in the chain alters the level, so we didn't know why. We put an EBU R128 loudness meter in the audio player's filter chain to log short-term, momentary, and integrated loudness every 30 seconds alongside the volume setting and the track.
The Pi's output stayed steady through a drop Dave heard, so the cause was further down the chain: the TV had DTS TruVolume on, a slow automatic volume control that turns the level down after loud sections. We switched it off, and the quiet intros are the music itself: a trance intro at -31 LUFS stays where it is, even after a Cher track at -8.
A Japanese artist name drew as a row of empty boxes. The Pi had only DejaVu, which has no CJK glyphs. Noto Sans CJK fixed the glyphs and changed the widths because libass scales a fallback font so its line height matches the requested size, making a kana or ideograph about 0.69 of the font size wide. window.py measures East Asian wide characters that way now, so a cut line still ends in an ellipsis where it should.
Library search came next, a Netflix-style on-screen keyboard with results drawn from the catalog in memory, every track with its album and artist, so they change with every key, and no request leaves the Pi. Albums whose name matches, then artists, then tracks, at most 60 rows with the full count on the right.
Where it stands
It has run on the basement office TV since the night it replaced the Roku. Dave uses it daily because the basement office has no window, and he feels disconnected from the rest of the world without it.