Fix HLS browser in prod e stabilizza broadcast RTMP su iOS.
Il proxy Rails gestisce il redirect MediaMTX su /live/match_* e riscrive le playlist; edge nginx reindirizza /live/ sotto /hls/. Su iOS, SessionBuilder HaishinKit, pipeline mixer corretta e refresh token. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
ed0f913138
commit
a303a94671
@@ -7,6 +7,10 @@ struct LoginRequest: Encodable {
|
||||
|
||||
struct RefreshRequest: Encodable {
|
||||
let refreshToken: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case refreshToken = "refresh_token"
|
||||
}
|
||||
}
|
||||
|
||||
struct LoginResponse: Decodable {
|
||||
|
||||
@@ -43,6 +43,8 @@ final class MatchLiveAPI: @unchecked Sendable {
|
||||
private let session: URLSession
|
||||
private var accessToken: String?
|
||||
private let baseURL: URL
|
||||
/// Chiamato su 401 per rinnovare il token e ritentare la richiesta (una volta).
|
||||
var tokenRefresher: (() async throws -> String?)?
|
||||
|
||||
init(baseURL: URL = URL(string: AppConfig.apiV1)!, session: URLSession = .shared) {
|
||||
self.baseURL = baseURL
|
||||
@@ -50,7 +52,7 @@ final class MatchLiveAPI: @unchecked Sendable {
|
||||
}
|
||||
|
||||
func setAccessToken(_ token: String?) {
|
||||
accessToken = token
|
||||
accessToken = token?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
// MARK: - Auth
|
||||
@@ -232,10 +234,20 @@ final class MatchLiveAPI: @unchecked Sendable {
|
||||
return try await execute(request)
|
||||
}
|
||||
|
||||
private func execute<T: Decodable>(_ request: URLRequest) async throws -> T {
|
||||
private func execute<T: Decodable>(_ request: URLRequest, canRefresh: Bool = true) async throws -> T {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else { throw APIError.invalidURL }
|
||||
if http.statusCode == 401 { throw APIError.unauthorized }
|
||||
if http.statusCode == 401 {
|
||||
if canRefresh, !isPublicAuthRequest(request), let tokenRefresher {
|
||||
if let newToken = try await tokenRefresher() {
|
||||
accessToken = newToken
|
||||
var retry = request
|
||||
retry.setValue("Bearer \(newToken)", forHTTPHeaderField: "Authorization")
|
||||
return try await execute(retry, canRefresh: false)
|
||||
}
|
||||
}
|
||||
throw APIError.unauthorized
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
let body = String(data: data, encoding: .utf8)
|
||||
throw APIError.http(http.statusCode, body)
|
||||
@@ -249,6 +261,13 @@ final class MatchLiveAPI: @unchecked Sendable {
|
||||
throw APIError.decoding(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func isPublicAuthRequest(_ request: URLRequest) -> Bool {
|
||||
guard let path = request.url?.path else { return false }
|
||||
return path.hasSuffix("/auth/login")
|
||||
|| path.hasSuffix("/auth/refresh")
|
||||
|| path.hasSuffix("/auth/register")
|
||||
}
|
||||
}
|
||||
|
||||
private struct EmptyResponse: Decodable {}
|
||||
|
||||
@@ -18,10 +18,15 @@ final class AppContainer: ObservableObject {
|
||||
let tokenStore = TokenStore()
|
||||
self.api = api
|
||||
self.tokenStore = tokenStore
|
||||
authRepository = AuthRepository(api: api, tokenStore: tokenStore) { token in
|
||||
let authRepository = AuthRepository(api: api, tokenStore: tokenStore) { token in
|
||||
api.setAccessToken(token)
|
||||
OverlayLogoCache.configure(token: token)
|
||||
}
|
||||
self.authRepository = authRepository
|
||||
api.tokenRefresher = {
|
||||
guard let stored = authRepository.currentSession else { return nil }
|
||||
return try await authRepository.refresh(stored.refreshToken).accessToken
|
||||
}
|
||||
matchRepository = MatchRepository(api: api, tokenStore: tokenStore)
|
||||
sessionRepository = SessionRepository(api: api)
|
||||
scoreRepository = ScoreRepository(api: api)
|
||||
|
||||
@@ -28,8 +28,13 @@ final class AuthRepository {
|
||||
do {
|
||||
_ = try await api.me()
|
||||
return stored
|
||||
} catch let error as APIError {
|
||||
if case .unauthorized = error {
|
||||
return try? await refresh(stored.refreshToken)
|
||||
}
|
||||
return stored
|
||||
} catch {
|
||||
return try? await refresh(stored.refreshToken)
|
||||
return stored
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import AVFoundation
|
||||
import UIKit
|
||||
|
||||
/// Allinea l'orientamento del sensore camera all'interfaccia (broadcast sempre landscape).
|
||||
enum BroadcastVideoOrientation {
|
||||
@MainActor
|
||||
static func captureOrientation() -> AVCaptureVideoOrientation {
|
||||
if let scene = UIApplication.shared.connectedScenes
|
||||
.compactMap({ $0 as? UIWindowScene })
|
||||
.first(where: { $0.activationState == .foregroundActive }) {
|
||||
return captureOrientation(for: scene.interfaceOrientation)
|
||||
}
|
||||
return .landscapeRight
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static var isPortraitContent: Bool {
|
||||
let orientation = captureOrientation()
|
||||
return orientation == .portrait || orientation == .portraitUpsideDown
|
||||
}
|
||||
|
||||
private static func captureOrientation(for orientation: UIInterfaceOrientation) -> AVCaptureVideoOrientation {
|
||||
switch orientation {
|
||||
case .portrait: return .portrait
|
||||
case .portraitUpsideDown: return .portraitUpsideDown
|
||||
case .landscapeLeft: return .landscapeLeft
|
||||
case .landscapeRight: return .landscapeRight
|
||||
default: return .landscapeRight
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,23 +3,20 @@ import Foundation
|
||||
import HaishinKit
|
||||
import RTMPHaishinKit
|
||||
import UIKit
|
||||
import VideoToolbox
|
||||
|
||||
struct RTMPPublishTarget: Sendable {
|
||||
let connectURL: String
|
||||
let streamName: String
|
||||
}
|
||||
private struct BroadcastBitrateMonitor: StreamBitRateStrategy {
|
||||
let mamimumVideoBitRate: Int = 0
|
||||
let mamimumAudioBitRate: Int = 0
|
||||
let onUpdate: @Sendable (Int) -> Void
|
||||
|
||||
enum RTMPUrlParser {
|
||||
static func parse(_ urlString: String) -> RTMPPublishTarget? {
|
||||
guard let url = URL(string: urlString), url.scheme?.hasPrefix("rtmp") == true else { return nil }
|
||||
let pathParts = url.path.split(separator: "/").map(String.init)
|
||||
guard pathParts.count >= 2 else { return nil }
|
||||
let app = pathParts.dropLast().joined(separator: "/")
|
||||
let streamName = pathParts.last ?? ""
|
||||
var connect = "rtmp://\(url.host ?? "")"
|
||||
if let port = url.port { connect += ":\(port)" }
|
||||
connect += "/\(app)"
|
||||
return RTMPPublishTarget(connectURL: connect, streamName: streamName)
|
||||
func adjustBitrate(_ event: NetworkMonitorEvent, stream: some StreamConvertible) async {
|
||||
switch event {
|
||||
case .status(let report), .publishInsufficientBWOccured(let report):
|
||||
onUpdate(max(0, report.currentBytesOutPerSecond / 1024))
|
||||
case .reset:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,20 +25,27 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
@Published private(set) var metrics = BroadcastMetrics()
|
||||
|
||||
let mixer = MediaMixer()
|
||||
let connection = RTMPConnection()
|
||||
let stream: RTMPStream
|
||||
private let overlayRenderer = OverlayRenderer()
|
||||
|
||||
private var config: BroadcastConfig?
|
||||
private var phase: BroadcastPhase = .idle
|
||||
private var metricsListener: ((BroadcastMetrics) -> Void)?
|
||||
private var reconnectAttempts = 0
|
||||
private var publishTask: Task<Void, Never>?
|
||||
private var readyStateTask: Task<Void, Never>?
|
||||
private var pipelineConfigured = false
|
||||
private var isPortrait = false
|
||||
private var orientationObserver: NSObjectProtocol?
|
||||
private weak var previewView: MTHKView?
|
||||
private var previewAttached = false
|
||||
private var publishPending = false
|
||||
private var publishInFlight = false
|
||||
private var rtmpSession: (any Session)?
|
||||
private static var rtmpFactoryRegistered = false
|
||||
|
||||
init() {
|
||||
stream = RTMPStream(connection: connection)
|
||||
private static func ensureRTMPFactoryRegistered() async {
|
||||
guard !rtmpFactoryRegistered else { return }
|
||||
await SessionBuilderFactory.shared.register(RTMPSessionFactory())
|
||||
rtmpFactoryRegistered = true
|
||||
}
|
||||
|
||||
func setMetricsListener(_ block: ((BroadcastMetrics) -> Void)?) {
|
||||
@@ -53,50 +57,71 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
overlayRenderer.update(state: state, isPortrait: isPortrait)
|
||||
}
|
||||
|
||||
/// Anteprima locale: output del mixer (come HaishinKit PublishViewModel), non dello stream RTMP.
|
||||
func bindPreview(to view: MTHKView) async {
|
||||
await stream.addOutput(view)
|
||||
previewView = view
|
||||
guard pipelineConfigured, !previewAttached else { return }
|
||||
await mixer.addOutput(view)
|
||||
previewAttached = true
|
||||
if publishPending {
|
||||
await startPendingPublish()
|
||||
}
|
||||
}
|
||||
|
||||
func preparePreview(config: BroadcastConfig) async throws {
|
||||
self.config = config
|
||||
publishPending = false
|
||||
try await configurePipeline(config)
|
||||
setPhase(.preview)
|
||||
}
|
||||
|
||||
func startBroadcast(config: BroadcastConfig) async throws {
|
||||
/// Configura camera/mixer e mette in coda il publish RTMP (eseguito quando l'anteprima è pronta).
|
||||
func prepareForBroadcast(config: BroadcastConfig) async throws {
|
||||
self.config = config
|
||||
publishTask?.cancel()
|
||||
publishTask = nil
|
||||
publishPending = true
|
||||
try await configurePipeline(config)
|
||||
setPhase(.connecting)
|
||||
try await Task.sleep(nanoseconds: 350_000_000)
|
||||
try await publish(config: config)
|
||||
await startPendingPublish()
|
||||
}
|
||||
|
||||
func startBroadcast(config: BroadcastConfig) async throws {
|
||||
try await prepareForBroadcast(config: config)
|
||||
}
|
||||
|
||||
func pauseBroadcast() async {
|
||||
try? await stream.close()
|
||||
publishPending = false
|
||||
publishTask?.cancel()
|
||||
publishTask = nil
|
||||
await teardownRTMP(keepPreview: true)
|
||||
setPhase(.paused)
|
||||
}
|
||||
|
||||
func resumeBroadcast(config: BroadcastConfig) async throws {
|
||||
self.config = config
|
||||
publishPending = true
|
||||
if !pipelineConfigured {
|
||||
try await configurePipeline(config)
|
||||
}
|
||||
setPhase(.connecting)
|
||||
try await publish(config: config)
|
||||
await startPendingPublish()
|
||||
}
|
||||
|
||||
func stopBroadcast() async {
|
||||
publishTask?.cancel()
|
||||
publishTask = nil
|
||||
publishPending = false
|
||||
publishInFlight = false
|
||||
stopOrientationMonitoring()
|
||||
await teardownRTMP(keepPreview: false)
|
||||
if let previewView, previewAttached {
|
||||
await mixer.removeOutput(previewView)
|
||||
}
|
||||
previewAttached = false
|
||||
previewView = nil
|
||||
overlayRenderer.detach()
|
||||
try? await stream.close()
|
||||
try? await connection.close()
|
||||
try? await mixer.stopRunning()
|
||||
pipelineConfigured = false
|
||||
setPhase(.idle)
|
||||
reconnectAttempts = 0
|
||||
}
|
||||
|
||||
func release() async {
|
||||
@@ -126,19 +151,22 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
if let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) {
|
||||
try await mixer.attachVideo(camera, track: 0)
|
||||
}
|
||||
try await mixer.addOutput(stream)
|
||||
try await mixer.startRunning()
|
||||
overlayRenderer.attach(mixer: mixer, width: config.width, height: config.height, isPortrait: config.portrait)
|
||||
await applyVideoOrientation()
|
||||
startOrientationMonitoring()
|
||||
|
||||
var streamSettings = await stream.videoSettings
|
||||
streamSettings.videoSize = .init(width: config.width, height: config.height)
|
||||
streamSettings.bitRate = config.videoBitrate
|
||||
streamSettings.maxKeyFrameIntervalDuration = 2
|
||||
try await stream.setVideoSettings(streamSettings)
|
||||
var audioSettings = await stream.audioSettings
|
||||
audioSettings.bitRate = config.audioBitrate
|
||||
try await stream.setAudioSettings(audioSettings)
|
||||
try await mixer.startRunning()
|
||||
overlayRenderer.attach(
|
||||
mixer: mixer,
|
||||
width: config.width,
|
||||
height: config.height,
|
||||
isPortrait: isPortrait
|
||||
)
|
||||
pipelineConfigured = true
|
||||
|
||||
if let previewView, !previewAttached {
|
||||
await mixer.addOutput(previewView)
|
||||
previewAttached = true
|
||||
}
|
||||
}
|
||||
|
||||
private func configureScreenSize(width: Int, height: Int) async {
|
||||
@@ -148,23 +176,175 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
}.value
|
||||
}
|
||||
|
||||
private func startPendingPublish() async {
|
||||
guard publishPending, !publishInFlight else { return }
|
||||
publishTask?.cancel()
|
||||
publishTask = Task {
|
||||
await waitForPreviewThenPublish()
|
||||
}
|
||||
}
|
||||
|
||||
private func waitForPreviewThenPublish() async {
|
||||
guard publishPending, let config else { return }
|
||||
publishInFlight = true
|
||||
defer { publishInFlight = false }
|
||||
|
||||
for _ in 0..<80 {
|
||||
if Task.isCancelled { return }
|
||||
if previewAttached { break }
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
guard previewAttached else {
|
||||
publishPending = false
|
||||
setPhase(.error, error: "Anteprima camera non pronta")
|
||||
return
|
||||
}
|
||||
|
||||
setPhase(.connecting)
|
||||
try? await Task.sleep(nanoseconds: 350_000_000)
|
||||
guard publishPending else { return }
|
||||
|
||||
do {
|
||||
try await publish(config: config)
|
||||
publishPending = false
|
||||
} catch {
|
||||
publishPending = false
|
||||
let message = UserFacingError.message(for: error) ?? "Connessione RTMP fallita"
|
||||
setPhase(.error, error: message)
|
||||
}
|
||||
}
|
||||
|
||||
private func publish(config: BroadcastConfig) async throws {
|
||||
guard let target = RTMPUrlParser.parse(config.rtmpUrl) else {
|
||||
guard let url = URL(string: config.rtmpUrl) else {
|
||||
throw APIError.http(400, "URL RTMP non valido")
|
||||
}
|
||||
try await connection.connect(target.connectURL)
|
||||
try await stream.publish(target.streamName)
|
||||
reconnectAttempts = 0
|
||||
await Self.ensureRTMPFactoryRegistered()
|
||||
await applyVideoOrientation()
|
||||
await teardownRTMP(keepPreview: true)
|
||||
|
||||
let session = try await SessionBuilderFactory.shared.make(url)
|
||||
.setMode(.publish)
|
||||
.build()
|
||||
guard let session else {
|
||||
throw APIError.http(500, "Impossibile creare sessione RTMP")
|
||||
}
|
||||
|
||||
let stream = await session.stream
|
||||
try await applyCodecSettings(to: stream, config: config)
|
||||
await stream.setBitRateStrategy(BroadcastBitrateMonitor { [weak self] kbps in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.metrics = BroadcastMetrics(
|
||||
phase: self.phase,
|
||||
bitrateKbps: kbps,
|
||||
fps: self.config?.fps ?? self.metrics.fps,
|
||||
connected: self.phase == .live,
|
||||
lastError: self.metrics.lastError
|
||||
)
|
||||
self.metricsListener?(self.metrics)
|
||||
}
|
||||
})
|
||||
await mixer.addOutput(stream)
|
||||
rtmpSession = session
|
||||
observeReadyState(session)
|
||||
|
||||
try await session.connect { [weak self] in
|
||||
Task { @MainActor in
|
||||
guard let self, self.phase == .live else { return }
|
||||
self.setPhase(.error, error: "Connessione RTMP interrotta")
|
||||
}
|
||||
}
|
||||
setPhase(.live)
|
||||
}
|
||||
|
||||
private func applyCodecSettings(to stream: any StreamConvertible, config: BroadcastConfig) async throws {
|
||||
var videoSettings = await stream.videoSettings
|
||||
videoSettings.videoSize = .init(width: config.width, height: config.height)
|
||||
videoSettings.bitRate = config.videoBitrate
|
||||
videoSettings.maxKeyFrameIntervalDuration = 2
|
||||
videoSettings.profileLevel = kVTProfileLevel_H264_Baseline_3_1 as String
|
||||
videoSettings.expectedFrameRate = Double(config.fps)
|
||||
try await stream.setVideoSettings(videoSettings)
|
||||
|
||||
var audioSettings = await stream.audioSettings
|
||||
audioSettings = AudioCodecSettings(
|
||||
bitRate: config.audioBitrate,
|
||||
downmix: audioSettings.downmix,
|
||||
channelMap: audioSettings.channelMap,
|
||||
sampleRate: 48_000,
|
||||
format: audioSettings.format
|
||||
)
|
||||
try await stream.setAudioSettings(audioSettings)
|
||||
}
|
||||
|
||||
private func observeReadyState(_ session: any Session) {
|
||||
readyStateTask?.cancel()
|
||||
readyStateTask = Task {
|
||||
for await state in await session.readyState {
|
||||
switch state {
|
||||
case .open:
|
||||
setPhase(.live)
|
||||
case .closed where phase == .live:
|
||||
setPhase(.error, error: "Connessione RTMP interrotta")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func teardownRTMP(keepPreview: Bool) async {
|
||||
readyStateTask?.cancel()
|
||||
readyStateTask = nil
|
||||
if let session = rtmpSession {
|
||||
let stream = await session.stream
|
||||
await mixer.removeOutput(stream)
|
||||
try? await session.close()
|
||||
}
|
||||
rtmpSession = nil
|
||||
if !keepPreview {
|
||||
publishPending = false
|
||||
}
|
||||
}
|
||||
|
||||
private func applyVideoOrientation() async {
|
||||
let orientation = BroadcastVideoOrientation.captureOrientation()
|
||||
await mixer.setVideoOrientation(orientation)
|
||||
let portrait = BroadcastVideoOrientation.isPortraitContent
|
||||
guard portrait != isPortrait else { return }
|
||||
isPortrait = portrait
|
||||
overlayRenderer.refreshOrientation(isPortrait: portrait)
|
||||
}
|
||||
|
||||
private func startOrientationMonitoring() {
|
||||
stopOrientationMonitoring()
|
||||
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
|
||||
orientationObserver = NotificationCenter.default.addObserver(
|
||||
forName: UIDevice.orientationDidChangeNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
await self?.applyVideoOrientation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopOrientationMonitoring() {
|
||||
if let orientationObserver {
|
||||
NotificationCenter.default.removeObserver(orientationObserver)
|
||||
self.orientationObserver = nil
|
||||
}
|
||||
UIDevice.current.endGeneratingDeviceOrientationNotifications()
|
||||
}
|
||||
|
||||
private func setPhase(_ newPhase: BroadcastPhase, error: String? = nil) {
|
||||
phase = newPhase
|
||||
metrics = BroadcastMetrics(
|
||||
phase: newPhase,
|
||||
bitrateKbps: metrics.bitrateKbps,
|
||||
fps: config?.fps ?? metrics.fps,
|
||||
connected: newPhase == .live || newPhase == .reconnecting,
|
||||
connected: newPhase == .live,
|
||||
lastError: error
|
||||
)
|
||||
metricsListener?(metrics)
|
||||
|
||||
@@ -79,7 +79,12 @@ struct BroadcastScreen: View {
|
||||
.onChange(of: scoreController.lastActionError) { message in
|
||||
if let message { snackbarMessage = message }
|
||||
}
|
||||
.onChange(of: broadcastCoordinator.metrics.phase) { _ in updateOverlay() }
|
||||
.onChange(of: broadcastCoordinator.metrics.phase) { phase in
|
||||
updateOverlay()
|
||||
if phase == .error, let message = broadcastCoordinator.metrics.lastError {
|
||||
self.error = message
|
||||
}
|
||||
}
|
||||
.alert("Errore", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
|
||||
Button("OK") { onFinished() }
|
||||
} message: {
|
||||
@@ -287,18 +292,19 @@ struct BroadcastScreen: View {
|
||||
match = loadedMatch
|
||||
container.scoreController.bind(sessionId: sessionId, initial: loaded.score)
|
||||
wireCable()
|
||||
await preloadLogos(for: loadedMatch)
|
||||
loading = false
|
||||
updateOverlay()
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
if let url = loaded.rtmpIngestUrl, !url.isEmpty {
|
||||
let config = broadcastConfig(for: loaded, rtmpUrl: url)
|
||||
if loaded.isPaused {
|
||||
try await container.broadcastCoordinator.engine.preparePreview(config: config)
|
||||
} else {
|
||||
try await container.broadcastCoordinator.startBroadcast(config: config)
|
||||
try await container.broadcastCoordinator.engine.prepareForBroadcast(config: config)
|
||||
}
|
||||
}
|
||||
await preloadLogos(for: loadedMatch)
|
||||
startPolling()
|
||||
loading = false
|
||||
updateOverlay()
|
||||
} catch {
|
||||
if let message = UserFacingError.message(for: error) {
|
||||
self.error = message
|
||||
|
||||
@@ -58,13 +58,13 @@ struct LoginScreen: View {
|
||||
_ = try await container.authRepository.login(email: email.trimmingCharacters(in: .whitespaces), password: password)
|
||||
onLoggedIn()
|
||||
} catch {
|
||||
let message = error.localizedDescription
|
||||
if message.contains("401") {
|
||||
if let apiError = error as? APIError, case .unauthorized = apiError {
|
||||
self.error = "Email o password non corretti"
|
||||
} else if message.localizedCaseInsensitiveContains("timeout") {
|
||||
} else if let message = UserFacingError.message(for: error),
|
||||
message.localizedCaseInsensitiveContains("timeout") {
|
||||
self.error = "Server non raggiungibile. Verifica la connessione."
|
||||
} else {
|
||||
self.error = message
|
||||
self.error = UserFacingError.message(for: error) ?? "Accesso non riuscito"
|
||||
}
|
||||
}
|
||||
loading = false
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import HaishinKit
|
||||
import RTMPHaishinKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@@ -77,6 +79,9 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool {
|
||||
AppOrientation.lockPortrait()
|
||||
Task {
|
||||
await SessionBuilderFactory.shared.register(RTMPSessionFactory())
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user