diff --git a/Stream/ContentView.swift b/Stream/ContentView.swift index d985e3f..287e4a9 100644 --- a/Stream/ContentView.swift +++ b/Stream/ContentView.swift @@ -58,6 +58,9 @@ struct ContentView: View { .overlay(alignment: .bottomTrailing) { if capture.isLive { micFAB } } + .overlay(alignment: .top) { + if capture.isLive { livePill } + } .animation(.spring(duration: 0.3, bounce: 0.2), value: capture.isLive) .navigationTitle("Stream") .navigationBarTitleDisplayMode(.inline) @@ -143,6 +146,39 @@ struct ContentView: View { .transition(.scale.combined(with: .opacity)) } + // MARK: - Live pill + + /// Persistent LIVE indicator + elapsed timer, pinned to the top while a broadcast + /// is live. A glass capsule matching the micFAB; the timer ticks in its own + /// `TimelineView` off `broadcastStartedAt` (a Date) so it keeps counting across + /// backgrounding and only the label — not the whole feed — refreshes each second. + private var livePill: some View { + HStack(spacing: 6) { + Circle() + .fill(.red) + .frame(width: 8, height: 8) + Text("LIVE") + .font(.caption.weight(.bold)) + .foregroundStyle(.red) + if let start = capture.broadcastStartedAt { + TimelineView(.periodic(from: start, by: 1)) { context in + Text(LiveStats.uptimeLabel(seconds: Int(context.date.timeIntervalSince(start)))) + .font(.caption.weight(.semibold).monospacedDigit()) + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 7) + .glassEffect(.regular, in: .capsule) + .padding(.top, 8) + .transition(.scale.combined(with: .opacity)) + // Purely informational: never intercept taps/scroll on the chat beneath it. + .allowsHitTesting(false) + // Combine into one element but keep the elapsed time in the label (a static + // override would drop it) — VoiceOver reads e.g. "LIVE, 12:34". + .accessibilityElement(children: .combine) + } + // MARK: - Setup banner /// Slim call-to-action shown when the connection isn't ready to publish. Tapping @@ -188,7 +224,7 @@ struct ContentView: View { /// the sheet to the exact height of whatever content is on screen (measured /// from the scroll view's real content size), resizing as sections are pushed. private var settingsSheet: some View { - SettingsView(settings: $settings, chat: chat, onChange: persist) + SettingsView(settings: $settings, chat: chat, capture: capture, onChange: persist) } } diff --git a/Stream/ScreenCaptureController.swift b/Stream/ScreenCaptureController.swift index 8409f55..da21a6f 100644 --- a/Stream/ScreenCaptureController.swift +++ b/Stream/ScreenCaptureController.swift @@ -19,8 +19,15 @@ final class ScreenCaptureController: NSObject { private(set) var isLive = false private(set) var errorMessage: String? /// A user-facing note when the device's thermal/power state is forcing a - /// quality reduction, or nil when unrestricted. Read by the (future) stats HUD. + /// quality reduction, or nil when unrestricted. Shown in the live stats HUD. private(set) var thermalNotice: String? + /// The latest live telemetry snapshot (bitrate / fps / queue health) for the + /// stats HUD, or nil when nothing is publishing yet. Polled ~1s while live. + private(set) var liveStats: LiveStats? + /// When the current broadcast went live, for the elapsed-time display; nil when + /// not live. Uptime is ticked in the UI (TimelineView) off this Date, not off + /// the encoder's `eventAgeSeconds`, which freezes when the app is backgrounded. + private(set) var broadcastStartedAt: Date? @ObservationIgnored private let picker = SCContentSharingPicker.shared @ObservationIgnored private var pendingSettings: StreamSettings? @@ -32,6 +39,9 @@ final class ScreenCaptureController: NSObject { @ObservationIgnored private var micVolumeObserver: DarwinSignalObserver? @ObservationIgnored private var lastAppliedCeiling: ThermalPowerCeiling? @ObservationIgnored private var thermalApplyTask: Task? + /// ~1s telemetry poll, live only. Cancelled on every teardown path (they all + /// funnel through `stopCapture`). + @ObservationIgnored private var statsTask: Task? override init() { super.init() @@ -96,6 +106,33 @@ final class ScreenCaptureController: NSObject { errorMessage = nil } + /// Polls the publisher's telemetry ~1s into `liveStats` for the stats HUD. + /// Sleeps AFTER the await so the cadence is (poll latency + 1s), and re-reads + /// `publisher` each turn so teardown — which nils it and cancels this task — + /// ends the loop cleanly with no stale write (statsSnapshot self-guards to nil). + private func startStatsPolling() { + statsTask?.cancel() + statsTask = Task { [weak self] in + while !Task.isCancelled { + let snapshot = await self?.publisher?.statsSnapshot() + // Re-check after the await: teardown may have cancelled us and + // cleared liveStats while this poll was in flight — don't write back. + if Task.isCancelled { return } + // Write through INCLUDING nil: the publishers return nil during a + // reconnect, which must clear the card to its "—" placeholders rather + // than freezing on stale last-good telemetry while the stream is down. + self?.updateLiveStats(snapshot) + try? await Task.sleep(for: .seconds(1)) + } + } + } + + /// Assigns the latest telemetry, skipping a redundant write (and the 1 Hz leaf + /// re-render it would otherwise trigger) when the snapshot is unchanged. + private func updateLiveStats(_ stats: LiveStats?) { + if liveStats != stats { liveStats = stats } + } + private func makePublisher(for transport: StreamCore.StreamProtocol) -> any Publisher { switch transport { case .rtmp, .rtmps: @@ -156,6 +193,9 @@ final class ScreenCaptureController: NSObject { try await stream.startCapture() isLive = true + broadcastStartedAt = Date() + liveStats = nil + startStatsPolling() publishState(true) lastAppliedCeiling = nil applyThermalCeiling() @@ -186,8 +226,12 @@ final class ScreenCaptureController: NSObject { publisherTask = nil heartbeatTask?.cancel() heartbeatTask = nil + statsTask?.cancel() + statsTask = nil isLive = false thermalNotice = nil + liveStats = nil + broadcastStartedAt = nil lastAppliedCeiling = nil thermalApplyTask = nil output?.finish() @@ -614,6 +658,8 @@ final class ScreenCaptureController { private(set) var isLive = false private(set) var errorMessage: String? private(set) var thermalNotice: String? + private(set) var liveStats: LiveStats? + private(set) var broadcastStartedAt: Date? func presentPicker(settings: StreamSettings) { errorMessage = "Screen capture requires a physical iOS 27 device." diff --git a/Stream/SettingsView.swift b/Stream/SettingsView.swift index 4c0b314..398b52c 100644 --- a/Stream/SettingsView.swift +++ b/Stream/SettingsView.swift @@ -57,6 +57,11 @@ struct SettingsView: View { /// root view so the connection survives the settings sheet being dismissed. var chat: RestreamChat + /// The live capture controller, so the launcher can show a live stats/uptime + /// card while broadcasting. Read-only here; its `@Observable` telemetry drives + /// the card's updates. + var capture: ScreenCaptureController + /// Called after any field mutation so the parent can persist immediately. var onChange: () -> Void @@ -216,6 +221,16 @@ struct SettingsView: View { private var launcherPane: some View { List { + // Live stats/uptime card, shown atop the launcher only while broadcasting. + // A fixed-height leaf subview so its ~1s telemetry ticks never re-measure + // the launcher and spring the drawer height (see LiveStatsCard). + if capture.isLive { + Section { + LiveStatsCard(capture: capture) + } + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + .listRowBackground(Color.clear) + } Section { ForEach(SettingsSection.allCases) { section in Button { open(section) } label: { @@ -1003,9 +1018,97 @@ private final class MicrophoneLevelMonitor { } } +/// The live stats/uptime card shown atop the settings launcher while broadcasting. +/// A leaf view so its ~1s telemetry ticks invalidate only this subtree (not the +/// whole launcher List), and every value is monospaced + single-line so the card's +/// height is invariant per tick — the content-sized drawer detent never springs on +/// a number change (only an occasional thermal-notice appearance resizes it). +private struct LiveStatsCard: View { + var capture: ScreenCaptureController + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + header + metricsRow + if let notice = capture.thermalNotice { + Label(notice, systemImage: "thermometer.medium") + .font(.caption2) + .foregroundStyle(.orange) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + + /// Red LIVE marker + the elapsed timer. The clock ticks inside its own + /// `TimelineView` off `broadcastStartedAt` (a Date), so it survives backgrounding + /// and invalidates only this line. + private var header: some View { + HStack(spacing: 8) { + Circle().fill(.red).frame(width: 8, height: 8) + Text("LIVE") + .font(.caption.weight(.bold)) + .foregroundStyle(.red) + Spacer() + if let start = capture.broadcastStartedAt { + TimelineView(.periodic(from: start, by: 1)) { context in + Text(LiveStats.uptimeLabel(seconds: Int(context.date.timeIntervalSince(start)))) + .font(.subheadline.weight(.semibold).monospacedDigit()) + } + } + } + } + + /// Bitrate (ABR target) · effective fps · uplink health. Values are the + /// encoder's applied targets, not measured throughput (measured fps needs M8). + private var metricsRow: some View { + HStack(spacing: 0) { + metric("Bitrate", value: capture.liveStats?.bitRateLabel ?? "—") + divider + metric("FPS", value: capture.liveStats.map { "\($0.frameRate)" } ?? "—") + divider + metric("Uplink", value: capture.liveStats?.linkHealth.label ?? "—", tint: uplinkTint) + } + } + + private var divider: some View { Divider().frame(height: 26) } + + private var uplinkTint: Color { + switch capture.liveStats?.linkHealth { + case .good: return .green + case .fair: return .yellow + case .congested: return .red + case .none: return .secondary + } + } + + private func metric(_ label: String, value: String, tint: Color = .primary) -> some View { + VStack(spacing: 2) { + Text(value) + .font(.callout.weight(.semibold).monospacedDigit()) + .foregroundStyle(tint) + .lineLimit(1) + .minimumScaleFactor(0.7) + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + // One element per metric read as "Bitrate: 2.4 Mbps", not the raw + // value-then-label order the VStack would otherwise expose. + .accessibilityElement(children: .ignore) + .accessibilityLabel(label) + .accessibilityValue(value) + } +} + #Preview { @Previewable @State var settings = StreamSettings.default return NavigationStack { - SettingsView(settings: $settings, chat: RestreamChat(), onChange: {}) + SettingsView(settings: $settings, chat: RestreamChat(), + capture: ScreenCaptureController(), onChange: {}) } } diff --git a/StreamBroadcast/RTMPPublisher.swift b/StreamBroadcast/RTMPPublisher.swift index 0f58800..bed23bb 100644 --- a/StreamBroadcast/RTMPPublisher.swift +++ b/StreamBroadcast/RTMPPublisher.swift @@ -768,6 +768,19 @@ actor RTMPPublisher: Publisher { applyingTo: stream) } + /// Live encode/uplink metrics for the stats HUD. `nil` until the stream is + /// actually attached and has published, so the UI shows "connecting…" rather + /// than stale zeros during the initial connect/reconnect windows. + func statsSnapshot() async -> LiveStats? { + guard isRunning, streamAttached else { return nil } + let health = await networkController.healthSnapshot() + let fps = await networkController.currentFrameRate() + return LiveStats(bitRate: health.targetBitRate, + frameRate: fps, + queueBytes: health.queueBytes, + zeroOutputSeconds: health.zeroOutputSeconds) + } + /// Appends a (raw or composited) screen video buffer. Dropped until the output /// size is locked, so the encoder never starts at the wrong dimensions. func appendVideo(_ sb: CMSampleBuffer) async { @@ -1184,6 +1197,17 @@ actor BroadcastAdaptiveBitRateController: StreamBitRateStrategy { func currentFrameInterval() -> Double { frameInterval(severe: false) } + /// The effective encode frame rate the ABR is currently targeting (fps), folding + /// in the same congestion + thermal caps as `frameInterval()` (most-restrictive + /// wins). Used by the live stats HUD; not a measured output rate (measured fps + /// needs M8). Mirrors `frameInterval`'s steady-state branches directly rather + /// than lossily inverting the interval Double. + func currentFrameRate() -> Int { + let congestionFps = (congestionActive && configuredFrameRate > 30) ? 30 : configuredFrameRate + let thermalFps = max(1, min(configuredFrameRate, thermalFrameRateCap)) + return min(congestionFps, thermalFps) + } + /// Applies the current (possibly thermally-clamped) target + frame interval to /// a freshly-connected stream. SRT/WHIP emit no `.reset` event, so the /// pre-connect store path relies on this to land the ceiling on the encoder. diff --git a/StreamBroadcast/SessionPublisher.swift b/StreamBroadcast/SessionPublisher.swift index 70aad44..bcd93ca 100644 --- a/StreamBroadcast/SessionPublisher.swift +++ b/StreamBroadcast/SessionPublisher.swift @@ -30,6 +30,9 @@ protocol Publisher: Actor { /// Applies a device thermal / Low-Power ceiling (bitrate scale + fps cap) to /// the encoder, composed with the network ceiling by the adaptive controller. func setThermalCeiling(bitRateScale: Double, frameRateCap: Int) async + /// A snapshot of the live encode/uplink metrics for the stats HUD, or `nil` + /// when nothing is being published yet (pre-connect / torn down). + func statsSnapshot() async -> LiveStats? } /// Publishes over SRT or WHIP via HaishinKit's protocol-agnostic `StreamSession` @@ -248,6 +251,18 @@ actor SessionPublisher: Publisher { } } + /// Live encode/uplink metrics for the stats HUD. `nil` until a session stream + /// exists (pre-connect / mid-reconnect), so the UI shows "connecting…". + func statsSnapshot() async -> LiveStats? { + guard isRunning, stream != nil else { return nil } + let health = await networkController.healthSnapshot() + let fps = await networkController.currentFrameRate() + return LiveStats(bitRate: health.targetBitRate, + frameRate: fps, + queueBytes: health.queueBytes, + zeroOutputSeconds: health.zeroOutputSeconds) + } + private func makeVideoSettings(_ current: VideoCodecSettings, size: CGSize? = nil) async -> VideoCodecSettings { let frameRate = encodeFrameRate var v = current diff --git a/StreamCore/LiveStats.swift b/StreamCore/LiveStats.swift new file mode 100644 index 0000000..d293d61 --- /dev/null +++ b/StreamCore/LiveStats.swift @@ -0,0 +1,92 @@ +import Foundation + +/// A snapshot of the live broadcast's health, surfaced from the encoder/ABR to the +/// UI (the Settings live-stats card and the LIVE pill). Distinct from +/// `BroadcastState`, which is the coarse cross-process *liveness* heartbeat — this +/// is the fine-grained *metrics* the stats HUD renders while a stream is running. +/// +/// The numbers are the encoder's current TARGETS after adaptive/thermal/path +/// clamping (the real, applied rate), not independently measured throughput — +/// measured fps/dropped-frame telemetry depends on M8, which is not yet built. +/// +/// Pure value type with no iOS dependencies, so the health classification and the +/// display formatting are unit-tested in StreamCore on CI. +public struct LiveStats: Equatable, Sendable { + /// Current encoder video bitrate (bits/sec), after ABR + thermal + path clamping. + public var bitRate: Int + /// Effective encode frame rate (fps) — the ABR/thermal target that is actually + /// applied to the encoder, not a measured output rate. + public var frameRate: Int + /// Bytes waiting in the outbound socket queue (the uplink backlog). + public var queueBytes: Int + /// Consecutive ~1s ticks the socket sent zero bytes while data was queued — the + /// stall signal the watchdog uses. + public var zeroOutputSeconds: Int + + public init(bitRate: Int, frameRate: Int, queueBytes: Int, zeroOutputSeconds: Int) { + self.bitRate = bitRate + self.frameRate = frameRate + self.queueBytes = queueBytes + self.zeroOutputSeconds = zeroOutputSeconds + } + + /// Uplink health, classified from the outbound backlog + stall signal. The + /// thresholds mirror the publisher's own `VideoFrameAdmission` / watchdog tiers + /// (RTMPPublisher): under 0.5 MB the queue is keeping up; a growing backlog or + /// any zero-output stall means the uplink can't drain what the encoder produces. + public enum LinkHealth: String, Sendable, Equatable, CaseIterable { + case good, fair, congested + + public var label: String { + switch self { + case .good: return "Good" + case .fair: return "Fair" + case .congested: return "Congested" + } + } + } + + /// 0.5 MB: the point at which `VideoFrameAdmission` starts shedding frames. + static let fairQueueBytes = 524_288 + /// 2 MB: a deep backlog several stale seconds of video would sit behind. + static let congestedQueueBytes = 2_097_152 + + public var linkHealth: LinkHealth { + if zeroOutputSeconds >= 2 || queueBytes >= Self.congestedQueueBytes { return .congested } + if queueBytes >= Self.fairQueueBytes { return .fair } + return .good + } + + /// A compact bitrate label: "2.4 Mbps" at/above 1 Mbps, "850 kbps" below (the + /// ABR can floor to ~300 kbps on a poor uplink). Matches the Settings video + /// picker's Mbps formatting for the common case. + public var bitRateLabel: String { + let bps = max(0, bitRate) + if bps >= 1_000_000 { + return String(format: "%.1f Mbps", Double(bps) / 1_000_000) + } + return "\(bps / 1000) kbps" + } + + /// A compact queue label in the card, e.g. "0 KB" / "512 KB" / "2.1 MB". + public var queueLabel: String { + let bytes = max(0, queueBytes) + if bytes >= 1_048_576 { + return String(format: "%.1f MB", Double(bytes) / 1_048_576) + } + return "\(bytes / 1024) KB" + } + + /// Formats an elapsed broadcast duration: "MM:SS" under an hour, "H:MM:SS" past + /// it (e.g. 65 → "01:05", 3665 → "1:01:05"). Negative inputs clamp to zero. + public static func uptimeLabel(seconds: Int) -> String { + let total = max(0, seconds) + let hours = total / 3600 + let minutes = (total % 3600) / 60 + let secs = total % 60 + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, secs) + } + return String(format: "%02d:%02d", minutes, secs) + } +} diff --git a/StreamCoreTests/LiveStatsTests.swift b/StreamCoreTests/LiveStatsTests.swift new file mode 100644 index 0000000..3308fe8 --- /dev/null +++ b/StreamCoreTests/LiveStatsTests.swift @@ -0,0 +1,79 @@ +import Testing +import StreamCore + +/// Unit tests for the pure live-telemetry logic surfaced in the stats HUD: the +/// uplink-health classification and the compact display formatting. +@Suite struct LiveStatsHealthTests { + private func stats(queueBytes: Int = 0, zeroOutputSeconds: Int = 0) -> LiveStats { + LiveStats(bitRate: 3_000_000, frameRate: 30, queueBytes: queueBytes, zeroOutputSeconds: zeroOutputSeconds) + } + + @Test("An empty, flowing queue reads as Good") + func goodWhenQueueEmpty() { + #expect(stats(queueBytes: 0).linkHealth == .good) + #expect(stats(queueBytes: 524_287).linkHealth == .good) // just under the fair floor + } + + @Test("A half-megabyte backlog crosses into Fair") + func fairAtHalfMegabyte() { + #expect(stats(queueBytes: 524_288).linkHealth == .fair) + #expect(stats(queueBytes: 2_097_151).linkHealth == .fair) // just under congested + } + + @Test("A 2 MB backlog is Congested") + func congestedAtTwoMegabytes() { + #expect(stats(queueBytes: 2_097_152).linkHealth == .congested) + } + + @Test("Any sustained zero-output stall is Congested regardless of queue size") + func zeroOutputStallIsCongested() { + #expect(stats(queueBytes: 0, zeroOutputSeconds: 2).linkHealth == .congested) + // A single zero-output tick with an empty queue is not yet congested. + #expect(stats(queueBytes: 0, zeroOutputSeconds: 1).linkHealth == .good) + } + + @Test("Every LinkHealth case has a display label") + func everyCaseHasLabel() { + for h in LiveStats.LinkHealth.allCases { + #expect(!h.label.isEmpty) + } + } +} + +@Suite struct LiveStatsFormattingTests { + private func stats(bitRate: Int = 0, queueBytes: Int = 0) -> LiveStats { + LiveStats(bitRate: bitRate, frameRate: 30, queueBytes: queueBytes, zeroOutputSeconds: 0) + } + + @Test("Bitrate labels as Mbps at/above 1 Mbps and kbps below") + func bitRateLabels() { + #expect(stats(bitRate: 2_400_000).bitRateLabel == "2.4 Mbps") + #expect(stats(bitRate: 1_000_000).bitRateLabel == "1.0 Mbps") + #expect(stats(bitRate: 850_000).bitRateLabel == "850 kbps") + #expect(stats(bitRate: 0).bitRateLabel == "0 kbps") + #expect(stats(bitRate: -100).bitRateLabel == "0 kbps") // clamps + } + + @Test("Queue labels as KB below a megabyte and MB above") + func queueLabels() { + #expect(stats(queueBytes: 0).queueLabel == "0 KB") + #expect(stats(queueBytes: 524_288).queueLabel == "512 KB") + #expect(stats(queueBytes: 1_048_576).queueLabel == "1.0 MB") + #expect(stats(queueBytes: 2_097_152).queueLabel == "2.0 MB") + } + + @Test("Uptime is MM:SS under an hour and H:MM:SS past it") + func uptimeLabels() { + #expect(LiveStats.uptimeLabel(seconds: 0) == "00:00") + #expect(LiveStats.uptimeLabel(seconds: 59) == "00:59") + #expect(LiveStats.uptimeLabel(seconds: 65) == "01:05") + #expect(LiveStats.uptimeLabel(seconds: 600) == "10:00") + #expect(LiveStats.uptimeLabel(seconds: 3600) == "1:00:00") + #expect(LiveStats.uptimeLabel(seconds: 3665) == "1:01:05") + } + + @Test("Negative uptime clamps to zero") + func negativeUptimeClamps() { + #expect(LiveStats.uptimeLabel(seconds: -5) == "00:00") + } +}