Merge branch 'feature/adaptive-bitrate' into collaudo
Integra bitrate adattivo e telemetria, con il mute audio come icona nella toolbar della regia. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -42,8 +42,12 @@ struct BroadcastControlsOverlay: View {
|
||||
let bitrateKbps: Int
|
||||
let networkType: String
|
||||
let deviceHealth: DeviceHealth
|
||||
var minQualityPreset: String = StreamQualityLadder.auto
|
||||
var sessionQualityPreset: String = "720p_30_2.5mbps"
|
||||
var onSelectMinQuality: (String) -> Void = { _ in }
|
||||
|
||||
@State private var showTerminateConfirm = false
|
||||
@State private var showMinQualityPicker = false
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
@@ -72,7 +76,11 @@ struct BroadcastControlsOverlay: View {
|
||||
bitrateKbps: bitrateKbps,
|
||||
networkType: networkType,
|
||||
deviceHealth: deviceHealth,
|
||||
audioMuted: audioMuted
|
||||
audioMuted: audioMuted,
|
||||
minQualityLabel: StreamQualityLadder.displayName(
|
||||
id: minQualityPreset,
|
||||
autoLabel: L10n.t("broadcast.min.quality.auto")
|
||||
)
|
||||
)
|
||||
}
|
||||
.padding(.trailing, 8)
|
||||
@@ -97,6 +105,19 @@ struct BroadcastControlsOverlay: View {
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if showMinQualityPicker {
|
||||
MinQualityPickerOverlay(
|
||||
selectedId: minQualityPreset,
|
||||
sessionQualityPreset: sessionQualityPreset,
|
||||
onSelect: { id in
|
||||
showMinQualityPicker = false
|
||||
onSelectMinQuality(id)
|
||||
},
|
||||
onDismiss: { showMinQualityPicker = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
.alert(L10n.t("broadcast.terminate.title"), isPresented: $showTerminateConfirm) {
|
||||
Button(L10n.t("action.cancel"), role: .cancel) {}
|
||||
Button(L10n.t("broadcast.terminate.confirm"), role: .destructive, action: onTerminate)
|
||||
@@ -118,6 +139,11 @@ struct BroadcastControlsOverlay: View {
|
||||
accessibilityLabel: L10n.t("broadcast.share.regia.cd"),
|
||||
action: onShareRegia
|
||||
)
|
||||
SideIconButton(
|
||||
systemName: "slider.horizontal.3",
|
||||
accessibilityLabel: L10n.t("broadcast.min.quality.cd"),
|
||||
action: { showMinQualityPicker = true }
|
||||
)
|
||||
if let onCloseSet {
|
||||
SideIconButton(
|
||||
systemName: "checkmark",
|
||||
@@ -197,6 +223,85 @@ struct BroadcastControlsOverlay: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct MinQualityPickerOverlay: View {
|
||||
let selectedId: String
|
||||
let sessionQualityPreset: String
|
||||
let onSelect: (String) -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
private var options: [String] {
|
||||
StreamQualityLadder.availableIds(sessionQualityPreset: sessionQualityPreset)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.opacity(0.55)
|
||||
.ignoresSafeArea()
|
||||
.onTapGesture(perform: onDismiss)
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text(L10n.t("broadcast.min.quality.title"))
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
Spacer()
|
||||
Button(L10n.t("action.cancel"), action: onDismiss)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
Text(L10n.t("broadcast.min.quality.hint"))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
HStack(spacing: 8) {
|
||||
ForEach(options, id: \.self) { id in
|
||||
let selected = id == selectedId
|
||||
Button {
|
||||
onSelect(id)
|
||||
} label: {
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(selected ? MatchColors.accentYellow : .clear)
|
||||
Text(
|
||||
StreamQualityLadder.displayName(
|
||||
id: id,
|
||||
autoLabel: L10n.t("broadcast.min.quality.auto")
|
||||
)
|
||||
)
|
||||
.font(.system(size: 13, weight: selected ? .semibold : .medium))
|
||||
.foregroundStyle(selected ? MatchColors.accentYellow : .white)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
selected
|
||||
? MatchColors.accentYellow.opacity(0.16)
|
||||
: MatchColors.surfaceElevated,
|
||||
in: RoundedRectangle(cornerRadius: 12)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(selected ? MatchColors.accentYellow : MatchColors.outline, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: 720)
|
||||
.background(MatchColors.surface, in: RoundedRectangle(cornerRadius: 16))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(MatchColors.outline, lineWidth: 1)
|
||||
)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct BroadcastTelemetryPanel: View {
|
||||
let cableConnected: Bool
|
||||
let fps: Int
|
||||
@@ -205,6 +310,7 @@ private struct BroadcastTelemetryPanel: View {
|
||||
let networkType: String
|
||||
let deviceHealth: DeviceHealth
|
||||
let audioMuted: Bool
|
||||
let minQualityLabel: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
@@ -221,6 +327,9 @@ private struct BroadcastTelemetryPanel: View {
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
Text(minQualityLabel)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(MatchColors.accentYellow)
|
||||
Text("\(networkType) · \(deviceHealth.batteryPercent)%")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
|
||||
@@ -28,6 +28,7 @@ struct BroadcastScreen: View {
|
||||
@State private var pauseInFlight = false
|
||||
@State private var resumeInFlight = false
|
||||
@State private var muteInFlight = false
|
||||
@State private var minQualityInFlight = false
|
||||
@State private var deviceHealth = DeviceTelemetry.snapshot()
|
||||
@State private var snackbarMessage: String?
|
||||
@State private var shareItem: ShareItem?
|
||||
@@ -84,9 +85,15 @@ struct BroadcastScreen: View {
|
||||
.task(id: sessionId) {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
if let remote = try? await container.sessionRepository.fetchSession(id: sessionId).score {
|
||||
container.scoreController.applyRemote(remote, source: .polling)
|
||||
guard !minQualityInFlight, !muteInFlight, !pauseInFlight, !resumeInFlight else { continue }
|
||||
guard let remote = try? await container.sessionRepository.fetchSession(id: sessionId) else { continue }
|
||||
if let score = remote.score {
|
||||
container.scoreController.applyRemote(score, source: .polling)
|
||||
}
|
||||
if session?.minQualityPreset != remote.minQualityPreset {
|
||||
applyMinQualityLocally(preset: remote.minQualityPreset, fromRemote: true, notify: false)
|
||||
}
|
||||
await syncBroadcastToRemoteSession(remote)
|
||||
}
|
||||
}
|
||||
.task(id: sessionId) {
|
||||
@@ -296,7 +303,12 @@ struct BroadcastScreen: View {
|
||||
targetFps: session.targetFps,
|
||||
bitrateKbps: metrics.bitrateKbps,
|
||||
networkType: deviceHealth.networkType,
|
||||
deviceHealth: DeviceTelemetry.snapshot(thermalState: thermalManager.state)
|
||||
deviceHealth: DeviceTelemetry.snapshot(thermalState: thermalManager.state),
|
||||
minQualityPreset: session.minQualityPreset,
|
||||
sessionQualityPreset: session.qualityPreset,
|
||||
onSelectMinQuality: { preset in
|
||||
Task { await persistMinQuality(preset) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -431,6 +443,12 @@ struct BroadcastScreen: View {
|
||||
await setAudioMuted(muted, remote: true)
|
||||
}
|
||||
}
|
||||
container.sessionCable.onMinQuality = { [weak container] preset in
|
||||
Task { @MainActor in
|
||||
guard let container else { return }
|
||||
applyMinQualityLocally(preset: preset, fromRemote: true)
|
||||
}
|
||||
}
|
||||
container.sessionCable.connect(sessionId: sessionId, accessToken: token)
|
||||
}
|
||||
|
||||
@@ -518,6 +536,33 @@ struct BroadcastScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Allinea encoder locale allo stato sessione se Cable non ha consegnato il comando.
|
||||
private func syncBroadcastToRemoteSession(_ remote: StreamSession) async {
|
||||
let phase = container.broadcastCoordinator.metrics.phase
|
||||
let publishing = phase == .live || phase == .connecting || phase == .reconnecting
|
||||
if remote.isEnded {
|
||||
if phase != .idle && phase != .preview {
|
||||
await stopStream()
|
||||
} else {
|
||||
session = remote
|
||||
}
|
||||
return
|
||||
}
|
||||
if remote.isPaused {
|
||||
if publishing {
|
||||
await applyRemotePause(container: container)
|
||||
} else {
|
||||
session = remote
|
||||
}
|
||||
return
|
||||
}
|
||||
if phase == .paused {
|
||||
await applyRemoteResume(container: container)
|
||||
return
|
||||
}
|
||||
session = remote
|
||||
}
|
||||
|
||||
/// Pausa dalla regia / echo cable: solo RTMP locale, senza PATCH pause.
|
||||
private func applyRemotePause(container: AppContainer) async {
|
||||
guard !pauseInFlight else { return }
|
||||
@@ -581,6 +626,43 @@ struct BroadcastScreen: View {
|
||||
)
|
||||
}
|
||||
|
||||
private func applyMinQualityLocally(preset: String, fromRemote: Bool, notify: Bool = true) {
|
||||
guard var current = session else { return }
|
||||
if current.minQualityPreset == preset && fromRemote { return }
|
||||
current = current.with(minQualityPreset: preset)
|
||||
session = current
|
||||
guard let url = current.rtmpIngestUrl, !url.isEmpty else { return }
|
||||
let config = broadcastConfig(for: current, rtmpUrl: url)
|
||||
Task {
|
||||
await container.broadcastCoordinator.engine.setMinQualityFloor(
|
||||
config.minVideoBitrate,
|
||||
ceiling: config.videoBitrate
|
||||
)
|
||||
thermalManager.updateBaseline(config)
|
||||
}
|
||||
if fromRemote && notify {
|
||||
snackbarMessage = L10n.t("broadcast.snackbar.min.quality.remote")
|
||||
}
|
||||
}
|
||||
|
||||
private func persistMinQuality(_ preset: String) async {
|
||||
minQualityInFlight = true
|
||||
defer { minQualityInFlight = false }
|
||||
do {
|
||||
let updated = try await container.sessionRepository.setMinQuality(id: sessionId, preset: preset)
|
||||
applyMinQualityLocally(preset: updated.minQualityPreset, fromRemote: false)
|
||||
snackbarMessage = L10n.t(
|
||||
"broadcast.min.quality.applied",
|
||||
StreamQualityLadder.displayName(
|
||||
id: updated.minQualityPreset,
|
||||
autoLabel: L10n.t("broadcast.min.quality.auto")
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
snackbarMessage = UserFacingError.message(for: error) ?? L10n.t("broadcast.min.quality.error")
|
||||
}
|
||||
}
|
||||
|
||||
private func retryBroadcast() async {
|
||||
error = nil
|
||||
guard let session, let url = session.rtmpIngestUrl, !url.isEmpty else { return }
|
||||
@@ -608,6 +690,7 @@ struct BroadcastScreen: View {
|
||||
container.sessionCable.onResumeStream = nil
|
||||
container.sessionCable.onStopStream = nil
|
||||
container.sessionCable.onAudioMute = nil
|
||||
container.sessionCable.onMinQuality = nil
|
||||
container.sessionCable.disconnect()
|
||||
await container.broadcastCoordinator.stopBroadcast(sessionId: sessionId)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user