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
@@ -201,6 +201,29 @@ struct SetPartialDto: Codable {
|
||||
let set: Int?
|
||||
let home: Int?
|
||||
let away: Int?
|
||||
|
||||
init(set: Int?, home: Int?, away: Int?) {
|
||||
self.set = set
|
||||
self.home = home
|
||||
self.away = away
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
set = Self.decodeInt(container, key: .set)
|
||||
home = Self.decodeInt(container, key: .home)
|
||||
away = Self.decodeInt(container, key: .away)
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case set, home, away
|
||||
}
|
||||
|
||||
private static func decodeInt(_ container: KeyedDecodingContainer<CodingKeys>, key: CodingKeys) -> Int? {
|
||||
if let value = try? container.decode(Int.self, forKey: key) { return value }
|
||||
if let text = try? container.decode(String.self, forKey: key), let value = Int(text) { return value }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
struct ScoreStateDto: Decodable {
|
||||
|
||||
@@ -18,6 +18,7 @@ final class SessionCableService: ObservableObject {
|
||||
private var deviceRole = "camera"
|
||||
|
||||
func connect(sessionId: String, accessToken: String, deviceRole: String = "camera") {
|
||||
disconnect()
|
||||
self.sessionId = sessionId
|
||||
self.accessToken = accessToken
|
||||
self.deviceRole = deviceRole
|
||||
@@ -29,9 +30,12 @@ final class SessionCableService: ObservableObject {
|
||||
func disconnect() {
|
||||
intentionalDisconnect = true
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = nil
|
||||
client?.disconnect()
|
||||
client = nil
|
||||
connected = false
|
||||
sessionId = nil
|
||||
accessToken = nil
|
||||
}
|
||||
|
||||
func sendScoreUpdate(_ score: ScoreState) {
|
||||
@@ -84,8 +88,8 @@ extension SessionCableService: ActionCableClient.Listener {
|
||||
case "score_update":
|
||||
onScoreUpdate?(ScoreState.fromCablePayload(payload))
|
||||
case "command":
|
||||
let command = payload["command"] as? String ?? ""
|
||||
switch command {
|
||||
let action = payload["action"] as? String ?? ""
|
||||
switch action {
|
||||
case "pause_stream": onPauseStream?()
|
||||
case "resume_stream": onResumeStream?()
|
||||
case "stop_stream": onStopStream?()
|
||||
|
||||
@@ -18,13 +18,24 @@ final class ScoreController: ObservableObject {
|
||||
}
|
||||
|
||||
func bind(sessionId: String, initial: ScoreState?) {
|
||||
let sessionChanged = self.sessionId != sessionId
|
||||
self.sessionId = sessionId
|
||||
if let initial { score = initial }
|
||||
if sessionChanged {
|
||||
syncTask?.cancel()
|
||||
syncGeneration += 1
|
||||
lastActionError = nil
|
||||
suppressRemoteUntil = .distantPast
|
||||
score = initial ?? ScoreState()
|
||||
} else if let initial {
|
||||
score = initial
|
||||
}
|
||||
}
|
||||
|
||||
func applyRemote(_ remote: ScoreState) {
|
||||
if remote == score { return }
|
||||
if Date() < suppressRemoteUntil && remote.progressKey() <= score.progressKey() { return }
|
||||
// Non accettare mai uno stato "più vecchio" (es. polling che sovrascrive close_set).
|
||||
if remote.progressKey() < score.progressKey() { return }
|
||||
if Date() < suppressRemoteUntil, remote.progressKey() == score.progressKey() { return }
|
||||
score = remote
|
||||
}
|
||||
|
||||
@@ -85,23 +96,55 @@ final class ScoreController: ObservableObject {
|
||||
|
||||
/// Chiude il set corrente tramite `close_set` sul motore backend.
|
||||
func closeSetAsync() async -> Bool {
|
||||
lastActionError = nil
|
||||
syncTask?.cancel()
|
||||
syncGeneration += 1
|
||||
guard let sessionId else { return false }
|
||||
|
||||
let optimistic = closedSetState(from: score)
|
||||
score = optimistic
|
||||
sessionCable.sendScoreUpdate(optimistic)
|
||||
|
||||
do {
|
||||
if let updated = try await scoreRepository.applyScoreAction(sessionId: sessionId, action: "close_set") {
|
||||
suppressRemoteUntil = Date().addingTimeInterval(1.2)
|
||||
suppressRemoteUntil = Date().addingTimeInterval(3)
|
||||
score = updated
|
||||
sessionCable.sendScoreUpdate(updated)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
suppressRemoteUntil = Date().addingTimeInterval(3)
|
||||
return true
|
||||
} catch {
|
||||
lastActionError = UserFacingError.message(for: error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func closedSetState(from current: ScoreState) -> ScoreState {
|
||||
let homeWon = current.homePoints > current.awayPoints
|
||||
let awayWon = current.awayPoints > current.homePoints
|
||||
var partials = current.setPartials
|
||||
if current.homePoints > 0 || current.awayPoints > 0 {
|
||||
partials.append(SetPartial(set: current.currentSet, home: current.homePoints, away: current.awayPoints))
|
||||
}
|
||||
return ScoreState(
|
||||
homeSets: homeWon ? current.homeSets + 1 : current.homeSets,
|
||||
awaySets: awayWon ? current.awaySets + 1 : current.awaySets,
|
||||
homePoints: 0,
|
||||
awayPoints: 0,
|
||||
currentSet: current.currentSet + 1,
|
||||
setPartials: partials,
|
||||
timeoutHome: false,
|
||||
timeoutAway: false,
|
||||
boardType: current.boardType,
|
||||
period: current.period,
|
||||
periodLabel: current.periodLabel,
|
||||
clockSecs: current.clockSecs,
|
||||
clockRunning: current.clockRunning,
|
||||
overtime: current.overtime
|
||||
)
|
||||
}
|
||||
|
||||
private func updateScore(_ transform: (inout ScoreState) -> Void) {
|
||||
var next = score
|
||||
transform(&next)
|
||||
@@ -143,28 +186,7 @@ final class ScoreController: ObservableObject {
|
||||
}
|
||||
|
||||
func closeSet() {
|
||||
let current = score
|
||||
let homeWon = current.homePoints > current.awayPoints
|
||||
var partials = current.setPartials
|
||||
if current.homePoints > 0 || current.awayPoints > 0 {
|
||||
partials.append(SetPartial(set: current.currentSet, home: current.homePoints, away: current.awayPoints))
|
||||
}
|
||||
score = ScoreState(
|
||||
homeSets: homeWon ? current.homeSets + 1 : current.homeSets,
|
||||
awaySets: homeWon ? current.awaySets : current.awaySets + 1,
|
||||
homePoints: 0,
|
||||
awayPoints: 0,
|
||||
currentSet: current.currentSet + 1,
|
||||
setPartials: partials,
|
||||
timeoutHome: false,
|
||||
timeoutAway: false,
|
||||
boardType: current.boardType,
|
||||
period: current.period,
|
||||
periodLabel: current.periodLabel,
|
||||
clockSecs: current.clockSecs,
|
||||
clockRunning: current.clockRunning,
|
||||
overtime: current.overtime
|
||||
)
|
||||
score = closedSetState(from: score)
|
||||
schedulePush()
|
||||
}
|
||||
|
||||
|
||||
@@ -51,12 +51,19 @@ struct ScoreState: Equatable, Sendable {
|
||||
}
|
||||
|
||||
func progressKey() -> Int64 {
|
||||
Int64(currentSet) * 10_000_000_000
|
||||
+ Int64(homeSets) * 1_000_000
|
||||
+ Int64(awaySets) * 100_000
|
||||
+ Int64(homePoints) * 1_000
|
||||
+ Int64(awayPoints)
|
||||
+ Int64(clockSecs)
|
||||
var key = Int64(currentSet) * 10_000_000
|
||||
key &+= Int64(homeSets) * 100_000
|
||||
key &+= Int64(awaySets) * 1_000
|
||||
key &+= Int64(homePoints) * 10
|
||||
key &+= Int64(awayPoints)
|
||||
key &+= Int64(clockSecs)
|
||||
key &+= Int64(setPartials.count) * 1_000_000
|
||||
for partial in setPartials {
|
||||
key &+= Int64(partial.set) * 10_000
|
||||
key &+= Int64(partial.home) * 100
|
||||
key &+= Int64(partial.away)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
static let periodBoards: Set<String> = ["basket", "timed"]
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import AVFoundation
|
||||
|
||||
/// Regole orientamento in diretta (allineate al debounce Android).
|
||||
enum BroadcastOrientationPolicy {
|
||||
/// Attesa prima di applicare rotazione al mixer (evita burst da giro iPad).
|
||||
static let debounceNanoseconds: UInt64 = 350_000_000
|
||||
/// Finestra in cui ignorare `closed` RTMP dopo rotazione encoder.
|
||||
static let suppressDisconnectSeconds: TimeInterval = 1.0
|
||||
|
||||
static func shouldApplyMixerOrientation(
|
||||
new: AVCaptureVideoOrientation,
|
||||
previous: AVCaptureVideoOrientation?,
|
||||
landscapeLocked: Bool
|
||||
) -> Bool {
|
||||
guard isApplicableForBroadcast(new, landscapeLocked: landscapeLocked) else { return false }
|
||||
guard let previous else { return true }
|
||||
return new != previous
|
||||
}
|
||||
|
||||
/// In diretta landscape ignoriamo portrait/unknown: evita glitch quando il sensore passa da verticale.
|
||||
static func isApplicableForBroadcast(
|
||||
_ orientation: AVCaptureVideoOrientation,
|
||||
landscapeLocked: Bool
|
||||
) -> Bool {
|
||||
guard landscapeLocked else { return true }
|
||||
return orientation == .landscapeLeft || orientation == .landscapeRight
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
import AVFoundation
|
||||
import HaishinKit
|
||||
import UIKit
|
||||
|
||||
/// Allinea l'orientamento del sensore camera all'interfaccia (broadcast sempre landscape).
|
||||
/// Orientamento camera allineato a HaishinKit DeviceUtil (broadcast landscape su iPad).
|
||||
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)
|
||||
if let scene = activeWindowScene(),
|
||||
let orientation = DeviceUtil.videoOrientation(by: scene.interfaceOrientation) {
|
||||
return orientation
|
||||
}
|
||||
// BroadcastScreen forza landscapeRight.
|
||||
return .landscapeRight
|
||||
}
|
||||
|
||||
@@ -19,13 +20,10 @@ enum BroadcastVideoOrientation {
|
||||
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
|
||||
}
|
||||
@MainActor
|
||||
private static func activeWindowScene() -> UIWindowScene? {
|
||||
UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.first(where: { $0.activationState == .foregroundActive })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ final class LiveBroadcastCoordinator: ObservableObject {
|
||||
let engine = LiveBroadcastEngine()
|
||||
@Published private(set) var metrics = BroadcastMetrics()
|
||||
|
||||
private(set) var activeBroadcastSessionId: String?
|
||||
|
||||
init() {
|
||||
engine.setMetricsListener { [weak self] value in
|
||||
Task { @MainActor in self?.metrics = value }
|
||||
@@ -22,24 +24,56 @@ final class LiveBroadcastCoordinator: ObservableObject {
|
||||
try? session.setActive(true)
|
||||
}
|
||||
|
||||
func startBroadcast(config: BroadcastConfig) async throws {
|
||||
func waitForPreviewSurface() async -> Bool {
|
||||
await engine.waitForPreviewSurface()
|
||||
}
|
||||
|
||||
func prepareBroadcast(sessionId: String, config: BroadcastConfig, paused: Bool) async throws {
|
||||
configureBackgroundAudio()
|
||||
try await engine.startBroadcast(config: config)
|
||||
if Self.shouldStopPreviousSession(active: activeBroadcastSessionId, incoming: sessionId) {
|
||||
await engine.stopBroadcast(suppressDisconnectError: true)
|
||||
}
|
||||
activeBroadcastSessionId = sessionId
|
||||
if paused {
|
||||
try await engine.preparePreview(config: config)
|
||||
_ = await engine.waitForPreviewReady()
|
||||
} else {
|
||||
try await engine.prepareForBroadcast(config: config)
|
||||
}
|
||||
}
|
||||
|
||||
func startBroadcast(sessionId: String, config: BroadcastConfig) async throws {
|
||||
try await prepareBroadcast(sessionId: sessionId, config: config, paused: false)
|
||||
}
|
||||
|
||||
func pauseBroadcast() async {
|
||||
await engine.pauseBroadcast()
|
||||
await engine.pauseBroadcast(suppressDisconnectError: true)
|
||||
}
|
||||
|
||||
func resumeBroadcast(config: BroadcastConfig) async throws {
|
||||
try await engine.resumeBroadcast(config: config)
|
||||
}
|
||||
|
||||
func stopBroadcast() async {
|
||||
await engine.stopBroadcast()
|
||||
/// Ferma RTMP solo se la sessione indicata è ancora quella attiva (evita race tra dirette).
|
||||
func stopBroadcast(sessionId: String) async {
|
||||
guard activeBroadcastSessionId == sessionId else { return }
|
||||
activeBroadcastSessionId = nil
|
||||
await engine.stopBroadcast(suppressDisconnectError: true)
|
||||
}
|
||||
|
||||
/// Chiusura cover / uscita forzata: ferma sempre lo stream corrente.
|
||||
func forceStopBroadcast() async {
|
||||
activeBroadcastSessionId = nil
|
||||
await engine.stopBroadcast(suppressDisconnectError: true)
|
||||
}
|
||||
|
||||
func release() async {
|
||||
await engine.release()
|
||||
await forceStopBroadcast()
|
||||
}
|
||||
|
||||
/// `true` solo quando si passa da una diretta già attiva a un'altra sessione (non al primo avvio).
|
||||
nonisolated static func shouldStopPreviousSession(active: String?, incoming: String) -> Bool {
|
||||
guard let active else { return false }
|
||||
return active != incoming
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,12 +4,12 @@ struct WatermarkElement: OverlayElement {
|
||||
private let logo = UIImage(named: "logo-white-m-256")
|
||||
|
||||
func draw(in context: CGContext, state: OverlayState, layout: OverlayLayout) {
|
||||
guard state.overlayKind != .none, state.watermarkVisible, let logo, let cg = logo.cgImage else { return }
|
||||
guard state.overlayKind != .none, state.watermarkVisible, let logo else { return }
|
||||
let targetWidth = min(72, max(28, Int((CGFloat(layout.canvasWidth) * 0.055).rounded())))
|
||||
let scale = CGFloat(targetWidth) / logo.size.width
|
||||
let targetHeight = Int(logo.size.height * scale)
|
||||
let left = layout.canvasWidth - layout.marginPx - targetWidth
|
||||
let top = layout.canvasHeight - layout.marginPx - targetHeight
|
||||
context.draw(cg, in: CGRect(x: left, y: top, width: targetWidth, height: targetHeight))
|
||||
logo.draw(in: CGRect(x: left, y: top, width: targetWidth, height: targetHeight))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,14 @@ struct LivePreviewView: UIViewRepresentable {
|
||||
|
||||
func makeUIView(context: Context) -> MTHKView {
|
||||
let view = MTHKView(frame: .zero)
|
||||
view.videoGravity = .resizeAspectFill
|
||||
// resizeAspect: mostra tutto il frame 16:9 trasmesso (resizeAspectFill taglia i lati su iPad).
|
||||
view.videoGravity = .resizeAspect
|
||||
view.isUserInteractionEnabled = false
|
||||
Task { await engine.bindPreview(to: view) }
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: MTHKView, context: Context) {}
|
||||
func updateUIView(_ uiView: MTHKView, context: Context) {
|
||||
Task { await engine.bindPreview(to: uiView) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +130,8 @@ struct BroadcastControlsOverlay: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(width: sideToolbarWidth)
|
||||
.allowsHitTesting(true)
|
||||
}
|
||||
|
||||
private var rightToolbar: some View {
|
||||
@@ -449,6 +451,7 @@ private struct SideIconButton: View {
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(enabled ? .white : MatchColors.textSecondary)
|
||||
.frame(width: iconButtonSize, height: iconButtonSize)
|
||||
.contentShape(Rectangle())
|
||||
.background(backgroundColor, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@@ -25,12 +25,21 @@ struct BroadcastScreen: View {
|
||||
@State private var controlsVisible = true
|
||||
@State private var logoReady = 0
|
||||
@State private var pauseInFlight = false
|
||||
@State private var resumeInFlight = false
|
||||
@State private var deviceHealth = DeviceTelemetry.snapshot()
|
||||
@State private var snackbarMessage: String?
|
||||
@State private var shareItem: ShareItem?
|
||||
@State private var bootstrapGeneration = 0
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
if permissions.allGranted {
|
||||
LivePreviewView(engine: broadcastCoordinator.engine)
|
||||
.ignoresSafeArea()
|
||||
.allowsHitTesting(false)
|
||||
.opacity(loading || session == nil || match == nil ? 0 : 1)
|
||||
}
|
||||
if loading {
|
||||
ProgressView().tint(MatchColors.primaryRed)
|
||||
} else if !permissions.allGranted {
|
||||
@@ -46,9 +55,6 @@ struct BroadcastScreen: View {
|
||||
}
|
||||
.padding(24)
|
||||
} else if let session, let match {
|
||||
LivePreviewView(engine: broadcastCoordinator.engine)
|
||||
.ignoresSafeArea()
|
||||
.allowsHitTesting(false)
|
||||
broadcastOverlay(session: session, match: match)
|
||||
.id(scoreController.score.progressKey())
|
||||
.zIndex(1)
|
||||
@@ -59,22 +65,47 @@ struct BroadcastScreen: View {
|
||||
.onAppear {
|
||||
UIDevice.current.isBatteryMonitoringEnabled = true
|
||||
}
|
||||
.onDisappear {
|
||||
Task { await teardown() }
|
||||
}
|
||||
.task {
|
||||
await permissions.refresh()
|
||||
if !permissions.allGranted {
|
||||
await permissions.requestAll()
|
||||
}
|
||||
}
|
||||
.task(id: permissions.allGranted) {
|
||||
.task(id: "\(sessionId)-\(permissions.allGranted)") {
|
||||
guard permissions.allGranted else {
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
await bootstrap()
|
||||
}
|
||||
.task(id: sessionId) {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 4_000_000_000)
|
||||
if let remote = try? await container.sessionRepository.fetchSession(id: sessionId).score {
|
||||
container.scoreController.applyRemote(remote)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task(id: sessionId) {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
deviceHealth = DeviceTelemetry.snapshot()
|
||||
}
|
||||
}
|
||||
.task(id: sessionId) {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 10_000_000_000)
|
||||
let health = DeviceTelemetry.snapshot()
|
||||
let metrics = container.broadcastCoordinator.metrics
|
||||
try? await container.sessionRepository.postTelemetry(
|
||||
sessionId: sessionId,
|
||||
health: health,
|
||||
currentBitrate: metrics.bitrateKbps * 1000,
|
||||
targetBitrate: session?.targetBitrate,
|
||||
fps: metrics.fps
|
||||
)
|
||||
}
|
||||
}
|
||||
.onChange(of: scoreController.score) { _ in updateOverlay() }
|
||||
.onChange(of: scoreController.lastActionError) { message in
|
||||
if let message { snackbarMessage = message }
|
||||
@@ -90,6 +121,11 @@ struct BroadcastScreen: View {
|
||||
} message: {
|
||||
Text(error ?? "")
|
||||
}
|
||||
.sheet(item: $shareItem) { item in
|
||||
ShareSheet(items: item.items, subject: item.subject) {
|
||||
shareItem = nil
|
||||
}
|
||||
}
|
||||
.background {
|
||||
if let match {
|
||||
ScoreDialogRouter(
|
||||
@@ -215,7 +251,7 @@ struct BroadcastScreen: View {
|
||||
onTerminate: { Task { await stopStream() } },
|
||||
onShareLive: { shareLiveLink(session: session, subject: shareSubject) },
|
||||
onShareRegia: { shareRegiaLink(subject: shareSubject) },
|
||||
shareLiveEnabled: session.watchShareUrl() != nil,
|
||||
shareLiveEnabled: true,
|
||||
fps: metrics.fps,
|
||||
targetFps: session.targetFps,
|
||||
bitrateKbps: metrics.bitrateKbps,
|
||||
@@ -229,7 +265,8 @@ struct BroadcastScreen: View {
|
||||
rules: MatchScoringContext(match: match),
|
||||
scoreController: scoreController,
|
||||
dialogHost: scoreDialogHost,
|
||||
onStopStream: { await stopStream() }
|
||||
onStopStream: { await stopStream() },
|
||||
onScoreboardChanged: { updateOverlay() }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -254,58 +291,62 @@ struct BroadcastScreen: View {
|
||||
}
|
||||
|
||||
private func shareLiveLink(session: StreamSession, subject: String) {
|
||||
guard let urlString = session.watchShareUrl(), let url = URL(string: urlString) else {
|
||||
let urlString = session.watchShareUrl() ?? "\(AppConfig.apiBaseUrl)/live/\(session.id)"
|
||||
guard let url = URL(string: urlString) else {
|
||||
snackbarMessage = "Link diretta non ancora disponibile"
|
||||
return
|
||||
}
|
||||
presentShare(items: [url], subject: subject)
|
||||
shareItem = ShareItem(items: [url], subject: subject)
|
||||
}
|
||||
|
||||
private func shareRegiaLink(subject: String) {
|
||||
Task {
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let urlString = try await container.sessionRepository.createRegiaLink(sessionId: sessionId)
|
||||
guard let url = URL(string: urlString) else { return }
|
||||
presentShare(items: [url], subject: "Link regia — \(subject)")
|
||||
guard let url = URL(string: urlString) else {
|
||||
snackbarMessage = "Link regia non valido"
|
||||
return
|
||||
}
|
||||
shareItem = ShareItem(items: [url], subject: "Link regia — \(subject)")
|
||||
} catch {
|
||||
snackbarMessage = UserFacingError.message(for: error) ?? "Errore link regia"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func presentShare(items: [Any], subject: String) {
|
||||
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let root = scene.windows.first?.rootViewController else { return }
|
||||
let controller = UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||
controller.setValue(subject, forKey: "subject")
|
||||
root.present(controller, animated: true)
|
||||
}
|
||||
|
||||
private func bootstrap() async {
|
||||
bootstrapGeneration += 1
|
||||
let generation = bootstrapGeneration
|
||||
loading = true
|
||||
error = nil
|
||||
await container.broadcastCoordinator.stopBroadcast()
|
||||
container.sessionCable.disconnect()
|
||||
do {
|
||||
let loaded = try await container.sessionRepository.fetchSession(id: sessionId)
|
||||
guard generation == bootstrapGeneration else { return }
|
||||
let loadedMatch = try await container.matchRepository.fetchMatch(matchId: loaded.matchId)
|
||||
guard generation == bootstrapGeneration else { return }
|
||||
session = loaded
|
||||
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)
|
||||
loading = false
|
||||
guard generation == bootstrapGeneration else { return }
|
||||
guard await container.broadcastCoordinator.waitForPreviewSurface() else {
|
||||
throw APIError.http(500, "Anteprima camera non pronta")
|
||||
}
|
||||
guard generation == bootstrapGeneration else { return }
|
||||
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.engine.prepareForBroadcast(config: config)
|
||||
}
|
||||
try await container.broadcastCoordinator.prepareBroadcast(
|
||||
sessionId: sessionId,
|
||||
config: config,
|
||||
paused: loaded.isPaused
|
||||
)
|
||||
}
|
||||
startPolling()
|
||||
} catch {
|
||||
guard generation == bootstrapGeneration else { return }
|
||||
if let message = UserFacingError.message(for: error) {
|
||||
self.error = message
|
||||
}
|
||||
@@ -319,11 +360,15 @@ struct BroadcastScreen: View {
|
||||
container?.scoreController.applyRemote(remote)
|
||||
}
|
||||
container.sessionCable.onPauseStream = { [weak container] in
|
||||
Task { @MainActor in await container?.broadcastCoordinator.pauseBroadcast() }
|
||||
Task { @MainActor in
|
||||
guard let container, !pauseInFlight else { return }
|
||||
await applyRemotePause(container: container)
|
||||
}
|
||||
}
|
||||
container.sessionCable.onResumeStream = { [weak container] in
|
||||
Task { @MainActor in
|
||||
await resumeStream()
|
||||
guard let container, !resumeInFlight else { return }
|
||||
await applyRemoteResume(container: container)
|
||||
}
|
||||
}
|
||||
container.sessionCable.onStopStream = { [weak container] in
|
||||
@@ -381,48 +426,26 @@ struct BroadcastScreen: View {
|
||||
container.broadcastCoordinator.updateOverlay(state)
|
||||
}
|
||||
|
||||
private func startPolling() {
|
||||
Task {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 4_000_000_000)
|
||||
if let remote = try? await container.sessionRepository.fetchSession(id: sessionId).score {
|
||||
container.scoreController.applyRemote(remote)
|
||||
}
|
||||
}
|
||||
}
|
||||
Task {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
deviceHealth = DeviceTelemetry.snapshot()
|
||||
}
|
||||
}
|
||||
Task {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 10_000_000_000)
|
||||
let health = DeviceTelemetry.snapshot()
|
||||
let metrics = container.broadcastCoordinator.metrics
|
||||
try? await container.sessionRepository.postTelemetry(
|
||||
sessionId: sessionId,
|
||||
health: health,
|
||||
currentBitrate: metrics.bitrateKbps * 1000,
|
||||
targetBitrate: session?.targetBitrate,
|
||||
fps: metrics.fps
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pauseStream() {
|
||||
guard !pauseInFlight else { return }
|
||||
pauseInFlight = true
|
||||
Task {
|
||||
_ = try? await container.sessionRepository.pauseSession(id: sessionId)
|
||||
await container.broadcastCoordinator.pauseBroadcast()
|
||||
pauseInFlight = false
|
||||
defer { pauseInFlight = false }
|
||||
do {
|
||||
let updated = try await container.sessionRepository.pauseSession(id: sessionId)
|
||||
session = updated
|
||||
await container.broadcastCoordinator.pauseBroadcast()
|
||||
snackbarMessage = "Diretta in pausa"
|
||||
} catch {
|
||||
snackbarMessage = UserFacingError.message(for: error) ?? "Pausa non riuscita"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resumeStream() async {
|
||||
guard !resumeInFlight else { return }
|
||||
resumeInFlight = true
|
||||
defer { resumeInFlight = false }
|
||||
do {
|
||||
var updated = try await container.sessionRepository.resumeSession(id: sessionId)
|
||||
session = updated
|
||||
@@ -431,10 +454,38 @@ struct BroadcastScreen: View {
|
||||
try await container.broadcastCoordinator.resumeBroadcast(config: config)
|
||||
updated = try await container.sessionRepository.fetchSession(id: sessionId)
|
||||
session = updated
|
||||
snackbarMessage = "Diretta ripresa"
|
||||
} catch {
|
||||
if let message = UserFacingError.message(for: error) {
|
||||
self.error = message
|
||||
}
|
||||
snackbarMessage = UserFacingError.message(for: error) ?? "Ripresa non riuscita"
|
||||
}
|
||||
}
|
||||
|
||||
/// Pausa dalla regia / echo cable: solo RTMP locale, senza PATCH pause.
|
||||
private func applyRemotePause(container: AppContainer) async {
|
||||
guard !pauseInFlight else { return }
|
||||
pauseInFlight = true
|
||||
defer { pauseInFlight = false }
|
||||
await container.broadcastCoordinator.pauseBroadcast()
|
||||
if let fetched = try? await container.sessionRepository.fetchSession(id: sessionId) {
|
||||
session = fetched
|
||||
}
|
||||
snackbarMessage = "Pausa dalla regia"
|
||||
}
|
||||
|
||||
/// Ripresa dalla regia / echo cable: solo RTMP locale, senza PATCH resume.
|
||||
private func applyRemoteResume(container: AppContainer) async {
|
||||
guard !resumeInFlight else { return }
|
||||
resumeInFlight = true
|
||||
defer { resumeInFlight = false }
|
||||
do {
|
||||
let current = try await container.sessionRepository.fetchSession(id: sessionId)
|
||||
session = current
|
||||
guard let url = current.rtmpIngestUrl, !url.isEmpty else { return }
|
||||
let config = broadcastConfig(for: current, rtmpUrl: url)
|
||||
try await container.broadcastCoordinator.resumeBroadcast(config: config)
|
||||
snackbarMessage = "Diretta ripresa"
|
||||
} catch {
|
||||
snackbarMessage = UserFacingError.message(for: error) ?? "Ripresa RTMP non riuscita"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,7 +507,11 @@ struct BroadcastScreen: View {
|
||||
}
|
||||
|
||||
private func teardown() async {
|
||||
container.sessionCable.onScoreUpdate = nil
|
||||
container.sessionCable.onPauseStream = nil
|
||||
container.sessionCable.onResumeStream = nil
|
||||
container.sessionCable.onStopStream = nil
|
||||
container.sessionCable.disconnect()
|
||||
await container.broadcastCoordinator.stopBroadcast()
|
||||
await container.broadcastCoordinator.stopBroadcast(sessionId: sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,17 +43,20 @@ final class LiveScoreActions {
|
||||
private let scoreController: ScoreController
|
||||
private let dialogHost: LiveScoreDialogHost
|
||||
private let onStopStream: () async -> Void
|
||||
private let onScoreboardChanged: () -> Void
|
||||
|
||||
init(
|
||||
rules: MatchScoringContext,
|
||||
scoreController: ScoreController,
|
||||
dialogHost: LiveScoreDialogHost,
|
||||
onStopStream: @escaping () async -> Void
|
||||
onStopStream: @escaping () async -> Void,
|
||||
onScoreboardChanged: @escaping () -> Void = {}
|
||||
) {
|
||||
self.rules = rules
|
||||
self.scoreController = scoreController
|
||||
self.dialogHost = dialogHost
|
||||
self.onStopStream = onStopStream
|
||||
self.onScoreboardChanged = onScoreboardChanged
|
||||
}
|
||||
|
||||
func afterPointChange(score: ScoreState) async {
|
||||
@@ -74,6 +77,7 @@ final class LiveScoreActions {
|
||||
)
|
||||
if close {
|
||||
guard await scoreController.closeSetAsync() else { return }
|
||||
onScoreboardChanged()
|
||||
await afterCloseSet()
|
||||
}
|
||||
}
|
||||
@@ -92,6 +96,7 @@ final class LiveScoreActions {
|
||||
if !ok { return }
|
||||
}
|
||||
guard await scoreController.closeSetAsync() else { return }
|
||||
onScoreboardChanged()
|
||||
await afterCloseSet()
|
||||
}
|
||||
|
||||
|
||||
@@ -58,10 +58,17 @@ struct AppNavHost: View {
|
||||
.lockPortraitOrientation()
|
||||
}
|
||||
.fullScreenCover(item: $broadcastRoute, onDismiss: {
|
||||
Task { @MainActor in
|
||||
container.sessionCable.disconnect()
|
||||
await container.broadcastCoordinator.forceStopBroadcast()
|
||||
}
|
||||
AppOrientation.lockPortrait()
|
||||
matchesRefreshToken += 1
|
||||
}) { route in
|
||||
BroadcastScreen(container: container, sessionId: route.sessionId) {
|
||||
Task { @MainActor in
|
||||
await container.broadcastCoordinator.stopBroadcast(sessionId: route.sessionId)
|
||||
}
|
||||
AppOrientation.lockPortrait()
|
||||
container.wizardSession.reset()
|
||||
broadcastRoute = nil
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct ShareItem: Identifiable {
|
||||
let id = UUID()
|
||||
let items: [Any]
|
||||
let subject: String?
|
||||
}
|
||||
|
||||
/// Foglio condivisione compatibile iPhone/iPad (evita present() diretto da root VC).
|
||||
struct ShareSheet: UIViewControllerRepresentable {
|
||||
let items: [Any]
|
||||
var subject: String?
|
||||
var onComplete: (() -> Void)?
|
||||
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
let controller = UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||
if let subject {
|
||||
controller.setValue(subject, forKey: "subject")
|
||||
}
|
||||
controller.completionWithItemsHandler = { _, _, _, _ in
|
||||
onComplete?()
|
||||
}
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ struct StepNetworkTestScreen: View {
|
||||
@State private var selectedQualityLabel: String?
|
||||
@State private var starting = false
|
||||
@State private var currentSession: StreamSession
|
||||
@State private var shareItem: ShareItem?
|
||||
|
||||
init(
|
||||
container: AppContainer,
|
||||
@@ -154,6 +155,11 @@ struct StepNetworkTestScreen: View {
|
||||
.task(id: session.id) {
|
||||
await pollYoutubeIfNeeded()
|
||||
}
|
||||
.sheet(item: $shareItem) { item in
|
||||
ShareSheet(items: item.items, subject: item.subject) {
|
||||
shareItem = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func runTest() {
|
||||
@@ -223,8 +229,14 @@ struct StepNetworkTestScreen: View {
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let url = try await container.sessionRepository.createRegiaLink(sessionId: currentSession.id)
|
||||
guard let link = URL(string: url) else { return }
|
||||
presentShare(items: [link])
|
||||
guard let link = URL(string: url) else {
|
||||
onError("Link regia non valido")
|
||||
return
|
||||
}
|
||||
shareItem = ShareItem(
|
||||
items: [link],
|
||||
subject: "Link regia — \(match.teamName) vs \(match.opponentName)"
|
||||
)
|
||||
} catch {
|
||||
if let message = UserFacingError.message(for: error) {
|
||||
onError(message)
|
||||
@@ -233,13 +245,6 @@ struct StepNetworkTestScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func presentShare(items: [Any]) {
|
||||
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let root = scene.windows.first?.rootViewController else { return }
|
||||
let controller = UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||
root.present(controller, animated: true)
|
||||
}
|
||||
|
||||
private func formatQualityLabel(_ result: NetworkTestResponse) -> String {
|
||||
let bitrateMbps = Double(result.targetBitrate ?? 2_500_000) / 1_000_000.0
|
||||
let fps = result.targetFps ?? 30
|
||||
|
||||
Reference in New Issue
Block a user