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:
2026-08-13 20:16:26 +02:00
co-authored by Cursor
65 changed files with 2727 additions and 138 deletions
@@ -28,6 +28,7 @@
39335ED5DE5847E5904222AB /* MatchSessionLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 813463F3E17D453EAA801739 /* MatchSessionLauncher.swift */; };
39F7D49D44244F51907E803D /* TeamColorPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7001BB5D65B34280B306ECCF /* TeamColorPicker.swift */; };
451211FDF3214D2098798DB6 /* StreamVideoPreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C2A2F7BAC2B4AC69E8BEAF7 /* StreamVideoPreset.swift */; };
5F9B2C3D4E5A4B72C8D9E0F1 /* AdaptiveBitrateController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8A1B2C3D4F4A61B7C8D9E0 /* AdaptiveBitrateController.swift */; };
454725D14B224276B06BCC67 /* MatchHubFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 951A7265EF5C41A9A12DD86A /* MatchHubFilter.swift */; };
45776A6A76D04EF5A0C0D187 /* TeamBrandingEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FBBE0F1399840FFA0E7EF2E /* TeamBrandingEditor.swift */; };
470957DE79A84D9889F0663C /* ScoreRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCC8ECFD24A14054BDD984C3 /* ScoreRepository.swift */; };
@@ -68,6 +69,7 @@
AA18DECE8D08461B9197EB71 /* MatchScoringRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8964CE287E2C4C2DB701497B /* MatchScoringRulesTests.swift */; };
AA3A68B2C1784294ADB89605 /* MatchLiveTv/Resources/it.lproj in Resources */ = {isa = PBXBuildFile; fileRef = 857C036B5B5C43368506DD8B /* MatchLiveTv/Resources/it.lproj */; };
AB6FF1A930EB459091B77922 /* ThermalAdaptationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377A2D2B2DA440AFB0CE2298 /* ThermalAdaptationTests.swift */; };
7B1D4E5F6A7C4D94E0F1A2B3 /* AdaptiveBitrateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A0C3D4E5F6B4C83D9E0F1A2 /* AdaptiveBitrateTests.swift */; };
ABC5853FEC554FA4AFBDCD2E /* OverlayState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DDC7305F3B549D0B7646B14 /* OverlayState.swift */; };
ACC54AA19C5747CFA118C70B /* ApiInstantTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75FEB862C3A34BF8AC24052E /* ApiInstantTests.swift */; };
AECC36D80A0C411AB47D581A /* MatchRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537FC2C063D341DF97A391B9 /* MatchRepository.swift */; };
@@ -127,6 +129,7 @@
31750F1B9E294337BDBD5B56 /* WatermarkElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WatermarkElement.swift; path = MatchLiveTv/Streaming/Overlay/WatermarkElement.swift; sourceTree = "<group>"; };
34BB2251A67E401A9F1E789E /* MatchStatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchStatusBadge.swift; path = MatchLiveTv/UI/Components/MatchStatusBadge.swift; sourceTree = "<group>"; };
377A2D2B2DA440AFB0CE2298 /* ThermalAdaptationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ThermalAdaptationTests.swift; path = MatchLiveTvTests/ThermalAdaptationTests.swift; sourceTree = "<group>"; };
6A0C3D4E5F6B4C83D9E0F1A2 /* AdaptiveBitrateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AdaptiveBitrateTests.swift; path = MatchLiveTvTests/AdaptiveBitrateTests.swift; sourceTree = "<group>"; };
39F3C00B63534E6AA6D8A90A /* MatchScreenScaffold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScreenScaffold.swift; path = MatchLiveTv/UI/Components/MatchScreenScaffold.swift; sourceTree = "<group>"; };
3A6F9AAB5A1249BBB4FC02E6 /* MatchLiveTv/Resources/en.lproj */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MatchLiveTv/Resources/en.lproj; sourceTree = "<group>"; };
3BFB2F18615747B1A1C9E9B2 /* BroadcastPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastPermissions.swift; path = MatchLiveTv/UI/Permissions/BroadcastPermissions.swift; sourceTree = "<group>"; };
@@ -160,6 +163,7 @@
87C01CC031C34343B3EBFF08 /* SessionRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SessionRepository.swift; path = MatchLiveTv/Data/Repository/SessionRepository.swift; sourceTree = "<group>"; };
8964CE287E2C4C2DB701497B /* MatchScoringRulesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScoringRulesTests.swift; path = MatchLiveTvTests/MatchScoringRulesTests.swift; sourceTree = "<group>"; };
8C2A2F7BAC2B4AC69E8BEAF7 /* StreamVideoPreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StreamVideoPreset.swift; path = MatchLiveTv/Streaming/StreamVideoPreset.swift; sourceTree = "<group>"; };
4E8A1B2C3D4F4A61B7C8D9E0 /* AdaptiveBitrateController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AdaptiveBitrateController.swift; path = MatchLiveTv/Streaming/AdaptiveBitrateController.swift; sourceTree = "<group>"; };
902831E3D87C46359F0D0A21 /* DeviceTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DeviceTelemetry.swift; path = MatchLiveTv/Core/DeviceTelemetry.swift; sourceTree = "<group>"; };
951A7265EF5C41A9A12DD86A /* MatchHubFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchHubFilter.swift; path = MatchLiveTv/Domain/MatchHubFilter.swift; sourceTree = "<group>"; };
98526C26B2584F55AADF0FFF /* ActionCableClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ActionCableClient.swift; path = MatchLiveTv/Data/Cable/ActionCableClient.swift; sourceTree = "<group>"; };
@@ -237,6 +241,7 @@
10AD0F18190542818572CAEF /* ScoreActionDecodeTests.swift */,
6DF142EBF33B45739BA8E42C /* ScoreControllerTests.swift */,
377A2D2B2DA440AFB0CE2298 /* ThermalAdaptationTests.swift */,
6A0C3D4E5F6B4C83D9E0F1A2 /* AdaptiveBitrateTests.swift */,
);
name = MatchLiveTvTests;
sourceTree = "<group>";
@@ -296,6 +301,7 @@
31750F1B9E294337BDBD5B56 /* WatermarkElement.swift */,
46CE696CAB994E2C8E399776 /* LivePreviewView.swift */,
8C2A2F7BAC2B4AC69E8BEAF7 /* StreamVideoPreset.swift */,
4E8A1B2C3D4F4A61B7C8D9E0 /* AdaptiveBitrateController.swift */,
4419E10415414556B7C58458 /* AccountScreen.swift */,
E860A0E35362427DA84F8972 /* BroadcastControlsOverlay.swift */,
71816F0D99E44551839474CE /* BroadcastScreen.swift */,
@@ -473,6 +479,7 @@
6AD0B3220B154EA2936C634A /* WatermarkElement.swift in Sources */,
713E0129656649FC8630FDC6 /* LivePreviewView.swift in Sources */,
451211FDF3214D2098798DB6 /* StreamVideoPreset.swift in Sources */,
5F9B2C3D4E5A4B72C8D9E0F1 /* AdaptiveBitrateController.swift in Sources */,
E5E21D088782469D8EB23543 /* AccountScreen.swift in Sources */,
5B51805239FA4D4D90919D86 /* BroadcastControlsOverlay.swift in Sources */,
EE568EA02C404102B4DFD651 /* BroadcastScreen.swift in Sources */,
@@ -516,6 +523,7 @@
5D91C53D2E8A4952937F816B /* ScoreActionDecodeTests.swift in Sources */,
FB66DD0422E14C54AC5B3EDB /* ScoreControllerTests.swift in Sources */,
AB6FF1A930EB459091B77922 /* ThermalAdaptationTests.swift in Sources */,
7B1D4E5F6A7C4D94E0F1A2B3 /* AdaptiveBitrateTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -119,6 +119,12 @@ enum L10n {
"broadcast.error.rtmp.url.invalid": "URL RTMP non valido",
"broadcast.error.session.unavailable": "Sessione non disponibile",
"broadcast.hide.controls.cd": "Nascondi controlli",
"broadcast.min.quality.applied": "Qualità minima: %1$@",
"broadcast.min.quality.auto": "Automatica",
"broadcast.min.quality.cd": "Qualità minima di trasmissione",
"broadcast.min.quality.error": "Impossibile impostare la qualità minima",
"broadcast.min.quality.hint": "L'encoder non scende sotto questo livello. Automatica adatta il bitrate per ridurre i salti.",
"broadcast.min.quality.title": "Qualità minima",
"broadcast.mute.cd": "Silenzia audio",
"broadcast.next.period.cd": "Periodo successivo",
"broadcast.pause.cd": "Pausa diretta",
@@ -141,6 +147,7 @@ enum L10n {
"broadcast.side.away": "ospite",
"broadcast.side.home": "casa",
"broadcast.snackbar.closed.remote": "Diretta chiusa dalla regia",
"broadcast.snackbar.min.quality.remote": "Qualità minima aggiornata dalla regia",
"broadcast.snackbar.mute.error": "Audio: %1$@",
"broadcast.snackbar.muted": "Audio silenziato",
"broadcast.snackbar.muted.remote": "Audio silenziato dalla regia",
@@ -417,6 +424,12 @@ enum L10n {
"broadcast.error.rtmp.url.invalid": "Invalid RTMP URL",
"broadcast.error.session.unavailable": "Session not available",
"broadcast.hide.controls.cd": "Hide controls",
"broadcast.min.quality.applied": "Minimum quality: %1$@",
"broadcast.min.quality.auto": "Automatic",
"broadcast.min.quality.cd": "Minimum streaming quality",
"broadcast.min.quality.error": "Unable to set minimum quality",
"broadcast.min.quality.hint": "The encoder will not go below this level. Automatic adapts bitrate to reduce stalls.",
"broadcast.min.quality.title": "Minimum quality",
"broadcast.mute.cd": "Mute audio",
"broadcast.next.period.cd": "Next period",
"broadcast.pause.cd": "Pause live",
@@ -439,6 +452,7 @@ enum L10n {
"broadcast.side.away": "away",
"broadcast.side.home": "home",
"broadcast.snackbar.closed.remote": "Live closed from control room",
"broadcast.snackbar.min.quality.remote": "Minimum quality updated from control room",
"broadcast.snackbar.mute.error": "Audio: %1$@",
"broadcast.snackbar.muted": "Audio muted",
"broadcast.snackbar.muted.remote": "Audio muted from control room",
@@ -715,6 +729,12 @@ enum L10n {
"broadcast.error.rtmp.url.invalid": "URL RTMP non valide",
"broadcast.error.session.unavailable": "Session non disponible",
"broadcast.hide.controls.cd": "Masquer les commandes",
"broadcast.min.quality.applied": "Qualité minimale : %1$@",
"broadcast.min.quality.auto": "Automatique",
"broadcast.min.quality.cd": "Qualité minimale de transmission",
"broadcast.min.quality.error": "Impossible de définir la qualité minimale",
"broadcast.min.quality.hint": "L'encodeur ne descend pas en dessous de ce niveau. Automatique adapte le débit pour limiter les coupures.",
"broadcast.min.quality.title": "Qualité minimale",
"broadcast.mute.cd": "Couper l'audio",
"broadcast.next.period.cd": "Période suivante",
"broadcast.pause.cd": "Mettre le direct en pause",
@@ -737,6 +757,7 @@ enum L10n {
"broadcast.side.away": "extérieur",
"broadcast.side.home": "domicile",
"broadcast.snackbar.closed.remote": "Direct fermé depuis la régie",
"broadcast.snackbar.min.quality.remote": "Qualité minimale mise à jour depuis la régie",
"broadcast.snackbar.mute.error": "Audio : %1$@",
"broadcast.snackbar.muted": "Audio coupé",
"broadcast.snackbar.muted.remote": "Audio coupé depuis la régie",
@@ -1013,6 +1034,12 @@ enum L10n {
"broadcast.error.rtmp.url.invalid": "Ungültige RTMP-URL",
"broadcast.error.session.unavailable": "Sitzung nicht verfügbar",
"broadcast.hide.controls.cd": "Steuerung ausblenden",
"broadcast.min.quality.applied": "Mindestqualität: %1$@",
"broadcast.min.quality.auto": "Automatisch",
"broadcast.min.quality.cd": "Minimale Übertragungsqualität",
"broadcast.min.quality.error": "Mindestqualität konnte nicht gesetzt werden",
"broadcast.min.quality.hint": "Der Encoder fällt nicht unter dieses Niveau. Automatisch passt die Bitrate an, um Aussetzer zu reduzieren.",
"broadcast.min.quality.title": "Mindestqualität",
"broadcast.mute.cd": "Audio stummschalten",
"broadcast.next.period.cd": "Nächste Periode",
"broadcast.pause.cd": "Livestream pausieren",
@@ -1035,6 +1062,7 @@ enum L10n {
"broadcast.side.away": "Gast",
"broadcast.side.home": "Heim",
"broadcast.snackbar.closed.remote": "Livestream von der Regie beendet",
"broadcast.snackbar.min.quality.remote": "Mindestqualität von der Regie aktualisiert",
"broadcast.snackbar.mute.error": "Audio: %1$@",
"broadcast.snackbar.muted": "Audio stummgeschaltet",
"broadcast.snackbar.muted.remote": "Audio von der Regie stummgeschaltet",
@@ -1311,6 +1339,12 @@ enum L10n {
"broadcast.error.rtmp.url.invalid": "URL RTMP no válida",
"broadcast.error.session.unavailable": "Sesión no disponible",
"broadcast.hide.controls.cd": "Ocultar controles",
"broadcast.min.quality.applied": "Calidad mínima: %1$@",
"broadcast.min.quality.auto": "Automática",
"broadcast.min.quality.cd": "Calidad mínima de transmisión",
"broadcast.min.quality.error": "No se pudo fijar la calidad mínima",
"broadcast.min.quality.hint": "El encoder no baja de este nivel. Automática adapta el bitrate para reducir los cortes.",
"broadcast.min.quality.title": "Calidad mínima",
"broadcast.mute.cd": "Silenciar audio",
"broadcast.next.period.cd": "Periodo siguiente",
"broadcast.pause.cd": "Pausar directo",
@@ -1333,6 +1367,7 @@ enum L10n {
"broadcast.side.away": "visitante",
"broadcast.side.home": "local",
"broadcast.snackbar.closed.remote": "Directo cerrado desde regie",
"broadcast.snackbar.min.quality.remote": "Calidad mínima actualizada desde la regie",
"broadcast.snackbar.mute.error": "Audio: %1$@",
"broadcast.snackbar.muted": "Audio silenciado",
"broadcast.snackbar.muted.remote": "Audio silenciado desde regie",
@@ -39,4 +39,13 @@ enum ThermalState: Int, Sendable, Equatable, Comparable {
case .critical: return "🔴"
}
}
var apiValue: String {
switch self {
case .nominal: return "nominal"
case .fair: return "fair"
case .serious: return "serious"
case .critical: return "critical"
}
}
}
@@ -202,6 +202,7 @@ struct StreamSessionDto: Decodable {
let targetBitrate: Int?
let targetFps: Int?
let qualityPreset: String?
let minQualityPreset: String?
let startedAt: String?
let audioMuted: Bool?
let score: ScoreStateDto?
@@ -222,6 +223,7 @@ struct StreamSessionDto: Decodable {
targetBitrate: targetBitrate ?? 2_500_000,
targetFps: targetFps ?? 30,
qualityPreset: qualityPreset ?? "720p_30_2.5mbps",
minQualityPreset: minQualityPreset ?? "auto",
startedAt: startedAt,
score: score?.toDomain(),
audioMuted: audioMuted ?? false
@@ -420,6 +422,10 @@ struct AudioMuteRequest: Encodable {
let muted: Bool
}
struct MinQualityRequest: Encodable {
let minQualityPreset: String
}
struct ScoringRulesBody: Codable {
let setsToWin: Int?
let pointsPerSet: Int?
@@ -505,6 +511,7 @@ struct TelemetryRequest: Encodable {
let currentBitrate: Int?
let targetBitrate: Int?
let fps: Int?
let thermalState: String?
}
extension ScoringRules {
@@ -190,6 +190,10 @@ final class MatchLiveAPI: @unchecked Sendable {
try await patch("sessions/\(id)/audio_mute", body: AudioMuteRequest(muted: muted))
}
func setMinQuality(id: String, preset: String) async throws -> StreamSessionDto {
try await patch("sessions/\(id)/min_quality", body: MinQualityRequest(minQualityPreset: preset))
}
func networkTest(sessionId: String, body: NetworkTestRequest) async throws -> NetworkTestResponse {
try await post("sessions/\(sessionId)/network_test", body: body)
}
@@ -9,6 +9,7 @@ final class SessionCableService: ObservableObject {
var onResumeStream: (() -> Void)?
var onStopStream: (() -> Void)?
var onAudioMute: ((Bool) -> Void)?
var onMinQuality: ((String) -> Void)?
private var client: ActionCableClient?
private var intentionalDisconnect = false
@@ -97,6 +98,10 @@ extension SessionCableService: ActionCableClient.Listener {
case "pause_stream": onPauseStream?()
case "resume_stream": onResumeStream?()
case "stop_stream": onStopStream?()
case "set_min_quality":
if let preset = payload["min_quality_preset"] as? String {
onMinQuality?(preset)
}
default: break
}
case "stream_event":
@@ -54,6 +54,10 @@ final class SessionRepository {
try await api.setAudioMute(id: id, muted: muted).toDomain()
}
func setMinQuality(id: String, preset: String) async throws -> StreamSession {
try await api.setMinQuality(id: id, preset: preset).toDomain()
}
func submitNetworkTest(
sessionId: String,
downloadMbps: Double,
@@ -92,7 +96,8 @@ final class SessionRepository {
signalStrength: nil,
currentBitrate: currentBitrate,
targetBitrate: targetBitrate,
fps: fps
fps: fps,
thermalState: health.thermalState.apiValue
)
)
}
@@ -119,12 +119,37 @@ struct StreamSession: Identifiable, Equatable, Sendable {
let targetBitrate: Int
let targetFps: Int
let qualityPreset: String
let minQualityPreset: String
let startedAt: String?
let score: ScoreState?
let audioMuted: Bool
var isLive: Bool { status == "live" || status == "reconnecting" }
var isPaused: Bool { status == "paused" }
var isEnded: Bool { status == "ended" || status == "error" }
func with(minQualityPreset: String) -> StreamSession {
StreamSession(
id: id,
matchId: matchId,
status: status,
platform: platform,
rtmpIngestUrl: rtmpIngestUrl,
hlsPlaybackUrl: hlsPlaybackUrl,
watchPageUrl: watchPageUrl,
shareUrl: shareUrl,
youtubeWatchUrl: youtubeWatchUrl,
youtubeReady: youtubeReady,
privacyStatus: privacyStatus,
targetBitrate: targetBitrate,
targetFps: targetFps,
qualityPreset: qualityPreset,
minQualityPreset: minQualityPreset,
startedAt: startedAt,
score: score,
audioMuted: audioMuted
)
}
func withAudioMuted(_ muted: Bool) -> StreamSession {
StreamSession(
@@ -142,6 +167,7 @@ struct StreamSession: Identifiable, Equatable, Sendable {
targetBitrate: targetBitrate,
targetFps: targetFps,
qualityPreset: qualityPreset,
minQualityPreset: minQualityPreset,
startedAt: startedAt,
score: score,
audioMuted: muted
@@ -0,0 +1,193 @@
import Foundation
enum StreamQualityLadder {
static let auto = "auto"
struct Preset: Equatable, Sendable {
let id: String
let bitrate: Int
let fps: Int
var is1080: Bool { id.hasPrefix("1080p") }
var resolutionLabel: String { is1080 ? "1080p" : "720p" }
}
static let presets: [Preset] = [
Preset(id: "1080p_30_4.5mbps", bitrate: 4_500_000, fps: 30),
Preset(id: "720p_30_4mbps", bitrate: 4_000_000, fps: 30),
Preset(id: "720p_30_2.5mbps", bitrate: 2_500_000, fps: 30),
Preset(id: "720p_30_1.5mbps", bitrate: 1_500_000, fps: 30)
]
static func normalize(_ presetId: String?) -> String {
let id = presetId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if id.isEmpty || id == auto { return auto }
return presets.contains(where: { $0.id == id }) ? id : auto
}
static func floorBitrate(_ presetId: String?) -> Int {
let id = normalize(presetId)
if id == auto { return AdaptiveBitrateController.autoFloor }
return presets.first(where: { $0.id == id })?.bitrate ?? AdaptiveBitrateController.autoFloor
}
static func ceilingContribution(_ presetId: String?) -> Int {
let id = normalize(presetId)
if id == auto { return 0 }
return presets.first(where: { $0.id == id })?.bitrate ?? 0
}
static func encoderCeiling(sessionTarget: Int, minPresetId: String?) -> Int {
max(sessionTarget, ceilingContribution(minPresetId))
}
static func encoderFloor(minPresetId: String?, ceiling: Int) -> Int {
min(floorBitrate(minPresetId), ceiling)
}
static func availableIds(sessionQualityPreset: String?) -> [String] {
var ids = [auto]
ids += presets.filter { !$0.is1080 }.reversed().map(\.id)
if sessionQualityPreset?.hasPrefix("1080p") == true,
let p1080 = presets.first(where: { $0.is1080 }) {
ids.append(p1080.id)
}
return ids
}
static func displayName(id: String, autoLabel: String) -> String {
if normalize(id) == auto { return autoLabel }
guard let preset = presets.first(where: { $0.id == id }) else { return id }
let mbps = Double(preset.bitrate) / 1_000_000.0
let mbpsText = mbps == Double(Int(mbps))
? String(Int(mbps))
: String(format: "%.1f", mbps)
return "\(preset.resolutionLabel) · \(mbpsText) Mbps"
}
}
/// ABR conservativo per RTMP: scende in fretta in congestione, risale piano.
final class AdaptiveBitrateController: @unchecked Sendable {
static let autoFloor = 800_000
static let minAbsolute = 500_000
static let defaultCeiling = 2_500_000
private let lock = NSLock()
private let audioBitrate: Int
private let healthySamplesBeforeUp: Int
private let minDelta: Int
private var healthyCount = 0
private(set) var ceiling: Int = AdaptiveBitrateController.defaultCeiling
private(set) var floor: Int = AdaptiveBitrateController.autoFloor
private(set) var currentBitrate: Int = AdaptiveBitrateController.defaultCeiling
init(
audioBitrate: Int = 128_000,
healthySamplesBeforeUp: Int = 8,
minDelta: Int = 50_000
) {
self.audioBitrate = audioBitrate
self.healthySamplesBeforeUp = healthySamplesBeforeUp
self.minDelta = minDelta
}
func configure(ceiling: Int, floor: Int) {
lock.lock()
defer { lock.unlock() }
self.ceiling = max(ceiling, Self.minAbsolute)
self.floor = min(max(floor, Self.minAbsolute), self.ceiling)
currentBitrate = self.ceiling
healthyCount = 0
}
@discardableResult
func setCeiling(_ value: Int) -> Int {
lock.lock()
defer { lock.unlock() }
ceiling = max(value, Self.minAbsolute)
if floor > ceiling { floor = ceiling }
if currentBitrate > ceiling {
currentBitrate = ceiling
healthyCount = 0
}
return currentBitrate
}
@discardableResult
func setFloor(_ value: Int) -> Int {
lock.lock()
defer { lock.unlock() }
floor = min(max(value, Self.minAbsolute), ceiling)
if currentBitrate < floor {
currentBitrate = floor
healthyCount = 0
}
return currentBitrate
}
func onCongestion(measuredBitsPerSecond: Int) -> Int? {
lock.lock()
defer { lock.unlock() }
return applyCongestionLocked(measuredBitsPerSecond)
}
func onHealthy() -> Int? {
lock.lock()
defer { lock.unlock() }
return applyHealthyLocked()
}
func onMeasured(measuredBitsPerSecond: Int, congested: Bool) -> Int? {
lock.lock()
defer { lock.unlock() }
if congested {
return applyCongestionLocked(measuredBitsPerSecond)
}
if measuredBitsPerSecond <= 0 { return nil }
let dropThreshold = Int(Double(currentBitrate) * 0.80)
if measuredBitsPerSecond < dropThreshold {
return applyCongestionLocked(measuredBitsPerSecond)
}
return applyHealthyLocked()
}
func resetToCeiling() -> Int {
lock.lock()
defer { lock.unlock() }
healthyCount = 0
currentBitrate = ceiling
return currentBitrate
}
private func applyCongestionLocked(_ measuredBitsPerSecond: Int) -> Int? {
healthyCount = 0
let next: Int
if measuredBitsPerSecond <= 0 {
next = Int(Double(currentBitrate) * 0.8)
} else {
next = min(currentBitrate, measuredBitsPerSecond - audioBitrate)
}
return maybeApplyLocked(min(max(next, floor), ceiling))
}
private func applyHealthyLocked() -> Int? {
if currentBitrate >= ceiling {
healthyCount = 0
return nil
}
healthyCount += 1
if healthyCount < healthySamplesBeforeUp { return nil }
healthyCount = 0
let step = max(ceiling / 10, minDelta)
return maybeApplyLocked(min(currentBitrate + step, ceiling))
}
private func maybeApplyLocked(_ next: Int) -> Int? {
if next == currentBitrate { return nil }
let atBound = next == floor || next == ceiling
if !atBound && abs(next - currentBitrate) < minDelta { return nil }
currentBitrate = next
return next
}
}
@@ -7,6 +7,7 @@ struct BroadcastConfig: Sendable {
var width: Int = 1280
var height: Int = 720
var videoBitrate: Int = 2_500_000
var minVideoBitrate: Int = 800_000
var audioBitrate: Int = 128_000
var fps: Int = 30
var rotation: Int = 0
@@ -5,18 +5,37 @@ import RTMPHaishinKit
import UIKit
import VideoToolbox
private struct BroadcastBitrateMonitor: StreamBitRateStrategy {
let mamimumVideoBitRate: Int = 0
let mamimumAudioBitRate: Int = 0
private final class AdaptiveBroadcastBitRateStrategy: StreamBitRateStrategy, @unchecked Sendable {
let controller: AdaptiveBitrateController
let onUpdate: @Sendable (Int) -> Void
var mamimumVideoBitRate: Int { controller.ceiling }
let mamimumAudioBitRate: Int = 0
init(controller: AdaptiveBitrateController, onUpdate: @escaping @Sendable (Int) -> Void) {
self.controller = controller
self.onUpdate = onUpdate
}
func adjustBitrate(_ event: NetworkMonitorEvent, stream: some StreamConvertible) async {
let next: Int?
switch event {
case .status(let report), .publishInsufficientBWOccured(let report):
case .status(let report):
let measuredBits = report.currentBytesOutPerSecond * 8
onUpdate(max(0, report.currentBytesOutPerSecond / 1024))
next = controller.onMeasured(measuredBitsPerSecond: measuredBits, congested: false)
case .publishInsufficientBWOccured(let report):
onUpdate(max(0, report.currentBytesOutPerSecond / 1024))
next = controller.onCongestion(measuredBitsPerSecond: report.currentBytesOutPerSecond * 8)
case .reset:
break
next = controller.resetToCeiling()
@unknown default:
next = nil
}
guard let bitrate = next else { return }
var videoSettings = await stream.videoSettings
guard videoSettings.bitRate != bitrate else { return }
videoSettings.bitRate = bitrate
try? await stream.setVideoSettings(videoSettings)
}
}
@@ -48,6 +67,7 @@ final class LiveBroadcastEngine: ObservableObject {
private var orientationDebounceTask: Task<Void, Never>?
private var rtmpSession: (any Session)?
private var audioMuted = false
private let adaptiveBitrate = AdaptiveBitrateController()
private static var rtmpFactoryRegistered = false
private static func ensureRTMPFactoryRegistered() async {
@@ -102,6 +122,7 @@ final class LiveBroadcastEngine: ObservableObject {
func preparePreview(config: BroadcastConfig) async throws {
self.config = config
adaptiveBitrate.configure(ceiling: config.videoBitrate, floor: config.minVideoBitrate)
publishPending = false
try await configurePipeline(config)
setPhase(.preview)
@@ -110,6 +131,7 @@ final class LiveBroadcastEngine: ObservableObject {
/// Configura camera/mixer e mette in coda il publish RTMP (eseguito quando l'anteprima è pronta).
func prepareForBroadcast(config: BroadcastConfig) async throws {
self.config = config
adaptiveBitrate.configure(ceiling: config.videoBitrate, floor: config.minVideoBitrate)
publishPending = true
try await configurePipeline(config)
setPhase(.connecting)
@@ -136,6 +158,7 @@ final class LiveBroadcastEngine: ObservableObject {
func resumeBroadcast(config: BroadcastConfig) async throws {
self.config = config
adaptiveBitrate.configure(ceiling: config.videoBitrate, floor: config.minVideoBitrate)
reconnectAttempts = 0
reconnectTask?.cancel()
reconnectTask = nil
@@ -189,18 +212,40 @@ final class LiveBroadcastEngine: ObservableObject {
emitMetrics()
}
/// Adattamento termico: aggiorna profilo video senza interrompere la sessione RTMP attiva.
/// Adattamento termico: aggiorna il soffitto ABR e l'FPS senza interrompere la sessione RTMP.
func applyThermalProfile(videoBitrate: Int, fps: Int) async {
guard var current = config else { return }
guard current.videoBitrate != videoBitrate || current.fps != fps else { return }
adaptiveBitrate.setCeiling(videoBitrate)
let applied = adaptiveBitrate.setFloor(current.minVideoBitrate)
guard current.videoBitrate != videoBitrate || current.fps != fps else {
await applyEncoderBitrate(applied)
return
}
current.videoBitrate = videoBitrate
current.fps = fps
config = current
if pipelineConfigured, let stream = await rtmpSession?.stream {
try? await applyCodecSettings(to: stream, config: current)
try? await applyCodecSettings(to: stream, config: current, videoBitrate: applied)
}
}
func setMinQualityFloor(_ floorBitrate: Int, ceiling: Int? = nil) async {
if var current = config {
if let ceiling { current.videoBitrate = ceiling }
current.minVideoBitrate = floorBitrate
config = current
}
await applyEncoderBitrate(adaptiveBitrate.setFloor(floorBitrate))
}
private func applyEncoderBitrate(_ bitrate: Int) async {
guard pipelineConfigured, let stream = await rtmpSession?.stream else { return }
var videoSettings = await stream.videoSettings
guard videoSettings.bitRate != bitrate else { return }
videoSettings.bitRate = bitrate
try? await stream.setVideoSettings(videoSettings)
}
private func configurePipeline(_ config: BroadcastConfig) async throws {
guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized,
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
@@ -327,7 +372,7 @@ final class LiveBroadcastEngine: ObservableObject {
let stream = await session.stream
try await applyCodecSettings(to: stream, config: config)
await stream.setBitRateStrategy(BroadcastBitrateMonitor { [weak self] kbps in
await stream.setBitRateStrategy(AdaptiveBroadcastBitRateStrategy(controller: adaptiveBitrate) { [weak self] kbps in
Task { @MainActor in
guard let self else { return }
self.metrics = BroadcastMetrics(
@@ -361,10 +406,10 @@ final class LiveBroadcastEngine: ObservableObject {
setPhase(.live)
}
private func applyCodecSettings(to stream: any StreamConvertible, config: BroadcastConfig) async throws {
private func applyCodecSettings(to stream: any StreamConvertible, config: BroadcastConfig, videoBitrate: Int? = nil) async throws {
var videoSettings = await stream.videoSettings
videoSettings.videoSize = .init(width: config.width, height: config.height)
videoSettings.bitRate = config.videoBitrate
videoSettings.bitRate = videoBitrate ?? adaptiveBitrate.currentBitrate
videoSettings.maxKeyFrameIntervalDuration = 2
videoSettings.profileLevel = config.height > 720
? (kVTProfileLevel_H264_Baseline_4_1 as String)
@@ -15,11 +15,17 @@ enum StreamVideoPreset {
static func broadcastConfig(for session: StreamSession, rtmpUrl: String) -> BroadcastConfig {
let dims = dimensions(for: session.qualityPreset)
let ceiling = StreamQualityLadder.encoderCeiling(
sessionTarget: session.targetBitrate,
minPresetId: session.minQualityPreset
)
let floor = StreamQualityLadder.encoderFloor(minPresetId: session.minQualityPreset, ceiling: ceiling)
return BroadcastConfig(
rtmpUrl: rtmpUrl,
width: dims.width,
height: dims.height,
videoBitrate: session.targetBitrate,
videoBitrate: ceiling,
minVideoBitrate: floor,
audioBitrate: 128_000,
fps: session.targetFps
)
@@ -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)
}
@@ -0,0 +1,92 @@
import XCTest
@testable import MatchLiveTv
final class AdaptiveBitrateTests: XCTestCase {
private func controller() -> AdaptiveBitrateController {
let abr = AdaptiveBitrateController()
abr.configure(ceiling: 2_500_000, floor: AdaptiveBitrateController.autoFloor)
return abr
}
func testCongestionDropsTowardMeasuredAndRespectsFloor() {
let abr = controller()
let next = abr.onCongestion(measuredBitsPerSecond: 1_200_000)
XCTAssertEqual(next, 1_200_000 - 128_000)
XCTAssertEqual(abr.currentBitrate, 1_072_000)
}
func testCongestionDoesNotGoBelowFloor() {
let abr = controller()
abr.setFloor(1_500_000)
let next = abr.onCongestion(measuredBitsPerSecond: 400_000)
XCTAssertEqual(next, 1_500_000)
}
func testHealthyRampsUpAfterEnoughSamples() {
let abr = controller()
abr.onCongestion(measuredBitsPerSecond: 1_200_000)
for _ in 0..<7 {
XCTAssertNil(abr.onHealthy())
}
XCTAssertEqual(abr.onHealthy(), 1_072_000 + 250_000)
}
func testThermalCeilingClampsCurrent() {
let abr = controller()
let applied = abr.setCeiling(1_250_000)
XCTAssertEqual(applied, 1_250_000)
XCTAssertEqual(abr.ceiling, 1_250_000)
XCTAssertLessThanOrEqual(abr.floor, abr.ceiling)
}
func testMinFloorBumpsCurrentUp() {
let abr = controller()
abr.onCongestion(measuredBitsPerSecond: 1_200_000)
XCTAssertEqual(abr.setFloor(2_000_000), 2_000_000)
}
func testAutoOptionsExclude1080On720Session() {
XCTAssertEqual(
StreamQualityLadder.availableIds(sessionQualityPreset: "720p_30_2.5mbps"),
[
StreamQualityLadder.auto,
"720p_30_1.5mbps",
"720p_30_2.5mbps",
"720p_30_4mbps"
]
)
}
func testMeasuredZeroWithoutCongestionDoesNotDrop() {
let abr = controller()
XCTAssertNil(abr.onMeasured(measuredBitsPerSecond: 0, congested: false))
XCTAssertEqual(abr.currentBitrate, 2_500_000)
}
func testMeasuredLowThroughputDropsWithoutCongestionFlag() {
let abr = controller()
XCTAssertEqual(
abr.onMeasured(measuredBitsPerSecond: 1_200_000, congested: false),
1_200_000 - 128_000
)
}
func testThermalDropThenRecoverRestoresUserFloor() {
let abr = controller()
abr.setFloor(1_500_000)
abr.setCeiling(1_000_000)
XCTAssertEqual(abr.floor, 1_000_000)
abr.setCeiling(2_500_000)
XCTAssertEqual(abr.floor, 1_000_000)
XCTAssertEqual(abr.setFloor(1_500_000), 1_500_000)
}
func testEncoderCeilingRisesWhenMinExceedsSessionTarget() {
let ceiling = StreamQualityLadder.encoderCeiling(
sessionTarget: 2_500_000,
minPresetId: "720p_30_4mbps"
)
XCTAssertEqual(ceiling, 4_000_000)
XCTAssertEqual(StreamQualityLadder.encoderFloor(minPresetId: "720p_30_4mbps", ceiling: ceiling), 4_000_000)
}
}