130 Widgets

Building tools. Learning to build tools. Learning to build learning tools.

5. App State — The StreamState Machine Arch

A streaming app has discrete, well-defined phases: the user hasn’t started anything, something is connecting, video is playing, or something went wrong. These aren’t continuous values — they’re states in a state machine. FredCam models this with a Swift enum, which turns out to be a remarkably good fit.

The StreamState Enum

enum StreamState {
    case idle
    case connecting
    case live
    case error(String)
}

This is a Swift enum with an associated value. Most cases carry no data, but error carries a String message describing what went wrong. This is equivalent to a discriminated union in F#, or a Rust enum variant with data — it’s more powerful than C#’s plain enum.

The state transitions are:

┌─────────────────────────────────────────────────────────────────┐ │ StreamState Machine │ └─────────────────────────────────────────────────────────────────┘ tap "Start Stream" KSPlayer bufferFinished idle ──────────────────▶ connecting ──────────────────▶ live ▲ │ │ │ tap Stop │ KSPlayer error │ KSPlayer error │ ▼ │ └──────────────────────── error(msg) ◀──────────────────┘ │ tap "Try Again" │ (→ connecting) │ ◀──────────────────┘

Pattern Matching: switch and if case

Reading the current state in Swift uses switch or if case pattern matching. You’ll see both patterns throughout ContentView.swift:

// switch — exhaustive, covers all cases
switch streamState {
case .idle:
    showIdleScreen()
case .connecting:
    showSpinner()
case .live:
    showVideo()
case .error(let message):
    showError(message)  // 'message' is the associated String
}

// if case — check a single case, optionally bind associated value
if case .error(let msg) = streamState {
    Text(msg)
}

// if case without binding — just check which case
if case .connecting = streamState {
    // stay in connecting state, don't overwrite it
}

The switch on a Swift enum is exhaustive: if you add a new case to StreamState, every switch statement in the codebase that doesn’t include it becomes a compile error. This is one of Swift’s best features for maintainability — the compiler enforces that you handle every state.

Enum Extensions for Convenience

Pattern matching is expressive, but it’s verbose for simple boolean checks. ContentView.swift extends StreamState with two helpers at the bottom:

extension StreamState {
    var isLive: Bool {
        if case .live = self { return true }
        return false
    }

    var phase: Int {
        switch self {
        case .idle:       return 0
        case .connecting: return 1
        case .live:       return 2
        case .error:      return 3
        }
    }
}

isLive is used to fade the video layer in or out:

CameraView(...)
    .opacity(streamState.isLive ? 1 : 0)
    .animation(.easeIn(duration: 0.5), value: streamState.isLive)

phase is used as the animation trigger for the overlay container. SwiftUI’s .animation(_:value:) modifier needs a value that conforms to Equatable — it compares the old and new values to decide when to animate. A raw enum with associated values isn’t directly Equatable in Swift (because associated values need their own equality), but an Int always is. Mapping states to phases gives SwiftUI a clean, comparable trigger.

Architecture Concept

Alternatively, you could make StreamState conform to Equatable manually or by synthesizing it with enum StreamState: Equatable. That works for cases without associated values, but the error(String) case complicates equality — two different error messages would be considered different states, which might or might not be what you want for animation triggers. The phase integer is a deliberate simplification: it says “the phase changed,” not “the exact state changed.”

isStreaming: The View-Control Computed Property

The top-level view body in ContentView switches between idle layout and streaming layout based on a computed property:

// True for every state except idle — keeps CameraView alive through errors
private var isStreaming: Bool {
    if case .idle = streamState { return false }
    return true
}

The comment is the key insight: isStreaming is true even in the .error state. This keeps CameraView (and therefore KSPlayer) alive in the view hierarchy when an error occurs, so that tapping “Try Again” can reconnect without tearing down and recreating the player from scratch.

If isStreaming returned false during errors, the view would flip back to the idle screen, then back to streaming when the user tapped Retry, which would destroy and recreate CameraView on every retry attempt. The current design keeps the player alive and lets the error overlay sit on top of it.

State in ContentView

StreamState lives as a @State variable in ContentView:

@State private var streamState: StreamState = .idle

@State is for view-local state — values that one view owns and that don’t need to be observed by sibling views or a parent. SwiftUI stores it outside the struct (since structs are value types and are recreated on every render) and gives you a stable storage location that persists as long as the view is in the hierarchy.

CameraView needs to both read and write streamState — it reads it to decide what to render, and the player’s delegate needs to write to it when the stream connects or fails. This is done with a @Binding:

// ContentView passes a binding down
CameraView(url: url, streamState: $streamState) { action in
    pipAction = action
}

// CameraView declares it as a binding
struct CameraView: UIViewRepresentable {
    @Binding var streamState: StreamState
    // ...
}

The $ prefix creates a Binding<StreamState> — a two-way reference that lets the child view read and write the parent’s state. Think of it as passing a reference to a variable rather than a copy of its value.

Summary

StreamState is a Swift enum with associated values modeling four app phases. Pattern matching (switch, if case) extracts state and associated data cleanly. Helper properties on the enum provide convenient boolean checks for SwiftUI view logic. State lives in ContentView as @State and is shared downward to CameraView via @Binding.