Skip to content

Commit ecf570e

Browse files
joeblauclaude
andauthored
feat: live stats/uptime card in Settings + LIVE pill (#18) (#30)
The broadcast computed BroadcastNetworkHealth (queue, zero-output, target bitrate) and the ABR's effective fps/bitrate internally but never surfaced them — the controller exposed only isLive/errorMessage/thermalNotice. Plumb the telemetry through the @observable controller and display it: - StreamCore: new pure LiveStats value type (bitRate, frameRate, queueBytes, zeroOutputSeconds) with an uplink-health classification (Good/Fair/Congested) and compact bitrate/queue/uptime formatting. Unit-tested (health boundaries + formatting). - Publishers: statsSnapshot() on the Publisher protocol, implemented by RTMPPublisher + SessionPublisher from the ABR's healthSnapshot() + a new currentFrameRate() (effective fps folding in the congestion + thermal caps). Returns nil unless actively publishing, so the HUD clears to "—" during connect/reconnect instead of freezing on stale telemetry. - ScreenCaptureController: @observable liveStats + broadcastStartedAt, fed by a ~1s poll that starts on go-live and tears down on every path (all funnel through stopCapture). Writes snapshots through — including the reconnect nil — with an equality guard and a post-await cancel check. - Settings: a live stats/uptime card atop the launcher while broadcasting (fixed per-tick height so the content-sized drawer never springs), showing uptime, bitrate (target), effective fps, uplink health, and the thermal notice; per-metric VoiceOver labels. - ContentView: a persistent, non-interactive LIVE pill + elapsed timer overlay while live. fps/bitrate are the encoder's applied ABR targets, not measured throughput — measured fps/dropped-frame telemetry depends on M8 (not yet built). Built via an understand/design + adversarial-review multi-agent workflow; the review caught and this fixes a reconnect-staleness bug where the HUD would freeze on last-good values while the stream was actually down. StreamCore: 69 tests pass. Full app builds against the iOS 27 SDK. fixes #18 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bab811e commit ecf570e

7 files changed

Lines changed: 398 additions & 3 deletions

File tree

Stream/ContentView.swift

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ struct ContentView: View {
5858
.overlay(alignment: .bottomTrailing) {
5959
if capture.isLive { micFAB }
6060
}
61+
.overlay(alignment: .top) {
62+
if capture.isLive { livePill }
63+
}
6164
.animation(.spring(duration: 0.3, bounce: 0.2), value: capture.isLive)
6265
.navigationTitle("Stream")
6366
.navigationBarTitleDisplayMode(.inline)
@@ -143,6 +146,39 @@ struct ContentView: View {
143146
.transition(.scale.combined(with: .opacity))
144147
}
145148

149+
// MARK: - Live pill
150+
151+
/// Persistent LIVE indicator + elapsed timer, pinned to the top while a broadcast
152+
/// is live. A glass capsule matching the micFAB; the timer ticks in its own
153+
/// `TimelineView` off `broadcastStartedAt` (a Date) so it keeps counting across
154+
/// backgrounding and only the label — not the whole feed — refreshes each second.
155+
private var livePill: some View {
156+
HStack(spacing: 6) {
157+
Circle()
158+
.fill(.red)
159+
.frame(width: 8, height: 8)
160+
Text("LIVE")
161+
.font(.caption.weight(.bold))
162+
.foregroundStyle(.red)
163+
if let start = capture.broadcastStartedAt {
164+
TimelineView(.periodic(from: start, by: 1)) { context in
165+
Text(LiveStats.uptimeLabel(seconds: Int(context.date.timeIntervalSince(start))))
166+
.font(.caption.weight(.semibold).monospacedDigit())
167+
}
168+
}
169+
}
170+
.padding(.horizontal, 12)
171+
.padding(.vertical, 7)
172+
.glassEffect(.regular, in: .capsule)
173+
.padding(.top, 8)
174+
.transition(.scale.combined(with: .opacity))
175+
// Purely informational: never intercept taps/scroll on the chat beneath it.
176+
.allowsHitTesting(false)
177+
// Combine into one element but keep the elapsed time in the label (a static
178+
// override would drop it) — VoiceOver reads e.g. "LIVE, 12:34".
179+
.accessibilityElement(children: .combine)
180+
}
181+
146182
// MARK: - Setup banner
147183

148184
/// Slim call-to-action shown when the connection isn't ready to publish. Tapping
@@ -188,7 +224,7 @@ struct ContentView: View {
188224
/// the sheet to the exact height of whatever content is on screen (measured
189225
/// from the scroll view's real content size), resizing as sections are pushed.
190226
private var settingsSheet: some View {
191-
SettingsView(settings: $settings, chat: chat, onChange: persist)
227+
SettingsView(settings: $settings, chat: chat, capture: capture, onChange: persist)
192228
}
193229
}
194230

Stream/ScreenCaptureController.swift

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,15 @@ final class ScreenCaptureController: NSObject {
1919
private(set) var isLive = false
2020
private(set) var errorMessage: String?
2121
/// A user-facing note when the device's thermal/power state is forcing a
22-
/// quality reduction, or nil when unrestricted. Read by the (future) stats HUD.
22+
/// quality reduction, or nil when unrestricted. Shown in the live stats HUD.
2323
private(set) var thermalNotice: String?
24+
/// The latest live telemetry snapshot (bitrate / fps / queue health) for the
25+
/// stats HUD, or nil when nothing is publishing yet. Polled ~1s while live.
26+
private(set) var liveStats: LiveStats?
27+
/// When the current broadcast went live, for the elapsed-time display; nil when
28+
/// not live. Uptime is ticked in the UI (TimelineView) off this Date, not off
29+
/// the encoder's `eventAgeSeconds`, which freezes when the app is backgrounded.
30+
private(set) var broadcastStartedAt: Date?
2431

2532
@ObservationIgnored private let picker = SCContentSharingPicker.shared
2633
@ObservationIgnored private var pendingSettings: StreamSettings?
@@ -32,6 +39,9 @@ final class ScreenCaptureController: NSObject {
3239
@ObservationIgnored private var micVolumeObserver: DarwinSignalObserver?
3340
@ObservationIgnored private var lastAppliedCeiling: ThermalPowerCeiling?
3441
@ObservationIgnored private var thermalApplyTask: Task<Void, Never>?
42+
/// ~1s telemetry poll, live only. Cancelled on every teardown path (they all
43+
/// funnel through `stopCapture`).
44+
@ObservationIgnored private var statsTask: Task<Void, Never>?
3545

3646
override init() {
3747
super.init()
@@ -96,6 +106,33 @@ final class ScreenCaptureController: NSObject {
96106
errorMessage = nil
97107
}
98108

109+
/// Polls the publisher's telemetry ~1s into `liveStats` for the stats HUD.
110+
/// Sleeps AFTER the await so the cadence is (poll latency + 1s), and re-reads
111+
/// `publisher` each turn so teardown — which nils it and cancels this task —
112+
/// ends the loop cleanly with no stale write (statsSnapshot self-guards to nil).
113+
private func startStatsPolling() {
114+
statsTask?.cancel()
115+
statsTask = Task { [weak self] in
116+
while !Task.isCancelled {
117+
let snapshot = await self?.publisher?.statsSnapshot()
118+
// Re-check after the await: teardown may have cancelled us and
119+
// cleared liveStats while this poll was in flight — don't write back.
120+
if Task.isCancelled { return }
121+
// Write through INCLUDING nil: the publishers return nil during a
122+
// reconnect, which must clear the card to its "—" placeholders rather
123+
// than freezing on stale last-good telemetry while the stream is down.
124+
self?.updateLiveStats(snapshot)
125+
try? await Task.sleep(for: .seconds(1))
126+
}
127+
}
128+
}
129+
130+
/// Assigns the latest telemetry, skipping a redundant write (and the 1 Hz leaf
131+
/// re-render it would otherwise trigger) when the snapshot is unchanged.
132+
private func updateLiveStats(_ stats: LiveStats?) {
133+
if liveStats != stats { liveStats = stats }
134+
}
135+
99136
private func makePublisher(for transport: StreamCore.StreamProtocol) -> any Publisher {
100137
switch transport {
101138
case .rtmp, .rtmps:
@@ -156,6 +193,9 @@ final class ScreenCaptureController: NSObject {
156193

157194
try await stream.startCapture()
158195
isLive = true
196+
broadcastStartedAt = Date()
197+
liveStats = nil
198+
startStatsPolling()
159199
publishState(true)
160200
lastAppliedCeiling = nil
161201
applyThermalCeiling()
@@ -186,8 +226,12 @@ final class ScreenCaptureController: NSObject {
186226
publisherTask = nil
187227
heartbeatTask?.cancel()
188228
heartbeatTask = nil
229+
statsTask?.cancel()
230+
statsTask = nil
189231
isLive = false
190232
thermalNotice = nil
233+
liveStats = nil
234+
broadcastStartedAt = nil
191235
lastAppliedCeiling = nil
192236
thermalApplyTask = nil
193237
output?.finish()
@@ -614,6 +658,8 @@ final class ScreenCaptureController {
614658
private(set) var isLive = false
615659
private(set) var errorMessage: String?
616660
private(set) var thermalNotice: String?
661+
private(set) var liveStats: LiveStats?
662+
private(set) var broadcastStartedAt: Date?
617663

618664
func presentPicker(settings: StreamSettings) {
619665
errorMessage = "Screen capture requires a physical iOS 27 device."

Stream/SettingsView.swift

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ struct SettingsView: View {
5757
/// root view so the connection survives the settings sheet being dismissed.
5858
var chat: RestreamChat
5959

60+
/// The live capture controller, so the launcher can show a live stats/uptime
61+
/// card while broadcasting. Read-only here; its `@Observable` telemetry drives
62+
/// the card's updates.
63+
var capture: ScreenCaptureController
64+
6065
/// Called after any field mutation so the parent can persist immediately.
6166
var onChange: () -> Void
6267

@@ -216,6 +221,16 @@ struct SettingsView: View {
216221

217222
private var launcherPane: some View {
218223
List {
224+
// Live stats/uptime card, shown atop the launcher only while broadcasting.
225+
// A fixed-height leaf subview so its ~1s telemetry ticks never re-measure
226+
// the launcher and spring the drawer height (see LiveStatsCard).
227+
if capture.isLive {
228+
Section {
229+
LiveStatsCard(capture: capture)
230+
}
231+
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
232+
.listRowBackground(Color.clear)
233+
}
219234
Section {
220235
ForEach(SettingsSection.allCases) { section in
221236
Button { open(section) } label: {
@@ -1003,9 +1018,97 @@ private final class MicrophoneLevelMonitor {
10031018
}
10041019
}
10051020

1021+
/// The live stats/uptime card shown atop the settings launcher while broadcasting.
1022+
/// A leaf view so its ~1s telemetry ticks invalidate only this subtree (not the
1023+
/// whole launcher List), and every value is monospaced + single-line so the card's
1024+
/// height is invariant per tick — the content-sized drawer detent never springs on
1025+
/// a number change (only an occasional thermal-notice appearance resizes it).
1026+
private struct LiveStatsCard: View {
1027+
var capture: ScreenCaptureController
1028+
1029+
var body: some View {
1030+
VStack(alignment: .leading, spacing: 12) {
1031+
header
1032+
metricsRow
1033+
if let notice = capture.thermalNotice {
1034+
Label(notice, systemImage: "thermometer.medium")
1035+
.font(.caption2)
1036+
.foregroundStyle(.orange)
1037+
.lineLimit(1)
1038+
.minimumScaleFactor(0.8)
1039+
}
1040+
}
1041+
.padding(14)
1042+
.frame(maxWidth: .infinity, alignment: .leading)
1043+
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
1044+
}
1045+
1046+
/// Red LIVE marker + the elapsed timer. The clock ticks inside its own
1047+
/// `TimelineView` off `broadcastStartedAt` (a Date), so it survives backgrounding
1048+
/// and invalidates only this line.
1049+
private var header: some View {
1050+
HStack(spacing: 8) {
1051+
Circle().fill(.red).frame(width: 8, height: 8)
1052+
Text("LIVE")
1053+
.font(.caption.weight(.bold))
1054+
.foregroundStyle(.red)
1055+
Spacer()
1056+
if let start = capture.broadcastStartedAt {
1057+
TimelineView(.periodic(from: start, by: 1)) { context in
1058+
Text(LiveStats.uptimeLabel(seconds: Int(context.date.timeIntervalSince(start))))
1059+
.font(.subheadline.weight(.semibold).monospacedDigit())
1060+
}
1061+
}
1062+
}
1063+
}
1064+
1065+
/// Bitrate (ABR target) · effective fps · uplink health. Values are the
1066+
/// encoder's applied targets, not measured throughput (measured fps needs M8).
1067+
private var metricsRow: some View {
1068+
HStack(spacing: 0) {
1069+
metric("Bitrate", value: capture.liveStats?.bitRateLabel ?? "")
1070+
divider
1071+
metric("FPS", value: capture.liveStats.map { "\($0.frameRate)" } ?? "")
1072+
divider
1073+
metric("Uplink", value: capture.liveStats?.linkHealth.label ?? "", tint: uplinkTint)
1074+
}
1075+
}
1076+
1077+
private var divider: some View { Divider().frame(height: 26) }
1078+
1079+
private var uplinkTint: Color {
1080+
switch capture.liveStats?.linkHealth {
1081+
case .good: return .green
1082+
case .fair: return .yellow
1083+
case .congested: return .red
1084+
case .none: return .secondary
1085+
}
1086+
}
1087+
1088+
private func metric(_ label: String, value: String, tint: Color = .primary) -> some View {
1089+
VStack(spacing: 2) {
1090+
Text(value)
1091+
.font(.callout.weight(.semibold).monospacedDigit())
1092+
.foregroundStyle(tint)
1093+
.lineLimit(1)
1094+
.minimumScaleFactor(0.7)
1095+
Text(label)
1096+
.font(.caption2)
1097+
.foregroundStyle(.secondary)
1098+
}
1099+
.frame(maxWidth: .infinity)
1100+
// One element per metric read as "Bitrate: 2.4 Mbps", not the raw
1101+
// value-then-label order the VStack would otherwise expose.
1102+
.accessibilityElement(children: .ignore)
1103+
.accessibilityLabel(label)
1104+
.accessibilityValue(value)
1105+
}
1106+
}
1107+
10061108
#Preview {
10071109
@Previewable @State var settings = StreamSettings.default
10081110
return NavigationStack {
1009-
SettingsView(settings: $settings, chat: RestreamChat(), onChange: {})
1111+
SettingsView(settings: $settings, chat: RestreamChat(),
1112+
capture: ScreenCaptureController(), onChange: {})
10101113
}
10111114
}

StreamBroadcast/RTMPPublisher.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,19 @@ actor RTMPPublisher: Publisher {
768768
applyingTo: stream)
769769
}
770770

771+
/// Live encode/uplink metrics for the stats HUD. `nil` until the stream is
772+
/// actually attached and has published, so the UI shows "connecting…" rather
773+
/// than stale zeros during the initial connect/reconnect windows.
774+
func statsSnapshot() async -> LiveStats? {
775+
guard isRunning, streamAttached else { return nil }
776+
let health = await networkController.healthSnapshot()
777+
let fps = await networkController.currentFrameRate()
778+
return LiveStats(bitRate: health.targetBitRate,
779+
frameRate: fps,
780+
queueBytes: health.queueBytes,
781+
zeroOutputSeconds: health.zeroOutputSeconds)
782+
}
783+
771784
/// Appends a (raw or composited) screen video buffer. Dropped until the output
772785
/// size is locked, so the encoder never starts at the wrong dimensions.
773786
func appendVideo(_ sb: CMSampleBuffer) async {
@@ -1184,6 +1197,17 @@ actor BroadcastAdaptiveBitRateController: StreamBitRateStrategy {
11841197

11851198
func currentFrameInterval() -> Double { frameInterval(severe: false) }
11861199

1200+
/// The effective encode frame rate the ABR is currently targeting (fps), folding
1201+
/// in the same congestion + thermal caps as `frameInterval()` (most-restrictive
1202+
/// wins). Used by the live stats HUD; not a measured output rate (measured fps
1203+
/// needs M8). Mirrors `frameInterval`'s steady-state branches directly rather
1204+
/// than lossily inverting the interval Double.
1205+
func currentFrameRate() -> Int {
1206+
let congestionFps = (congestionActive && configuredFrameRate > 30) ? 30 : configuredFrameRate
1207+
let thermalFps = max(1, min(configuredFrameRate, thermalFrameRateCap))
1208+
return min(congestionFps, thermalFps)
1209+
}
1210+
11871211
/// Applies the current (possibly thermally-clamped) target + frame interval to
11881212
/// a freshly-connected stream. SRT/WHIP emit no `.reset` event, so the
11891213
/// pre-connect store path relies on this to land the ceiling on the encoder.

StreamBroadcast/SessionPublisher.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ protocol Publisher: Actor {
3030
/// Applies a device thermal / Low-Power ceiling (bitrate scale + fps cap) to
3131
/// the encoder, composed with the network ceiling by the adaptive controller.
3232
func setThermalCeiling(bitRateScale: Double, frameRateCap: Int) async
33+
/// A snapshot of the live encode/uplink metrics for the stats HUD, or `nil`
34+
/// when nothing is being published yet (pre-connect / torn down).
35+
func statsSnapshot() async -> LiveStats?
3336
}
3437

3538
/// Publishes over SRT or WHIP via HaishinKit's protocol-agnostic `StreamSession`
@@ -248,6 +251,18 @@ actor SessionPublisher: Publisher {
248251
}
249252
}
250253

254+
/// Live encode/uplink metrics for the stats HUD. `nil` until a session stream
255+
/// exists (pre-connect / mid-reconnect), so the UI shows "connecting…".
256+
func statsSnapshot() async -> LiveStats? {
257+
guard isRunning, stream != nil else { return nil }
258+
let health = await networkController.healthSnapshot()
259+
let fps = await networkController.currentFrameRate()
260+
return LiveStats(bitRate: health.targetBitRate,
261+
frameRate: fps,
262+
queueBytes: health.queueBytes,
263+
zeroOutputSeconds: health.zeroOutputSeconds)
264+
}
265+
251266
private func makeVideoSettings(_ current: VideoCodecSettings, size: CGSize? = nil) async -> VideoCodecSettings {
252267
let frameRate = encodeFrameRate
253268
var v = current

0 commit comments

Comments
 (0)