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
@@ -0,0 +1,71 @@
package com.matchlivetv.match_live_tv
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.matchlivetv.match_live_tv.core.AppConfig
import com.matchlivetv.match_live_tv.data.AppContainer
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import retrofit2.HttpException
/**
* Contratto API qualità minima contro il backend locale (emulatore → 10.0.2.2).
*/
@RunWith(AndroidJUnit4::class)
class LocalAdaptiveBitrateApiTest {
private lateinit var container: AppContainer
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
container = AppContainer(context)
assertTrue(
"Questo test richiede API locale, trovato ${AppConfig.apiBaseUrl}",
AppConfig.apiBaseUrl.contains("10.0.2.2") || AppConfig.apiBaseUrl.contains("localhost"),
)
}
@Test
fun setMinQuality_persistsAndRejects1080On720Session() = runBlocking {
container.authRepository.login(
email = "coach@matchlivetv.test",
password = "Password123",
)
val teams = container.matchRepository.fetchTeams()
assertTrue(teams.isNotEmpty())
val match = container.matchRepository.createQuickMatch(teams.first().id)
val created = container.sessionRepository.createSession(match.id)
assertEquals("auto", created.minQualityPreset)
val updated = container.sessionRepository.setMinQuality(created.id, "720p_30_1.5mbps")
assertEquals("720p_30_1.5mbps", updated.minQualityPreset)
val fetched = container.sessionRepository.fetchSession(created.id)
assertEquals("720p_30_1.5mbps", fetched.minQualityPreset)
try {
container.sessionRepository.setMinQuality(created.id, "1080p_30_4.5mbps")
fail("1080p avrebbe dovuto essere rifiutato su sessione 720p")
} catch (e: HttpException) {
assertEquals(422, e.code())
}
val forced = container.sessionRepository.setMinQuality(created.id, "720p_30_4mbps")
assertEquals("720p_30_4mbps", forced.minQualityPreset)
val auto = container.sessionRepository.setMinQuality(created.id, "auto")
assertEquals("auto", auto.minQualityPreset)
val started = container.sessionRepository.startSession(created.id)
assertFalse(started.rtmpIngestUrl.isNullOrBlank())
container.sessionRepository.stopSession(created.id)
Unit
}
}
@@ -0,0 +1,265 @@
package com.matchlivetv.match_live_tv
import android.content.Intent
import android.view.KeyEvent
import android.os.SystemClock
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.Until
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
/**
* E2E locale: login seed → wizard → schermata ripresa → qualità minima.
*/
@RunWith(AndroidJUnit4::class)
class LocalAdaptiveBitrateUiTest {
private lateinit var device: UiDevice
private val pkg = "com.matchlivetv.match_live_tv"
@Before
fun setUp() {
device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
grantRuntimePermissions()
launchApp()
}
@Test
fun broadcastScreen_canForceMinimumQuality() {
waitForAny(45_000, "Email", "ACCEDI", "LOG IN", "NUOVA PARTITA", "NEW MATCH")
if (!hasAny("NUOVA PARTITA", "NEW MATCH")) {
fillLogin()
if (!waitQuietly(8_000, "NUOVA PARTITA", "NEW MATCH")) {
tapClickableLogin()
}
waitForAny(45_000, "NUOVA PARTITA", "NEW MATCH")
}
tapAny("NUOVA PARTITA", "NEW MATCH")
waitForAny(15_000, "Avvia subito", "Start now")
tapAny("Avvia subito", "Start now")
waitForAny(45_000, "01 · Partita", "01 · Match")
scrollDown()
tapAny("AVANTI >", "NEXT >")
waitForAny(45_000, "02 · Trasmissione", "02 · Broadcast")
waitForAny(30_000, "Piattaforma", "Platform")
scrollDown()
tapAny("AVANTI >", "NEXT >")
waitForAny(45_000, "03 · Test rete", "03 · Network test")
waitForAny(30_000, "AVVIA TEST RETE", "START NETWORK TEST")
tapAny("AVVIA TEST RETE", "START NETWORK TEST")
waitForAny(30_000, "INIZIA >", "START >")
waitUntilEnabledAny("INIZIA >", "START >", timeoutMs = 25_000)
scrollDown()
tapAny("INIZIA >", "START >")
waitForAnyDesc(
60_000,
"Qualità minima di trasmissione",
"Minimum streaming quality",
)
tapAnyDesc("Qualità minima di trasmissione", "Minimum streaming quality")
waitForAny(10_000, "Qualità minima", "Minimum quality")
waitForAny(5_000, "Automatica", "Automatic")
tapClickableText("720p · 1.5 Mbps")
assertNotNull(
waitForAny(
20_000,
"Qualità minima: 720p · 1.5 Mbps",
"Minimum quality: 720p · 1.5 Mbps",
"720p · 1.5 Mbps",
),
)
}
private fun grantRuntimePermissions() {
listOf(
"android.permission.CAMERA",
"android.permission.RECORD_AUDIO",
"android.permission.POST_NOTIFICATIONS",
).forEach { permission ->
device.executeShellCommand("pm grant $pkg $permission")
}
}
private fun launchApp() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val intent = context.packageManager.getLaunchIntentForPackage(pkg)?.apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
} ?: error("Launch intent mancante per $pkg")
context.startActivity(intent)
device.wait(Until.hasObject(By.pkg(pkg).depth(0)), 15_000)
}
private fun fillLogin() {
val email = waitForRes("login_email")
val password = waitForRes("login_password")
typeInto(email, "coach@matchlivetv.test")
typeInto(password, "Password123")
device.pressKeyCode(KeyEvent.KEYCODE_ENTER)
device.waitForIdle()
SystemClock.sleep(800)
}
private fun hasAny(vararg texts: String): Boolean =
texts.any { device.hasObject(By.text(it)) || device.hasObject(By.desc(it)) }
private fun waitQuietly(timeoutMs: Long, vararg texts: String): Boolean {
val deadline = SystemClock.elapsedRealtime() + timeoutMs
while (SystemClock.elapsedRealtime() < deadline) {
if (hasAny(*texts)) return true
SystemClock.sleep(250)
}
return false
}
private fun waitForRes(tag: String): UiObject2 {
val obj = device.wait(Until.findObject(By.res(tag)), 10_000)
?: device.wait(Until.findObject(By.res("$pkg:id/$tag")), 2_000)
?: error("Nodo non trovato: $tag | visibili: ${visibleTexts()}")
return obj
}
private fun typeInto(field: UiObject2, value: String) {
field.click()
device.waitForIdle()
field.text = value
device.waitForIdle()
SystemClock.sleep(300)
}
private fun tapClickableLogin() {
waitForResOrNull("login_submit")?.let { btn ->
val bounds = btn.visibleBounds
device.click(bounds.centerX(), bounds.centerY())
device.waitForIdle()
return
}
val deadline = SystemClock.elapsedRealtime() + 10_000
while (SystemClock.elapsedRealtime() < deadline) {
val candidates = device.findObjects(By.text("ACCEDI")) +
device.findObjects(By.text("LOG IN"))
for (node in candidates) {
if (clickEnabled(node)) return
}
SystemClock.sleep(250)
}
error("Login non cliccabile | visibili: ${visibleTexts()}")
}
private fun waitForResOrNull(tag: String): UiObject2? {
return device.wait(Until.findObject(By.res(tag)), 3_000)
?: device.wait(Until.findObject(By.res("$pkg:id/$tag")), 1_000)
}
private fun clickEnabled(start: UiObject2): Boolean {
var node: UiObject2? = start
for (i in 0 until 6) {
val current = node ?: break
if (current.isClickable && current.isEnabled) {
current.click()
device.waitForIdle()
return true
}
node = current.parent
}
return false
}
private fun visibleTexts(): String {
val interesting = listOf("ACCEDI", "LOG IN", "Email", "Password", "NUOVA", "NEW MATCH", "error", "Error", "Server")
return device.findObjects(By.pkg(pkg)).mapNotNull { it.text?.takeIf { t -> t.isNotBlank() } }
.distinct()
.filter { text -> interesting.any { key -> text.contains(key, ignoreCase = true) } || text.length < 40 }
.take(30)
.joinToString(" | ")
}
private fun waitForAny(timeoutMs: Long, vararg texts: String): UiObject2 {
val deadline = SystemClock.elapsedRealtime() + timeoutMs
while (SystemClock.elapsedRealtime() < deadline) {
for (text in texts) {
device.findObject(By.text(text))?.let { return it }
device.findObject(By.desc(text))?.let { return it }
}
SystemClock.sleep(250)
}
error("Nessuno dei testi trovato: ${texts.joinToString()} | visibili: ${visibleTexts()}")
}
private fun waitForAnyDesc(timeoutMs: Long, vararg descs: String): UiObject2 {
val deadline = SystemClock.elapsedRealtime() + timeoutMs
while (SystemClock.elapsedRealtime() < deadline) {
for (desc in descs) {
device.findObject(By.desc(desc))?.let { return it }
}
SystemClock.sleep(250)
}
error("Nessuna contentDescription trovata: ${descs.joinToString()}")
}
private fun tapAny(vararg texts: String) {
val label = waitForAny(15_000, *texts)
tapNode(label)
}
private fun tapClickableText(text: String) {
tapAny(text)
}
private fun tapAnyDesc(vararg descs: String) {
tapNode(waitForAnyDesc(10_000, *descs))
}
private fun tapNode(label: UiObject2) {
if (label.isClickable) {
label.click()
device.waitForIdle()
return
}
var node: UiObject2? = label
for (i in 0 until 6) {
val current = node ?: break
if (current.isClickable) {
current.click()
device.waitForIdle()
return
}
node = current.parent
}
label.click()
device.waitForIdle()
}
private fun waitUntilEnabledAny(vararg texts: String, timeoutMs: Long) {
val deadline = SystemClock.elapsedRealtime() + timeoutMs
while (SystemClock.elapsedRealtime() < deadline) {
for (text in texts) {
val label = device.findObject(By.text(text)) ?: continue
var node: UiObject2? = label
for (i in 0 until 6) {
val current = node ?: break
if (current.isClickable && current.isEnabled) return
node = current.parent
}
}
SystemClock.sleep(500)
}
error("Pulsante non abilitato entro timeout: ${texts.joinToString()}")
}
private fun scrollDown(steps: Int = 2) {
val centerX = device.displayWidth / 2
val startY = (device.displayHeight * 0.75).toInt()
val endY = (device.displayHeight * 0.25).toInt()
repeat(steps) {
device.swipe(centerX, startY, centerX, endY, 24)
device.waitForIdle()
SystemClock.sleep(300)
}
}
}
@@ -6,11 +6,17 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import com.matchlivetv.match_live_tv.core.AppLocale
import com.matchlivetv.match_live_tv.core.ProvideAppLocale
import com.matchlivetv.match_live_tv.ui.navigation.AppNavHost
import com.matchlivetv.match_live_tv.ui.theme.MatchLiveTheme
@OptIn(ExperimentalComposeUiApi::class)
class MainActivity : ComponentActivity() {
private val container by lazy { (application as MatchLiveTvApplication).container }
@@ -33,7 +39,9 @@ class MainActivity : ComponentActivity() {
setContent {
ProvideAppLocale {
MatchLiveTheme {
AppNavHost(container)
Box(Modifier.semantics { testTagsAsResourceId = true }) {
AppNavHost(container)
}
}
}
}
@@ -183,6 +183,7 @@ data class StreamSessionDto(
@Json(name = "target_bitrate") val targetBitrate: Int? = null,
@Json(name = "target_fps") val targetFps: Int? = null,
@Json(name = "quality_preset") val qualityPreset: String? = null,
@Json(name = "min_quality_preset") val minQualityPreset: String? = null,
@Json(name = "started_at") val startedAt: String? = null,
@Json(name = "audio_muted") val audioMuted: Boolean? = null,
val score: ScoreStateDto? = null,
@@ -202,6 +203,7 @@ data class StreamSessionDto(
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,
@@ -287,6 +289,10 @@ data class CreateSessionRequest(
@Json(name = "youtube_channel") val youtubeChannel: String? = null,
)
data class MinQualityRequest(
@Json(name = "min_quality_preset") val minQualityPreset: String,
)
data class UpdateMatchBody(
@Json(name = "opponent_name") val opponentName: String? = null,
val location: String? = null,
@@ -398,4 +404,5 @@ data class TelemetryRequest(
@Json(name = "current_bitrate") val currentBitrate: Int? = null,
@Json(name = "target_bitrate") val targetBitrate: Int? = null,
val fps: Int? = null,
@Json(name = "thermal_state") val thermalState: String? = null,
)
@@ -123,6 +123,12 @@ interface MatchLiveApi {
@Body body: AudioMuteRequest,
): StreamSessionDto
@PATCH("sessions/{sessionId}/min_quality")
suspend fun setMinQuality(
@Path("sessionId") sessionId: String,
@Body body: MinQualityRequest,
): StreamSessionDto
@POST("sessions/{sessionId}/network_test")
suspend fun networkTest(
@Path("sessionId") sessionId: String,
@@ -34,6 +34,7 @@ class SessionCableService(
var onResumeStream: (() -> Unit)? = null
var onEndStream: (() -> Unit)? = null
var onAudioMute: ((Boolean) -> Unit)? = null
var onMinQuality: ((String) -> Unit)? = null
fun connect(sessionId: String, accessToken: String, deviceRole: String = "camera") {
intentionalDisconnect = false
@@ -115,6 +116,7 @@ class SessionCableService(
"pause_stream" -> onPauseStream?.invoke()
"resume_stream" -> onResumeStream?.invoke()
"stop_stream" -> onEndStream?.invoke()
"set_min_quality" -> (payload["min_quality_preset"] as? String)?.let { onMinQuality?.invoke(it) }
}
}
"stream_event" -> {
@@ -2,6 +2,7 @@ package com.matchlivetv.match_live_tv.data.repository
import com.matchlivetv.match_live_tv.data.api.CreateSessionRequest
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
import com.matchlivetv.match_live_tv.data.api.MinQualityRequest
import com.matchlivetv.match_live_tv.data.api.NetworkTestRequest
import com.matchlivetv.match_live_tv.data.api.NetworkTestResponse
import com.matchlivetv.match_live_tv.domain.StreamSession
@@ -41,6 +42,9 @@ class SessionRepository(
suspend fun setAudioMute(sessionId: String, muted: Boolean): StreamSession =
api.setAudioMute(sessionId, com.matchlivetv.match_live_tv.data.api.AudioMuteRequest(muted)).toDomain()
suspend fun setMinQuality(sessionId: String, preset: String): StreamSession =
api.setMinQuality(sessionId, MinQualityRequest(minQualityPreset = preset)).toDomain()
suspend fun submitNetworkTest(
sessionId: String,
downloadMbps: Double,
@@ -64,6 +68,7 @@ class SessionRepository(
currentBitrate: Int? = null,
targetBitrate: Int? = null,
fps: Int? = null,
thermalState: String? = null,
) {
api.postTelemetry(
sessionId,
@@ -75,6 +80,7 @@ class SessionRepository(
currentBitrate = currentBitrate,
targetBitrate = targetBitrate,
fps = fps,
thermalState = thermalState,
),
)
}
@@ -126,6 +126,7 @@ data class StreamSession(
val targetBitrate: Int = 2_500_000,
val targetFps: Int = 30,
val qualityPreset: String = "720p_30_2.5mbps",
val minQualityPreset: String = "auto",
val startedAt: String? = null,
val score: ScoreState? = null,
val audioMuted: Boolean = false,
@@ -134,6 +135,8 @@ data class StreamSession(
val isPaused: Boolean get() = status == "paused"
val isEnded: Boolean get() = status == "ended" || status == "error"
fun watchShareUrl(): String? = when {
platform == "youtube" && !youtubeWatchUrl.isNullOrBlank() -> youtubeWatchUrl
!shareUrl.isNullOrBlank() -> shareUrl
@@ -0,0 +1,115 @@
package com.matchlivetv.match_live_tv.streaming
import kotlin.math.abs
import kotlin.math.min
/**
* ABR conservativo per RTMP: scende in fretta in congestione, risale piano.
* Il pavimento è la qualità minima scelta dall'utente; il soffitto è il target
* di sessione (eventualmente ridotto dal termico).
*/
class AdaptiveBitrateController(
private val audioBitrate: Int = 128_000,
private val healthySamplesBeforeUp: Int = 8,
private val minDelta: Int = 50_000,
) {
@Volatile
var ceiling: Int = DEFAULT_CEILING
private set
@Volatile
var floor: Int = AUTO_FLOOR
private set
@Volatile
var currentBitrate: Int = DEFAULT_CEILING
private set
private var healthyCount = 0
@Synchronized
fun configure(ceiling: Int, floor: Int) {
this.ceiling = ceiling.coerceAtLeast(MIN_ABSOLUTE)
this.floor = floor.coerceIn(MIN_ABSOLUTE, this.ceiling)
currentBitrate = this.ceiling
healthyCount = 0
}
@Synchronized
fun setCeiling(value: Int): Int {
ceiling = value.coerceAtLeast(MIN_ABSOLUTE)
if (floor > ceiling) floor = ceiling
if (currentBitrate > ceiling) {
currentBitrate = ceiling
healthyCount = 0
}
return currentBitrate
}
@Synchronized
fun setFloor(value: Int): Int {
floor = value.coerceIn(MIN_ABSOLUTE, ceiling)
if (currentBitrate < floor) {
currentBitrate = floor
healthyCount = 0
}
return currentBitrate
}
@Synchronized
fun onCongestion(measuredBitsPerSecond: Int): Int? {
healthyCount = 0
val next = if (measuredBitsPerSecond <= 0) {
(currentBitrate * 0.8).toInt()
} else {
min(currentBitrate, measuredBitsPerSecond - audioBitrate)
}
return maybeApply(next.coerceIn(floor, ceiling))
}
@Synchronized
fun onHealthy(): Int? {
if (currentBitrate >= ceiling) {
healthyCount = 0
return null
}
healthyCount += 1
if (healthyCount < healthySamplesBeforeUp) return null
healthyCount = 0
val step = (ceiling / 10).coerceAtLeast(minDelta)
return maybeApply(min(currentBitrate + step, ceiling))
}
@Synchronized
fun onMeasured(measuredBitsPerSecond: Long, congested: Boolean): Int? {
if (congested) return onCongestion(measuredBitsPerSecond.toInt())
if (measuredBitsPerSecond <= 0L) return null
val dropThreshold = (currentBitrate * 0.80).toLong()
return if (measuredBitsPerSecond < dropThreshold) {
onCongestion(measuredBitsPerSecond.toInt())
} else {
onHealthy()
}
}
@Synchronized
fun resetToCeiling(): Int {
healthyCount = 0
currentBitrate = ceiling
return currentBitrate
}
private fun maybeApply(next: Int): Int? {
if (next == currentBitrate) return null
val atBound = next == floor || next == ceiling
if (!atBound && abs(next - currentBitrate) < minDelta) return null
currentBitrate = next
return next
}
companion object {
const val AUTO_FLOOR = 800_000
const val MIN_ABSOLUTE = 500_000
const val DEFAULT_CEILING = 2_500_000
}
}
@@ -9,6 +9,7 @@ data class BroadcastConfig(
val width: Int = 1280,
val height: Int = 720,
val videoBitrate: Int = 2_500_000,
val minVideoBitrate: Int = 800_000,
val audioBitrate: Int = 128_000,
val fps: Int = 30,
val rotation: Int = 0,
@@ -41,6 +41,7 @@ class LiveBroadcastEngine(
private var publishInProgress = false
private var currentBitrateKbps: Long = 0
private var currentFps: Int = 0
private val adaptiveBitrate = AdaptiveBitrateController()
private val pausingIntentionally = AtomicBoolean(false)
private val reconnectAttempts = AtomicInteger(0)
private val overlayRenderer = OverlayRenderer(appContext)
@@ -136,6 +137,7 @@ class LiveBroadcastEngine(
fun startBroadcast(broadcastConfig: BroadcastConfig) {
mainHandler.post {
config = broadcastConfig
adaptiveBitrate.configure(broadcastConfig.videoBitrate, broadcastConfig.minVideoBitrate)
reconnectAttempts.set(0)
streamStartPending = true
publishInProgress = false
@@ -254,6 +256,7 @@ class LiveBroadcastEngine(
fun resumeBroadcast(broadcastConfig: BroadcastConfig) {
mainHandler.post {
config = broadcastConfig
adaptiveBitrate.configure(broadcastConfig.videoBitrate, broadcastConfig.minVideoBitrate)
reconnectAttempts.set(0)
streamStartPending = true
publishInProgress = false
@@ -279,21 +282,45 @@ class LiveBroadcastEngine(
fun isAudioMuted(): Boolean = desiredAudioMuted.get()
/** Adattamento termico: riduce bitrate/FPS senza fermare lo stream. */
/** Adattamento termico: aggiorna il soffitto ABR e l'FPS senza fermare lo stream. */
fun applyThermalProfile(videoBitrate: Int, fps: Int) {
mainHandler.post {
val current = config ?: return@post
if (current.videoBitrate == videoBitrate && current.fps == fps) return@post
adaptiveBitrate.setCeiling(videoBitrate)
val applied = adaptiveBitrate.setFloor(current.minVideoBitrate)
if (current.videoBitrate == videoBitrate && current.fps == fps) {
applyAdaptiveBitrate(applied)
return@post
}
config = current.copy(videoBitrate = videoBitrate, fps = fps)
genericStream?.let { stream ->
runCatching { stream.setVideoBitrateOnFly(videoBitrate) }
.onFailure { Log.w(TAG, "applyThermalProfile bitrate: ${it.message}") }
applyAdaptiveBitrate(applied)
runCatching { stream.getGlInterface().setForceRender(true, fps) }
.onFailure { Log.w(TAG, "applyThermalProfile fps: ${it.message}") }
}
}
}
fun setMinQualityFloor(floorBitrate: Int, ceilingBitrate: Int? = null) {
mainHandler.post {
val current = config
if (current != null) {
config = current.copy(
videoBitrate = ceilingBitrate ?: current.videoBitrate,
minVideoBitrate = floorBitrate,
)
}
applyAdaptiveBitrate(adaptiveBitrate.setFloor(floorBitrate))
}
}
private fun applyAdaptiveBitrate(bitrate: Int) {
genericStream?.let { stream ->
runCatching { stream.setVideoBitrateOnFly(bitrate) }
.onFailure { Log.w(TAG, "ABR bitrate: ${it.message}") }
}
}
private fun obtainStream(cfg: BroadcastConfig): GenericStream {
val existing = genericStream
if (existing != null) return existing
@@ -569,6 +596,12 @@ class LiveBroadcastEngine(
override fun onNewBitrate(bitrate: Long) {
mainHandler.post {
currentBitrateKbps = bitrate / 1000
val congestion = runCatching {
genericStream?.getStreamClient()?.hasCongestion() == true
}.getOrDefault(false)
adaptiveBitrate.onMeasured(bitrate, congestion)?.let { next ->
applyAdaptiveBitrate(next)
}
emitMetrics()
}
}
@@ -0,0 +1,72 @@
package com.matchlivetv.match_live_tv.streaming
import java.util.Locale
/**
* Scala qualità allineata a `Sessions::SelectQuality`.
* In diretta si adatta solo il bitrate (niente cambio risoluzione a caldo).
*/
object StreamQualityLadder {
const val AUTO = "auto"
data class Preset(
val id: String,
val bitrate: Int,
val fps: Int = 30,
) {
val is1080: Boolean get() = id.startsWith("1080p")
val resolutionLabel: String get() = if (is1080) "1080p" else "720p"
}
val PRESETS = listOf(
Preset("1080p_30_4.5mbps", 4_500_000),
Preset("720p_30_4mbps", 4_000_000),
Preset("720p_30_2.5mbps", 2_500_000),
Preset("720p_30_1.5mbps", 1_500_000),
)
fun normalize(presetId: String?): String {
val id = presetId?.ifBlank { AUTO } ?: AUTO
if (id == AUTO) return AUTO
return if (PRESETS.any { it.id == id }) id else AUTO
}
fun floorBitrate(presetId: String?): Int {
val id = normalize(presetId)
if (id == AUTO) return AdaptiveBitrateController.AUTO_FLOOR
return PRESETS.find { it.id == id }?.bitrate ?: AdaptiveBitrateController.AUTO_FLOOR
}
fun ceilingContribution(presetId: String?): Int {
val id = normalize(presetId)
if (id == AUTO) return 0
return PRESETS.find { it.id == id }?.bitrate ?: 0
}
fun encoderCeiling(sessionTarget: Int, minPresetId: String?): Int =
maxOf(sessionTarget, ceilingContribution(minPresetId))
fun encoderFloor(minPresetId: String?, ceiling: Int): Int =
minOf(floorBitrate(minPresetId), ceiling)
fun availableIds(sessionQualityPreset: String?): List<String> {
val ids = mutableListOf(AUTO)
ids += PRESETS.filter { !it.is1080 }.asReversed().map { it.id }
if (sessionQualityPreset?.startsWith("1080p") == true) {
PRESETS.find { it.is1080 }?.let { ids += it.id }
}
return ids
}
fun displayName(id: String, autoLabel: String): String {
if (normalize(id) == AUTO) return autoLabel
val preset = PRESETS.find { it.id == id } ?: return id
val mbps = preset.bitrate / 1_000_000.0
val mbpsText = if (mbps == mbps.toInt().toDouble()) {
mbps.toInt().toString()
} else {
"%.1f".format(Locale.US, mbps)
}
return "${preset.resolutionLabel} · $mbpsText Mbps"
}
}
@@ -15,11 +15,14 @@ object StreamVideoPreset {
fun broadcastConfig(session: StreamSession, rtmpUrl: String): BroadcastConfig {
val dims = dimensions(session.qualityPreset)
val ceiling = StreamQualityLadder.encoderCeiling(session.targetBitrate, session.minQualityPreset)
val floor = StreamQualityLadder.encoderFloor(session.minQualityPreset, ceiling)
return BroadcastConfig(
rtmpUrl = rtmpUrl,
width = dims.width,
height = dims.height,
videoBitrate = session.targetBitrate,
videoBitrate = ceiling,
minVideoBitrate = floor,
fps = session.targetFps,
)
}
@@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -29,6 +30,7 @@ import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material.icons.filled.Videocam
@@ -60,6 +62,8 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.layout.ContentScale
import coil.compose.AsyncImage
import com.matchlivetv.match_live_tv.R
@@ -67,6 +71,7 @@ import com.matchlivetv.match_live_tv.core.resolveMediaUrl
import com.matchlivetv.match_live_tv.core.DeviceHealthSnapshot
import com.matchlivetv.match_live_tv.core.ThermalState
import com.matchlivetv.match_live_tv.domain.ScoreState
import com.matchlivetv.match_live_tv.streaming.StreamQualityLadder
import com.matchlivetv.match_live_tv.ui.components.MatchStatusBadge
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
@@ -113,9 +118,27 @@ fun BroadcastControlsOverlay(
bitrateKbps: Long,
networkType: String,
deviceHealth: DeviceHealthSnapshot,
minQualityPreset: String = StreamQualityLadder.AUTO,
sessionQualityPreset: String = "720p_30_2.5mbps",
onSelectMinQuality: (String) -> Unit = {},
modifier: Modifier = Modifier,
) {
var showTerminateConfirm by remember { mutableStateOf(false) }
var showMinQualityPicker by remember { mutableStateOf(false) }
val autoLabel = stringResource(R.string.broadcast_min_quality_auto)
if (showMinQualityPicker) {
MinQualityPickerDialog(
selectedId = minQualityPreset,
sessionQualityPreset = sessionQualityPreset,
autoLabel = autoLabel,
onSelect = { id ->
showMinQualityPicker = false
onSelectMinQuality(id)
},
onDismiss = { showMinQualityPicker = false },
)
}
if (showTerminateConfirm) {
AlertDialog(
@@ -179,6 +202,7 @@ fun BroadcastControlsOverlay(
networkType = networkType,
deviceHealth = deviceHealth,
audioMuted = audioMuted,
minQualityLabel = StreamQualityLadder.displayName(minQualityPreset, autoLabel),
)
}
@@ -200,6 +224,11 @@ fun BroadcastControlsOverlay(
contentDescription = stringResource(R.string.broadcast_share_regia_cd),
onClick = onShareRegia,
)
SideIconButton(
icon = Icons.Default.Tune,
contentDescription = stringResource(R.string.broadcast_min_quality_cd),
onClick = { showMinQualityPicker = true },
)
if (onCloseSet != null) {
SideIconButton(
icon = Icons.Default.Check,
@@ -303,6 +332,7 @@ private fun BroadcastTelemetryPanel(
networkType: String,
deviceHealth: DeviceHealthSnapshot,
audioMuted: Boolean,
minQualityLabel: String,
modifier: Modifier = Modifier,
) {
val labelStyle = MaterialTheme.typography.labelSmall
@@ -337,6 +367,11 @@ private fun BroadcastTelemetryPanel(
color = MatchColors.TextSecondary,
)
}
Text(
minQualityLabel,
style = labelStyle,
color = MatchColors.AccentYellow,
)
Text(
"${networkType} · ${deviceHealth.batteryPercent}%",
style = labelStyle,
@@ -628,6 +663,113 @@ private fun TeamLogoThumbnail(url: String) {
}
}
@Composable
private fun MinQualityPickerDialog(
selectedId: String,
sessionQualityPreset: String,
autoLabel: String,
onSelect: (String) -> Unit,
onDismiss: () -> Unit,
) {
val options = StreamQualityLadder.availableIds(sessionQualityPreset)
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Column(
modifier = Modifier
.padding(horizontal = 24.dp)
.widthIn(max = 720.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(MatchColors.Surface)
.border(1.dp, MatchColors.Outline, RoundedCornerShape(16.dp))
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringResource(R.string.broadcast_min_quality_title),
color = Color.White,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel), color = MatchColors.TextSecondary)
}
}
Text(
stringResource(R.string.broadcast_min_quality_hint),
color = MatchColors.TextSecondary,
style = MaterialTheme.typography.bodySmall,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
options.forEach { id ->
MinQualityOptionChip(
label = StreamQualityLadder.displayName(id, autoLabel),
selected = id == selectedId,
onClick = { onSelect(id) },
modifier = Modifier.weight(1f),
)
}
}
}
}
}
@Composable
private fun MinQualityOptionChip(
label: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val shape = RoundedCornerShape(12.dp)
val border = if (selected) MatchColors.AccentYellow else MatchColors.Outline
val background = if (selected) {
MatchColors.AccentYellow.copy(alpha = 0.16f)
} else {
MatchColors.SurfaceElevated
}
Column(
modifier = modifier
.clip(shape)
.background(background)
.border(1.dp, border, shape)
.clickable(onClick = onClick)
.padding(horizontal = 8.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
if (selected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MatchColors.AccentYellow,
modifier = Modifier.size(16.dp),
)
} else {
Spacer(Modifier.height(16.dp))
}
Text(
label,
color = if (selected) MatchColors.AccentYellow else Color.White,
style = MaterialTheme.typography.labelMedium,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SideIconButton(
@@ -44,6 +44,7 @@ import com.matchlivetv.match_live_tv.domain.Match
import com.matchlivetv.match_live_tv.domain.MatchScoringRules
import com.matchlivetv.match_live_tv.domain.StreamSession
import com.matchlivetv.match_live_tv.streaming.BroadcastConfig
import com.matchlivetv.match_live_tv.streaming.StreamQualityLadder
import com.matchlivetv.match_live_tv.streaming.StreamVideoPreset
import com.matchlivetv.match_live_tv.streaming.BroadcastPhase
import com.matchlivetv.match_live_tv.streaming.LivePreviewView
@@ -79,6 +80,7 @@ fun BroadcastScreen(
var pauseInFlight by remember { mutableStateOf(false) }
var resumeInFlight by remember { mutableStateOf(false) }
var muteInFlight by remember { mutableStateOf(false) }
var minQualityInFlight by remember { mutableStateOf(false) }
var controlsVisible by remember { mutableStateOf(true) }
var logoReady by remember { mutableStateOf(0) }
var deviceHealth by remember { mutableStateOf(DeviceTelemetry.snapshot(context)) }
@@ -158,10 +160,16 @@ fun BroadcastScreen(
}
suspend fun applyRemotePause() {
container.broadcastCoordinator.engine.pauseBroadcast()
session = runCatching { container.sessionRepository.fetchSession(sessionId) }
.getOrNull() ?: session?.copy(status = "paused")
snackbar.showSnackbar(context.getString(R.string.broadcast_snackbar_paused_remote))
if (pauseInFlight) return
pauseInFlight = true
try {
container.broadcastCoordinator.engine.pauseBroadcast()
session = runCatching { container.sessionRepository.fetchSession(sessionId) }
.getOrNull() ?: session?.copy(status = "paused")
snackbar.showSnackbar(context.getString(R.string.broadcast_snackbar_paused_remote))
} finally {
pauseInFlight = false
}
}
suspend fun applyRemoteResume() {
@@ -228,6 +236,38 @@ fun BroadcastScreen(
}
}
fun applyMinQualityLocally(preset: String, loaded: StreamSession) {
val updated = loaded.copy(minQualityPreset = preset)
session = updated
val cfg = broadcastConfig(updated)
container.broadcastCoordinator.engine.setMinQualityFloor(cfg.minVideoBitrate, cfg.videoBitrate)
thermalManager.updateBaseline(cfg)
}
suspend fun persistMinQuality(preset: String) {
val current = session ?: return
minQualityInFlight = true
runCatching { container.sessionRepository.setMinQuality(sessionId, preset) }
.onSuccess { updated ->
applyMinQualityLocally(updated.minQualityPreset, updated)
snackbar.showSnackbar(
context.getString(
R.string.broadcast_min_quality_applied,
StreamQualityLadder.displayName(
updated.minQualityPreset,
context.getString(R.string.broadcast_min_quality_auto),
),
),
)
}
.onFailure {
snackbar.showSnackbar(
it.message ?: context.getString(R.string.broadcast_min_quality_error),
)
}
minQualityInFlight = false
}
LaunchedEffect(sessionId, permissions.granted) {
if (!permissions.granted) return@LaunchedEffect
loading = true
@@ -255,6 +295,15 @@ fun BroadcastScreen(
container.sessionCable.onAudioMute = { muted ->
if (!muteInFlight) scope.launch { setAudioMuted(muted, remote = true) }
}
container.sessionCable.onMinQuality = { preset ->
scope.launch {
val current = session
if (current != null && current.minQualityPreset != preset) {
applyMinQualityLocally(preset, current)
snackbar.showSnackbar(context.getString(R.string.broadcast_snackbar_min_quality_remote))
}
}
}
container.sessionCable.connect(sessionId, token, deviceRole = "camera")
}
container.broadcastCoordinator.startService()
@@ -371,12 +420,28 @@ fun BroadcastScreen(
LaunchedEffect(sessionId) {
while (true) {
delay(4_000)
runCatching {
container.sessionRepository.fetchSession(sessionId).score
}.getOrNull()?.let { remote ->
container.scoreController.applyRemote(remote)
}
delay(2_000)
if (minQualityInFlight || muteInFlight || pauseInFlight || resumeInFlight) continue
runCatching { container.sessionRepository.fetchSession(sessionId) }
.getOrNull()
?.let { fresh ->
fresh.score?.let { container.scoreController.applyRemote(it) }
val current = session ?: return@let
if (fresh.minQualityPreset != current.minQualityPreset) {
applyMinQualityLocally(fresh.minQualityPreset, current)
}
val phase = container.broadcastCoordinator.metrics.value.phase
val publishing = phase == BroadcastPhase.LIVE ||
phase == BroadcastPhase.CONNECTING ||
phase == BroadcastPhase.RECONNECTING
when {
fresh.isEnded && phase != BroadcastPhase.IDLE -> applyRemoteStop()
fresh.isPaused && publishing -> applyRemotePause()
!fresh.isPaused && !fresh.isEnded && phase == BroadcastPhase.PAUSED ->
applyRemoteResume()
else -> session = fresh
}
}
}
}
@@ -384,7 +449,6 @@ fun BroadcastScreen(
while (true) {
delay(10_000)
val current = session ?: continue
if (current.isPaused) continue
runCatching {
container.sessionRepository.postTelemetry(
sessionId = sessionId,
@@ -394,6 +458,7 @@ fun BroadcastScreen(
currentBitrate = (metrics.bitrateKbps * 1000).toInt().takeIf { it > 0 },
targetBitrate = current.targetBitrate,
fps = metrics.fps.takeIf { it > 0 },
thermalState = thermalManager.state.value.name.lowercase(),
)
}
}
@@ -407,6 +472,7 @@ fun BroadcastScreen(
container.sessionCable.onResumeStream = null
container.sessionCable.onEndStream = null
container.sessionCable.onAudioMute = null
container.sessionCable.onMinQuality = null
container.sessionCable.disconnect()
container.broadcastCoordinator.stopService()
container.broadcastCoordinator.engine.stopBroadcast()
@@ -626,6 +692,11 @@ fun BroadcastScreen(
bitrateKbps = metrics.bitrateKbps,
networkType = DeviceTelemetry.networkType(context),
deviceHealth = DeviceTelemetry.snapshot(context, thermalState),
minQualityPreset = currentSession?.minQualityPreset ?: StreamQualityLadder.AUTO,
sessionQualityPreset = currentSession?.qualityPreset ?: "720p_30_2.5mbps",
onSelectMinQuality = { preset ->
scope.launch { persistMinQuality(preset) }
},
)
}
}
@@ -29,11 +29,15 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -50,7 +54,7 @@ import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
fun LoginScreen(
container: AppContainer,
@@ -130,7 +134,10 @@ fun LoginScreen(
onValueChange = { email = it },
label = { Text(stringResource(R.string.login_email)) },
placeholder = { Text(stringResource(R.string.login_email_placeholder)) },
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.testTag("login_email")
.semantics { testTagsAsResourceId = true },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
@@ -146,7 +153,10 @@ fun LoginScreen(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.login_password)) },
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.testTag("login_password")
.semantics { testTagsAsResourceId = true },
singleLine = true,
visualTransformation = if (passwordVisible) {
VisualTransformation.None
@@ -198,6 +208,9 @@ fun LoginScreen(
loading = loading,
enabled = email.isNotBlank() && password.isNotBlank(),
onClick = { submitLogin() },
modifier = Modifier
.testTag("login_submit")
.semantics { testTagsAsResourceId = true },
)
}
}
@@ -172,6 +172,13 @@
<string name="broadcast_unmute_cd">Audio einschalten</string>
<string name="broadcast_audio_muted_label">Audio aus</string>
<string name="broadcast_terminate_cd">Livestream beenden</string>
<string name="broadcast_min_quality_title">Mindestqualität</string>
<string name="broadcast_min_quality_cd">Minimale Übertragungsqualität</string>
<string name="broadcast_min_quality_hint">Der Encoder fällt nicht unter dieses Niveau. Automatisch passt die Bitrate an, um Aussetzer zu reduzieren.</string>
<string name="broadcast_min_quality_auto">Automatisch</string>
<string name="broadcast_min_quality_applied">Mindestqualität: %1$s</string>
<string name="broadcast_min_quality_error">Mindestqualität konnte nicht gesetzt werden</string>
<string name="broadcast_snackbar_min_quality_remote">Mindestqualität von der Regie aktualisiert</string>
<string name="broadcast_team_home_label">HEIM</string>
<string name="broadcast_team_away_label">GAST</string>
<string name="broadcast_scoreboard_connected">Anzeigetafel OK</string>
@@ -172,6 +172,13 @@
<string name="broadcast_unmute_cd">Unmute audio</string>
<string name="broadcast_audio_muted_label">Audio off</string>
<string name="broadcast_terminate_cd">End live</string>
<string name="broadcast_min_quality_title">Minimum quality</string>
<string name="broadcast_min_quality_cd">Minimum streaming quality</string>
<string name="broadcast_min_quality_hint">The encoder will not go below this level. Automatic adapts bitrate to reduce stalls.</string>
<string name="broadcast_min_quality_auto">Automatic</string>
<string name="broadcast_min_quality_applied">Minimum quality: %1$s</string>
<string name="broadcast_min_quality_error">Unable to set minimum quality</string>
<string name="broadcast_snackbar_min_quality_remote">Minimum quality updated from control room</string>
<string name="broadcast_team_home_label">HOME</string>
<string name="broadcast_team_away_label">AWAY</string>
<string name="broadcast_scoreboard_connected">Scoreboard OK</string>
@@ -172,6 +172,13 @@
<string name="broadcast_unmute_cd">Reactivar audio</string>
<string name="broadcast_audio_muted_label">Audio off</string>
<string name="broadcast_terminate_cd">Terminar directo</string>
<string name="broadcast_min_quality_title">Calidad mínima</string>
<string name="broadcast_min_quality_cd">Calidad mínima de transmisión</string>
<string name="broadcast_min_quality_hint">El encoder no baja de este nivel. Automática adapta el bitrate para reducir los cortes.</string>
<string name="broadcast_min_quality_auto">Automática</string>
<string name="broadcast_min_quality_applied">Calidad mínima: %1$s</string>
<string name="broadcast_min_quality_error">No se pudo fijar la calidad mínima</string>
<string name="broadcast_snackbar_min_quality_remote">Calidad mínima actualizada desde la regie</string>
<string name="broadcast_team_home_label">LOCAL</string>
<string name="broadcast_team_away_label">VISITANTE</string>
<string name="broadcast_scoreboard_connected">Marcador OK</string>
@@ -172,6 +172,13 @@
<string name="broadcast_unmute_cd">Réactiver l\'audio</string>
<string name="broadcast_audio_muted_label">Audio off</string>
<string name="broadcast_terminate_cd">Terminer le direct</string>
<string name="broadcast_min_quality_title">Qualité minimale</string>
<string name="broadcast_min_quality_cd">Qualité minimale de transmission</string>
<string name="broadcast_min_quality_hint">L\'encodeur ne descend pas en dessous de ce niveau. Automatique adapte le débit pour limiter les coupures.</string>
<string name="broadcast_min_quality_auto">Automatique</string>
<string name="broadcast_min_quality_applied">Qualité minimale : %1$s</string>
<string name="broadcast_min_quality_error">Impossible de définir la qualité minimale</string>
<string name="broadcast_snackbar_min_quality_remote">Qualité minimale mise à jour depuis la régie</string>
<string name="broadcast_team_home_label">DOMICILE</string>
<string name="broadcast_team_away_label">EXTÉRIEUR</string>
<string name="broadcast_scoreboard_connected">Tableau OK</string>
@@ -172,6 +172,13 @@
<string name="broadcast_unmute_cd">Riattiva audio</string>
<string name="broadcast_audio_muted_label">Audio off</string>
<string name="broadcast_terminate_cd">Termina diretta</string>
<string name="broadcast_min_quality_title">Qualità minima</string>
<string name="broadcast_min_quality_cd">Qualità minima di trasmissione</string>
<string name="broadcast_min_quality_hint">L\'encoder non scende sotto questo livello. Automatica adatta il bitrate per ridurre i salti.</string>
<string name="broadcast_min_quality_auto">Automatica</string>
<string name="broadcast_min_quality_applied">Qualità minima: %1$s</string>
<string name="broadcast_min_quality_error">Impossibile impostare la qualità minima</string>
<string name="broadcast_snackbar_min_quality_remote">Qualità minima aggiornata dalla regia</string>
<string name="broadcast_team_home_label">CASA</string>
<string name="broadcast_team_away_label">OSPITE</string>
<string name="broadcast_scoreboard_connected">Tabellone OK</string>
@@ -0,0 +1,106 @@
package com.matchlivetv.match_live_tv.streaming
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class AdaptiveBitrateControllerTest {
private fun controller() = AdaptiveBitrateController().also {
it.configure(ceiling = 2_500_000, floor = AdaptiveBitrateController.AUTO_FLOOR)
}
@Test
fun congestionDropsTowardMeasuredAndRespectsFloor() {
val abr = controller()
val next = abr.onCongestion(1_200_000)
assertEquals(1_200_000 - 128_000, next)
assertEquals(1_072_000, abr.currentBitrate)
}
@Test
fun congestionDoesNotGoBelowFloor() {
val abr = controller()
abr.setFloor(1_500_000)
val next = abr.onCongestion(400_000)
assertEquals(1_500_000, next)
}
@Test
fun healthyRampsUpAfterEnoughSamples() {
val abr = controller()
abr.onCongestion(1_200_000)
repeat(7) { assertNull(abr.onHealthy()) }
val raised = abr.onHealthy()
assertEquals(1_072_000 + 250_000, raised)
}
@Test
fun thermalCeilingClampsCurrent() {
val abr = controller()
val applied = abr.setCeiling(1_250_000)
assertEquals(1_250_000, applied)
assertEquals(1_250_000, abr.ceiling)
assertTrue(abr.floor <= abr.ceiling)
}
@Test
fun minFloorBumpsCurrentUp() {
val abr = controller()
abr.onCongestion(1_200_000)
val applied = abr.setFloor(2_000_000)
assertEquals(2_000_000, applied)
}
@Test
fun autoOptionsExclude1080On720Session() {
val ids = StreamQualityLadder.availableIds("720p_30_2.5mbps")
assertEquals(
listOf(
StreamQualityLadder.AUTO,
"720p_30_1.5mbps",
"720p_30_2.5mbps",
"720p_30_4mbps",
),
ids,
)
}
@Test
fun measuredZeroWithoutCongestionDoesNotDrop() {
val abr = controller()
assertNull(abr.onMeasured(0L, congested = false))
assertEquals(2_500_000, abr.currentBitrate)
}
@Test
fun measuredLowThroughputDropsWithoutCongestionFlag() {
val abr = controller()
val next = abr.onMeasured(1_200_000L, congested = false)
assertEquals(1_200_000 - 128_000, next)
}
@Test
fun thermalDropThenRecoverRestoresUserFloor() {
val abr = controller()
abr.setFloor(1_500_000)
abr.setCeiling(1_000_000)
assertEquals(1_000_000, abr.floor)
abr.setCeiling(2_500_000)
assertEquals(1_000_000, abr.floor)
assertEquals(1_500_000, abr.setFloor(1_500_000))
}
@Test
fun encoderCeilingRisesWhenMinExceedsSessionTarget() {
val ceiling = StreamQualityLadder.encoderCeiling(
sessionTarget = 2_500_000,
minPresetId = "720p_30_4mbps",
)
assertEquals(4_000_000, ceiling)
assertEquals(
4_000_000,
StreamQualityLadder.encoderFloor("720p_30_4mbps", ceiling),
)
}
}
@@ -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)
}
}