Building tools. Learning to build tools. Learning to build learning tools.
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.
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:
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.
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.
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.”
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.
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.
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.