Aggiunge bitrate adattivo con qualità minima da regia e telemetria del telefono.

L'encoder scende in congestione e risale piano; la regia imposta il pavimento, mostra lo stato del telefono e apre il selettore dal bitrate colorato.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-13 20:05:16 +02:00
co-authored by Cursor
parent bc2257efd2
commit f92ab3a813
65 changed files with 2745 additions and 147 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,
val score: ScoreStateDto? = null,
) {
@@ -201,6 +202,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(),
)
@@ -285,6 +287,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,
@@ -392,4 +398,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,
)
@@ -117,6 +117,12 @@ interface MatchLiveApi {
@PATCH("sessions/{sessionId}/resume")
suspend fun resumeSession(@Path("sessionId") sessionId: String): 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,
@@ -33,6 +33,7 @@ class SessionCableService(
var onPauseStream: (() -> Unit)? = null
var onResumeStream: (() -> Unit)? = null
var onEndStream: (() -> Unit)? = null
var onMinQuality: ((String) -> Unit)? = null
fun connect(sessionId: String, accessToken: String, deviceRole: String = "camera") {
intentionalDisconnect = false
@@ -109,6 +110,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" -> when (payload["event"] as? String) {
"paused" -> onPauseStream?.invoke()
@@ -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
@@ -38,6 +39,9 @@ class SessionRepository(
suspend fun resumeSession(sessionId: String): StreamSession =
api.resumeSession(sessionId).toDomain()
suspend fun setMinQuality(sessionId: String, preset: String): StreamSession =
api.setMinQuality(sessionId, MinQualityRequest(minQualityPreset = preset)).toDomain()
suspend fun submitNetworkTest(
sessionId: String,
downloadMbps: Double,
@@ -61,6 +65,7 @@ class SessionRepository(
currentBitrate: Int? = null,
targetBitrate: Int? = null,
fps: Int? = null,
thermalState: String? = null,
) {
api.postTelemetry(
sessionId,
@@ -72,6 +77,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,
) {
@@ -133,6 +134,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,
@@ -39,6 +39,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)
@@ -133,6 +134,7 @@ class LiveBroadcastEngine(
fun startBroadcast(broadcastConfig: BroadcastConfig) {
mainHandler.post {
config = broadcastConfig
adaptiveBitrate.configure(broadcastConfig.videoBitrate, broadcastConfig.minVideoBitrate)
reconnectAttempts.set(0)
streamStartPending = true
publishInProgress = false
@@ -250,6 +252,7 @@ class LiveBroadcastEngine(
fun resumeBroadcast(broadcastConfig: BroadcastConfig) {
mainHandler.post {
config = broadcastConfig
adaptiveBitrate.configure(broadcastConfig.videoBitrate, broadcastConfig.minVideoBitrate)
reconnectAttempts.set(0)
streamStartPending = true
publishInProgress = false
@@ -264,21 +267,45 @@ class LiveBroadcastEngine(
}
}
/** 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
@@ -552,6 +579,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
@@ -58,6 +60,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
@@ -65,6 +69,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
@@ -109,9 +114,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(
@@ -174,6 +197,7 @@ fun BroadcastControlsOverlay(
bitrateKbps = bitrateKbps,
networkType = networkType,
deviceHealth = deviceHealth,
minQualityLabel = StreamQualityLadder.displayName(minQualityPreset, autoLabel),
)
}
@@ -195,6 +219,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,
@@ -287,6 +316,7 @@ private fun BroadcastTelemetryPanel(
bitrateKbps: Long,
networkType: String,
deviceHealth: DeviceHealthSnapshot,
minQualityLabel: String,
modifier: Modifier = Modifier,
) {
val labelStyle = MaterialTheme.typography.labelSmall
@@ -321,6 +351,11 @@ private fun BroadcastTelemetryPanel(
color = MatchColors.TextSecondary,
)
}
Text(
minQualityLabel,
style = labelStyle,
color = MatchColors.AccentYellow,
)
Text(
"${networkType} · ${deviceHealth.batteryPercent}%",
style = labelStyle,
@@ -604,6 +639,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
@@ -78,6 +79,7 @@ fun BroadcastScreen(
var loading by remember { mutableStateOf(true) }
var pauseInFlight by remember { mutableStateOf(false) }
var resumeInFlight 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)) }
@@ -156,10 +158,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() {
@@ -191,6 +199,38 @@ fun BroadcastScreen(
onFinished()
}
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
@@ -215,6 +255,15 @@ fun BroadcastScreen(
container.sessionCable.onEndStream = {
scope.launch { applyRemoteStop() }
}
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()
@@ -329,12 +378,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 || 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
}
}
}
}
@@ -342,7 +407,6 @@ fun BroadcastScreen(
while (true) {
delay(10_000)
val current = session ?: continue
if (current.isPaused) continue
runCatching {
container.sessionRepository.postTelemetry(
sessionId = sessionId,
@@ -352,6 +416,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(),
)
}
}
@@ -364,6 +429,7 @@ fun BroadcastScreen(
container.sessionCable.onPauseStream = null
container.sessionCable.onResumeStream = null
container.sessionCable.onEndStream = null
container.sessionCable.onMinQuality = null
container.sessionCable.disconnect()
container.broadcastCoordinator.stopService()
container.broadcastCoordinator.engine.stopBroadcast()
@@ -578,6 +644,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 },
)
}
}
@@ -164,6 +164,13 @@
<string name="broadcast_resume_cd">Livestream fortsetzen</string>
<string name="broadcast_pause_cd">Livestream pausieren</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>
@@ -164,6 +164,13 @@
<string name="broadcast_resume_cd">Resume live</string>
<string name="broadcast_pause_cd">Pause live</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>
@@ -164,6 +164,13 @@
<string name="broadcast_resume_cd">Reanudar directo</string>
<string name="broadcast_pause_cd">Pausar directo</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>
@@ -164,6 +164,13 @@
<string name="broadcast_resume_cd">Reprendre le direct</string>
<string name="broadcast_pause_cd">Mettre le direct en pause</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>
@@ -164,6 +164,13 @@
<string name="broadcast_resume_cd">Riprendi diretta</string>
<string name="broadcast_pause_cd">Pausa diretta</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),
)
}
}