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:
+71
@@ -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
|
||||
}
|
||||
}
|
||||
+265
@@ -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,
|
||||
)
|
||||
|
||||
+6
@@ -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,
|
||||
|
||||
+2
@@ -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" -> {
|
||||
|
||||
+6
@@ -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
|
||||
|
||||
+115
@@ -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
|
||||
}
|
||||
}
|
||||
+1
@@ -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,
|
||||
|
||||
+37
-4
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+72
@@ -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"
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+142
@@ -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(
|
||||
|
||||
+82
-11
@@ -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) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+16
-3
@@ -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>
|
||||
|
||||
+106
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user