Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion Stream/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand Down
48 changes: 47 additions & 1 deletion Stream/ScreenCaptureController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -32,6 +39,9 @@ final class ScreenCaptureController: NSObject {
@ObservationIgnored private var micVolumeObserver: DarwinSignalObserver?
@ObservationIgnored private var lastAppliedCeiling: ThermalPowerCeiling?
@ObservationIgnored private var thermalApplyTask: Task<Void, Never>?
/// ~1s telemetry poll, live only. Cancelled on every teardown path (they all
/// funnel through `stopCapture`).
@ObservationIgnored private var statsTask: Task<Void, Never>?

override init() {
super.init()
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -156,6 +193,9 @@ final class ScreenCaptureController: NSObject {

try await stream.startCapture()
isLive = true
broadcastStartedAt = Date()
liveStats = nil
startStatsPolling()
publishState(true)
lastAppliedCeiling = nil
applyThermalCeiling()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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."
Expand Down
105 changes: 104 additions & 1 deletion Stream/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {})
}
}
24 changes: 24 additions & 0 deletions StreamBroadcast/RTMPPublisher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions StreamBroadcast/SessionPublisher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading