c43dd3ca4a
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.
89 lines
2.8 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|