Internet Radio
NullPlayer supports Shoutcast and Icecast internet radio streaming with automatic reconnection and live metadata display.
Architecture
Sources/NullPlayer/
├── Radio/
│ └── RadioManager.swift # Singleton managing radio state
├── Data/Models/
│ └── RadioStation.swift # Station data model
└── Windows/Radio/
└── AddRadioStationSheet.swift # Add/edit station UI
Key Components
RadioManager
Singleton (RadioManager.shared) that manages:
- •Station list (persisted to UserDefaults)
- •Current playing station
- •Connection state (disconnected/connecting/connected/reconnecting/failed)
- •ICY stream metadata (current song title)
- •Auto-reconnect logic
Key Properties:
var stations: [RadioStation] // All saved stations var currentStation: RadioStation? // Currently playing (nil if not radio) var currentStreamTitle: String? // ICY metadata "Artist - Song" var connectionState: ConnectionState // Current connection state var isActive: Bool // True if radio is playing var statusText: String? // Display text for marquee
Notifications:
- •
stationsDidChangeNotification- Station list modified - •
streamMetadataDidChangeNotification- ICY metadata received - •
connectionStateDidChangeNotification- Connection state changed
RadioStation Model
struct RadioStation: Identifiable, Codable {
let id: UUID
var name: String
var url: URL
var genre: String?
var iconURL: URL?
func toTrack() -> Track // Convert to playable Track
}
Connection States
enum ConnectionState {
case disconnected // Not playing radio
case connecting // Initial connection attempt
case connected // Successfully streaming
case reconnecting(attempt) // Auto-reconnect in progress
case failed(message) // Connection failed
}
Audio Engine Integration
RadioManager integrates with AudioEngine through delegate callbacks:
| Callback | Purpose |
|---|---|
streamDidConnect() | Called when stream starts playing |
streamDidDisconnect(error:) | Called on stream end/error, triggers reconnect |
streamDidReceiveMetadata(_:) | Receives ICY metadata for display |
Critical: State Preservation
When loadTracks() is called with radio content:
- •Detect radio content by comparing
track.urlwithcurrentStation.url - •Use
stopLocalOnly()instead ofstop()to preserve RadioManager state - •Calling
stop()would triggerRadioManager.stop(), clearing state
// In loadTracks()
let isRadioContent: Bool
if RadioManager.shared.isActive {
isRadioContent = validTracks.first.map { track in
RadioManager.shared.currentStation?.url == track.url
} ?? false
// ...
}
// Use stopLocalOnly() for radio, stop() for other content
if isRadioContent {
stopLocalOnly() // Preserves RadioManager state
} else {
stop() // Calls RadioManager.stop() for non-radio
}
Playlist URL Resolution
Radio stations often use playlist URLs (.pls, .m3u, .m3u8) that must be resolved to actual stream URLs:
func startPlayback(station: RadioStation) {
let ext = station.url.pathExtension.lowercased()
if ext == "pls" || ext == "m3u" || ext == "m3u8" {
resolvePlaylistURL(station.url) { resolvedURL in
// IMPORTANT: Check casting state fresh here, not before async call
if !CastManager.shared.isCasting {
WindowManager.shared.audioEngine.play()
}
}
}
}
Gotcha: The resolvePlaylistURL call can take up to 10 seconds. Always check CastManager.shared.isCasting fresh inside the callback, not captured before the async call.
Auto-Reconnect
When a stream disconnects unexpectedly:
- •RadioManager receives
streamDidDisconnect(error:)from AudioEngine - •If
manualStopRequestedis false andautoReconnectEnabledis true:- •Increment
reconnectAttempts - •Set state to
.reconnecting(attempt: n) - •Schedule reconnect with exponential backoff (2s, 4s, 8s, 16s, 32s)
- •Increment
- •After
maxReconnectAttempts(5), set state to.failed
Manual stop does NOT trigger reconnect:
- •User pressing Stop →
manualStopRequested = true - •Loading non-radio content →
RadioManager.stop()called - •Switching stations →
currentStationchanges, resets attempts
ICY Metadata
Shoutcast/Icecast streams include in-band metadata with current song info:
- •
StreamingAudioPlayerreceives metadata from AudioStreaming library - •Forwards via
streamingPlayerDidReceiveMetadata(_:)delegate method - •AudioEngine checks
RadioManager.shared.isActiveand forwards to RadioManager - •RadioManager updates
currentStreamTitleand posts notification - •MainWindowView observes notification and updates marquee display
Metadata keys:
- •
StreamTitle- Usually "Artist - Song" format - •
StreamUrl- Stream URL (sometimes) - •
icy-name- Station name - •
icy-genre- Station genre
Casting Radio to Sonos
Internet radio can be cast to Sonos speakers:
- •Sonos receives the stream URL directly (no proxy needed)
- •Time resets to 0:00 (live stream, no seeking)
- •Local playback stops when casting starts
Flow:
- •User starts radio playback locally
- •Opens Sonos menu, selects rooms
- •Clicks "Start Casting"
- •
CastManager.castToSonos()sends stream URL to Sonos - •Local playback stops via
stopLocalForCasting()
UI Integration
Library Browser
Radio stations appear in the Library Browser when "Internet Radio" source is selected:
- •Station list with name, genre, stream URL
- •Double-click to play
- •Right-click context menu (Play, Edit, Delete)
- •"+ADD" button for adding stations
Main Window Marquee
The marquee displays (in priority order):
- •Error message (if any)
- •Video title (if video playing)
- •Radio status/stream title (if radio active)
- •Track title
func getMarqueeDisplayText() -> String {
if let error = errorMessage { return error }
if WindowManager.shared.isVideoActivePlayback { return videoTitle }
if RadioManager.shared.isActive { return RadioManager.shared.statusText }
return currentTrack?.displayTitle ?? "NullPlayer"
}
Station Persistence
Stations are stored in UserDefaults as JSON:
private let stationsKey = "RadioStations"
func saveStations() {
let data = try? JSONEncoder().encode(stations)
UserDefaults.standard.set(data, forKey: stationsKey)
}
func loadStations() {
guard let data = UserDefaults.standard.data(forKey: stationsKey),
let saved = try? JSONDecoder().decode([RadioStation].self, from: data) else {
stations = defaultStations()
return
}
stations = saved
}
Default Stations
New installations include sample SomaFM stations:
- •Groove Salad
- •Drone Zone
- •DEF CON Radio
Testing Radio
Manual QA checklist:
- • Add station with direct stream URL
- • Add station with .pls/.m3u URL (resolves to stream)
- • Play station, verify ICY metadata displays
- • Stop playback, verify no auto-reconnect
- • Disconnect network, verify auto-reconnect attempts
- • Cast to Sonos, verify stream plays on speaker
- • Switch from radio to Plex/local, verify clean transition
- • Switch from Plex/local to radio, verify state preserved