← Work

Built a SwiftUI app that casts iPhone photos to a TV too old for AirPlay.

A personal iOS app for my family's 2017 Samsung TV, taken through ten rounds of real-hardware testing to TestFlight: discovery, streaming, collections, drag-to-select, and pinch-to-zoom mirrored on the big screen.

  • Product Designer & iOS Developer · 2026
  • Native iOS
  • SwiftUI
  • Built with Claude Code
The NazCast app icon, a donkey between two pairs of cast arcs, beside three iPhone screens: the photo library, drag-to-select, and a zoomed photo
10
Rounds of iteration after v1, each one from testing on the real TV
~2.8k
Lines of Swift across 17 files: SwiftUI, PhotoKit, Network.framework
8.1s
To find the TV without Apple's multicast entitlement
4
TestFlight builds uploaded for family testers

Overview

Our living-room TV is a 2017 Samsung 6 Series. It's a good screen, but it predates AirPlay, so getting family photos onto it meant a USB stick or a paid casting app with ads and a subscription. I wanted the thing those apps do, without the paywall: open my photo library, tap a picture, and it's on the TV.

NazCast does exactly that. It finds the TV on the Wi-Fi, streams photos and videos to it over DLNA, plays slideshows, builds collections from shared albums, and mirrors pinch-to-zoom onto the TV. It's native SwiftUI, targets iOS 18+, uses the iOS 26 Liquid Glass icon format, and is on TestFlight for my family.

Four NazCast screens: the photo library grid, a saved collection with a Play Slideshow on TV button, a photo pinched in to the C4 square, and the Choose a TV sheet with troubleshooting tips
The shipped app, captured in the iOS 26 Simulator. The colour squares and the lettered grid are test fixtures I made so every zoom and selection could be checked exactly.

How I built it

I ran this project as product owner, designer, and QA, with Claude Code as my pair programmer. I set the direction and the bar, tested every build on my own iPhone against the actual TV, and turned what I saw into the next change. Claude wrote and debugged the Swift with me, and drove the Simulator and the TV directly so that every fix was proven on hardware, not assumed.

That loop is the real subject of this case study: eleven requests across two sessions, each one coming from using the previous build in the living room.

Timeline of eleven requests across October 12 and 13, from the first build through audio debugging, collections, drag-to-select, pinch-to-zoom, three icon revisions, and external TestFlight

How it works

A DLNA TV doesn't accept pushed media. You tell it a URL and it downloads the file itself. So the app is two things: a SwiftUI front end over PhotoKit, and a small HTTP server built on Network.framework that serves the current photo or video to the TV, with the byte-range support a TV needs to seek in a video.

Before serving anything, the app converts each photo into something a 2017 TV can show: HEIC becomes an upright JPEG, because old TVs ignore the EXIF rotation flag, and video is exported to H.264 MP4. Control goes over UPnP SOAP: SetAVTransportURI, Play, Seek, volume, and a position poll that drives the on-phone scrubber.

Hand-drawn sketch: the phone sends the TV a note reading 'come get /photo.jpg', and the TV slurps the photo back from the phone through a long straw over Wi-Fi
The short version: the phone never pushes anything. It hands the TV an address, and the TV pulls the photo itself.
Architecture diagram: the iPhone app's layers (Views, CastController, MediaPreparer, MediaServer) talking to the Samsung DLNA renderer in four steps: find the TV, SetAVTransportURI, the TV pulls the file over HTTP, then play, seek and volume

v1: the TV wouldn't show up

The first build compiled cleanly and found nothing. Rather than guess, we tested each discovery method from the Mac against the real TV. Targeted searches timed out. A broad search only woke the TV's Netflix service. The TV did answer a standard multicast search, but iOS blocks multicast unless Apple grants the app a special entitlement.

