Stabilizza diretta iOS e aggiunge test simulatore pre-release.
Corregge lifecycle RTMP/preview tra partite, reset tabellone, orientamento con debounce, condivisione su iPad e script test_ios_simulator per CI locale. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
0f69ebcccb
commit
731b43ea88
@@ -39,6 +39,11 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
private var previewAttached = false
|
||||
private var publishPending = false
|
||||
private var publishInFlight = false
|
||||
private var broadcastGeneration = 0
|
||||
private var suppressDisconnectError = false
|
||||
private var orientationTransitionUntil: Date = .distantPast
|
||||
private var lastAppliedCaptureOrientation: AVCaptureVideoOrientation?
|
||||
private var orientationDebounceTask: Task<Void, Never>?
|
||||
private var rtmpSession: (any Session)?
|
||||
private static var rtmpFactoryRegistered = false
|
||||
|
||||
@@ -60,14 +65,38 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
/// Anteprima locale: output del mixer (come HaishinKit PublishViewModel), non dello stream RTMP.
|
||||
func bindPreview(to view: MTHKView) async {
|
||||
previewView = view
|
||||
guard pipelineConfigured, !previewAttached else { return }
|
||||
await mixer.addOutput(view)
|
||||
await attachPreviewIfNeeded()
|
||||
}
|
||||
|
||||
private func attachPreviewIfNeeded() async {
|
||||
guard let previewView, pipelineConfigured, !previewAttached else { return }
|
||||
await mixer.addOutput(previewView)
|
||||
previewAttached = true
|
||||
if publishPending {
|
||||
await startPendingPublish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Attende che SwiftUI monti la surface di anteprima (MTHKView).
|
||||
func waitForPreviewSurface(maxAttempts: Int = 100) async -> Bool {
|
||||
for _ in 0..<maxAttempts {
|
||||
if previewView != nil { return true }
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
return previewView != nil
|
||||
}
|
||||
|
||||
/// Attende che l'anteprima sia collegata al mixer (dopo `configurePipeline`).
|
||||
func waitForPreviewReady(maxAttempts: Int = 100) async -> Bool {
|
||||
for _ in 0..<maxAttempts {
|
||||
if Task.isCancelled { return previewAttached }
|
||||
await attachPreviewIfNeeded()
|
||||
if previewAttached { return true }
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
return previewAttached
|
||||
}
|
||||
|
||||
func preparePreview(config: BroadcastConfig) async throws {
|
||||
self.config = config
|
||||
publishPending = false
|
||||
@@ -88,12 +117,15 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
try await prepareForBroadcast(config: config)
|
||||
}
|
||||
|
||||
func pauseBroadcast() async {
|
||||
func pauseBroadcast(suppressDisconnectError: Bool = false) async {
|
||||
self.suppressDisconnectError = suppressDisconnectError
|
||||
defer { self.suppressDisconnectError = false }
|
||||
broadcastGeneration += 1
|
||||
publishPending = false
|
||||
publishTask?.cancel()
|
||||
publishTask = nil
|
||||
await teardownRTMP(keepPreview: true)
|
||||
setPhase(.paused)
|
||||
await teardownRTMP(keepPreview: true)
|
||||
}
|
||||
|
||||
func resumeBroadcast(config: BroadcastConfig) async throws {
|
||||
@@ -101,27 +133,35 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
publishPending = true
|
||||
if !pipelineConfigured {
|
||||
try await configurePipeline(config)
|
||||
} else {
|
||||
await attachPreviewIfNeeded()
|
||||
}
|
||||
setPhase(.connecting)
|
||||
await startPendingPublish()
|
||||
}
|
||||
|
||||
func stopBroadcast() async {
|
||||
func stopBroadcast(suppressDisconnectError: Bool = false) async {
|
||||
self.suppressDisconnectError = suppressDisconnectError
|
||||
defer { self.suppressDisconnectError = false }
|
||||
broadcastGeneration += 1
|
||||
publishTask?.cancel()
|
||||
publishTask = nil
|
||||
publishPending = false
|
||||
publishInFlight = false
|
||||
stopOrientationMonitoring()
|
||||
orientationDebounceTask?.cancel()
|
||||
orientationDebounceTask = nil
|
||||
lastAppliedCaptureOrientation = nil
|
||||
orientationTransitionUntil = .distantPast
|
||||
setPhase(.idle)
|
||||
await teardownRTMP(keepPreview: false)
|
||||
if let previewView, previewAttached {
|
||||
await mixer.removeOutput(previewView)
|
||||
}
|
||||
previewAttached = false
|
||||
previewView = nil
|
||||
overlayRenderer.detach()
|
||||
try? await mixer.stopRunning()
|
||||
pipelineConfigured = false
|
||||
setPhase(.idle)
|
||||
}
|
||||
|
||||
func release() async {
|
||||
@@ -133,7 +173,10 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
||||
throw APIError.http(403, "Permessi camera e microfono richiesti")
|
||||
}
|
||||
if pipelineConfigured { return }
|
||||
if pipelineConfigured {
|
||||
await attachPreviewIfNeeded()
|
||||
return
|
||||
}
|
||||
|
||||
let audioSession = AVAudioSession.sharedInstance()
|
||||
try audioSession.setCategory(.playAndRecord, mode: .videoChat, options: [.defaultToSpeaker, .allowBluetooth])
|
||||
@@ -149,9 +192,11 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
try await mixer.attachAudio(microphone, track: 0)
|
||||
}
|
||||
if let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) {
|
||||
try await mixer.attachVideo(camera, track: 0)
|
||||
try await mixer.attachVideo(camera, track: 0) { videoUnit in
|
||||
videoUnit.isVideoMirrored = false
|
||||
}
|
||||
}
|
||||
await applyVideoOrientation()
|
||||
await applyVideoOrientationIfNeeded(force: true)
|
||||
startOrientationMonitoring()
|
||||
|
||||
try await mixer.startRunning()
|
||||
@@ -162,11 +207,7 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
isPortrait: isPortrait
|
||||
)
|
||||
pipelineConfigured = true
|
||||
|
||||
if let previewView, !previewAttached {
|
||||
await mixer.addOutput(previewView)
|
||||
previewAttached = true
|
||||
}
|
||||
await attachPreviewIfNeeded()
|
||||
}
|
||||
|
||||
private func configureScreenSize(width: Int, height: Int) async {
|
||||
@@ -186,40 +227,48 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
|
||||
private func waitForPreviewThenPublish() async {
|
||||
guard publishPending, let config else { return }
|
||||
let generation = broadcastGeneration
|
||||
publishInFlight = true
|
||||
defer { publishInFlight = false }
|
||||
|
||||
for _ in 0..<80 {
|
||||
for _ in 0..<120 {
|
||||
if Task.isCancelled { return }
|
||||
if generation != broadcastGeneration { return }
|
||||
await attachPreviewIfNeeded()
|
||||
if previewAttached { break }
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
guard previewAttached else {
|
||||
publishPending = false
|
||||
setPhase(.error, error: "Anteprima camera non pronta")
|
||||
guard previewAttached, generation == broadcastGeneration else {
|
||||
if generation == broadcastGeneration {
|
||||
publishPending = false
|
||||
setPhase(.error, error: "Anteprima camera non pronta")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setPhase(.connecting)
|
||||
try? await Task.sleep(nanoseconds: 350_000_000)
|
||||
guard publishPending else { return }
|
||||
guard publishPending, generation == broadcastGeneration else { return }
|
||||
|
||||
do {
|
||||
try await publish(config: config)
|
||||
publishPending = false
|
||||
try await publish(config: config, generation: generation)
|
||||
if generation == broadcastGeneration {
|
||||
publishPending = false
|
||||
}
|
||||
} catch {
|
||||
guard generation == broadcastGeneration else { return }
|
||||
publishPending = false
|
||||
let message = UserFacingError.message(for: error) ?? "Connessione RTMP fallita"
|
||||
setPhase(.error, error: message)
|
||||
}
|
||||
}
|
||||
|
||||
private func publish(config: BroadcastConfig) async throws {
|
||||
private func publish(config: BroadcastConfig, generation: Int) async throws {
|
||||
guard let url = URL(string: config.rtmpUrl) else {
|
||||
throw APIError.http(400, "URL RTMP non valido")
|
||||
}
|
||||
await Self.ensureRTMPFactoryRegistered()
|
||||
await applyVideoOrientation()
|
||||
await applyVideoOrientationIfNeeded(force: true)
|
||||
await teardownRTMP(keepPreview: true)
|
||||
|
||||
let session = try await SessionBuilderFactory.shared.make(url)
|
||||
@@ -246,14 +295,21 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
})
|
||||
await mixer.addOutput(stream)
|
||||
rtmpSession = session
|
||||
observeReadyState(session)
|
||||
observeReadyState(session, generation: generation)
|
||||
|
||||
try await session.connect { [weak self] in
|
||||
Task { @MainActor in
|
||||
guard let self, self.phase == .live else { return }
|
||||
guard let self,
|
||||
!self.shouldSuppressDisconnectError,
|
||||
self.broadcastGeneration == generation,
|
||||
self.phase == .live else { return }
|
||||
self.setPhase(.error, error: "Connessione RTMP interrotta")
|
||||
}
|
||||
}
|
||||
guard generation == broadcastGeneration else {
|
||||
await teardownRTMP(keepPreview: true)
|
||||
return
|
||||
}
|
||||
setPhase(.live)
|
||||
}
|
||||
|
||||
@@ -277,14 +333,16 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
try await stream.setAudioSettings(audioSettings)
|
||||
}
|
||||
|
||||
private func observeReadyState(_ session: any Session) {
|
||||
private func observeReadyState(_ session: any Session, generation: Int) {
|
||||
readyStateTask?.cancel()
|
||||
readyStateTask = Task {
|
||||
for await state in await session.readyState {
|
||||
guard generation == broadcastGeneration else { return }
|
||||
switch state {
|
||||
case .open:
|
||||
setPhase(.live)
|
||||
case .closed where phase == .live:
|
||||
guard !shouldSuppressDisconnectError else { return }
|
||||
setPhase(.error, error: "Connessione RTMP interrotta")
|
||||
default:
|
||||
break
|
||||
@@ -307,15 +365,49 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func applyVideoOrientation() async {
|
||||
private var shouldSuppressDisconnectError: Bool {
|
||||
suppressDisconnectError || Date() < orientationTransitionUntil
|
||||
}
|
||||
|
||||
private func applyVideoOrientationIfNeeded(force: Bool = false) async {
|
||||
let orientation = BroadcastVideoOrientation.captureOrientation()
|
||||
let landscapeLocked = AppOrientation.mode == .landscape
|
||||
if !force,
|
||||
!BroadcastOrientationPolicy.shouldApplyMixerOrientation(
|
||||
new: orientation,
|
||||
previous: lastAppliedCaptureOrientation,
|
||||
landscapeLocked: landscapeLocked
|
||||
) {
|
||||
return
|
||||
}
|
||||
guard BroadcastOrientationPolicy.isApplicableForBroadcast(orientation, landscapeLocked: landscapeLocked) else {
|
||||
return
|
||||
}
|
||||
|
||||
if phase == .live || phase == .connecting {
|
||||
orientationTransitionUntil = Date().addingTimeInterval(BroadcastOrientationPolicy.suppressDisconnectSeconds)
|
||||
}
|
||||
lastAppliedCaptureOrientation = orientation
|
||||
|
||||
await mixer.setVideoOrientation(orientation)
|
||||
try? await mixer.configuration(video: 0) { videoUnit in
|
||||
videoUnit.isVideoMirrored = false
|
||||
}
|
||||
let portrait = BroadcastVideoOrientation.isPortraitContent
|
||||
guard portrait != isPortrait else { return }
|
||||
isPortrait = portrait
|
||||
overlayRenderer.refreshOrientation(isPortrait: portrait)
|
||||
}
|
||||
|
||||
private func scheduleOrientationUpdate() {
|
||||
orientationDebounceTask?.cancel()
|
||||
orientationDebounceTask = Task {
|
||||
try? await Task.sleep(nanoseconds: BroadcastOrientationPolicy.debounceNanoseconds)
|
||||
guard !Task.isCancelled else { return }
|
||||
await applyVideoOrientationIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func startOrientationMonitoring() {
|
||||
stopOrientationMonitoring()
|
||||
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
|
||||
@@ -325,12 +417,14 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
await self?.applyVideoOrientation()
|
||||
self?.scheduleOrientationUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopOrientationMonitoring() {
|
||||
orientationDebounceTask?.cancel()
|
||||
orientationDebounceTask = nil
|
||||
if let orientationObserver {
|
||||
NotificationCenter.default.removeObserver(orientationObserver)
|
||||
self.orientationObserver = nil
|
||||
|
||||
Reference in New Issue
Block a user