---
title: "One Media Element: Getting Spotify and a Camera Feed Onto the Same TV"
date: "2026-09-20"
publish_date: "2026-09-29"
excerpt: "webOS allows an app exactly one media element, which kills the obvious approach stone dead. Here's how to mux Spotify audio into a 3D printer's camera stream so the television only ever sees one."
tags: ["webos", "go2rtc", "ffmpeg", "spotify", "librespot", "mse", "3d-printing"]
author: "Martin Harris"
canonical: "https://blog.nakomis.com/2026-09-20-one-media-element"
repos:
  - name: "naktv"
    url: "https://github.com/nakomis/naktv"
approved: true
---

# One Media Element: Getting Spotify and a Camera Feed Onto the Same TV

![A living room television showing a close-up of a 3D printer mid-print, whilst a record player spins quietly on the shelf below it](images/one-media-element-1.png)

I have a TV app that shows the camera on my 3D printer. It works nicely: 1080p, hardware decoded, smooth. What it didn't do was play music, and watching a printer lay down layers in silence for four hours is a worse hobby than it sounds.

The obvious fix is to add an `<audio>` element. That does not work, for a reason that took an evening to find and about ninety seconds to verify. This guide is the working version: by the end you'll have a webOS app showing a live camera feed at full quality, appearing in Spotify's device list as a Connect target, and playing both through a single video element — because a single video element is all the television will give you.

## What you'll need

