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
@@ -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