Skip to content
Development

Building a music player I can't actually hear

By Victor Da Luz
iosswiftmusickitdev-logdeep-cut-atlas

This app was later renamed Deep Cut Atlas. It’s called “Discoverer” throughout below, because that’s what it was called on the day this happened.

The History tab in my music app shows your recently played tracks, and I wanted you to be able to tap one to play it - a tiny memory-jogger, not a full player. Just enough to go “oh right, that song.” Simple feature. There was one catch: I can’t run it.

MusicKit playback doesn’t work in the iOS Simulator. At all. No audio, no error, just silence. The only way to hear a single second of what I’m building is to put it on a physical iPhone with an Apple Music subscription - and I don’t have the paid developer account wired up yet (that’s a later task). So I’m building a music player I have no way to listen to.

That sounds like a reason to not build it yet. But “I can’t run the final 5%” isn’t a reason to skip the other 95%, and most of this feature isn’t the audio - it’s the list, the paging, the tap handling, the now-playing indicator, the add-to-playlist flow. All of that I can build and verify. The trick is drawing the line in the right place.

Put the untestable part behind a tiny door

The actual playback is four lines:

let song = try await resolveSong(id: track.id)
player.queue = [song]
try await player.play()
// ...and player.pause()

So I hid those four lines behind a protocol with exactly two methods:

@MainActor
protocol MusicPlaying: AnyObject {
    func play(_ track: LibraryTrack) async throws
    func pause()
}

The real implementation wraps ApplicationMusicPlayer. A mock implementation just records what it was asked to play. On the simulator the app injects the mock; on a device, the real one. This is the same pattern I use for the whole library layer, so it wasn’t new - it just meant the “can’t test this” surface shrank from a whole feature to two method bodies.

The indicator is a lie I’m telling on purpose

Here’s the part I had to be careful about. When you tap a row, a little waveform appears to show it’s playing. Where does that come from?

Not from the player. The player is a fire-and-forget command - I tell it to play, and it does (or, on the simulator, doesn’t). The indicator is driven by the view model’s own state: a nowPlayingTrackID and an isPlaying flag that I flip when you tap. Optimistic state.

func togglePlayback(_ track: LibraryTrack) async {
    if nowPlayingTrackID == track.id, isPlaying {
        player.pause(); isPlaying = false; return
    }
    try? await player.play(track)
    nowPlayingTrackID = track.id
    isPlaying = true
}

This means the indicator works perfectly in the simulator - tap a row, waveform appears, tap again, it pauses - while no audio plays at all. And that’s the trap I had to name out loud when I checked the feature: a toggling indicator is evidence the UI is wired up, not evidence the music plays. Those are two completely different claims, and the demo makes them look like one.

I wrote it down explicitly so I wouldn’t fool myself later: the simulator confirms the gestures, the list, the paging, the toasts. Whether you actually hear Khruangbin is unverified until the feature runs on a real phone. Not “probably fine” - unverified.

What the optimistic state will cost me later

There’s a real bug lurking in that optimistic approach, and I’m leaving it in on purpose for now. Because the indicator is driven by my flag and not by the player, it doesn’t know when a song actually ends. On a device, a track will finish, the audio will stop, and my little waveform will keep happily animating because nothing told it otherwise.

Fixing that means subscribing to the player’s real playbackStatus and reconciling my state with it. That’s genuinely device-only work - I can’t even write it meaningfully without a device to observe - so it goes in the same bucket as “verify the audio.” I left a note on the issue so future-me doesn’t rediscover it the hard way.

The lesson

When part of a feature is genuinely untestable in your environment, the move isn’t to skip it or to pretend the green checkmark covers it. It’s to make that part as small as possible, build and verify everything around it honestly, and be precise about what your “it works” actually proves. A mini-player I can’t hear is still mostly buildable - as long as I don’t confuse the part I tested with the part I didn’t.

Related reading