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:
Emiliano Frascaro
2026-07-24 11:19:37 +02:00
co-authored by Cursor
parent ff4de33cc5
commit ea5da1eb86
46 changed files with 2675 additions and 1241 deletions
@@ -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"
}
}
@@ -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 -> "🔴"
}
}
@@ -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),
)
}
}
}
@@ -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
@@ -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,
)
}
}
}
@@ -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
@@ -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)
}
}