The fix was a fallback that doesn't need permission: probe every address on the home network at the URLs Samsung renderers use. It found the TV in 8.1 seconds. Two more quirks came out of the same testing. The first cast from a new device shows an allow prompt on the TV, during which it answers 705, “Transport is locked”. And Samsung starts showing a photo the moment it gets the URL, so its 701 reply to Play actually means success. Both are handled in the app.

Table of four discovery attempts: two unicast searches failed, multicast worked but is blocked on iOS, and an HTTP probe of the subnet found the TV in 8.1 seconds
/// Samsung's renderer only answers *multicast* SSDP, and iOS blocks multicast
/// without an entitlement, so ask every host on the /24 over plain HTTP.
private static func probe(_ host: String) async -> URL? {
    await withTaskGroup(of: URL?.self) { group in
        // 2014+ models, then 2010–2013 models
        group.addTask { await check("http://\(host):9197/dmr") }
        group.addTask { await check("http://\(host):52235/dmr/SamsungMRDesc.xml") }
        // First description document that lists AVTransport wins.
        for await url in group where url != nil {
            group.cancelAll()
            return url
        }
        return nil
    }
}
DMRProbe.swift, trimmed. Up to 64 hosts are probed at once, each racing the description URLs Samsung has used over the years.

Round 1: “I can’t hear the videos”

Video played on the TV but nothing came out of the home theatre, which is connected over optical. It was tempting to start rewriting the video export. Instead I wanted to know whether the app or the hardware was at fault.

