NTS Status Bar App

SwiftUIInteraction

If you know me then you know that I'm a big NTS Radio listener. When I learned that they were coming out with a physical switcher I was really excited, but the £139 price tag, combined with the fact that I don't have any real hi-fi set up, meant that I couldn't really justify it.

I saw on Reddit that sennabecool had vibecoded a status bar player called NTS Dial. What a great idea! I think what makes the physical player so special gets lost in translation to a digital experience though, so I decided to build my own status bar app, optimised for macOS but equally as delightful to use, just in a different way.

NTS Dial by sennabecool.
NTS Dial by sennabecool.

NTS Dial goes all in on skeuomorphism, with a chunky dial, and at 542 by 362 points it's pretty big for something that opens from the menu bar. I wanted mine to be smaller and flatter and feel like it belonged on a Mac, and I kept coming back to Dieter Rams. His radios for Braun in the fifties and sixties are about as simple as a radio gets, basically a speaker grille and a dial.

Dieter Rams's T 1000 world receiver for Braun, 1963. The red in Tuner is sampled from it. Photo by Daderot, CC0, via Wikimedia Commons.
Dieter Rams's T 1000 world receiver for Braun, 1963. The red in Tuner is sampled from it. Photo by Daderot, CC0, via Wikimedia Commons.

Apple borrowed a lot from Rams, from the iPod to the early iPhone apps, and the World Clock in iOS 6 and 7 used plain analog faces a lot like the clocks his team made at Braun.

Rams's T3 pocket radio from 1958 next to the first iPod from 2001. Image via Alex Lacerda.
Rams's T3 pocket radio from 1958 next to the first iPod from 2001. Image via Alex Lacerda.
World Clock in iOS 6 and iOS 7. Screenshots by Ars Technica.
World Clock in iOS 6 and iOS 7. Screenshots by Ars Technica.

The dial is a white disc on a pale grey background with sixteen dots round the edge. The colours are basically white and #1D1D1D with a few greys in between, and one red, #E0330C, which I sampled from a button on the T 1000 and only use for the NTS 1 badge and the small dots that show what's playing.

Loading demo...
The dial running in the page, without the sound. Point at it to see the mixtapes, then drag the bead round.

How it works

The dial only has the sixteen Infinite Mixtapes on it, each as a small dot on the ring, and a black bead moves along the ring to whichever one you've picked. When you point at the dial the dots turn into each mixtape's NTS icon and the bead grows into a black circle with the picked mixtape's icon inside it. You can drag the bead round or scroll over the dial, and on a Force Touch trackpad you feel a small tick each time it passes a mixtape. Clicking the middle of the dial plays or stops it. The first time you open it, the bead goes once round the ring on its own, so you know the dial turns.

The two live channels sit in a list under the dial, NTS 1 with a red badge and NTS 2 with a white one, each showing the show that's on and what's next. A small dot in the corner of a row lights up once sound is coming out of that channel, and pointing at a row shows where the show is broadcasting from and when it finishes in the middle of the dial. While a live channel is on the bead goes hollow, so you can still see where the dial is set.

The menu bar icon is the dial again at 14 points, a ring with the bead where it's set, and it fills in solid while something's playing. When NTS 1 or 2 is on it shows the channel's number inside the ring instead.

The API

The NTS website gets its data from a handful of JSON endpoints, and anyone can call them without a key. /api/v2/live returns both live channels, each with the show that's on now and the next seventeen after it. Every broadcast has a title and a start and end time with the UTC offset, and most have an embeds.details object with things like the location and the artwork.

GET /api/v2/live, trimmed
{
  "channel_name": "1",
  "now": {
    "broadcast_title": "THE NTS BREAKFAST SHOW W/ FLO",
    "start_timestamp": "2026-10-22T09:00:00+01:00",
    "end_timestamp": "2026-10-22T11:00:00+01:00",
    "embeds": {
      "details": {
        "name": "The NTS Breakfast Show w/ Flo",
        "location_long": "London"
      }
    }
  },
  "next2": {
    "broadcast_title": "Ayo Edibiri",
    "embeds": []
  }
}

The title on each broadcast is in capitals, but the name inside details keeps the show's own casing, so Tuner uses that one when it's there. When NTS doesn't have details for a broadcast, embeds comes back as an empty array instead of an object, so the decoder accepts either.

Tuner fetches the schedule once a minute, or four seconds after the current show is due to end if that's sooner, so the name in the middle changes within a few seconds of the show changing.

/api/v2/mixtapes lists the sixteen Infinite Mixtapes with a one line description and three stream URLs each, one Icecast and two HLS. The icons on the dial come from there too, as 128 pixel black PNGs, which work as template images in SwiftUI so I can tint them grey or black.

Icecast and HLS

NTS streams every channel in two ways. Icecast is the older one, a single HTTP request that never finishes, so the server keeps sending audio for as long as you're listening and the player can start as soon as it has a little of it. The URL NTS Dial uses for NTS 1 redirects to a streaming host called Radiomast.

Terminal
$ curl -sI https://stream-relay-geo.ntslive.net/stream
HTTP/2 302
location: https://streams.radiomast.io/nts1

HLS (HTTP Live Streaming) is Apple's format. The player downloads a playlist of short audio files and plays them one after another, and the playlist keeps getting longer as new files are added. It handles bad connections better, but players start a few files back from the newest one, so it runs behind live.

The first version of Tuner took about three and a half seconds to make any sound after you pressed play, because AVPlayer waits until it has a few seconds buffered before it starts. A physical radio comes on straight away, so I tried turning the waiting off. AVPlayer's status changed to playing after 0.1 seconds, but the playhead never moved and there was no sound. Asking for a one second buffer instead got it down to about 0.7 seconds on most of the stations I tested. Once the audio is playing it goes back to AVPlayer's normal buffer, so a bad connection is less likely to cut it out.

Radio.swift
let item = AVPlayerItem(url: station.stream)
item.preferredForwardBufferDuration = 1
player.replaceCurrentItem(with: item)
player.play()

// Later, once timeControlStatus is .playing
player.currentItem?.preferredForwardBufferDuration = 0

When I played Tuner next to the NTS app the two didn't line up. The NTS website's JavaScript points at streams.radiomast.io/nts1/hls.m3u8, the HLS version of the same channel, so I recorded both feeds with ffmpeg at the same time for 110 seconds. Then I turned each recording into a loudness curve with 100 points a second and slid one along the other until they matched. The HLS feed was 40.9 seconds behind on NTS 1 and 38.1 seconds behind on Poolside, with correlations of 0.89 and 0.98, so it's the same audio, just later.

Tuner stays on Icecast, so it's about 40 seconds closer to live than the website.

Tuner is open source on GitHub under the MIT licence, so if anyone at NTS wants to use it, they can (happy to be compensated with an Atonemo).

View project ↗

Posted on 22 October 2026
Permalink, All Playground