- **A webOS TV app.** Mine targets an LG B3 (webOS 23, Chromium 108). Any platform with a one-media-element restriction has the same problem and the same solution.
- **An always-on box with `ffmpeg`, [go2rtc](https://github.com/AlexxIT/go2rtc) and [librespot](https://github.com/librespot-org/librespot).** A Mac mini, a NUC, a Raspberry Pi 4 — anything that can encode 1080p H.264 in hardware. This machine does all the work; the TV just plays what it's handed.
- **A camera stream.** Mine is `mjpg-streamer` on the OctoPrint box, serving 1080p15 MJPEG.
- **Spotify Premium.** librespot needs it.

## Step 0: Verify the constraint before you waste an evening

Do this first. It takes two minutes and it determines the entire architecture.

Put a `<video>` and an `<audio>` on a page, play both, and log `paused` on each. On the B3:

| Action | Video | Audio |
|---|---|---|
| Tone playing | `paused=true` (the OS paused it) | playing |
| Force `video.play()` | `paused=false` | `paused=true`, frozen |
| Stop tone, resume video | `paused=false` | — |

Whichever element starts last wins, and the OS pauses the other. It is not a DRM restriction and it is not Spotify's doing — that test used a plain AAC file off the local disk. LG document it as "use only one audio element in your app" to avoid hardware decoder conflicts, and their forums describe a second media element quietly taking the first one's rendering surface away.

So everything that plays audio *alongside* the video is dead on arrival. That includes the Spotify Web Playback SDK, which is otherwise perfectly happy on this TV — Widevine instantiates, the SDK accepts the environment, the device appears in the Connect list. It just can't coexist with the picture.

Three other routes I tried, so you don't have to:

- **MJPEG in an `<img>` plus an `<audio>`.** This genuinely works, because an `<img>` isn't a media element and doesn't touch the hardware pipeline. But the TV software-decodes 1080p JPEGs at ~228 KB a frame and it's visibly jerky. Downscaling smooths it out and throws away the quality you wanted.
- **WebRTC video plus the Spotify SDK.** Also coexists, because webOS software-decodes WebRTC rather than routing it through the hardware pipeline — and that's precisely why it's unusable. ~73 ms per frame at 1080p is a ~13.7 fps ceiling against a 15 fps source, so it falls slowly behind and then jumps to catch up. Switching the encoder to `constrained_baseline` got it from ~91 ms to ~73 ms and still didn't unlock hardware decode.
- **WebCodecs.** `VideoDecoder` exists, but `prefer-hardware` reports `supported: false`. Software only, so the same class of problem.

The pattern is hard to miss: anything that dodges the one-element rule does so by leaving the hardware decoder behind, and the hardware decoder is the whole reason the picture is good.

## The shape of the solution

If the TV will only give you one media element, give it one media element — with both tracks already inside it. Mux upstream, on a machine that has no such restrictions.

![Architecture diagram of the NakTV feed: mjpg-streamer on the OctoPrint host feeds an ffmpeg video producer, whilst librespot feeds a PCM pacer through a named pipe into an ffmpeg audio producer; go2rtc muxes both into a single printer_av stream which the TV plays over MSE in one video element](images/naktv-feed-architecture.svg)

Two producers, one stream. go2rtc owns the muxing and the TV plays the result. The interesting part is that librespot sits *outside* go2rtc's process tree, and Step 4 explains why that matters more than it looks.

## Step 1: Encode the video with hardware acceleration

go2rtc's `exec:` producer takes an ffmpeg command line with `{output}` standing in for the RTSP URL it wants you to publish to:

```yaml
streams:
  printer_av:
    - "exec:ffmpeg -hide_banner -v error
         -hwaccel videotoolbox -hwaccel_output_format videotoolbox_vld
         -fflags nobuffer -flags low_delay
         -use_wallclock_as_timestamps 1
         -i http://172.29.0.32:8090/?action=stream
         -c:v h264_videotoolbox -g 30 -bf 0 -profile:v high -level:v 4.1
         -an -fps_mode passthrough
         -rtsp_transport tcp -f rtsp {output}"
```

`-use_wallclock_as_timestamps 1` matters far more than it appears to, and Step 5 is where it bites.

The acceleration flags are the only platform-specific part of this whole setup. On macOS it's VideoToolbox; on Linux/x86 with an Intel or AMD GPU it's `-hwaccel vaapi` and `h264_vaapi`; on a Raspberry Pi 4 it's `h264_v4l2m2m`. The Pi 5, in a decision I'm still cross about, has no H.264 hardware encoder at all — use `libx264 -preset ultrafast`, which its A76 handles comfortably at 1080p15.

While you're in the config, set this:

```yaml
api:
  listen: ":1984"
  origin: "*"
```

A webOS app runs from `file://com.nakomis.naktv-webos`, which is a genuine origin rather than an opaque one, and go2rtc will return a 403 on the WebSocket upgrade without it. That is a cheap mistake to spend an hour on.

## Step 2: Turn the box into a Spotify Connect device

librespot does the whole Connect protocol — zeroconf discovery, the lot — and its `pipe` backend writes raw PCM to stdout:

```bash
librespot --name NakTV --backend pipe --bitrate 320 --initial-volume 50
```

Run that and "NakTV" appears in the device list on your phone. What comes out of stdout is signed 16-bit little-endian stereo at 44.1 kHz, which is a detail you need in two places shortly.

Don't pipe it straight into ffmpeg. That was my first attempt and it produces something genuinely memorable.

## Step 3: Pace the PCM

![A fire hose at full pressure aimed into a single small drinking glass on a kitchen worktop, water spraying in every direction](images/one-media-element-2.png)

**librespot has no internal rate limiting.** Its pipe backend relies on the sink blocking to pace it, the way a real sound card does. Consume eagerly and it dumps an entire track as fast as the pipe will take it — which sounds like garbled static, and then librespot concludes the track has finished and skips to the next one. And the next. My first working version chewed through an album in about forty seconds whilst producing a noise my wife described, generously, as experimental techno.

There's a second problem hiding behind the first: **librespot writes nothing at all whilst paused.** That starves ffmpeg, the audio track stalls, and on a muxed stream a stalled track can take the picture down with it.

Both are fixed by a small pacer between librespot and ffmpeg. It reads at most one 20 ms chunk per tick — which back-pressures librespot into behaving like a sound card is attached — and substitutes silence when nothing arrives:

```python
RATE = 44100
CHANNELS = 2
SAMPLE_BYTES = 2
FRAME = CHANNELS * SAMPLE_BYTES        # 4 bytes: one sample for each ear
CHUNK_MS = 20
CHUNK = int(RATE * CHUNK_MS / 1000) * FRAME
```

That `FRAME` constant is the part I'd like you to take away from this post, because getting it wrong is genuinely hard to diagnose. A stereo S16 frame is four bytes, and **you must never write a partial one**. Split a frame down the middle and every subsequent sample is offset by two bytes, so the left channel's data arrives in the right channel's slot and stays there — permanently, for the rest of the stream. It sounds like static, not like a glitch, and nothing in any log mentions it.

So the pacer only ever emits whole frames, and pads the remainder with silence:

```python
available = min(len(pending), CHUNK)
usable = available - (available % FRAME)
frame = bytes(pending[:usable])
del pending[:usable]
if len(frame) < CHUNK:
    frame += silence[len(frame):]
```

A test that counted misaligned frames across a run found 2,017 before that change and zero after.

## Step 4: Keep librespot out of go2rtc's process tree

This is the design decision that's easiest to get wrong, because the tidy-looking option is the broken one.

go2rtc starts and stops its producers whenever the last consumer comes or goes — an app reload, a tab switch, a sideload, a brief network blip. If librespot is a child of the audio producer, every single one of those events mints a *new* Connect device and leaves Spotify talking to a dead one. Pressing play on your phone then does nothing whatsoever, silently, and the device list slowly fills up with identical entries named NakTV.

So librespot and the pacer run as their own long-lived process, writing into a named pipe, and go2rtc's audio producer only ever reads from that pipe:

```bash
librespot --name NakTV --backend pipe --bitrate 320 \
  | python3 pcm-pacer.py --output /tmp/naktv-spotify.pcm
```

Two details make the pipe survive go2rtc restarting the far end at will. The pacer opens it **read-write**, so it never sees EOF or a broken pipe no matter how often ffmpeg comes and goes; and it writes **non-blocking**, so with nothing reading, audio is dropped on the floor rather than backing up and stalling playback. go2rtc can restart its side as often as it likes and the Spotify session never notices.

One more thing worth knowing: librespot does not exit on a broken pipe. Kill the pipeline carelessly and you leave orphans advertising duplicate devices, so start the whole thing under `set -m` and kill the process group, and sweep for strays on startup.

## Step 5: Mux the audio in

The audio producer is deliberately boring, because go2rtc restarts it freely and nothing important may live here:

```bash
exec ffmpeg -hide_banner -loglevel warning \
  -use_wallclock_as_timestamps 1 -f s16le -ar 44100 -ac 2 -i "$FIFO" \
  -af aresample=async=1 \
  -c:a aac -b:a 160k -ar 48000 -ac 2 \
  -rtsp_transport tcp -f rtsp "$OUT"
```

Two flags there are load-bearing.

**`-use_wallclock_as_timestamps 1`**, again. The video producer stamps frames with the wall clock because the MJPEG source carries no timestamps of its own. Raw PCM on a pipe has none either, so without this ffmpeg numbers the audio from zero — and the two tracks end up on completely different timelines. Muxed together, the player locks onto one and decides the other's frames are wildly out of range, so it never renders them. The symptom is *sound plays and the picture never appears*, which sends you looking at the video pipeline, which is fine.

**`-af aresample=async=1`.** The wallclock stamps arrive with the jitter of whenever ffmpeg happened to read the pipe rather than when the samples were generated, so consecutive packets can land slightly out of order. ffmpeg logs "Non-monotonic DTS" for every packet and quietly rewrites the timestamps itself, and on a muxed stream that unsettles the video too — the player stalls waiting on audio and then jumps. Resampling to a continuous timeline keeps the wallclock base the video is aligned to whilst making the output strictly monotonic.

Add it to the stream as a second producer and go2rtc does the muxing:

```yaml
streams:
  printer_av:
    - "exec:ffmpeg ... {output}"          # video
    - "exec:spotify-audio.sh {output}"    # audio
```

`curl -X POST http://127.0.0.1:1984/api/restart` reloads the config in place.

## Step 6: Play it on the TV with MSE

Now the last trap. go2rtc offers several transports and on this TV **only one of them carries both tracks**:

| Transport | Result |
|---|---|
| Progressive `stream.mp4` | Plays the audio, never renders the video |
| HLS `stream.m3u8` | Refused outright with `NotSupportedError` |
| **MSE over WebSocket** | **Both tracks, hardware decoded** |

The protocol is go2rtc's own: connect to `/api/ws?src=NAME`, send a `mse` message listing the codecs you support, get back a MIME type to open a `SourceBuffer` with, then append every binary frame that follows.

```ts
const CODECS = 'avc1.640029,avc1.42E01E,mp4a.40.2';

socket.onopen = () => socket.send(JSON.stringify({ type: 'mse', value: CODECS }));

socket.onmessage = (event) => {
  if (typeof event.data === 'string') {
    const message = JSON.parse(event.data);
    sourceBuffer = mediaSource.addSourceBuffer(message.value);
    sourceBuffer.mode = 'segments';
    sourceBuffer.addEventListener('updateend', flush);
    return;
  }
  queue.push(event.data);
  flush();
};
```

Advertise both codecs in that string. It's what tells go2rtc which mux to build, and asking for video alone gets you exactly that.

## Step 7: Make it survive the night

A demo that works for ten minutes is not the same as a picture that's still there in the morning. Three things earned their place, all of them after the fact.

**Watch segment arrival, not `currentTime`.** When go2rtc restarts a producer the WebSocket stays open and simply stops delivering. Nothing fires an error, nothing closes, and the picture freezes silently until somebody relaunches the app. My first watchdog timed `currentTime` and never once fired — because the live-edge chase nudges `currentTime` every second, which resets a progress-based clock and hides exactly the stall you're hunting. Time the arrival of segments instead; that's the thing that actually stops.

**Never drop queued segments.** When the queue backs up, dropping a few to catch up is the obvious move and it is a trap. A segment that's dropped before it's appended punches a hole in the buffer, and MSE will not skip a hole — the playhead reaches it and stops there forever, with perfectly good data buffered on the far side it will never reach. Reconnecting is cleaner: it starts fresh at the live edge, and the hole never exists.

**Skip gaps you're already stranded in.** For holes that predate your good intentions — the encoding box asleep, a flaky link — check whether the playhead sits outside every buffered range. If it does, playback cannot proceed from there under any circumstance, so jumping to the start of the next range is always safe:

```ts
for (let i = 0; i < ranges.length; i += 1) {
  if (ranges.start(i) > video.currentTime) {
    // A shade past the boundary: landing exactly on it can strand us again.
    video.currentTime = ranges.start(i) + 0.05;
    return true;
  }
}
```

And set your live-edge target somewhere sensible. I originally chased to within 300 ms, which on a 15 fps source guarantees starvation — the picture hung for a second, then continued, over and over. 3 seconds of target with an 8 second maximum lag is smooth and still feels live.

## What you've got

A television showing a 1080p hardware-decoded camera feed, appearing in Spotify's device list, playing music through the same speakers — all through a single `<video>` element, because that's all it was ever going to allow.

The general lesson travels further than webOS. When a platform hands you a hard limit of one, the instinct is to look for a way round it. It's usually cheaper to satisfy it honestly and move the complexity somewhere the rules don't apply — in this case, a Mac in the study doing the muxing that the TV was never going to permit.

## Where to go next

The encoding box is currently my laptop, which is a fine place to prove something works and a terrible place to leave it — laptops sleep, and a sleeping laptop is an indistinguishable-from-broken camera feed.

Mine's moving to a 2011 MacBook Pro that I revived from a dead graphics chip and put Ubuntu on. That sounds like a downgrade and mostly isn't: the whole pipeline measured at 0.41 of a core on a modern i9, so even fifteen-year-old silicon has room, and the thing has a healthy battery, which makes it the only machine in the house with a built-in UPS. Pick whatever you've got that stays awake.

The whole thing — go2rtc config, the pacer, the supervision scripts and the MSE client — is in [nakomis/naktv](https://github.com/nakomis/naktv) under CC0. Take any of it.

---

*Martin Harris connects cloud infrastructure to the real world. If you've ever muxed a music stream into a 3D printer's camera feed because the television flatly refused to play both, you're in the right place.*


{{donate}}
