Allinea iOS a i18n Android 2.0.5 e aggiunge monitoraggio termico nativo.
Completa L10n su login/hub/wizard/broadcast e introduce ThermalStateManager su iOS/Android con degradazione qualità e indicatore in overlay. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
ff4de33cc5
commit
ea5da1eb86
+4
-60
@@ -6,21 +6,10 @@ import android.content.IntentFilter
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.BatteryManager
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
|
||||
enum class ThermalLevel {
|
||||
NORMAL,
|
||||
WARM,
|
||||
HOT,
|
||||
CRITICAL,
|
||||
}
|
||||
|
||||
data class DeviceHealthSnapshot(
|
||||
val batteryPercent: Int,
|
||||
val batteryTempC: Float?,
|
||||
val thermalLevel: ThermalLevel,
|
||||
val thermalLabel: String,
|
||||
val thermalState: ThermalState,
|
||||
)
|
||||
|
||||
object DeviceTelemetry {
|
||||
@@ -38,16 +27,13 @@ object DeviceTelemetry {
|
||||
}
|
||||
}.getOrDefault("Sconosciuto")
|
||||
|
||||
fun snapshot(context: Context): DeviceHealthSnapshot {
|
||||
fun snapshot(context: Context, thermalState: ThermalState? = null): DeviceHealthSnapshot {
|
||||
val batteryIntent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
||||
val batteryPercent = readBatteryPercent(batteryIntent)
|
||||
val batteryTempC = readBatteryTemperatureC(batteryIntent)
|
||||
val thermalLevel = resolveThermalLevel(context, batteryTempC)
|
||||
val thermal = thermalState ?: ThermalMonitor(context, Runnable::run).currentState
|
||||
return DeviceHealthSnapshot(
|
||||
batteryPercent = batteryPercent,
|
||||
batteryTempC = batteryTempC,
|
||||
thermalLevel = thermalLevel,
|
||||
thermalLabel = thermalLabel(thermalLevel),
|
||||
thermalState = thermal,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -58,46 +44,4 @@ object DeviceTelemetry {
|
||||
if (level < 0 || scale <= 0) return 100
|
||||
return ((level * 100f) / scale).toInt().coerceIn(0, 100)
|
||||
}
|
||||
|
||||
private fun readBatteryTemperatureC(intent: Intent?): Float? {
|
||||
val raw = intent?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, -1) ?: -1
|
||||
if (raw <= 0) return null
|
||||
return raw / 10f
|
||||
}
|
||||
|
||||
private fun resolveThermalLevel(context: Context, batteryTempC: Float?): ThermalLevel {
|
||||
val fromStatus = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
when (pm.currentThermalStatus) {
|
||||
PowerManager.THERMAL_STATUS_NONE,
|
||||
PowerManager.THERMAL_STATUS_LIGHT,
|
||||
-> ThermalLevel.NORMAL
|
||||
PowerManager.THERMAL_STATUS_MODERATE -> ThermalLevel.WARM
|
||||
PowerManager.THERMAL_STATUS_SEVERE -> ThermalLevel.HOT
|
||||
PowerManager.THERMAL_STATUS_CRITICAL,
|
||||
PowerManager.THERMAL_STATUS_EMERGENCY,
|
||||
PowerManager.THERMAL_STATUS_SHUTDOWN,
|
||||
-> ThermalLevel.CRITICAL
|
||||
else -> null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val fromTemp = batteryTempC?.let { tempFromBattery(it) }
|
||||
return listOfNotNull(fromStatus, fromTemp).maxByOrNull { it.ordinal } ?: ThermalLevel.NORMAL
|
||||
}
|
||||
|
||||
private fun tempFromBattery(tempC: Float): ThermalLevel = when {
|
||||
tempC >= 45f -> ThermalLevel.CRITICAL
|
||||
tempC >= 42f -> ThermalLevel.HOT
|
||||
tempC >= 38f -> ThermalLevel.WARM
|
||||
else -> ThermalLevel.NORMAL
|
||||
}
|
||||
|
||||
private fun thermalLabel(level: ThermalLevel): String = when (level) {
|
||||
ThermalLevel.NORMAL -> "OK"
|
||||
ThermalLevel.WARM -> "Caldo"
|
||||
ThermalLevel.HOT -> "Surriscaldamento"
|
||||
ThermalLevel.CRITICAL -> "Troppo caldo"
|
||||
}
|
||||
}
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package com.matchlivetv.match_live_tv.core
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import java.util.concurrent.Executor
|
||||
|
||||
/**
|
||||
* Ascolta lo stato termico di sistema (`PowerManager.currentThermalStatus`, API 29+)
|
||||
* ed espone un flusso osservabile per la UI.
|
||||
*/
|
||||
class ThermalMonitor(
|
||||
context: Context,
|
||||
private val executor: Executor,
|
||||
) {
|
||||
private val appContext = context.applicationContext
|
||||
private val powerManager = appContext.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
private var listener: PowerManager.OnThermalStatusChangedListener? = null
|
||||
|
||||
var currentState: ThermalState = readCurrentState()
|
||||
private set
|
||||
|
||||
var onStateChanged: ((ThermalState) -> Unit)? = null
|
||||
|
||||
fun start() {
|
||||
if (listener != null) return
|
||||
currentState = readCurrentState()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val thermalListener = PowerManager.OnThermalStatusChangedListener { status ->
|
||||
val next = mapThermalStatus(status)
|
||||
if (next != currentState) {
|
||||
val previous = currentState
|
||||
currentState = next
|
||||
Log.i(TAG, "[Thermal] $previous → $next")
|
||||
onStateChanged?.invoke(next)
|
||||
}
|
||||
}
|
||||
listener = thermalListener
|
||||
powerManager.addThermalStatusListener(executor, thermalListener)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
listener?.let { powerManager.removeThermalStatusListener(it) }
|
||||
}
|
||||
listener = null
|
||||
}
|
||||
|
||||
private fun readCurrentState(): ThermalState {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
return mapThermalStatus(powerManager.currentThermalStatus)
|
||||
}
|
||||
return ThermalState.NOMINAL
|
||||
}
|
||||
|
||||
private fun mapThermalStatus(status: Int): ThermalState = when (status) {
|
||||
PowerManager.THERMAL_STATUS_NONE,
|
||||
PowerManager.THERMAL_STATUS_LIGHT,
|
||||
-> ThermalState.NOMINAL
|
||||
PowerManager.THERMAL_STATUS_MODERATE -> ThermalState.FAIR
|
||||
PowerManager.THERMAL_STATUS_SEVERE -> ThermalState.SERIOUS
|
||||
PowerManager.THERMAL_STATUS_CRITICAL,
|
||||
PowerManager.THERMAL_STATUS_EMERGENCY,
|
||||
PowerManager.THERMAL_STATUS_SHUTDOWN,
|
||||
-> ThermalState.CRITICAL
|
||||
else -> ThermalState.NOMINAL
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ThermalMonitor"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.matchlivetv.match_live_tv.core
|
||||
|
||||
/** Stato termico normalizzato (API di sistema, senza temperatura in gradi). */
|
||||
enum class ThermalState {
|
||||
NOMINAL,
|
||||
FAIR,
|
||||
SERIOUS,
|
||||
CRITICAL,
|
||||
;
|
||||
|
||||
val displayLabel: String
|
||||
get() = when (this) {
|
||||
NOMINAL -> "Temperatura OK"
|
||||
FAIR -> "Temperatura alta"
|
||||
SERIOUS -> "Dispositivo caldo"
|
||||
CRITICAL -> "Rischio surriscaldamento"
|
||||
}
|
||||
|
||||
val indicatorSymbol: String
|
||||
get() = when (this) {
|
||||
NOMINAL -> "🟢"
|
||||
FAIR -> "🟡"
|
||||
SERIOUS -> "🟠"
|
||||
CRITICAL -> "🔴"
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
package com.matchlivetv.match_live_tv.core
|
||||
|
||||
import android.util.Log
|
||||
import com.matchlivetv.match_live_tv.streaming.BroadcastConfig
|
||||
import com.matchlivetv.match_live_tv.streaming.LiveBroadcastEngine
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* Coordina monitor termico, messaggi utente e degradazione progressiva bitrate/FPS.
|
||||
*
|
||||
* Politica:
|
||||
* - NOMINAL / FAIR: solo indicatore UI.
|
||||
* - SERIOUS: −25% bitrate, FPS max 24.
|
||||
* - CRITICAL: −50% bitrate, FPS max 20; alert dopo 60s continui.
|
||||
*/
|
||||
class ThermalStateManager(
|
||||
private val monitor: ThermalMonitor,
|
||||
private val engine: LiveBroadcastEngine,
|
||||
) {
|
||||
private val _state = MutableStateFlow(monitor.currentState)
|
||||
val state: StateFlow<ThermalState> = _state.asStateFlow()
|
||||
|
||||
private val _noticeMessage = MutableStateFlow<String?>(null)
|
||||
val noticeMessage: StateFlow<String?> = _noticeMessage.asStateFlow()
|
||||
|
||||
private val _showCriticalAlert = MutableStateFlow(false)
|
||||
val showCriticalAlert: StateFlow<Boolean> = _showCriticalAlert.asStateFlow()
|
||||
|
||||
private var baselineConfig: BroadcastConfig? = null
|
||||
private var criticalSinceMs: Long? = null
|
||||
private var lastAppliedThermal: ThermalState? = null
|
||||
|
||||
fun updateBaseline(config: BroadcastConfig) {
|
||||
baselineConfig = config
|
||||
applyAdaptation(_state.value, force = true)
|
||||
}
|
||||
|
||||
fun start() {
|
||||
monitor.onStateChanged = { next ->
|
||||
val previous = _state.value
|
||||
if (next == previous) return@onStateChanged
|
||||
Log.i(TAG, "[Thermal] $previous → $next")
|
||||
_state.value = next
|
||||
_noticeMessage.value = userNotice(next, previous)
|
||||
applyAdaptation(next, force = false)
|
||||
updateCriticalWatch(next)
|
||||
}
|
||||
monitor.start()
|
||||
_state.value = monitor.currentState
|
||||
applyAdaptation(_state.value, force = true)
|
||||
updateCriticalWatch(_state.value)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
monitor.stop()
|
||||
monitor.onStateChanged = null
|
||||
criticalSinceMs = null
|
||||
_showCriticalAlert.value = false
|
||||
_noticeMessage.value = null
|
||||
lastAppliedThermal = null
|
||||
}
|
||||
|
||||
fun acknowledgeCriticalAlert() {
|
||||
_showCriticalAlert.value = false
|
||||
}
|
||||
|
||||
private fun userNotice(new: ThermalState, previous: ThermalState): String? = when {
|
||||
new == ThermalState.SERIOUS && previous < ThermalState.SERIOUS ->
|
||||
"Il dispositivo si sta scaldando. La qualità dello streaming è stata ridotta per garantire stabilità."
|
||||
new == ThermalState.CRITICAL && previous < ThermalState.CRITICAL ->
|
||||
"Surriscaldamento imminente: qualità ulteriormente ridotta."
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun updateCriticalWatch(state: ThermalState) {
|
||||
if (state == ThermalState.CRITICAL) {
|
||||
if (criticalSinceMs == null) criticalSinceMs = System.currentTimeMillis()
|
||||
val elapsed = System.currentTimeMillis() - (criticalSinceMs ?: return)
|
||||
if (elapsed >= 60_000L) _showCriticalAlert.value = true
|
||||
} else {
|
||||
criticalSinceMs = null
|
||||
_showCriticalAlert.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun tickCriticalWatch() {
|
||||
if (_state.value == ThermalState.CRITICAL) updateCriticalWatch(ThermalState.CRITICAL)
|
||||
}
|
||||
|
||||
private fun applyAdaptation(thermal: ThermalState, force: Boolean) {
|
||||
val baseline = baselineConfig ?: return
|
||||
if (!force && lastAppliedThermal == thermal) return
|
||||
val adapted = adaptedConfig(baseline, thermal)
|
||||
val previousApplied = lastAppliedThermal?.let { adaptedConfig(baseline, it) } ?: baseline
|
||||
engine.applyThermalProfile(adapted.videoBitrate, adapted.fps)
|
||||
lastAppliedThermal = thermal
|
||||
if (adapted.videoBitrate != previousApplied.videoBitrate) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"[Thermal] bitrate changed from ${previousApplied.videoBitrate / 1000} kbps to ${adapted.videoBitrate / 1000} kbps",
|
||||
)
|
||||
}
|
||||
if (adapted.fps != previousApplied.fps) {
|
||||
Log.i(TAG, "[Thermal] fps changed from ${previousApplied.fps} to ${adapted.fps}")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ThermalStateManager"
|
||||
|
||||
fun adaptedConfig(baseline: BroadcastConfig, thermal: ThermalState): BroadcastConfig = when (thermal) {
|
||||
ThermalState.NOMINAL, ThermalState.FAIR -> baseline
|
||||
ThermalState.SERIOUS -> baseline.copy(
|
||||
videoBitrate = (baseline.videoBitrate * 0.75).toInt(),
|
||||
fps = minOf(baseline.fps, 24),
|
||||
)
|
||||
ThermalState.CRITICAL -> baseline.copy(
|
||||
videoBitrate = (baseline.videoBitrate / 2),
|
||||
fps = minOf(baseline.fps, 20),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -264,6 +264,21 @@ class LiveBroadcastEngine(
|
||||
}
|
||||
}
|
||||
|
||||
/** Adattamento termico: riduce bitrate/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
|
||||
config = current.copy(videoBitrate = videoBitrate, fps = fps)
|
||||
genericStream?.let { stream ->
|
||||
runCatching { stream.setVideoBitrateOnFly(videoBitrate) }
|
||||
.onFailure { Log.w(TAG, "applyThermalProfile bitrate: ${it.message}") }
|
||||
runCatching { stream.getGlInterface().setForceRender(true, fps) }
|
||||
.onFailure { Log.w(TAG, "applyThermalProfile fps: ${it.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun obtainStream(cfg: BroadcastConfig): GenericStream {
|
||||
val existing = genericStream
|
||||
if (existing != null) return existing
|
||||
|
||||
+17
-29
@@ -63,7 +63,7 @@ import coil.compose.AsyncImage
|
||||
import com.matchlivetv.match_live_tv.R
|
||||
import com.matchlivetv.match_live_tv.core.resolveMediaUrl
|
||||
import com.matchlivetv.match_live_tv.core.DeviceHealthSnapshot
|
||||
import com.matchlivetv.match_live_tv.core.ThermalLevel
|
||||
import com.matchlivetv.match_live_tv.core.ThermalState
|
||||
import com.matchlivetv.match_live_tv.domain.ScoreState
|
||||
import com.matchlivetv.match_live_tv.ui.components.MatchStatusBadge
|
||||
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
|
||||
@@ -326,29 +326,20 @@ private fun BroadcastTelemetryPanel(
|
||||
style = labelStyle,
|
||||
color = MatchColors.TextSecondary,
|
||||
)
|
||||
ThermalIndicator(
|
||||
tempC = deviceHealth.batteryTempC,
|
||||
level = deviceHealth.thermalLevel,
|
||||
label = deviceHealth.thermalLabel,
|
||||
)
|
||||
ThermalIndicator(state = deviceHealth.thermalState)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThermalIndicator(
|
||||
tempC: Float?,
|
||||
level: ThermalLevel,
|
||||
label: String,
|
||||
) {
|
||||
val color = when (level) {
|
||||
ThermalLevel.NORMAL -> MatchColors.SuccessGreen
|
||||
ThermalLevel.WARM -> MatchColors.AccentYellow
|
||||
ThermalLevel.HOT -> Color(0xFFFF9800)
|
||||
ThermalLevel.CRITICAL -> MatchColors.PrimaryRed
|
||||
private fun ThermalIndicator(state: ThermalState) {
|
||||
val color = when (state) {
|
||||
ThermalState.NOMINAL -> MatchColors.SuccessGreen
|
||||
ThermalState.FAIR -> MatchColors.AccentYellow
|
||||
ThermalState.SERIOUS -> Color(0xFFFF9800)
|
||||
ThermalState.CRITICAL -> MatchColors.PrimaryRed
|
||||
}
|
||||
val tempText = tempC?.let { "${it.toInt()}°C" } ?: "—°C"
|
||||
val warningBg = when (level) {
|
||||
ThermalLevel.NORMAL -> Color.Transparent
|
||||
val warningBg = when (state) {
|
||||
ThermalState.NOMINAL -> Color.Transparent
|
||||
else -> color.copy(alpha = 0.18f)
|
||||
}
|
||||
Row(
|
||||
@@ -360,19 +351,16 @@ private fun ThermalIndicator(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
tempText,
|
||||
state.indicatorSymbol,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
Text(
|
||||
state.displayLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = color,
|
||||
fontWeight = if (level >= ThermalLevel.WARM) FontWeight.Bold else FontWeight.Normal,
|
||||
fontWeight = if (state >= ThermalState.FAIR) FontWeight.Bold else FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
)
|
||||
if (level >= ThermalLevel.WARM) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = color,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+64
-5
@@ -8,11 +8,13 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -32,6 +34,8 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.matchlivetv.match_live_tv.R
|
||||
import com.matchlivetv.match_live_tv.core.DeviceTelemetry
|
||||
import com.matchlivetv.match_live_tv.core.ThermalMonitor
|
||||
import com.matchlivetv.match_live_tv.core.ThermalStateManager
|
||||
import com.matchlivetv.match_live_tv.core.parseColorHex
|
||||
import com.matchlivetv.match_live_tv.core.resolveMediaUrl
|
||||
import com.matchlivetv.match_live_tv.core.RtmpIngestUrl
|
||||
@@ -77,6 +81,13 @@ fun BroadcastScreen(
|
||||
var controlsVisible by remember { mutableStateOf(true) }
|
||||
var logoReady by remember { mutableStateOf(0) }
|
||||
var deviceHealth by remember { mutableStateOf(DeviceTelemetry.snapshot(context)) }
|
||||
val thermalMonitor = remember { ThermalMonitor(context, context.mainExecutor) }
|
||||
val thermalManager = remember {
|
||||
ThermalStateManager(thermalMonitor, container.broadcastCoordinator.engine)
|
||||
}
|
||||
val thermalState by thermalManager.state.collectAsState()
|
||||
val thermalNotice by thermalManager.noticeMessage.collectAsState()
|
||||
val showCriticalThermalAlert by thermalManager.showCriticalAlert.collectAsState()
|
||||
val metrics by container.broadcastCoordinator.metrics.collectAsState()
|
||||
val score by container.scoreController.score.collectAsState()
|
||||
val cableConnected by container.sessionCable.connected.collectAsState()
|
||||
@@ -128,7 +139,9 @@ fun BroadcastScreen(
|
||||
session = updated
|
||||
val url = updated.rtmpIngestUrl
|
||||
if (!url.isNullOrBlank()) {
|
||||
container.broadcastCoordinator.engine.resumeBroadcast(broadcastConfig(updated))
|
||||
val cfg = broadcastConfig(updated)
|
||||
thermalManager.updateBaseline(cfg)
|
||||
container.broadcastCoordinator.engine.resumeBroadcast(cfg)
|
||||
}
|
||||
updated = container.sessionRepository.fetchSession(sessionId)
|
||||
session = updated
|
||||
@@ -157,7 +170,9 @@ fun BroadcastScreen(
|
||||
resumeInFlight = true
|
||||
try {
|
||||
session = current
|
||||
container.broadcastCoordinator.engine.resumeBroadcast(broadcastConfig(current))
|
||||
val cfg = broadcastConfig(current)
|
||||
thermalManager.updateBaseline(cfg)
|
||||
container.broadcastCoordinator.engine.resumeBroadcast(cfg)
|
||||
snackbar.showSnackbar(context.getString(R.string.broadcast_snackbar_resumed_remote))
|
||||
} catch (e: Exception) {
|
||||
snackbar.showSnackbar(
|
||||
@@ -207,7 +222,11 @@ fun BroadcastScreen(
|
||||
if (url.isNullOrBlank()) {
|
||||
error(context.getString(R.string.broadcast_error_rtmp_missing))
|
||||
} else if (!loaded.isPaused) {
|
||||
container.broadcastCoordinator.engine.startBroadcast(broadcastConfig(loaded))
|
||||
val cfg = broadcastConfig(loaded)
|
||||
thermalManager.updateBaseline(cfg)
|
||||
container.broadcastCoordinator.engine.startBroadcast(cfg)
|
||||
} else {
|
||||
thermalManager.updateBaseline(broadcastConfig(loaded))
|
||||
}
|
||||
}.onFailure {
|
||||
error = it.message ?: context.getString(R.string.broadcast_error_session_unavailable)
|
||||
@@ -280,11 +299,23 @@ fun BroadcastScreen(
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
deviceHealth = DeviceTelemetry.snapshot(context)
|
||||
deviceHealth = DeviceTelemetry.snapshot(context, thermalState)
|
||||
delay(2_000)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(thermalNotice) {
|
||||
thermalNotice?.let { snackbar.showSnackbar(it) }
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
thermalManager.start()
|
||||
while (true) {
|
||||
thermalManager.tickCriticalWatch()
|
||||
delay(1_000)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(sessionId) {
|
||||
container.sessionCable.connected.collect { connected ->
|
||||
if (!connected) return@collect
|
||||
@@ -328,6 +359,7 @@ fun BroadcastScreen(
|
||||
|
||||
DisposableEffect(sessionId) {
|
||||
onDispose {
|
||||
thermalManager.stop()
|
||||
container.sessionCable.onScoreUpdate = null
|
||||
container.sessionCable.onPauseStream = null
|
||||
container.sessionCable.onResumeStream = null
|
||||
@@ -545,11 +577,38 @@ fun BroadcastScreen(
|
||||
targetFps = currentSession?.targetFps ?: 30,
|
||||
bitrateKbps = metrics.bitrateKbps,
|
||||
networkType = DeviceTelemetry.networkType(context),
|
||||
deviceHealth = deviceHealth,
|
||||
deviceHealth = DeviceTelemetry.snapshot(context, thermalState),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showCriticalThermalAlert) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { thermalManager.acknowledgeCriticalAlert() },
|
||||
title = { Text("Surriscaldamento") },
|
||||
text = {
|
||||
Text(
|
||||
"Il dispositivo è molto caldo da oltre un minuto. " +
|
||||
"Il sistema potrebbe chiudere l'app per proteggere il dispositivo. " +
|
||||
"Considera di interrompere la diretta.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
thermalManager.acknowledgeCriticalAlert()
|
||||
scope.launch { stopStreamPermanently() }
|
||||
}) {
|
||||
Text("Interrompi diretta", color = MatchColors.PrimaryRed)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { thermalManager.acknowledgeCriticalAlert() }) {
|
||||
Text("Continua")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
SnackbarHost(
|
||||
hostState = snackbar,
|
||||
modifier = Modifier
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.matchlivetv.match_live_tv.core
|
||||
|
||||
import com.matchlivetv.match_live_tv.streaming.BroadcastConfig
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ThermalAdaptationTest {
|
||||
private val baseline = BroadcastConfig(
|
||||
rtmpUrl = "rtmp://localhost/live/key",
|
||||
videoBitrate = 2_500_000,
|
||||
fps = 30,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun seriousReducesBitrateAndFps() {
|
||||
val adapted = ThermalStateManager.adaptedConfig(baseline, ThermalState.SERIOUS)
|
||||
assertEquals(1_875_000, adapted.videoBitrate)
|
||||
assertEquals(24, adapted.fps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun criticalReducesFurther() {
|
||||
val adapted = ThermalStateManager.adaptedConfig(baseline, ThermalState.CRITICAL)
|
||||
assertEquals(1_250_000, adapted.videoBitrate)
|
||||
assertEquals(20, adapted.fps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nominalKeepsBaseline() {
|
||||
val adapted = ThermalStateManager.adaptedConfig(baseline, ThermalState.NOMINAL)
|
||||
assertEquals(baseline.videoBitrate, adapted.videoBitrate)
|
||||
assertEquals(baseline.fps, adapted.fps)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user