We asked the TV whether it was muted (it wasn't), pulled an exported video off the phone to inspect it (standard AAC audio), and then cast three test clips with different audio codecs, told apart by colour so I could report back from the couch. All three were audible on the re-run. The fault was in the TV-to-receiver chain, so no code shipped, and a planned change to strip iPhone metadata tracks was dropped because the evidence didn't support it.

Audio troubleshooting: ask the TV for its mute state, inspect the served file's codecs, then an A/B/C test of AC-3, PCM and AAC clips, all audible, so the fault was not in the app

Rounds 3–4: collections and Photos-style selection

My family's best photos live in iCloud Shared Albums, not my library. So the app grew three tabs, Library, Albums, and Collections, sharing one grid component with a select mode. A collection can mix photos from anywhere, is saved on the phone, and plays as a looping slideshow on the TV. Underneath, a small AssetList type lets the same grid and viewer work with a lazy PhotoKit fetch of thousands of items or a plain array of hand-picked ones.

Then I asked for selection that feels like the Photos app: slide a finger across photos to grab them all. SwiftUI has no built-in gesture for that, so it's a UIKit pan recogniser bridged with UIGestureRecognizerRepresentable (iOS 18), which only claims the touch when the swipe is mostly sideways, so vertical swipes still scroll. Holding near an edge auto-scrolls with the new ScrollPosition API. The first version recognised the drag but wouldn't scroll; logging the real coordinates showed a mismatch between the grid's and the scroll view's coordinate spaces. I also swapped the original coral accent for system blue at this point, so the app reads as native.

Two screens: ten photos selected with a single sideways drag, and a collection's sort menu with Order Added, Newest First and Oldest First
/// A one-finger pan that only starts on a mostly-horizontal swipe, so vertical
/// swipes still scroll the grid (the same split the Photos app uses).
func gestureRecognizerShouldBegin(_ recognizer: UIGestureRecognizer) -> Bool {
    guard let pan = recognizer as? UIPanGestureRecognizer else { return true }
    let velocity = pan.velocity(in: pan.view)
    return abs(velocity.x) > abs(velocity.y)
}

/// Everything between the first photo touched and the one under the finger.
private func applyDrag() {
    guard let drag, let current = index(at: drag.point, clamp: true) else { return }
    var next = drag.base
    for i in min(drag.start, current)...max(drag.start, current) {
        let id = assets[i].localIdentifier
        if drag.adding { next.insert(id) } else { next.remove(id) }
    }
    if next != selection { selection = next }
}
SelectableAssetGrid.swift. The selection is recomputed from the drag's starting snapshot each frame, so dragging back un-selects cleanly.

Round 6: pinch on the phone, zoom on the TV

DLNA has no zoom command, so the phone does the zooming. When a pinch or pan settles, the app works out which part of the photo you're looking at, widens it to the TV's 16:9 shape, re-renders that crop from a higher-resolution source so it stays sharp, and sends it as a new image. The TV updates when you lift your fingers, not live, because each image change on this Samsung takes about a second and live updates would stutter.

To test it properly, we made a 4000×3000 photo of lettered squares, then downloaded the frame the TV was actually showing after each gesture. The first attempt failed in a way a quick look would have missed: the phone zoomed, but the TV still got the whole photo. Logging the zoom, pan and fitted size led to reworked crop math, and the same double-tap then sent the TV a zoomed crop.

Before and after: the same double-tap on a lettered test grid. First the TV received the whole photo; after the fix it received a zoomed 1920 by 1080 crop
Proof from the TV itself: each right-hand frame is the image the TV downloaded, fetched back from it after the gesture.
/// The part of the photo to send the TV, as a normalized rect, or nil when zoomed out.
private func tvCrop() -> CGRect? {
    guard zoom > 1.01, fittedSize.width > 0, let image else { return nil }
    // Centre of what's on the phone screen, in normalized photo coordinates.
    let cx = 0.5 - pan.width / (fittedSize.width * zoom)
    let cy = 0.5 - pan.height / (fittedSize.height * zoom)
    // What a 16:9 TV shows of the photo at zoom 1, divided by the zoom.
    let px = image.size.width, py = image.size.height, tv: CGFloat = 16 / 9
    let vw = (px > py * tv ? px : py * tv) / zoom
    let vh = (px > py * tv ? px / tv : py) / zoom
    let cw = min(vw, px), ch = min(vh, py)
    let left = min(max(cx * px - cw / 2, 0), px - cw)
    let top = min(max(cy * py - ch / 2, 0), py - ch)
    return CGRect(x: left / px, y: top / py, width: cw / px, height: ch / py)
}
ViewerView.swift, condensed. The crop matches the phone's zoom level and centre point, so a tall phone view and a wide TV view show the same moment.

Rounds 7–9: a name and an icon

The working name was MafooCast, with a placeholder character icon I'd dropped in for fun. When I asked for TestFlight, that icon became a real problem: it's someone else's character, and Apple's beta review would likely reject it for a public link. Shipping meant owning the brand.

I renamed it NazCast and moved the icon to Apple's Icon Composer format, so iOS 26 renders it in Liquid Glass. The internal bundle ID stayed the same on purpose, so collections and settings carried over to the TestFlight version. The icon went through three rounds: a generic cast symbol, then a donkey with Wi-Fi waves (the donkey is a long-running inside joke in my family, and this app was always for them), traced from reference art into a clean vector, then the donkey centred between stream-style arcs.

That last round surfaced a rendering bug: faint vertical lines inside each arc. Liquid Glass treats every shape as closed, so an open curved stroke got a glass edge drawn across its open side. Redrawing each arc as a solid band with rounded ends fixed it.

Four icon iterations on the iOS 26 home screen: a TV with Wi-Fi, a donkey with Wi-Fi waves, the donkey between arcs with faint artefact lines, and the final fixed version
Each tile is a crop from the iOS 26 Simulator home screen, where the real glass effect renders.

Round 10: shipping to the family

TestFlight needed the unglamorous parts done right: an encryption declaration, a privacy manifest, a registered App ID, an App Store Connect record, and archives exported and uploaded from the command line. Four builds went up in one morning as the icon evolved. External testing then took one more discovery: App Store Connect only shows External Testing once an internal group exists. With that done, my family gets a public link and installs it through TestFlight.

What I'd do next

Request Apple's multicast entitlement, which would cut discovery from about 8 seconds to near-instant. Keep videos streaming when the app goes to the background, which iOS currently pauses. And commission or draw original donkey art before any public App Store release, since the current mark is traced from reference artwork.