Files
fegger c43dd3ca4a feat(ios): native receiver app (Swift, min iOS 17)
A native iOS receiver so an iPhone can act as the second receiver, speaking the existing signaling + RTP protocol (no C++ changes) and mirroring the Android receiver (Phase 8) source-to-source.

- RTP core (header/packet, jitter buffer, H.264 depacketizer) ported from the Android receiver
- BSD-socket signaling server (dual-stack, most-recent-peer, never-throwing sends) + NSBonjourServices
- VideoToolbox H.264 decode (in-band SPS/PPS, real-time, rebuilds on size change) -> AVSampleBufferDisplayLayer
- PLI keyframe recovery (500 ms) + pendingOffer for late surface attach
- XcodeGen project + bootstrap.sh; XCTest port of the Android suite + new coverage
- .gitignore for generated artifacts; CHANGELOG; PHASES + MEMORY updated

Status: authored; on-device validation pending a Mac + Xcode 26 + iPhone 16.
2026-09-10 21:31:53 +02:00

89 lines
2.8 KiB
Swift

import SwiftUI
import UIKit
/// Owns the pipeline and mirrors its status to the UI. The pipeline posts its
/// callbacks to the main thread, so the @Published mutations happen there.
final class ReceiverController: ObservableObject {
@Published var status = "Starting…"
@Published var showStatus = true
private let pipeline: ReceiverPipeline
private let pixelSize: CGSize
init() {
let localIP = LocalAddress.primaryIPv4()
let size = ReceiverController.screenPixelSize()
pixelSize = size
pipeline = ReceiverPipeline(
localIP: localIP,
displaySize: { [weak self] in self?.pixelSize ?? .zero },
onStatus: { [weak self] text in self?.apply(status: text) },
onFirstFrame: { [weak self] in self?.apply(showStatus: false) },
onVideoSize: { _ in })
}
func start() { pipeline.start() }
func stop() { pipeline.stop() }
func bindSink(_ sink: RenderSink) { pipeline.attachSink(sink) }
private func apply(status: String? = nil, showStatus: Bool? = nil) {
if let s = status { self.status = s }
if let v = showStatus { self.showStatus = v }
}
static func screenPixelSize() -> CGSize {
let scale = UIScreen.main.scale
let b = UIScreen.main.bounds
return CGSize(width: b.width * scale, height: b.height * scale)
}
}
struct ContentView: View {
@StateObject private var controller = ReceiverController()
@Environment(\.scenePhase) private var scenePhase
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
VideoSurfaceView { sink in controller.bindSink(sink) }
.ignoresSafeArea()
if controller.showStatus {
VStack {
Spacer()
Text(controller.status)
.font(.footnote)
.foregroundStyle(.white)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
.padding(.vertical, 12)
.background(.black.opacity(0.55), in: RoundedRectangle(cornerRadius: 10))
Spacer()
Spacer()
}
.transition(.opacity)
}
}
.onAppear { controller.start() }
.onChange(of: scenePhase) { _, phase in
switch phase {
case .active: controller.start()
case .inactive, .background: controller.stop()
@unknown default: break
}
}
}
}
@main
struct ReceiverApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.preferredColorScheme(.dark)
.statusBarHidden(true)
.persistentSystemOverlays(.hidden)
}
}
}