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
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,12 @@ cannot keep a camera session active during multitasking fall back to screen-only

The Video settings offer H.264 (default) or HEVC. HEVC gives roughly a 40%
quality-per-bit gain on text-heavy screen content in the 2–8 Mbps band, but needs
a compatible ingest: it rides SRT (MPEG-TS), WHIP, and *enhanced*-RTMP (the
a compatible ingest: it rides SRT (MPEG-TS) and *enhanced*-RTMP (the
publisher advertises the `hvc1` FourCC in the E-RTMP connect command). Traditional
RTMP services such as Restream speak H.264 only, so leave the codec on H.264 for
them — HEVC over RTMP should be verified against the specific endpoint on a real
device. Low-latency VideoToolbox rate control is enabled for both codecs.
RTMP services such as Restream and the current WHIP transport speak H.264 only, so
leave the codec on H.264 for them — HEVC over RTMP should be verified against the
specific endpoint on a real device. Low-latency VideoToolbox rate control is enabled
for both codecs.

## Notes

Expand Down
562 changes: 540 additions & 22 deletions Stream/AudioInputProvider.swift

Large diffs are not rendered by default.

13 changes: 10 additions & 3 deletions Stream/CameraSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,23 @@ final class CameraSupport {
private(set) var permission: Permission = .undetermined

/// Whether the camera can run during a system broadcast on this device.
let multitaskingSupported: Bool
private(set) var multitaskingSupported = false

init() {
// Reading the capability off a non-running session reflects the hardware.
multitaskingSupported = AVCaptureSession().isMultitaskingCameraAccessSupported
syncPermission()
}

func refresh() {
syncPermission()
// Constructing an AVCaptureSession can synchronously consult media-server
// state. Do it only when PiP is opened and never in the sheet's main-actor
// presentation turn.
Task {
let supported = await Task.detached(priority: .utility) {
AVCaptureSession().isMultitaskingCameraAccessSupported
}.value
multitaskingSupported = supported
}
}

/// Requests camera permission when the user enables the facecam.
Expand Down
185 changes: 167 additions & 18 deletions Stream/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ private struct BluetoothConnection: Equatable, Identifiable {
/// sheet. Settings are owned here and threaded into `SettingsView`; every edit is
/// persisted via `SettingsStore` before ScreenCaptureKit starts the stream.
struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase

/// The single source of truth for the editable settings, loaded from the
/// shared App Group suite on launch.
@State private var settings: StreamSettings = SettingsStore().load()
@State private var settings: StreamSettings

/// Owns ScreenCaptureKit selection/capture and the network publisher.
@State private var capture = ScreenCaptureController()
Expand All @@ -31,6 +33,11 @@ struct ContentView: View {
/// and is threaded into the Settings sheet so both share one instance/observer.
@State private var audio = AudioInputProvider()

/// The single mic level monitor, driving both the toolbar's status meter and the
/// Audio settings pane. Owned here so one instance serves both — a second would
/// open a competing record session whenever the meter falls back to local capture.
@State private var micLevel = MicrophoneLevelMonitor()

/// The Bluetooth device that most recently paired mid-session, shown as a
/// transient banner. Set from `audio.onBluetoothConnected`; auto-cleared after
/// a few seconds by a `.task` keyed on its `id` (a fresh `id` restarts the
Expand All @@ -39,6 +46,16 @@ struct ContentView: View {

@State private var showingSettings = false

/// Coalesces rapid UI edits and performs Keychain/file writes away from the
/// main actor. Text fields and sliders can update dozens of times per second;
/// synchronously persisting each intermediate value made sheet navigation and
/// control drags contend with Security.framework and atomic file I/O.
@State private var settingsPersistence: SettingsPersistenceCoordinator

/// Prevents a second go-live tap while the final settings snapshot is being
/// flushed. Capture never starts before the latest scheduled save completes.
@State private var isPreparingBroadcast = false

/// Gates the "Stop Broadcast" confirmation. A live stream is easy to kill by a
/// stray tap on the toolbar's stop button, so tapping it while live asks first
/// instead of tearing the broadcast down immediately.
Expand All @@ -48,17 +65,31 @@ struct ContentView: View {
/// moment they mute so toggling back returns to their chosen level, not unity.
@State private var preMuteVolume: Double = 1.0

/// Persists `settings` into the shared App Group suite. Called on every edit.
init() {
let loaded = SettingsStore().load()
_settings = State(initialValue: loaded)
_settingsPersistence = State(
initialValue: SettingsPersistenceCoordinator(initialSettings: loaded)
)
}

/// Schedules a coalesced background save. `startBroadcast()` and sheet dismissal
/// explicitly flush the latest snapshot, so debounce never weakens durability.
private func persist() {
SettingsStore().save(settings)
settingsPersistence.schedule(settings)
}

private func flushSettings() {
let snapshot = settings
Task { await settingsPersistence.flush(snapshot) }
}

/// A gain at/below this reads as muted — matches the Settings "Muted" label.
private var isMicMuted: Bool { settings.micVolume <= 0.0001 }

/// Toggles the microphone between muted (gain 0) and the last chosen level.
/// Persists and posts the live-apply signal so a running broadcast responds
/// immediately, exactly like the Settings mic-volume slider does.
/// Persists and applies directly so a running broadcast responds immediately,
/// exactly like the Settings mic-volume slider does.
private func toggleMicMute() {
Haptics.tap()
if isMicMuted {
Expand All @@ -68,7 +99,30 @@ struct ContentView: View {
settings.micVolume = 0
}
persist()
BroadcastControl.post(BroadcastControl.micVolumeSignal)
capture.setMicVolume(settings.micVolume)
// The idle meter applies gain itself, so the toolbar bars have to be told
// about a mute that didn't come from the Settings slider.
micLevel.setGain(settings.micVolume)
}

/// Keeps the toolbar meter live whenever the app is foregrounded, so the mic can
/// be verified before going live and not only during a broadcast. While live the
/// level arrives free over `MicrophoneLevelChannel`; when idle the monitor opens
/// its own input tap, which is why this releases it the moment the app leaves the
/// foreground rather than holding the mic (and the orange privacy indicator) open
/// behind other apps.
///
/// - Parameter phase: the incoming phase when called from `onChange`, whose
/// closure still sees the pre-change `scenePhase`.
private func syncStatusMeter(phase: ScenePhase? = nil) {
micLevel.setBroadcasting(capture.isLive)
micLevel.setGain(settings.micVolume)
micLevel.setPreferredInput(settings.preferredAudioInputUID)
if (phase ?? scenePhase) == .active {
micLevel.start(for: .statusBar)
} else {
micLevel.stop(for: .statusBar)
}
}

var body: some View {
Expand All @@ -89,6 +143,8 @@ struct ContentView: View {
.animation(.spring(duration: 0.3, bounce: 0.2), value: capture.isLive)
.animation(.spring(duration: 0.35, bounce: 0.25), value: bluetoothBanner)
.navigationTitle("Stream")
// Kept for VoiceOver/system context only — the principal toolbar
// item above replaces the visible title with the mic meter.
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Expand All @@ -100,14 +156,22 @@ struct ContentView: View {
}
.accessibilityLabel("Settings")
}
// Takes the title's slot: "Stream" is redundant on the app's own
// root screen, and the mic meter is the one thing worth a glance
// every time you look up here.
ToolbarItem(placement: .principal) {
MicrophoneStatusMeter(monitor: micLevel, isMuted: isMicMuted)
}
ToolbarItem(placement: .topBarTrailing) { broadcastButton }
}
.safeAreaInset(edge: .bottom) {
if !settings.isPublishable && !capture.isLive {
setupBanner
}
}
.sheet(isPresented: $showingSettings) { settingsSheet }
.sheet(isPresented: $showingSettings, onDismiss: flushSettings) {
settingsSheet
}
}
.task {
chat.autoConnect()
Expand All @@ -116,12 +180,16 @@ struct ContentView: View {
bluetoothBanner = BluetoothConnection(name: name)
Haptics.tap()
}
// Seed the baseline set + start the route-change observer without
// prompting for mic access here (Settings owns the permission ask).
// Already-connected devices only populate the baseline; they don't
// trigger a banner — only devices that arrive afterward do.
audio.refresh(requestPermission: false)
Haptics.warmUp()
// Seed the baseline set + start the route-change observer. The ask has
// to happen here now that the toolbar meter is always on screen —
// without permission it can only ever read "no signal", with nothing on
// the main screen to explain why. Already-connected devices only
// populate the baseline; they don't trigger a banner — only arrivals do.
audio.refresh(requestPermission: true)
syncStatusMeter()
}
.onChange(of: capture.isLive) { _, _ in syncStatusMeter() }
// Auto-dismiss the Bluetooth banner. Keyed on the connection id so a new
// device restarts the timer; cancellation (id change) skips the stale clear.
.task(id: bluetoothBanner?.id) {
Expand All @@ -130,6 +198,10 @@ struct ContentView: View {
guard !Task.isCancelled else { return }
bluetoothBanner = nil
}
.onChange(of: scenePhase) { _, phase in
if phase != .active { flushSettings() }
syncStatusMeter(phase: phase)
}
.confirmationDialog(
"Stop the broadcast?",
isPresented: $showingStopConfirmation,
Expand Down Expand Up @@ -162,16 +234,30 @@ struct ContentView: View {
if capture.isLive {
showingStopConfirmation = true
} else {
persist()
capture.presentPicker(settings: settings)
startBroadcast()
}
} label: {
Image(systemName: capture.isLive ? "stop.circle" : "record.circle")
}
.tint(capture.isLive ? .red : .primary)
.disabled(isPreparingBroadcast || capture.isStopping)
.accessibilityLabel(capture.isLive ? "Stop broadcast" : "Start broadcast")
}

/// Flushes through the serial writer before handing the immutable snapshot to
/// ScreenCaptureKit. This preserves the existing "saved before live" contract
/// without doing Keychain or disk work in the toolbar button's main-actor turn.
private func startBroadcast() {
guard !isPreparingBroadcast, !capture.isStopping else { return }
isPreparingBroadcast = true
let snapshot = settings
Task {
await settingsPersistence.flush(snapshot)
isPreparingBroadcast = false
capture.presentPicker(settings: snapshot)
}
}

// MARK: - Mute FAB

/// Floating mic mute/unmute control, pinned to the bottom-trailing corner while
Expand Down Expand Up @@ -288,11 +374,74 @@ struct ContentView: View {

// MARK: - Settings sheet

/// One Vaul-style drawer. `SettingsView` owns the navigation stack and sizes
/// 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.
/// One Vaul-style drawer. `SettingsView` owns its two-level navigation and
/// measures only the visible pane to drive its content-hugging detent.
private var settingsSheet: some View {
SettingsView(settings: $settings, chat: chat, capture: capture, onChange: persist, audio: audio)
SettingsView(settings: $settings, chat: chat, capture: capture,
onChange: persist, audio: audio, micLevel: micLevel)
}
}

/// Serializes settings writes so an older debounced snapshot can never finish
/// after a newer flush and overwrite it. Actor isolation also keeps the synchronous
/// Keychain + atomic-file implementation off the main actor.
private actor SettingsWriter {
private let store = SettingsStore()
private var lastSaved: StreamSettings

init(lastSaved: StreamSettings) {
self.lastSaved = lastSaved
}

func save(_ settings: StreamSettings) {
if settings.selectedProtocol != lastSaved.selectedProtocol
|| settings.rtmpURL != lastSaved.rtmpURL
|| settings.streamKey != lastSaved.streamKey {
store.saveConnection(settings)
}

var redacted = settings
redacted.rtmpURL = ""
redacted.streamKey = ""
var previousRedacted = lastSaved
previousRedacted.rtmpURL = ""
previousRedacted.streamKey = ""
if redacted != previousRedacted {
store.saveNonSecret(settings)
}
lastSaved = settings
}
}

/// Main-actor scheduler owned by `ContentView`. Cancellation is cheap while the
/// task is sleeping; once a write reaches `SettingsWriter`, subsequent writes queue
/// behind it and therefore retain strict snapshot order.
@MainActor
private final class SettingsPersistenceCoordinator {
private let writer: SettingsWriter
private var pending: Task<Void, Never>?

init(initialSettings: StreamSettings) {
writer = SettingsWriter(lastSaved: initialSettings)
}

func schedule(_ settings: StreamSettings) {
pending?.cancel()
pending = Task { [writer] in
do {
try await Task.sleep(for: .milliseconds(250))
} catch {
return
}
guard !Task.isCancelled else { return }
await writer.save(settings)
}
}

func flush(_ settings: StreamSettings) async {
pending?.cancel()
pending = nil
await writer.save(settings)
}
}

Expand Down
7 changes: 7 additions & 0 deletions Stream/DynamicDetentSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ final class DynamicDetentSheetModel {
max(minimumContentHeight, content) + chrome
}

/// Whether a pane already has a cached intrinsic height. Callers can use this
/// to choose between a fully synchronized cached resize and a lazy first-open
/// transition whose visible measurement starts the resize a frame later.
func hasMeasurement<Key: Hashable>(for key: Key) -> Bool {
measured[AnyHashable(key)] != nil
}

/// Raw measurement from a pane's scroll/intrinsic geometry. Cheap + idempotent:
/// rounded, thresholded, cached under the pane's key, and it only commits an
/// ANIMATED resize when the reporting pane is the ACTIVE one. A late measurement
Expand Down
20 changes: 18 additions & 2 deletions Stream/Haptics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,26 @@ import UIKit

/// Centralized haptic feedback so every control interaction feels consistent.
/// Respects the system's haptic settings (no-ops when the user disables them).
@MainActor
enum Haptics {
/// One long-lived generator, kept warm. Allocating a generator per tap makes
/// the Taptic Engine cold-start inside the button's action — a stall the user
/// feels as the whole control responding late, not just the haptic. Reusing a
/// prepared instance keeps the engine spun up so the tap fires immediately.
private static let impact = UIImpactFeedbackGenerator(style: .light)

/// A light tap for button presses. Call from SwiftUI action closures, which
/// already run on the main actor.
@MainActor static func tap() {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
static func tap() {
impact.impactOccurred()
// Re-arm for the next press. The engine idles down after a couple of
// seconds, so this is what keeps repeat taps snappy.
impact.prepare()
}

/// Warms the Taptic Engine ahead of the first interaction (scene activation),
/// so even the very first button press doesn't pay the spin-up cost.
static func warmUp() {
impact.prepare()
}
}
Loading
Loading