Stabilizza regia, live web e sync punteggi app nativa.

Corregge scadenza token regia oltre le 8h, tabellone HTML sulla pagina live,
pulsanti pausa/ripresa in regia, link admin e broadcast ActionCable diretto.
App Android: overlay branding, sync score da regia via WebSocket con reconnect e poll.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-08 09:30:43 +02:00
parent 08e800120a
commit ae36d17adb
55 changed files with 2819 additions and 285 deletions

View File

@@ -12,7 +12,7 @@ android {
applicationId = "com.matchlivetv.match_live_tv"
minSdk = 24
targetSdk = 35
versionCode = 14
versionCode = 15
versionName = "2.0.0-native"
val apiBaseUrl = project.findProperty("API_BASE_URL") as String?
@@ -74,6 +74,8 @@ dependencies {
testImplementation("junit:junit:4.13.2")
implementation("io.coil-kt:coil-compose:2.7.0")
implementation("com.github.pedroSG94.RootEncoder:library:2.5.5")
androidTestImplementation("androidx.test.ext:junit:1.2.1")

View File

@@ -28,6 +28,7 @@
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:screenOrientation="portrait"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">

View File

@@ -0,0 +1,40 @@
package com.matchlivetv.match_live_tv.core
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
private val HEX_PATTERN = Regex("^#?[0-9A-Fa-f]{6}$")
fun normalizeHexColor(input: String?, fallback: String = "#E53935"): String {
val raw = input?.trim().orEmpty()
if (raw.isEmpty()) return fallback
val withHash = if (raw.startsWith("#")) raw else "#$raw"
return if (HEX_PATTERN.matches(withHash)) withHash.uppercase() else fallback
}
fun parseColorHex(hex: String?, fallbackArgb: Int): Int {
val normalized = normalizeHexColor(hex, "")
if (normalized.isEmpty()) return fallbackArgb
return runCatching {
Color(android.graphics.Color.parseColor(normalized)).toArgb()
}.getOrDefault(fallbackArgb)
}
fun colorToHex(color: Color): String {
val argb = color.toArgb()
return String.format("#%06X", 0xFFFFFF and argb)
}
fun hsvToHex(h: Float, s: Float, v: Float): String =
colorToHex(Color(android.graphics.Color.HSVToColor(floatArrayOf(h, s, v))))
private val PLACEHOLDER_TEAM_COLORS = listOf(
"#E53935", "#1E88E5", "#43A047", "#FB8C00",
"#8E24AA", "#00897B", "#5E35B1", "#F4511E",
)
/** Colore decorativo per squadre senza branding completo (logo + colore). */
fun placeholderTeamColor(seed: String): String {
val index = kotlin.math.abs(seed.hashCode()) % PLACEHOLDER_TEAM_COLORS.size
return PLACEHOLDER_TEAM_COLORS[index]
}

View File

@@ -6,16 +6,25 @@ 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,
)
object DeviceTelemetry {
fun batteryLevel(context: Context): Int {
val intent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
?: return 100
val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
if (level < 0 || scale <= 0) return 100
return ((level * 100f) / scale).toInt().coerceIn(0, 100)
}
fun batteryLevel(context: Context): Int = snapshot(context).batteryPercent
fun networkType(context: Context): String = runCatching {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
@@ -28,4 +37,67 @@ object DeviceTelemetry {
else -> "Sconosciuto"
}
}.getOrDefault("Sconosciuto")
fun snapshot(context: Context): DeviceHealthSnapshot {
val batteryIntent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val batteryPercent = readBatteryPercent(batteryIntent)
val batteryTempC = readBatteryTemperatureC(batteryIntent)
val thermalLevel = resolveThermalLevel(context, batteryTempC)
return DeviceHealthSnapshot(
batteryPercent = batteryPercent,
batteryTempC = batteryTempC,
thermalLevel = thermalLevel,
thermalLabel = thermalLabel(thermalLevel),
)
}
private fun readBatteryPercent(intent: Intent?): Int {
if (intent == null) return 100
val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
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"
}
}

View File

@@ -0,0 +1,11 @@
package com.matchlivetv.match_live_tv.core
fun resolveMediaUrl(path: String?): String? {
val value = path?.trim().orEmpty()
if (value.isEmpty()) return null
if (value.startsWith("http://", ignoreCase = true) || value.startsWith("https://", ignoreCase = true)) {
return value
}
val base = AppConfig.apiBaseUrl
return if (value.startsWith("/")) "$base$value" else "$base/$value"
}

View File

@@ -12,6 +12,7 @@ import com.matchlivetv.match_live_tv.data.repository.SessionRepository
import com.matchlivetv.match_live_tv.data.scoring.ScoreController
import com.matchlivetv.match_live_tv.BuildConfig
import com.matchlivetv.match_live_tv.streaming.LiveBroadcastCoordinator
import com.matchlivetv.match_live_tv.streaming.overlay.OverlayLogoCache
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.CoroutineScope
@@ -69,18 +70,19 @@ class AppContainer(context: Context) {
val authRepository = AuthRepository(api, tokenStore) { token ->
accessToken = token
OverlayLogoCache.configure(token)
}
val matchRepository = MatchRepository(api, tokenStore)
val matchRepository = MatchRepository(api, tokenStore, appContext)
val sessionRepository = SessionRepository(api)
val scoreRepository = ScoreRepository(api)
val sessionCable = SessionCableService(moshi)
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val sessionCable = SessionCableService(moshi, appScope)
val scoreController = ScoreController(scoreRepository, sessionCable, appScope)
val wizardSession = WizardSessionHolder()
@@ -88,6 +90,9 @@ class AppContainer(context: Context) {
val broadcastCoordinator = LiveBroadcastCoordinator(appContext)
suspend fun bootstrapAuth() {
authRepository.currentSession()?.let { accessToken = it.accessToken }
authRepository.currentSession()?.let {
accessToken = it.accessToken
OverlayLogoCache.configure(it.accessToken)
}
}
}

View File

@@ -41,12 +41,16 @@ data class TeamDto(
@Json(name = "youtube_connected") val youtubeConnected: Boolean? = false,
@Json(name = "youtube_selectable") val youtubeSelectable: Boolean? = false,
@Json(name = "youtube_channel_title") val youtubeChannelTitle: String? = null,
@Json(name = "youtube_uses_platform_channel") val youtubeUsesPlatformChannel: Boolean? = null,
@Json(name = "youtube_team_channel_title") val youtubeTeamChannelTitle: String? = null,
@Json(name = "plan_name") val planName: String? = null,
@Json(name = "recordings_enabled") val recordingsEnabled: Boolean? = false,
@Json(name = "phone_download_enabled") val phoneDownloadEnabled: Boolean? = false,
@Json(name = "billing_url") val billingUrl: String? = null,
@Json(name = "staff_manage_url") val staffManageUrl: String? = null,
@Json(name = "logo_url") val logoUrl: String? = null,
@Json(name = "primary_color") val primaryColor: String? = null,
@Json(name = "secondary_color") val secondaryColor: String? = null,
) {
fun toDomain() = Team(
id = id,
@@ -54,10 +58,14 @@ data class TeamDto(
sport = sport ?: "volleyball",
canStream = canStream ?: true,
clubName = clubName,
logoUrl = logoUrl,
primaryColor = primaryColor,
secondaryColor = secondaryColor,
youtubeEnabled = youtubeEnabled ?: false,
youtubeConnected = youtubeConnected ?: false,
youtubeSelectable = youtubeSelectable ?: false,
youtubeChannelTitle = youtubeChannelTitle,
youtubeUsesPlatformChannel = youtubeUsesPlatformChannel ?: false,
youtubeTeamChannelTitle = youtubeTeamChannelTitle,
planName = planName,
recordingsEnabled = recordingsEnabled ?: false,
@@ -82,6 +90,11 @@ data class MatchDto(
@Json(name = "active_session_status") val activeSessionStatus: String? = null,
@Json(name = "stream_completed") val streamCompleted: Boolean? = null,
@Json(name = "coach_hub_visible") val coachHubVisible: Boolean? = null,
@Json(name = "home_primary_color") val homePrimaryColor: String? = null,
@Json(name = "home_secondary_color") val homeSecondaryColor: String? = null,
@Json(name = "home_logo_url") val homeLogoUrl: String? = null,
@Json(name = "opponent_primary_color") val opponentPrimaryColor: String? = null,
@Json(name = "opponent_logo_url") val opponentLogoUrl: String? = null,
) {
fun toDomain() = Match(
id = id,
@@ -97,6 +110,11 @@ data class MatchDto(
activeSessionStatus = activeSessionStatus,
streamCompleted = streamCompleted ?: false,
coachHubVisible = coachHubVisible,
homePrimaryColor = homePrimaryColor,
homeSecondaryColor = homeSecondaryColor,
homeLogoUrl = homeLogoUrl,
opponentPrimaryColor = opponentPrimaryColor,
opponentLogoUrl = opponentLogoUrl,
)
}
@@ -218,14 +236,25 @@ data class UpdateMatchBodyFull(
@Json(name = "scheduled_at") val scheduledAt: String? = null,
@Json(name = "sets_to_win") val setsToWin: Int,
val category: String? = null,
@Json(name = "opponent_primary_color") val opponentPrimaryColor: String? = null,
@Json(name = "scoring_rules") val scoringRules: ScoringRulesBody? = null,
)
data class UpdateTeamRequest(val team: UpdateTeamBody)
data class UpdateTeamBody(
@Json(name = "primary_color") val primaryColor: String? = null,
@Json(name = "secondary_color") val secondaryColor: String? = null,
)
data class UpdateMatchRequestFull(val match: UpdateMatchBodyFull)
data class NetworkTestResponse(
val ready: Boolean,
@Json(name = "target_upload_mbps") val targetUploadMbps: Double? = null,
@Json(name = "quality_preset") val qualityPreset: String? = null,
@Json(name = "target_bitrate") val targetBitrate: Int? = null,
@Json(name = "target_fps") val targetFps: Int? = null,
)
data class RecordingDto(

View File

@@ -3,9 +3,12 @@ package com.matchlivetv.match_live_tv.data.api
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
import okhttp3.MultipartBody
interface MatchLiveApi {
@POST("auth/login")
@@ -23,6 +26,24 @@ interface MatchLiveApi {
@GET("teams")
suspend fun teams(): List<TeamDto>
@GET("teams/{teamId}")
suspend fun team(@Path("teamId") teamId: String): TeamDto
@PATCH("teams/{teamId}")
suspend fun updateTeam(
@Path("teamId") teamId: String,
@Body body: UpdateTeamRequest,
): TeamDto
@Multipart
@PATCH("teams/{teamId}")
suspend fun updateTeamMultipart(
@Path("teamId") teamId: String,
@Part("team[primary_color]") primaryColor: okhttp3.RequestBody?,
@Part("team[secondary_color]") secondaryColor: okhttp3.RequestBody?,
@Part logoFile: MultipartBody.Part?,
): TeamDto
@GET("teams/{teamId}/recordings")
suspend fun recordings(@Path("teamId") teamId: String): List<RecordingDto>
@@ -44,6 +65,22 @@ interface MatchLiveApi {
@Body body: UpdateMatchRequestFull,
): MatchDto
@Multipart
@PATCH("matches/{matchId}")
suspend fun updateMatchMultipart(
@Path("matchId") matchId: String,
@Part("match[opponent_name]") opponentName: okhttp3.RequestBody,
@Part("match[location]") location: okhttp3.RequestBody?,
@Part("match[scheduled_at]") scheduledAt: okhttp3.RequestBody?,
@Part("match[sets_to_win]") setsToWin: okhttp3.RequestBody,
@Part("match[category]") category: okhttp3.RequestBody?,
@Part("match[opponent_primary_color]") opponentPrimaryColor: okhttp3.RequestBody?,
@Part("match[scoring_rules][points_per_set]") pointsPerSet: okhttp3.RequestBody?,
@Part("match[scoring_rules][points_deciding_set]") pointsDecidingSet: okhttp3.RequestBody?,
@Part("match[scoring_rules][min_point_lead]") minPointLead: okhttp3.RequestBody?,
@Part opponentLogoFile: MultipartBody.Part?,
): MatchDto
@DELETE("matches/{matchId}")
suspend fun deleteMatch(@Path("matchId") matchId: String)

View File

@@ -0,0 +1,29 @@
package com.matchlivetv.match_live_tv.data.api
import android.content.Context
import android.net.Uri
import android.webkit.MimeTypeMap
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
object MultipartBodies {
fun textPart(value: String) = value.toRequestBody("text/plain".toMediaTypeOrNull())
fun logoPart(context: Context, uri: Uri, fieldName: String): MultipartBody.Part {
val contentResolver = context.applicationContext.contentResolver
val mime = contentResolver.getType(uri) ?: "image/jpeg"
val ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "jpg"
val temp = File.createTempFile("upload_", ".$ext", context.cacheDir)
contentResolver.openInputStream(uri)?.use { input ->
temp.outputStream().use { output -> input.copyTo(output) }
}
return MultipartBody.Part.createFormData(
fieldName,
"logo.$ext",
temp.asRequestBody(mime.toMediaTypeOrNull()),
)
}
}

View File

@@ -116,11 +116,16 @@ class ActionCableClient(
"welcome" -> Unit
else -> {
if (json.has("message")) {
@Suppress("UNCHECKED_CAST")
val message = json.opt("message")
when (message) {
is JSONObject -> listener?.onChannelMessage(messageToMap(message))
is Map<*, *> -> listener?.onChannelMessage(message as Map<String, Any?>)
is String -> runCatching { JSONObject(message) }
.getOrNull()
?.let { listener?.onChannelMessage(messageToMap(it)) }
is Map<*, *> -> {
@Suppress("UNCHECKED_CAST")
listener?.onChannelMessage(message as Map<String, Any?>)
}
}
}
}
@@ -130,11 +135,20 @@ class ActionCableClient(
private fun messageToMap(json: JSONObject): Map<String, Any?> {
val map = mutableMapOf<String, Any?>()
json.keys().forEach { key ->
map[key] = json.get(key)
map[key] = jsonValueToKotlin(json.get(key))
}
return map
}
private fun jsonValueToKotlin(value: Any?): Any? = when (value) {
is JSONObject -> messageToMap(value)
is org.json.JSONArray -> List(value.length()) { index ->
jsonValueToKotlin(value.get(index))
}
JSONObject.NULL -> null
else -> value
}
companion object {
private const val TAG = "ActionCableClient"
}

View File

@@ -1,14 +1,30 @@
package com.matchlivetv.match_live_tv.data.cable
import android.util.Log
import com.matchlivetv.match_live_tv.core.AppConfig
import com.matchlivetv.match_live_tv.domain.ScoreState
import com.squareup.moshi.Moshi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlin.math.min
class SessionCableService(private val moshi: Moshi) {
class SessionCableService(
private val moshi: Moshi,
private val scope: CoroutineScope,
) {
private var client: ActionCableClient? = null
private var reconnectJob: Job? = null
private var reconnectAttempt = 0
private var intentionalDisconnect = false
private var pendingSessionId: String? = null
private var pendingToken: String? = null
private var pendingDeviceRole: String = "camera"
private val _connected = MutableStateFlow(false)
val connected: StateFlow<Boolean> = _connected.asStateFlow()
@@ -16,23 +32,53 @@ class SessionCableService(private val moshi: Moshi) {
var onScoreUpdate: ((ScoreState) -> Unit)? = null
var onPauseStream: (() -> Unit)? = null
var onResumeStream: (() -> Unit)? = null
var onEndStream: (() -> Unit)? = null
fun connect(sessionId: String, accessToken: String, deviceRole: String = "camera") {
disconnect()
client = ActionCableClient(AppConfig.cableUrl, accessToken, moshi).also { cable ->
intentionalDisconnect = false
reconnectJob?.cancel()
reconnectAttempt = 0
pendingSessionId = sessionId
pendingToken = accessToken
pendingDeviceRole = deviceRole
openSocket()
}
fun sendScoreUpdate(score: ScoreState) {
client?.performReceive(score.cablePayload())
}
fun disconnect() {
intentionalDisconnect = true
reconnectJob?.cancel()
reconnectJob = null
pendingSessionId = null
pendingToken = null
client?.disconnect()
client = null
_connected.value = false
}
private fun openSocket() {
val sessionId = pendingSessionId ?: return
val token = pendingToken ?: return
client?.disconnect()
client = ActionCableClient(AppConfig.cableUrl, token, moshi).also { cable ->
cable.connect(
channel = "SessionChannel",
params = mapOf(
"session_id" to sessionId,
"device_role" to deviceRole,
"device_role" to pendingDeviceRole,
),
listener = object : ActionCableClient.Listener {
override fun onConnected() {
reconnectAttempt = 0
_connected.value = true
}
override fun onDisconnected() {
_connected.value = false
scheduleReconnect()
}
override fun onChannelMessage(payload: Map<String, Any?>) {
@@ -43,44 +89,65 @@ class SessionCableService(private val moshi: Moshi) {
}
}
fun sendScoreUpdate(score: ScoreState) {
client?.performReceive(score.cablePayload())
}
fun disconnect() {
client?.disconnect()
client = null
_connected.value = false
private fun scheduleReconnect() {
if (intentionalDisconnect || pendingSessionId == null || pendingToken == null) return
reconnectJob?.cancel()
reconnectJob = scope.launch {
reconnectAttempt += 1
val delayMs = min(30_000L, 1_000L shl min(reconnectAttempt - 1, 4))
Log.i(TAG, "ActionCable reconnect in ${delayMs}ms (attempt $reconnectAttempt)")
delay(delayMs)
if (!intentionalDisconnect) openSocket()
}
}
private fun dispatch(data: Map<String, Any?>) {
when (data["type"] as? String) {
"score_update" -> onScoreUpdate?.invoke(parseScore(data))
"stream_event" -> when (data["event"] as? String) {
val payload = unwrapCablePayload(data)
when (payload["type"] as? String) {
"score_update" -> onScoreUpdate?.invoke(parseScore(payload))
"command" -> when (payload["action"] as? String) {
"pause_stream" -> onPauseStream?.invoke()
"resume_stream" -> onResumeStream?.invoke()
"stop_stream" -> onEndStream?.invoke()
}
"stream_event" -> when (payload["event"] as? String) {
"paused" -> onPauseStream?.invoke()
"resumed" -> onResumeStream?.invoke()
"ended" -> onEndStream?.invoke()
}
}
}
private fun parseScore(data: Map<String, Any?>): ScoreState {
/** Compat: messaggi legacy `{ method: receive_message, data: {...} }`. */
private fun unwrapCablePayload(raw: Map<String, Any?>): Map<String, Any?> {
if (raw["method"] != "receive_message") return raw
val nested = raw["data"] ?: return raw
@Suppress("UNCHECKED_CAST")
val partialsRaw = data["set_partials"] as? List<Map<String, Any?>> ?: emptyList()
return nested as? Map<String, Any?> ?: raw
}
private fun parseScore(data: Map<String, Any?>): ScoreState {
val partialsRaw = data["set_partials"] as? List<*> ?: emptyList<Any>()
return ScoreState(
homeSets = (data["home_sets"] as? Number)?.toInt() ?: 0,
awaySets = (data["away_sets"] as? Number)?.toInt() ?: 0,
homePoints = (data["home_points"] as? Number)?.toInt() ?: 0,
awayPoints = (data["away_points"] as? Number)?.toInt() ?: 0,
currentSet = (data["current_set"] as? Number)?.toInt() ?: 1,
setPartials = partialsRaw.map {
setPartials = partialsRaw.mapNotNull { item ->
val row = item as? Map<*, *> ?: return@mapNotNull null
com.matchlivetv.match_live_tv.domain.SetPartial(
set = (it["set"] as? Number)?.toInt() ?: 1,
home = (it["home"] as? Number)?.toInt() ?: 0,
away = (it["away"] as? Number)?.toInt() ?: 0,
set = (row["set"] as? Number)?.toInt() ?: 1,
home = (row["home"] as? Number)?.toInt() ?: 0,
away = (row["away"] as? Number)?.toInt() ?: 0,
)
},
timeoutHome = data["timeout_home"] as? Boolean ?: false,
timeoutAway = data["timeout_away"] as? Boolean ?: false,
)
}
companion object {
private const val TAG = "SessionCableService"
}
}

View File

@@ -1,13 +1,18 @@
package com.matchlivetv.match_live_tv.data.repository
import android.content.Context
import android.net.Uri
import com.matchlivetv.match_live_tv.core.TokenStore
import com.matchlivetv.match_live_tv.core.parseApiInstant
import com.matchlivetv.match_live_tv.data.api.CreateMatchBody
import com.matchlivetv.match_live_tv.data.api.CreateMatchRequest
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
import com.matchlivetv.match_live_tv.data.api.MultipartBodies
import com.matchlivetv.match_live_tv.data.api.ScoringRulesBody
import com.matchlivetv.match_live_tv.data.api.UpdateMatchBodyFull
import com.matchlivetv.match_live_tv.data.api.UpdateMatchRequestFull
import com.matchlivetv.match_live_tv.data.api.UpdateTeamBody
import com.matchlivetv.match_live_tv.data.api.UpdateTeamRequest
import com.matchlivetv.match_live_tv.domain.Match
import com.matchlivetv.match_live_tv.domain.Recording
import com.matchlivetv.match_live_tv.domain.Team
@@ -18,12 +23,14 @@ import java.time.Instant
class MatchRepository(
private val api: MatchLiveApi,
private val tokenStore: TokenStore,
private val appContext: Context,
) {
suspend fun fetchTeams(): List<Team> =
api.teams().map { it.toDomain() }
suspend fun fetchTeam(teamId: String): Team? =
fetchTeams().firstOrNull { it.id == teamId }
runCatching { api.team(teamId).toDomain() }.getOrNull()
?: fetchTeams().firstOrNull { it.id == teamId }
suspend fun resolveActiveTeam(teams: List<Team>): Team? {
if (teams.isEmpty()) return null
@@ -77,6 +84,31 @@ class MatchRepository(
),
).toDomain()
suspend fun updateTeamBranding(
teamId: String,
primaryColor: String,
secondaryColor: String?,
logoUri: Uri?,
): Team {
if (logoUri != null) {
return api.updateTeamMultipart(
teamId = teamId,
primaryColor = MultipartBodies.textPart(primaryColor),
secondaryColor = secondaryColor?.let { MultipartBodies.textPart(it) },
logoFile = MultipartBodies.logoPart(appContext, logoUri, "logo_file"),
).toDomain()
}
return api.updateTeam(
teamId,
UpdateTeamRequest(
team = UpdateTeamBody(
primaryColor = primaryColor,
secondaryColor = secondaryColor,
),
),
).toDomain()
}
suspend fun updateMatch(
matchId: String,
opponentName: String,
@@ -84,20 +116,39 @@ class MatchRepository(
scheduledAt: String?,
setsToWin: Int,
category: String?,
opponentPrimaryColor: String?,
scoringRules: ScoringRulesBody?,
): Match = api.updateMatch(
matchId,
UpdateMatchRequestFull(
match = UpdateMatchBodyFull(
opponentName = opponentName,
location = location?.takeIf { it.isNotBlank() },
scheduledAt = scheduledAt,
setsToWin = setsToWin,
category = category?.takeIf { it.isNotBlank() },
scoringRules = scoringRules,
),
),
).toDomain()
opponentLogoUri: Uri? = null,
): Match {
val body = UpdateMatchBodyFull(
opponentName = opponentName,
location = location?.takeIf { it.isNotBlank() },
scheduledAt = scheduledAt,
setsToWin = setsToWin,
category = category?.takeIf { it.isNotBlank() },
opponentPrimaryColor = opponentPrimaryColor,
scoringRules = scoringRules,
)
if (opponentLogoUri != null) {
return api.updateMatchMultipart(
matchId = matchId,
opponentName = MultipartBodies.textPart(opponentName),
location = body.location?.let { MultipartBodies.textPart(it) },
scheduledAt = scheduledAt?.let { MultipartBodies.textPart(it) },
setsToWin = MultipartBodies.textPart(setsToWin.toString()),
category = body.category?.let { MultipartBodies.textPart(it) },
opponentPrimaryColor = opponentPrimaryColor?.let { MultipartBodies.textPart(it) },
pointsPerSet = scoringRules?.pointsPerSet?.let { MultipartBodies.textPart(it.toString()) },
pointsDecidingSet = scoringRules?.pointsDecidingSet?.let { MultipartBodies.textPart(it.toString()) },
minPointLead = scoringRules?.minPointLead?.let { MultipartBodies.textPart(it.toString()) },
opponentLogoFile = MultipartBodies.logoPart(appContext, opponentLogoUri, "opponent_logo_file"),
).toDomain()
}
return api.updateMatch(
matchId,
UpdateMatchRequestFull(match = body),
).toDomain()
}
suspend fun deleteMatch(matchId: String) {
api.deleteMatch(matchId)

View File

@@ -35,8 +35,9 @@ class ScoreController(
}
fun applyRemote(remote: ScoreState) {
if (remote == _score.value) return
val now = System.currentTimeMillis()
if (now < suppressRemoteUntilMs && remote.progressKey() < _score.value.progressKey()) {
if (now < suppressRemoteUntilMs && remote.progressKey() <= _score.value.progressKey()) {
return
}
_score.value = remote

View File

@@ -17,11 +17,15 @@ data class Team(
val name: String,
val sport: String,
val clubName: String? = null,
val logoUrl: String? = null,
val primaryColor: String? = null,
val secondaryColor: String? = null,
val canStream: Boolean = true,
val youtubeEnabled: Boolean = false,
val youtubeConnected: Boolean = false,
val youtubeSelectable: Boolean = false,
val youtubeChannelTitle: String? = null,
val youtubeUsesPlatformChannel: Boolean = false,
val youtubeTeamChannelTitle: String? = null,
val planName: String? = null,
val recordingsEnabled: Boolean = false,
@@ -31,6 +35,24 @@ data class Team(
) {
val canUseYoutube: Boolean get() = youtubeEnabled
val isYoutubeReady: Boolean get() = youtubeSelectable
/** Canale YouTube effettivo per la prossima diretta (`platform` | `team`). */
val effectiveYoutubeChannel: String?
get() = if (isYoutubeReady) {
if (youtubeUsesPlatformChannel) "platform" else "team"
} else {
null
}
val youtubeDestinationLabel: String
get() = if (youtubeUsesPlatformChannel) {
"Canale Match Live TV"
} else {
val name = youtubeTeamChannelTitle?.takeIf { it.isNotBlank() }
?: clubName?.takeIf { it.isNotBlank() }
?: "società"
"Canale società · $name"
}
}
data class ScoringRules(
@@ -54,6 +76,11 @@ data class Match(
val activeSessionStatus: String? = null,
val streamCompleted: Boolean = false,
val coachHubVisible: Boolean? = null,
val homePrimaryColor: String? = null,
val homeSecondaryColor: String? = null,
val homeLogoUrl: String? = null,
val opponentPrimaryColor: String? = null,
val opponentLogoUrl: String? = null,
) {
val hasActiveSession: Boolean get() = activeSessionId != null

View File

@@ -28,6 +28,10 @@ data class ScoreState(
},
)
fun progressKey(): Int =
homeSets * 1_000_000 + awaySets * 100_000 + homePoints * 1_000 + awayPoints
fun progressKey(): Long =
currentSet * 10_000_000_000L +
homeSets * 1_000_000L +
awaySets * 100_000L +
homePoints * 1_000L +
awayPoints
}

View File

@@ -0,0 +1,103 @@
package com.matchlivetv.match_live_tv.streaming.overlay
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
/** Cache bitmap loghi squadra per il tabellone overlay. */
object OverlayLogoCache {
private const val TAG = "OverlayLogoCache"
private val cache = mutableMapOf<String, Bitmap>()
private val mutex = Mutex()
@Volatile
private var authToken: String? = null
private val httpClient = OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.followRedirects(true)
.build()
fun configure(authToken: String?) {
this.authToken = authToken
}
fun get(url: String?): Bitmap? {
if (url.isNullOrBlank()) return null
return cache[url]
}
suspend fun preload(context: Context, url: String?, bearerToken: String? = authToken) {
if (url.isNullOrBlank()) return
if (cache.containsKey(url)) return
mutex.withLock {
if (cache.containsKey(url)) return
val bitmap = loadBitmap(context, url, bearerToken) ?: run {
Log.w(TAG, "logo non caricato: $url")
return
}
cache[url] = bitmap
Log.i(TAG, "logo in cache: $url (${bitmap.width}x${bitmap.height})")
}
}
suspend fun preloadAll(
context: Context,
urls: List<String?>,
bearerToken: String? = authToken,
) {
urls.filterNotNull().distinct().forEach { preload(context, it, bearerToken) }
}
fun clear() {
cache.values.forEach { it.recycle() }
cache.clear()
}
private suspend fun loadBitmap(
context: Context,
url: String,
bearerToken: String?,
): Bitmap? = withContext(Dispatchers.IO) {
runCatching {
val request = Request.Builder()
.url(url)
.apply {
bearerToken?.takeIf { it.isNotBlank() }?.let {
header("Authorization", "Bearer $it")
}
}
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
Log.w(TAG, "HTTP ${response.code} per $url")
return@withContext null
}
response.body?.byteStream()?.use { stream ->
BitmapFactory.decodeStream(stream)
}
}
}.getOrNull()?.let { scaleDown(it) }
}
private fun scaleDown(source: Bitmap, maxSide: Int = 128): Bitmap {
val largest = maxOf(source.width, source.height)
if (largest <= maxSide) return source
val scale = maxSide.toFloat() / largest
val w = (source.width * scale).toInt().coerceAtLeast(1)
val h = (source.height * scale).toInt().coerceAtLeast(1)
return Bitmap.createScaledBitmap(source, w, h, true).also {
if (it !== source) source.recycle()
}
}
}

View File

@@ -3,7 +3,14 @@ package com.matchlivetv.match_live_tv.streaming.overlay
import com.matchlivetv.match_live_tv.domain.ScoreState
import com.matchlivetv.match_live_tv.streaming.BroadcastPhase
fun ScoreState.toScoreboardState(homeTeamName: String, awayTeamName: String): ScoreboardState {
fun ScoreState.toScoreboardState(
homeTeamName: String,
awayTeamName: String,
homeAccentColor: Int = 0xFFFF2D2D.toInt(),
awayAccentColor: Int = 0xFF1E3A8A.toInt(),
homeLogoUrl: String? = null,
awayLogoUrl: String? = null,
): ScoreboardState {
val columns = buildList {
setPartials.forEach { partial ->
add(
@@ -28,6 +35,10 @@ fun ScoreState.toScoreboardState(homeTeamName: String, awayTeamName: String): Sc
homeTeamName = homeTeamName,
awayTeamName = awayTeamName,
columns = columns,
homeAccentColor = homeAccentColor,
awayAccentColor = awayAccentColor,
homeLogoUrl = homeLogoUrl,
awayLogoUrl = awayLogoUrl,
)
}

View File

@@ -17,6 +17,8 @@ data class ScoreboardState(
val columns: List<ScoreboardSetColumn>,
val homeAccentColor: Int = 0xFFFF2D2D.toInt(),
val awayAccentColor: Int = 0xFF1E3A8A.toInt(),
val homeLogoUrl: String? = null,
val awayLogoUrl: String? = null,
)
enum class BroadcastOverlayStatus {
@@ -48,6 +50,10 @@ private fun ScoreboardState.fingerprint(): Int {
result = 31 * result + awayTeamName.hashCode()
result = 31 * result + homeAccentColor
result = 31 * result + awayAccentColor
result = 31 * result + (homeLogoUrl?.hashCode() ?: 0)
result = 31 * result + (awayLogoUrl?.hashCode() ?: 0)
result = 31 * result + logoLoadedKey(homeLogoUrl)
result = 31 * result + logoLoadedKey(awayLogoUrl)
columns.forEach { col ->
result = 31 * result + col.setNumber
result = 31 * result + col.homePoints
@@ -56,3 +62,5 @@ private fun ScoreboardState.fingerprint(): Int {
}
return result
}
private fun logoLoadedKey(url: String?): Int = if (OverlayLogoCache.get(url) != null) 1 else 0

View File

@@ -33,6 +33,7 @@ class ScoreboardElement : OverlayElement {
textAlign = Paint.Align.CENTER
}
private val accentBarPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val logoPaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
private val winPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#FF2D2D")
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
@@ -42,7 +43,23 @@ class ScoreboardElement : OverlayElement {
color = Color.parseColor("#CCCCCC")
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL)
}
private val rect = RectF()
private val brandMatchPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
}
private val brandTvPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#9CA3AF")
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
}
private val brandLiveBgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#FF2D2D")
}
private val brandLiveTextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
}
private val boxRect = RectF()
private val liveRect = RectF()
override fun draw(canvas: Canvas, state: OverlayState, layout: OverlayLayout) {
val board = state.scoreboard ?: return
@@ -51,20 +68,24 @@ class ScoreboardElement : OverlayElement {
val scale = layout.canvasHeight / 720f * SIZE_BOOST
val pad = (8f * scale).roundToInt().coerceAtLeast(6)
val colorBarW = (5f * scale).roundToInt().coerceAtLeast(4)
val teamW = (118f * scale).roundToInt()
val logoSize = (24f * scale).roundToInt().coerceAtLeast(16)
val logoGap = (4f * scale).roundToInt().coerceAtLeast(3)
val teamW = (158f * scale).roundToInt()
val colW = (28f * scale).roundToInt().coerceAtLeast(22)
val brandH = (18f * scale).roundToInt().coerceAtLeast(14)
val headH = (16f * scale).roundToInt().coerceAtLeast(12)
val rowH = (20f * scale).roundToInt().coerceAtLeast(16)
val rowH = (22f * scale).roundToInt().coerceAtLeast(18)
val footerH = (18f * scale).roundToInt().coerceAtLeast(14)
val n = board.columns.size
val boxW = pad * 2 + colorBarW + teamW + n * colW
val boxH = pad * 2 + headH + rowH * 2 + footerH
val logoSlotW = logoSlotWidth(board, logoSize, logoGap)
val boxW = pad * 2 + colorBarW + logoSlotW + teamW + n * colW
val boxH = pad * 2 + brandH + headH + rowH * 2 + footerH
val left = layout.marginPx
val top = layout.marginPx
rect.set(left.toFloat(), top.toFloat(), (left + boxW).toFloat(), (top + boxH).toFloat())
canvas.drawRoundRect(rect, 6f * scale, 6f * scale, backgroundPaint)
boxRect.set(left.toFloat(), top.toFloat(), (left + boxW).toFloat(), (top + boxH).toFloat())
canvas.drawRoundRect(boxRect, 6f * scale, 6f * scale, backgroundPaint)
val headerSize = (10f * scale).coerceAtLeast(8f)
val teamSize = (11f * scale).coerceAtLeast(9f)
@@ -76,15 +97,17 @@ class ScoreboardElement : OverlayElement {
winPaint.textSize = scoreSize
footerPaint.textSize = footerSize
val scoresLeft = left + pad + colorBarW + teamW
val headerBaseline = top + pad + headH - (2f * scale)
drawBrandHeader(canvas, left, top, pad, scale, brandH)
val scoresLeft = left + pad + colorBarW + logoSlotW + teamW
val headerBaseline = top + pad + brandH + headH - (2f * scale)
board.columns.forEachIndexed { index, column ->
val cx = scoresLeft + index * colW + colW / 2f
canvas.drawText(column.setNumber.toString(), cx, headerBaseline, headerPaint)
}
val homeRowTop = top + pad + headH
val homeRowTop = top + pad + brandH + headH
val awayRowTop = homeRowTop + rowH
drawTeamRow(
canvas = canvas,
@@ -94,6 +117,9 @@ class ScoreboardElement : OverlayElement {
left = left,
pad = pad,
colorBarW = colorBarW,
logoSize = logoSize,
logoGap = logoGap,
logoSlotW = logoSlotW,
teamW = teamW,
colW = colW,
rowH = rowH,
@@ -108,6 +134,9 @@ class ScoreboardElement : OverlayElement {
left = left,
pad = pad,
colorBarW = colorBarW,
logoSize = logoSize,
logoGap = logoGap,
logoSlotW = logoSlotW,
teamW = teamW,
colW = colW,
rowH = rowH,
@@ -117,8 +146,50 @@ class ScoreboardElement : OverlayElement {
val currentSet = board.columns.lastOrNull { it.isCurrent }?.setNumber ?: board.columns.size
val footerText = ordinalSetLabel(currentSet)
val footerBaseline = rect.bottom - pad - (2f * scale)
canvas.drawText(footerText, rect.left + pad + colorBarW + 4f * scale, footerBaseline, footerPaint)
val footerBaseline = boxRect.bottom - pad - (2f * scale)
canvas.drawText(
footerText,
boxRect.left + pad + colorBarW + 4f * scale,
footerBaseline,
footerPaint,
)
}
private fun logoSlotWidth(board: ScoreboardState, logoSize: Int, logoGap: Int): Int {
val needsSlot = !board.homeLogoUrl.isNullOrBlank() || !board.awayLogoUrl.isNullOrBlank()
return if (needsSlot) logoSize + logoGap else 0
}
private fun drawBrandHeader(
canvas: Canvas,
left: Int,
top: Int,
pad: Int,
scale: Float,
brandH: Int,
) {
val textSize = (9f * scale).coerceAtLeast(7f)
brandMatchPaint.textSize = textSize
brandTvPaint.textSize = textSize
brandLiveTextPaint.textSize = textSize * 0.88f
var x = left + pad.toFloat()
val baseline = top + pad + brandH * 0.72f
canvas.drawText("MATCH", x, baseline, brandMatchPaint)
x += brandMatchPaint.measureText("MATCH") + 4f * scale
val liveText = "LIVE"
val livePadH = 4f * scale
val livePadV = 2f * scale
val liveW = brandLiveTextPaint.measureText(liveText) + livePadH * 2
val liveH = textSize + livePadV * 2
val liveTop = baseline - textSize - livePadV * 0.5f
liveRect.set(x, liveTop, x + liveW, liveTop + liveH)
canvas.drawRoundRect(liveRect, 3f * scale, 3f * scale, brandLiveBgPaint)
canvas.drawText(liveText, x + livePadH, baseline - livePadV * 0.3f, brandLiveTextPaint)
x += liveW + 4f * scale
canvas.drawText("TV", x, baseline, brandTvPaint)
}
private fun drawTeamRow(
@@ -129,6 +200,9 @@ class ScoreboardElement : OverlayElement {
left: Int,
pad: Int,
colorBarW: Int,
logoSize: Int,
logoGap: Int,
logoSlotW: Int,
teamW: Int,
colW: Int,
rowH: Int,
@@ -141,12 +215,25 @@ class ScoreboardElement : OverlayElement {
val barBottom = rowTop + rowH - (2f * scale)
canvas.drawRect(barLeft, barTop, barLeft + colorBarW, barBottom, accentBarPaint)
val logoUrl = if (isHome) board.homeLogoUrl else board.awayLogoUrl
val logo = OverlayLogoCache.get(logoUrl)
var nameLeft = barLeft + colorBarW + logoGap
if (logoSlotW > 0) {
if (logo != null) {
val logoLeft = nameLeft
val logoTop = rowTop + (rowH - logoSize) / 2f
val dest = RectF(logoLeft, logoTop, logoLeft + logoSize, logoTop + logoSize)
canvas.drawBitmap(logo, null, dest, logoPaint)
}
nameLeft += logoSize + logoGap
}
val rawName = if (isHome) board.homeTeamName else board.awayTeamName
val name = abbreviate(rawName).uppercase()
val nameMaxW = teamW - (8f * scale)
val name = rawName.trim().uppercase()
val nameMaxW = teamW - (6f * scale)
val displayName = ellipsize(name, teamPaint, nameMaxW)
val nameBaseline = rowTop + rowH * 0.72f
canvas.drawText(displayName, barLeft + colorBarW + 6f * scale, nameBaseline, teamPaint)
canvas.drawText(displayName, nameLeft, nameBaseline, teamPaint)
board.columns.forEachIndexed { index, column ->
val cx = scoresLeft + index * colW + colW / 2f
@@ -167,14 +254,8 @@ class ScoreboardElement : OverlayElement {
}
}
private fun abbreviate(name: String): String {
val trimmed = name.trim()
if (trimmed.length <= 12) return trimmed
return trimmed.take(11) + ""
}
private fun ellipsize(text: String, paint: TextPaint, maxWidth: Float): String {
val safeMax = max(32f, maxWidth)
val safeMax = max(48f, maxWidth)
return TextUtils.ellipsize(text, paint, safeMax, TextUtils.TruncateAt.END)?.toString() ?: text
}

View File

@@ -0,0 +1,614 @@
package com.matchlivetv.match_live_tv.ui.broadcast
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
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.layout.ContentScale
import coil.compose.AsyncImage
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.domain.ScoreState
import com.matchlivetv.match_live_tv.ui.components.MatchStatusBadge
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
private val SideToolbarWidth = 44.dp
private val IconButtonSize = 36.dp
private val ScoreButtonSize = 32.dp
@Composable
fun BroadcastControlsOverlay(
controlsVisible: Boolean,
onToggleControls: () -> Unit,
statusText: String,
statusColor: Color,
cableConnected: Boolean,
isPaused: Boolean,
homeName: String,
awayName: String,
homeAccentColor: Color,
awayAccentColor: Color,
homeLogoUrl: String?,
awayLogoUrl: String?,
score: ScoreState,
pointsTarget: Int,
onPointHome: () -> Unit,
onPointAway: () -> Unit,
onMinusHome: () -> Unit,
onMinusAway: () -> Unit,
onCloseSet: () -> Unit,
onPauseOrResume: () -> Unit,
onTerminate: () -> Unit,
onShareLive: () -> Unit,
onShareRegia: () -> Unit,
shareLiveEnabled: Boolean,
fps: Int,
targetFps: Int,
bitrateKbps: Long,
networkType: String,
deviceHealth: DeviceHealthSnapshot,
modifier: Modifier = Modifier,
) {
var showTerminateConfirm by remember { mutableStateOf(false) }
if (showTerminateConfirm) {
AlertDialog(
onDismissRequest = { showTerminateConfirm = false },
containerColor = MatchColors.SurfaceElevated,
title = { Text("Terminare la diretta?") },
text = { Text("Lo streaming verrà chiuso per tutti gli spettatori.") },
confirmButton = {
TextButton(
onClick = {
showTerminateConfirm = false
onTerminate()
},
) {
Text("TERMINA", color = MatchColors.PrimaryRed)
}
},
dismissButton = {
TextButton(onClick = { showTerminateConfirm = false }) {
Text("Annulla", color = MatchColors.TextSecondary)
}
},
)
}
Box(modifier.fillMaxSize()) {
MatchStatusBadge(
text = statusText,
textColor = statusColor,
backgroundColor = MatchColors.Background.copy(alpha = 0.78f),
modifier = Modifier
.align(Alignment.TopStart)
.windowInsetsPadding(WindowInsets.statusBars)
.padding(start = 8.dp, top = 4.dp),
)
Column(
Modifier
.align(Alignment.TopEnd)
.windowInsetsPadding(WindowInsets.statusBars)
.padding(top = 4.dp, end = 8.dp),
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
SideIconButton(
icon = if (controlsVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = if (controlsVisible) "Nascondi controlli" else "Mostra controlli",
onClick = onToggleControls,
)
BroadcastTelemetryPanel(
cableConnected = cableConnected,
fps = fps,
targetFps = targetFps,
bitrateKbps = bitrateKbps,
networkType = networkType,
deviceHealth = deviceHealth,
)
}
if (controlsVisible) {
Column(
Modifier
.align(Alignment.CenterStart)
.padding(start = 6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
SideIconButton(
icon = Icons.Default.Share,
contentDescription = "Condividi diretta",
onClick = onShareLive,
enabled = shareLiveEnabled,
)
SideIconButton(
icon = Icons.Default.Videocam,
contentDescription = "Condividi link regia",
onClick = onShareRegia,
)
SideIconButton(
icon = Icons.Default.Check,
contentDescription = "Chiudi set",
onClick = onCloseSet,
)
}
Column(
Modifier
.align(Alignment.CenterEnd)
.padding(end = 6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
SideIconButton(
icon = if (isPaused) Icons.Default.PlayArrow else Icons.Default.Pause,
contentDescription = if (isPaused) "Riprendi diretta" else "Pausa diretta",
onClick = onPauseOrResume,
highlighted = isPaused,
)
SideIconButton(
icon = Icons.Default.Stop,
contentDescription = "Termina diretta",
onClick = { showTerminateConfirm = true },
danger = true,
)
}
Row(
Modifier
.align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.navigationBars)
.padding(start = SideToolbarWidth, end = SideToolbarWidth, bottom = 8.dp),
verticalAlignment = Alignment.Bottom,
) {
TeamScoreColumn(
teamLabel = "CASA",
teamName = homeName,
accentColor = homeAccentColor,
logoUrl = homeLogoUrl,
points = score.homePoints,
onPlus = onPointHome,
onMinus = onMinusHome,
alignEnd = false,
modifier = Modifier.weight(1f),
)
Column(
Modifier
.padding(horizontal = 12.dp)
.padding(bottom = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"${score.homeSets} - ${score.awaySets}",
style = MaterialTheme.typography.titleMedium,
color = Color.White,
fontWeight = FontWeight.Bold,
)
Text(
"Set ${score.currentSet} · $pointsTarget pt",
style = MaterialTheme.typography.labelSmall,
color = MatchColors.TextSecondary,
)
}
TeamScoreColumn(
teamLabel = "OSPITE",
teamName = awayName,
accentColor = awayAccentColor,
logoUrl = awayLogoUrl,
points = score.awayPoints,
onPlus = onPointAway,
onMinus = onMinusAway,
alignEnd = true,
modifier = Modifier.weight(1f),
)
}
}
}
}
@Composable
private fun BroadcastTelemetryPanel(
cableConnected: Boolean,
fps: Int,
targetFps: Int,
bitrateKbps: Long,
networkType: String,
deviceHealth: DeviceHealthSnapshot,
modifier: Modifier = Modifier,
) {
val labelStyle = MaterialTheme.typography.labelSmall
Column(
modifier
.clip(RoundedCornerShape(8.dp))
.background(MatchColors.Background.copy(alpha = 0.78f))
.padding(horizontal = 10.dp, vertical = 6.dp),
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
if (cableConnected) "Tabellone OK" else "Tabellone offline",
style = labelStyle,
color = if (cableConnected) MatchColors.SuccessGreen else MatchColors.TextSecondary,
)
val fpsLabel = if (fps > 0) "$fps fps" else "— fps"
val targetSuffix = if (targetFps > 0) " / $targetFps" else ""
Text(
fpsLabel + targetSuffix,
style = labelStyle,
color = Color.White,
)
if (bitrateKbps > 0) {
Text(
formatBitrateKbps(bitrateKbps),
style = labelStyle,
color = MatchColors.TextSecondary,
)
}
Text(
"${networkType} · ${deviceHealth.batteryPercent}%",
style = labelStyle,
color = MatchColors.TextSecondary,
)
ThermalIndicator(
tempC = deviceHealth.batteryTempC,
level = deviceHealth.thermalLevel,
label = deviceHealth.thermalLabel,
)
}
}
@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
}
val tempText = tempC?.let { "${it.toInt()}°C" } ?: "—°C"
val warningBg = when (level) {
ThermalLevel.NORMAL -> Color.Transparent
else -> color.copy(alpha = 0.18f)
}
Row(
Modifier
.clip(RoundedCornerShape(4.dp))
.background(warningBg)
.padding(horizontal = 4.dp, vertical = 1.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
tempText,
style = MaterialTheme.typography.labelSmall,
color = color,
fontWeight = if (level >= ThermalLevel.WARM) FontWeight.Bold else FontWeight.Normal,
)
if (level >= ThermalLevel.WARM) {
Text(
label,
style = MaterialTheme.typography.labelSmall,
color = color,
fontWeight = FontWeight.Bold,
)
}
}
}
private fun formatBitrateKbps(kbps: Long): String {
if (kbps >= 1000) return "${"%.1f".format(kbps / 1000.0)} Mbps"
return "$kbps kbps"
}
@Composable
private fun TeamScoreColumn(
teamLabel: String,
teamName: String,
accentColor: Color,
logoUrl: String?,
points: Int,
onPlus: () -> Unit,
onMinus: () -> Unit,
alignEnd: Boolean,
modifier: Modifier = Modifier,
) {
Column(
modifier
.clip(RoundedCornerShape(10.dp))
.background(MatchColors.Background.copy(alpha = 0.72f))
.padding(horizontal = 10.dp, vertical = 8.dp),
horizontalAlignment = if (alignEnd) Alignment.End else Alignment.Start,
) {
TeamIdentityRow(
teamLabel = teamLabel,
teamName = teamName,
accentColor = accentColor,
logoUrl = logoUrl,
alignEnd = alignEnd,
)
Spacer(Modifier.height(6.dp))
Text(
"$points",
style = MaterialTheme.typography.headlineMedium,
color = accentColor,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 4.dp),
)
Spacer(Modifier.height(4.dp))
val teamSide = if (alignEnd) "ospite" else "casa"
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (alignEnd) Arrangement.End else Arrangement.Start,
) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
if (alignEnd) {
ScoreIconButton(
label = "+1",
tooltip = "Aggiungi punto $teamSide",
onClick = onPlus,
primary = true,
)
ScoreIconButton(label = "", tooltip = "Togli punto $teamSide", onClick = onMinus)
} else {
ScoreIconButton(label = "", tooltip = "Togli punto $teamSide", onClick = onMinus)
ScoreIconButton(
label = "+1",
tooltip = "Aggiungi punto $teamSide",
onClick = onPlus,
primary = true,
)
}
}
}
}
}
@Composable
private fun TeamIdentityRow(
teamLabel: String,
teamName: String,
accentColor: Color,
logoUrl: String?,
alignEnd: Boolean,
) {
val resolvedLogoUrl = resolveMediaUrl(logoUrl)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = if (alignEnd) Arrangement.End else Arrangement.Start,
) {
if (alignEnd) {
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.End,
) {
Text(
teamLabel,
style = MaterialTheme.typography.labelSmall,
color = MatchColors.TextSecondary,
letterSpacing = 1.sp,
)
Text(
teamName,
style = MaterialTheme.typography.labelMedium,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.End,
)
}
if (!resolvedLogoUrl.isNullOrBlank()) {
Spacer(Modifier.width(8.dp))
TeamLogoThumbnail(resolvedLogoUrl)
}
Spacer(Modifier.width(6.dp))
TeamColorBar(accentColor)
} else {
TeamColorBar(accentColor)
if (!resolvedLogoUrl.isNullOrBlank()) {
Spacer(Modifier.width(6.dp))
TeamLogoThumbnail(resolvedLogoUrl)
}
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
teamLabel,
style = MaterialTheme.typography.labelSmall,
color = MatchColors.TextSecondary,
letterSpacing = 1.sp,
)
Text(
teamName,
style = MaterialTheme.typography.labelMedium,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@Composable
private fun TeamColorBar(color: Color) {
Box(
Modifier
.width(4.dp)
.height(36.dp)
.clip(RoundedCornerShape(2.dp))
.background(color),
)
}
@Composable
private fun TeamLogoThumbnail(url: String) {
Box(
Modifier
.size(32.dp)
.clip(RoundedCornerShape(6.dp))
.background(MatchColors.SurfaceElevated)
.border(1.dp, MatchColors.Outline, RoundedCornerShape(6.dp)),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = url,
contentDescription = null,
modifier = Modifier.size(32.dp),
contentScale = ContentScale.Crop,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SideIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
highlighted: Boolean = false,
danger: Boolean = false,
) {
val bg = when {
danger -> MatchColors.PrimaryRed.copy(alpha = 0.88f)
highlighted -> MatchColors.SuccessGreen.copy(alpha = 0.55f)
else -> MatchColors.Background.copy(alpha = 0.78f)
}
TooltipBox(
modifier = modifier,
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
tooltip = {
PlainTooltip {
Text(contentDescription)
}
},
state = rememberTooltipState(),
) {
Box(
Modifier
.size(IconButtonSize)
.clip(RoundedCornerShape(8.dp))
.background(bg)
.clickable(enabled = enabled, onClick = onClick),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = if (enabled) Color.White else MatchColors.TextSecondary,
modifier = Modifier.size(20.dp),
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ScoreIconButton(
label: String,
tooltip: String,
onClick: () -> Unit,
primary: Boolean = false,
) {
TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
tooltip = {
PlainTooltip {
Text(tooltip)
}
},
state = rememberTooltipState(),
) {
Box(
Modifier
.size(width = if (label == "+1") 40.dp else ScoreButtonSize, height = ScoreButtonSize)
.clip(RoundedCornerShape(6.dp))
.background(if (primary) MatchColors.PrimaryRed else MatchColors.SurfaceElevated)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Text(
label,
style = MaterialTheme.typography.labelMedium,
color = Color.White,
)
}
}
}
fun shareBroadcastLink(context: Context, url: String, subject: String) {
context.startActivity(
Intent.createChooser(
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, url)
putExtra(Intent.EXTRA_SUBJECT, subject)
},
"Condividi",
),
)
}
fun copyBroadcastLink(context: Context, url: String) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("link", url))
}

View File

@@ -1,25 +1,18 @@
package com.matchlivetv.match_live_tv.ui.broadcast
import android.view.ViewGroup
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -32,11 +25,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.matchlivetv.match_live_tv.core.DeviceTelemetry
import com.matchlivetv.match_live_tv.core.parseColorHex
import com.matchlivetv.match_live_tv.core.resolveMediaUrl
import com.matchlivetv.match_live_tv.data.AppContainer
import com.matchlivetv.match_live_tv.domain.Match
import com.matchlivetv.match_live_tv.domain.MatchScoringRules
@@ -44,25 +38,26 @@ import com.matchlivetv.match_live_tv.domain.StreamSession
import com.matchlivetv.match_live_tv.streaming.BroadcastConfig
import com.matchlivetv.match_live_tv.streaming.BroadcastPhase
import com.matchlivetv.match_live_tv.streaming.LivePreviewView
import com.matchlivetv.match_live_tv.streaming.overlay.OverlayLogoCache
import com.matchlivetv.match_live_tv.streaming.overlay.OverlayState
import com.matchlivetv.match_live_tv.streaming.overlay.toOverlayStatus
import com.matchlivetv.match_live_tv.streaming.overlay.toScoreboardState
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold
import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton
import com.matchlivetv.match_live_tv.ui.components.MatchStatusBadge
import com.matchlivetv.match_live_tv.ui.permissions.rememberBroadcastPermissionsState
import com.matchlivetv.match_live_tv.ui.system.KeepScreenOnEffect
import com.matchlivetv.match_live_tv.ui.system.LockLandscapeOrientationEffect
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BroadcastScreen(
container: AppContainer,
sessionId: String,
onFinished: () -> Unit,
) {
LockLandscapeOrientationEffect()
KeepScreenOnEffect()
val scope = rememberCoroutineScope()
val context = LocalContext.current
val snackbar = remember { SnackbarHostState() }
@@ -72,6 +67,8 @@ fun BroadcastScreen(
var loading by remember { mutableStateOf(true) }
var pauseInFlight by remember { mutableStateOf(false) }
var resumeInFlight by remember { mutableStateOf(false) }
var controlsVisible by remember { mutableStateOf(true) }
var deviceHealth by remember { mutableStateOf(DeviceTelemetry.snapshot(context)) }
val metrics by container.broadcastCoordinator.metrics.collectAsState()
val score by container.scoreController.score.collectAsState()
val cableConnected by container.sessionCable.connected.collectAsState()
@@ -105,9 +102,9 @@ fun BroadcastScreen(
val updated = container.sessionRepository.pauseSession(sessionId)
session = updated
container.broadcastCoordinator.engine.pauseBroadcast()
snackbar.showSnackbar("Diretta in pausa — gli spettatori vedono la copertina")
snackbar.showSnackbar("Diretta in pausa")
} catch (e: Exception) {
snackbar.showSnackbar("Pausa sessione: ${e.message ?: "errore"}")
snackbar.showSnackbar("Pausa: ${e.message ?: "errore"}")
} finally {
pauseInFlight = false
}
@@ -125,9 +122,9 @@ fun BroadcastScreen(
}
updated = container.sessionRepository.fetchSession(sessionId)
session = updated
snackbar.showSnackbar("Diretta ripresa — di nuovo in onda")
snackbar.showSnackbar("Diretta ripresa")
} catch (e: Exception) {
snackbar.showSnackbar("Ripresa diretta: ${e.message ?: "errore"}")
snackbar.showSnackbar("Ripresa: ${e.message ?: "errore"}")
} finally {
resumeInFlight = false
}
@@ -135,18 +132,21 @@ fun BroadcastScreen(
suspend fun applyRemotePause() {
container.broadcastCoordinator.engine.pauseBroadcast()
session = session?.copy(status = "paused")
snackbar.showSnackbar("Pausa dalla regia — trasmissione fermata")
session = runCatching { container.sessionRepository.fetchSession(sessionId) }
.getOrNull() ?: session?.copy(status = "paused")
snackbar.showSnackbar("Pausa dalla regia")
}
suspend fun applyRemoteResume() {
if (resumeInFlight) return
val current = session ?: return
val current = runCatching { container.sessionRepository.fetchSession(sessionId) }
.getOrNull() ?: session ?: return
val url = current.rtmpIngestUrl ?: return
resumeInFlight = true
try {
session = current
container.broadcastCoordinator.engine.resumeBroadcast(broadcastConfig(current))
snackbar.showSnackbar("Ripresa dalla regia — di nuovo in onda")
snackbar.showSnackbar("Ripresa dalla regia")
} catch (e: Exception) {
snackbar.showSnackbar("Ripresa RTMP: ${e.message ?: "errore"}")
} finally {
@@ -154,6 +154,14 @@ fun BroadcastScreen(
}
}
suspend fun applyRemoteStop() {
container.sessionCable.disconnect()
container.broadcastCoordinator.engine.stopBroadcast()
container.broadcastCoordinator.stopService()
snackbar.showSnackbar("Diretta chiusa dalla regia")
onFinished()
}
LaunchedEffect(sessionId, permissions.granted) {
if (!permissions.granted) return@LaunchedEffect
loading = true
@@ -175,6 +183,9 @@ fun BroadcastScreen(
container.sessionCable.onResumeStream = {
if (!resumeInFlight) scope.launch { applyRemoteResume() }
}
container.sessionCable.onEndStream = {
scope.launch { applyRemoteStop() }
}
container.sessionCable.connect(sessionId, token, deviceRole = "camera")
}
container.broadcastCoordinator.startService()
@@ -192,15 +203,62 @@ fun BroadcastScreen(
LaunchedEffect(score, match, metrics.phase) {
val currentMatch = match ?: return@LaunchedEffect
val authToken = container.authRepository.currentSession()?.accessToken
val homeLogoUrl = resolveMediaUrl(currentMatch.homeLogoUrl)
val awayLogoUrl = resolveMediaUrl(currentMatch.opponentLogoUrl)
OverlayLogoCache.preloadAll(
context,
listOf(homeLogoUrl, awayLogoUrl),
bearerToken = authToken,
)
container.broadcastCoordinator.updateOverlay(
OverlayState(
scoreboard = score.toScoreboardState(currentMatch.teamName, currentMatch.opponentName),
scoreboard = score.toScoreboardState(
homeTeamName = currentMatch.teamName,
awayTeamName = currentMatch.opponentName,
homeAccentColor = parseColorHex(currentMatch.homePrimaryColor, 0xFFFF2D2D.toInt()),
awayAccentColor = parseColorHex(
currentMatch.opponentPrimaryColor,
0xFF1E3A8A.toInt(),
),
homeLogoUrl = homeLogoUrl,
awayLogoUrl = awayLogoUrl,
),
watermarkVisible = true,
broadcastStatus = metrics.phase.toOverlayStatus(),
),
)
}
LaunchedEffect(Unit) {
while (true) {
deviceHealth = DeviceTelemetry.snapshot(context)
delay(2_000)
}
}
LaunchedEffect(sessionId) {
container.sessionCable.connected.collect { connected ->
if (!connected) return@collect
runCatching {
container.sessionRepository.fetchSession(sessionId).score
}.getOrNull()?.let { remote ->
container.scoreController.applyRemote(remote)
}
}
}
LaunchedEffect(sessionId) {
while (true) {
delay(4_000)
runCatching {
container.sessionRepository.fetchSession(sessionId).score
}.getOrNull()?.let { remote ->
container.scoreController.applyRemote(remote)
}
}
}
LaunchedEffect(sessionId) {
while (true) {
delay(10_000)
@@ -222,8 +280,10 @@ fun BroadcastScreen(
DisposableEffect(sessionId) {
onDispose {
container.sessionCable.onScoreUpdate = null
container.sessionCable.onPauseStream = null
container.sessionCable.onResumeStream = null
container.sessionCable.onEndStream = null
container.sessionCable.disconnect()
container.broadcastCoordinator.stopService()
container.broadcastCoordinator.engine.stopBroadcast()
@@ -246,156 +306,144 @@ fun BroadcastScreen(
else -> MatchColors.AccentYellow
}
val pointsTarget = scoringRules?.pointsTarget(score.currentSet) ?: 25
val currentSession = session
val currentMatch = match
val shareUrl = currentSession?.watchShareUrl()
val shareSubject = currentMatch?.let { "${it.teamName} vs ${it.opponentName}" } ?: "Diretta Match Live TV"
match?.let { currentMatch ->
currentMatch?.let { m ->
ScoreDialogRouter(
host = dialogHost,
homeName = currentMatch.teamName,
awayName = currentMatch.opponentName,
homeName = m.teamName,
awayName = m.opponentName,
)
}
MatchScreenScaffold(
topBar = {
Column {
TopAppBar(
title = {
Column {
Text("Diretta", style = MaterialTheme.typography.titleMedium)
Text(
if (cableConnected) "Tabellone collegato" else "Tabellone offline",
style = MaterialTheme.typography.labelSmall,
color = if (cableConnected) MatchColors.SuccessGreen else MatchColors.TextSecondary,
)
}
},
navigationIcon = {
IconButton(onClick = onFinished) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Indietro",
tint = Color.White,
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MatchColors.Background,
titleContentColor = Color.White,
),
)
SnackbarHost(hostState = snackbar)
}
},
Box(
Modifier
.fillMaxSize()
.background(Color.Black),
) {
Column(Modifier.fillMaxSize()) {
Box(
Modifier
.weight(1f)
.fillMaxWidth(),
) {
if (loading) {
CircularProgressIndicator(
color = MatchColors.PrimaryRed,
modifier = Modifier.align(Alignment.Center),
)
} else {
CameraPreviewPanel(container)
}
MatchStatusBadge(
text = statusText,
textColor = statusColor,
backgroundColor = MatchColors.Background.copy(alpha = 0.75f),
modifier = Modifier
.align(Alignment.TopStart)
.padding(16.dp),
when {
loading -> {
CircularProgressIndicator(
color = MatchColors.PrimaryRed,
modifier = Modifier.align(Alignment.Center),
)
if (isPaused && !loading) {
MatchPrimaryButton(
label = "RIPRENDI DIRETTA",
onClick = { scope.launch { resumeStream() } },
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(16.dp),
)
}
}
Column(
Modifier
.weight(1f)
.verticalScroll(rememberScrollState()),
) {
error?.let {
!permissions.granted -> {
Column(
Modifier
.align(Alignment.Center)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
it,
color = MatchColors.PrimaryRed,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
match?.let { currentMatch ->
scoreActions?.let { actions ->
LiveScoreControls(
homeName = currentMatch.teamName,
awayName = currentMatch.opponentName,
score = score,
pointsTarget = pointsTarget,
onPointHome = {
scope.launch {
container.scoreController.incrementHome()
actions.afterPointChange(
container.scoreController.score.value,
) { stopStreamPermanently() }
}
},
onPointAway = {
scope.launch {
container.scoreController.incrementAway()
actions.afterPointChange(
container.scoreController.score.value,
) { stopStreamPermanently() }
}
},
onMinusHome = { container.scoreController.decrementHome() },
onMinusAway = { container.scoreController.decrementAway() },
onCloseSet = {
scope.launch {
actions.requestCloseSet { stopStreamPermanently() }
}
},
)
}
}
if (!permissions.granted) {
Text(
"Consenti accesso a camera e microfono per andare in diretta",
"Consenti camera e microfono per andare in diretta",
color = MatchColors.AccentYellow,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
Spacer(Modifier.height(12.dp))
MatchPrimaryButton(
label = "CONCEDI PERMESSI",
onClick = permissions.request,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
if (isPaused) {
MatchPrimaryButton(
label = "RIPRENDI DIRETTA",
onClick = { scope.launch { resumeStream() } },
modifier = Modifier.padding(horizontal = 16.dp),
)
} else if (!loading && session != null) {
MatchSecondaryButton(
label = "PAUSA DIRETTA",
onClick = { scope.launch { pauseStream() } },
modifier = Modifier.padding(horizontal = 16.dp),
)
}
MatchPrimaryButton(
label = "TERMINA DIRETTA",
onClick = { scope.launch { stopStreamPermanently() } },
modifier = Modifier.padding(16.dp),
)
}
else -> {
CameraPreviewPanel(container)
}
}
error?.let {
Text(
it,
color = MatchColors.PrimaryRed,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.align(Alignment.Center)
.padding(24.dp),
)
}
if (!loading && permissions.granted && currentMatch != null && scoreActions != null) {
BroadcastControlsOverlay(
controlsVisible = controlsVisible,
onToggleControls = { controlsVisible = !controlsVisible },
statusText = statusText,
statusColor = statusColor,
cableConnected = cableConnected,
isPaused = isPaused,
homeName = currentMatch.teamName,
awayName = currentMatch.opponentName,
homeAccentColor = Color(parseColorHex(currentMatch.homePrimaryColor, 0xFFFF2D2D.toInt())),
awayAccentColor = Color(parseColorHex(currentMatch.opponentPrimaryColor, 0xFF1E3A8A.toInt())),
homeLogoUrl = currentMatch.homeLogoUrl,
awayLogoUrl = currentMatch.opponentLogoUrl,
score = score,
pointsTarget = pointsTarget,
onPointHome = {
scope.launch {
container.scoreController.incrementHome()
scoreActions.afterPointChange(container.scoreController.score.value) {
stopStreamPermanently()
}
}
},
onPointAway = {
scope.launch {
container.scoreController.incrementAway()
scoreActions.afterPointChange(container.scoreController.score.value) {
stopStreamPermanently()
}
}
},
onMinusHome = { container.scoreController.decrementHome() },
onMinusAway = { container.scoreController.decrementAway() },
onCloseSet = {
scope.launch {
scoreActions.requestCloseSet { stopStreamPermanently() }
}
},
onPauseOrResume = {
scope.launch {
if (isPaused) resumeStream() else pauseStream()
}
},
onTerminate = { scope.launch { stopStreamPermanently() } },
onShareLive = {
val url = shareUrl
if (url.isNullOrBlank()) {
scope.launch { snackbar.showSnackbar("Link diretta non ancora disponibile") }
} else {
shareBroadcastLink(context, url, shareSubject)
}
},
onShareRegia = {
scope.launch {
runCatching { container.sessionRepository.createRegiaLink(sessionId) }
.onSuccess { url ->
shareBroadcastLink(context, url, "Link regia — $shareSubject")
}
.onFailure {
snackbar.showSnackbar(it.message ?: "Errore link regia")
}
}
},
shareLiveEnabled = !shareUrl.isNullOrBlank(),
fps = metrics.fps,
targetFps = currentSession?.targetFps ?: 30,
bitrateKbps = metrics.bitrateKbps,
networkType = DeviceTelemetry.networkType(context),
deviceHealth = deviceHealth,
)
}
SnackbarHost(
hostState = snackbar,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 48.dp),
)
}
}

View File

@@ -0,0 +1,17 @@
package com.matchlivetv.match_live_tv.ui.system
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalView
/** Impedisce allo schermo di andare in standby durante la diretta. */
@Composable
fun KeepScreenOnEffect() {
val view = LocalView.current
DisposableEffect(Unit) {
view.keepScreenOn = true
onDispose {
view.keepScreenOn = false
}
}
}

View File

@@ -0,0 +1,31 @@
package com.matchlivetv.match_live_tv.ui.system
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.pm.ActivityInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalContext
private fun Context.findActivity(): Activity? {
var ctx: Context = this
while (ctx is ContextWrapper) {
if (ctx is Activity) return ctx
ctx = ctx.baseContext
}
return null
}
/** Blocca in orizzontale (es. schermata diretta). Al termine torna al portrait. */
@Composable
fun LockLandscapeOrientationEffect() {
val context = LocalContext.current
DisposableEffect(Unit) {
val activity = context.findActivity() ?: return@DisposableEffect onDispose {}
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
onDispose {
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
}
}

View File

@@ -1,5 +1,9 @@
package com.matchlivetv.match_live_tv.ui.wizard
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -13,6 +17,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -22,9 +27,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.core.normalizeHexColor
import com.matchlivetv.match_live_tv.data.AppContainer
import com.matchlivetv.match_live_tv.data.api.ScoringRulesBody
import com.matchlivetv.match_live_tv.domain.Match
import com.matchlivetv.match_live_tv.domain.Team
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton
import com.matchlivetv.match_live_tv.ui.matches.MatchOutlinedField
@@ -36,6 +43,7 @@ private const val DEFAULT_SETS_TO_WIN = 3
private const val DEFAULT_POINTS_PER_SET = 25
private const val DEFAULT_POINTS_DECIDING_SET = 15
private const val DEFAULT_MIN_POINT_LEAD = 2
private const val DEFAULT_OPPONENT_COLOR = "#1E3A8A"
@Composable
fun StepMatchScreen(
@@ -51,6 +59,37 @@ fun StepMatchScreen(
var location by remember { mutableStateOf(match.location.orEmpty()) }
var campionato by remember { mutableStateOf(match.category.orEmpty()) }
var homeTeam by remember { mutableStateOf<Team?>(null) }
var homeLogoUrl by remember { mutableStateOf(match.homeLogoUrl) }
var opponentLogoUrl by remember { mutableStateOf(match.opponentLogoUrl) }
var homePrimaryColor by remember {
mutableStateOf(normalizeHexColor(match.homePrimaryColor))
}
var opponentPrimaryColor by remember {
mutableStateOf(normalizeHexColor(match.opponentPrimaryColor, DEFAULT_OPPONENT_COLOR))
}
var homeLogoUri by remember { mutableStateOf<Uri?>(null) }
var opponentLogoUri by remember { mutableStateOf<Uri?>(null) }
LaunchedEffect(match.teamId) {
runCatching { container.matchRepository.fetchTeam(match.teamId) }
.onSuccess { team ->
homeTeam = team
if (team != null) {
homePrimaryColor = normalizeHexColor(team.primaryColor ?: match.homePrimaryColor)
homeLogoUrl = team.logoUrl ?: match.homeLogoUrl
}
}
}
val pickHomeLogo = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia(),
) { uri -> homeLogoUri = uri }
val pickOpponentLogo = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia(),
) { uri -> opponentLogoUri = uri }
val existingRules = match.scoringRules
var customRules by remember {
mutableStateOf(existingRules != null || match.setsToWin != DEFAULT_SETS_TO_WIN)
@@ -74,10 +113,38 @@ fun StepMatchScreen(
Text("Dettagli partita", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(20.dp))
WizardReadOnlyField(label = "Squadra casa", value = match.teamName)
Spacer(Modifier.height(12.dp))
MatchOutlinedField(value = opponent, onValueChange = { opponent = it }, label = "Avversario")
Spacer(Modifier.height(12.dp))
TeamBrandingRow(
sectionLabel = "Squadra di casa",
teamName = match.teamName,
nameEditable = false,
onTeamNameChange = {},
remoteLogoUrl = homeLogoUrl,
localLogoUri = homeLogoUri,
primaryColorHex = homePrimaryColor,
placeholderColorSeed = "home-${match.teamId}",
onPrimaryColorChange = { homePrimaryColor = it },
onPickLogo = {
pickHomeLogo.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
},
onClearLogo = { homeLogoUri = null },
)
Spacer(Modifier.height(16.dp))
TeamBrandingRow(
sectionLabel = "Squadra avversaria",
teamName = opponent,
nameEditable = true,
onTeamNameChange = { opponent = it },
remoteLogoUrl = opponentLogoUrl,
localLogoUri = opponentLogoUri,
primaryColorHex = opponentPrimaryColor,
placeholderColorSeed = "away-${match.id}",
onPrimaryColorChange = { opponentPrimaryColor = it },
onPickLogo = {
pickOpponentLogo.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
},
onClearLogo = { opponentLogoUri = null },
)
Spacer(Modifier.height(16.dp))
MatchOutlinedField(value = location, onValueChange = { location = it }, label = "Luogo")
Spacer(Modifier.height(12.dp))
MatchOutlinedField(
@@ -202,9 +269,23 @@ fun StepMatchScreen(
)
}
val initialHomeColor = normalizeHexColor(
homeTeam?.primaryColor ?: match.homePrimaryColor,
)
val homeBrandingChanged =
homePrimaryColor != initialHomeColor || homeLogoUri != null
saving = true
scope.launch {
runCatching {
if (homeBrandingChanged) {
container.matchRepository.updateTeamBranding(
teamId = match.teamId,
primaryColor = homePrimaryColor,
secondaryColor = homeTeam?.secondaryColor,
logoUri = homeLogoUri,
)
}
container.matchRepository.updateMatch(
matchId = match.id,
opponentName = opponent.trim(),
@@ -212,7 +293,9 @@ fun StepMatchScreen(
scheduledAt = match.scheduledAt,
setsToWin = resolvedSets,
category = campionato.trim(),
opponentPrimaryColor = opponentPrimaryColor,
scoringRules = resolvedRules,
opponentLogoUri = opponentLogoUri,
)
}.onSuccess { updated ->
container.wizardSession.match = updated

View File

@@ -56,6 +56,7 @@ fun StepNetworkTestScreen(
var uploadMbps by remember { mutableStateOf(0.0) }
var latencyMs by remember { mutableStateOf(0) }
var networkType by remember { mutableStateOf("") }
var selectedQualityLabel by remember { mutableStateOf<String?>(null) }
var starting by remember { mutableStateOf(false) }
var currentSession by remember { mutableStateOf(session) }
@@ -92,6 +93,14 @@ fun StepNetworkTestScreen(
networkType = networkType,
)
}.getOrNull()
if (result != null) {
selectedQualityLabel = formatQualityLabel(result)
runCatching { container.sessionRepository.fetchSession(currentSession.id) }
.onSuccess { updated ->
currentSession = updated
container.wizardSession.setSession(updated, match)
}
}
ready = result?.ready ?: (uploadMbps >= 2.0)
testCompleted = true
testing = false
@@ -141,6 +150,13 @@ fun StepNetworkTestScreen(
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
selectedQualityLabel?.let { quality ->
Spacer(Modifier.height(12.dp))
WizardReadOnlyField(
label = "Qualità streaming (automatica)",
value = quality,
)
}
}
if (testCompleted && shareUrl != null) {
Spacer(Modifier.height(16.dp))
@@ -240,3 +256,13 @@ private fun copyToClipboard(context: Context, text: String) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("link", text))
}
private fun formatQualityLabel(result: com.matchlivetv.match_live_tv.data.api.NetworkTestResponse): String {
val bitrateMbps = (result.targetBitrate ?: 2_500_000) / 1_000_000.0
val fps = result.targetFps ?: 30
val resolution = when {
result.qualityPreset?.startsWith("1080p") == true -> "1080p"
else -> "720p"
}
return "$resolution · ${fps}fps · ${"%.1f".format(bitrateMbps)} Mbps"
}

View File

@@ -42,7 +42,6 @@ fun StepTransmissionScreen(
var loadingTeam by remember { mutableStateOf(true) }
var platform by remember { mutableStateOf("matchlivetv") }
var privacy by remember { mutableStateOf("public") }
var youtubeChannel by remember { mutableStateOf<String?>("platform") }
var creating by remember { mutableStateOf(false) }
LaunchedEffect(match.teamId) {
@@ -56,7 +55,14 @@ fun StepTransmissionScreen(
return
}
val youtubeReady = team?.isYoutubeReady == true
val loadedTeam = team
val youtubeReady = loadedTeam?.isYoutubeReady == true
val youtubeSubtitle = when {
loadedTeam == null -> "Canale in attivazione"
!loadedTeam.canUseYoutube -> "Premium Light o Full"
!loadedTeam.isYoutubeReady -> "Canale in attivazione"
else -> loadedTeam.youtubeDestinationLabel
}
Column(
Modifier
@@ -67,6 +73,7 @@ fun StepTransmissionScreen(
Text(
"Piano $plan",
style = MaterialTheme.typography.bodyMedium,
color = MatchColors.TextSecondary,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
@@ -83,18 +90,13 @@ fun StepTransmissionScreen(
Spacer(Modifier.height(8.dp))
WizardPlatformCard(
title = "YouTube Live",
subtitle = when {
team?.canUseYoutube != true -> "Premium Light o Full"
!youtubeReady -> "Canale in attivazione"
else -> "Canale YouTube collegato"
},
subtitle = youtubeSubtitle,
selected = platform == "youtube",
enabled = youtubeReady,
badge = if (team?.canUseYoutube != true) "Premium" else null,
onClick = {
if (youtubeReady) {
platform = "youtube"
youtubeChannel = "platform"
} else {
onError("YouTube non disponibile per questa squadra")
}
@@ -104,13 +106,15 @@ fun StepTransmissionScreen(
Text("Visibilità", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)) {
MatchSecondaryButton(
WizardChoiceButton(
label = "PUBBLICO",
selected = privacy == "public",
onClick = { privacy = "public" },
modifier = Modifier.weight(1f),
)
MatchSecondaryButton(
WizardChoiceButton(
label = "NON IN ELENCO",
selected = privacy == "unlisted",
onClick = { privacy = "unlisted" },
modifier = Modifier.weight(1f),
)
@@ -118,16 +122,19 @@ fun StepTransmissionScreen(
Spacer(Modifier.height(8.dp))
Text(
if (privacy == "public") {
"Compare nell'elenco dirette e sul canale scelto."
"Compare nell'elenco dirette su MatchLiveTV.it, nelle ricerche e sul canale YouTube (se selezionato)."
} else {
"Solo chi ha il link."
"Non compare negli elenchi pubblici né nelle ricerche. Solo chi ha il link può guardare."
},
style = MaterialTheme.typography.bodyMedium,
color = MatchColors.TextSecondary,
)
Text(
"La partita resta sempre visibile nel backend della squadra per tutta la durata dell'abbonamento.",
style = MaterialTheme.typography.bodyMedium,
color = MatchColors.TextSecondary,
modifier = Modifier.padding(top = 6.dp),
)
Spacer(Modifier.height(24.dp))
Text("Qualità", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(12.dp))
WizardReadOnlyField(label = "Preset", value = "720p · 30fps · 2.5 Mbps")
Spacer(Modifier.height(32.dp))
Row {
MatchSecondaryButton(
@@ -147,7 +154,7 @@ fun StepTransmissionScreen(
matchId = match.id,
platform = platform,
privacyStatus = privacy,
youtubeChannel = if (platform == "youtube") youtubeChannel else null,
youtubeChannel = if (platform == "youtube") loadedTeam?.effectiveYoutubeChannel else null,
)
}.onSuccess { session ->
container.wizardSession.setSession(session, match)

View File

@@ -0,0 +1,377 @@
package com.matchlivetv.match_live_tv.ui.wizard
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.matchlivetv.match_live_tv.core.parseColorHex
import com.matchlivetv.match_live_tv.core.placeholderTeamColor
import com.matchlivetv.match_live_tv.core.resolveMediaUrl
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
@Composable
fun TeamBrandingRow(
sectionLabel: String,
teamName: String,
nameEditable: Boolean,
onTeamNameChange: (String) -> Unit,
remoteLogoUrl: String?,
localLogoUri: Uri?,
primaryColorHex: String,
placeholderColorSeed: String,
onPrimaryColorChange: (String) -> Unit,
onPickLogo: () -> Unit,
onClearLogo: () -> Unit,
modifier: Modifier = Modifier,
) {
val hasLogo = localLogoUri != null || !remoteLogoUrl.isNullOrBlank()
val hasColor = primaryColorHex.isNotBlank()
val isConfigured = hasLogo && hasColor
val placeholderColor = remember(placeholderColorSeed) { placeholderTeamColor(placeholderColorSeed) }
val displayColorHex = primaryColorHex.ifBlank { placeholderColor }
val displayColor = Color(parseColorHex(displayColorHex, 0xFFE53935.toInt()))
var menuExpanded by remember { mutableStateOf(false) }
var showCustomize by remember { mutableStateOf(false) }
Column(modifier.fillMaxWidth()) {
Text(
sectionLabel,
style = MaterialTheme.typography.labelLarge,
color = MatchColors.TextSecondary,
)
Spacer(Modifier.height(8.dp))
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MatchColors.Surface)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
if (isConfigured) {
TeamLogoImage(
remoteLogoUrl = remoteLogoUrl,
localLogoUri = localLogoUri,
size = 44.dp,
)
ColorAccentBar(color = displayColor, height = 36.dp)
Text(
text = teamName.ifBlank { "Squadra" },
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
} else {
ColorAccentBar(color = displayColor, height = 32.dp)
if (nameEditable) {
InlineTeamNameField(
value = teamName,
onValueChange = onTeamNameChange,
placeholder = "Nome avversario",
modifier = Modifier.weight(1f),
)
} else {
Text(
text = teamName.ifBlank { "Squadra" },
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
Box {
IconButton(onClick = { menuExpanded = true }) {
Icon(
Icons.Default.MoreVert,
contentDescription = "Modifica squadra",
tint = MatchColors.TextSecondary,
)
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false },
) {
DropdownMenuItem(
text = { Text("Personalizza") },
onClick = {
menuExpanded = false
showCustomize = true
},
)
}
}
}
}
if (showCustomize) {
TeamBrandingCustomizeSheet(
title = sectionLabel,
teamName = teamName,
nameEditable = nameEditable,
remoteLogoUrl = remoteLogoUrl,
localLogoUri = localLogoUri,
primaryColorHex = primaryColorHex,
fallbackColorHex = placeholderColor,
onDismiss = { showCustomize = false },
onConfirm = { name, color ->
if (nameEditable) onTeamNameChange(name)
onPrimaryColorChange(color)
showCustomize = false
},
onPickLogo = onPickLogo,
onClearLogo = onClearLogo,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TeamBrandingCustomizeSheet(
title: String,
teamName: String,
nameEditable: Boolean,
remoteLogoUrl: String?,
localLogoUri: Uri?,
primaryColorHex: String,
fallbackColorHex: String,
onDismiss: () -> Unit,
onConfirm: (name: String, colorHex: String) -> Unit,
onPickLogo: () -> Unit,
onClearLogo: () -> Unit,
) {
val initialColor = primaryColorHex.ifBlank { fallbackColorHex }
val pickerInitialColor = remember { initialColor }
var draftName by remember { mutableStateOf(teamName) }
var draftColor by remember { mutableStateOf(initialColor) }
val hasLogo = localLogoUri != null || !remoteLogoUrl.isNullOrBlank()
ModalBottomSheet(
onDismissRequest = onDismiss,
containerColor = MatchColors.SurfaceElevated,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp)
.padding(bottom = 32.dp),
) {
Text("Personalizza", style = MaterialTheme.typography.headlineSmall)
Text(
title,
style = MaterialTheme.typography.bodyMedium,
color = MatchColors.TextSecondary,
)
Spacer(Modifier.height(20.dp))
if (nameEditable) {
Text("Nome squadra", style = MaterialTheme.typography.labelLarge)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = draftName,
onValueChange = { draftName = it },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Nome avversario", color = MatchColors.TextSecondary) },
colors = outlinedFieldColors(),
)
Spacer(Modifier.height(20.dp))
} else {
Text("Nome squadra", style = MaterialTheme.typography.labelLarge)
Spacer(Modifier.height(4.dp))
Text(teamName, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(20.dp))
}
Text("Logo", style = MaterialTheme.typography.labelLarge)
Spacer(Modifier.height(10.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
TeamLogoPreview(
remoteLogoUrl = remoteLogoUrl,
localLogoUri = localLogoUri,
size = 72.dp,
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
MatchSecondaryButton(label = "CARICA LOGO", onClick = onPickLogo)
if (hasLogo) {
MatchSecondaryButton(label = "RIMUOVI", onClick = onClearLogo)
}
}
}
Spacer(Modifier.height(20.dp))
Text("Colore squadra", style = MaterialTheme.typography.labelLarge)
Spacer(Modifier.height(12.dp))
TeamColorPickerPanel(
initialColorHex = pickerInitialColor,
onColorChange = { draftColor = it },
)
Spacer(Modifier.height(24.dp))
MatchPrimaryButton(
label = "SALVA",
onClick = { onConfirm(draftName.trim(), draftColor.trim()) },
)
Spacer(Modifier.height(8.dp))
MatchSecondaryButton(label = "ANNULLA", onClick = onDismiss)
}
}
}
@Composable
private fun TeamLogoPreview(
remoteLogoUrl: String?,
localLogoUri: Uri?,
size: androidx.compose.ui.unit.Dp,
) {
Box(
Modifier
.size(size)
.clip(RoundedCornerShape(12.dp))
.background(MatchColors.Surface)
.border(1.dp, MatchColors.Outline, RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center,
) {
when {
localLogoUri != null -> AsyncImage(
model = localLogoUri,
contentDescription = null,
modifier = Modifier.size(size),
contentScale = ContentScale.Crop,
)
!remoteLogoUrl.isNullOrBlank() -> AsyncImage(
model = resolveMediaUrl(remoteLogoUrl),
contentDescription = null,
modifier = Modifier.size(size),
contentScale = ContentScale.Crop,
)
else -> Text("Nessun logo", style = MaterialTheme.typography.labelMedium, color = MatchColors.TextSecondary)
}
}
}
@Composable
private fun TeamLogoImage(
remoteLogoUrl: String?,
localLogoUri: Uri?,
size: androidx.compose.ui.unit.Dp,
) {
Box(
Modifier
.size(size)
.clip(RoundedCornerShape(8.dp))
.background(MatchColors.SurfaceElevated)
.border(1.dp, MatchColors.Outline, RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
when {
localLogoUri != null -> AsyncImage(
model = localLogoUri,
contentDescription = null,
modifier = Modifier.size(size),
contentScale = ContentScale.Crop,
)
!remoteLogoUrl.isNullOrBlank() -> AsyncImage(
model = resolveMediaUrl(remoteLogoUrl),
contentDescription = null,
modifier = Modifier.size(size),
contentScale = ContentScale.Crop,
)
}
}
}
@Composable
private fun ColorAccentBar(
color: Color,
height: androidx.compose.ui.unit.Dp,
) {
Box(
Modifier
.width(5.dp)
.height(height)
.clip(RoundedCornerShape(2.dp))
.background(color),
)
}
@Composable
private fun InlineTeamNameField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
modifier: Modifier = Modifier,
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
placeholder = {
Text(placeholder, color = MatchColors.TextSecondary)
},
modifier = modifier,
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge,
colors = outlinedFieldColors(),
)
}
@Composable
private fun outlinedFieldColors() = OutlinedTextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedBorderColor = MatchColors.PrimaryRed,
unfocusedBorderColor = MatchColors.Outline,
focusedLabelColor = MatchColors.PrimaryRed,
unfocusedLabelColor = MatchColors.TextSecondary,
cursorColor = MatchColors.PrimaryRed,
)

View File

@@ -0,0 +1,190 @@
package com.matchlivetv.match_live_tv.ui.wizard
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.core.hsvToHex
import com.matchlivetv.match_live_tv.core.parseColorHex
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
@Composable
fun TeamColorPickerPanel(
initialColorHex: String,
onColorChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val initialArgb = parseColorHex(initialColorHex, 0xFFE53935.toInt())
val initialHsv = remember(initialColorHex) {
FloatArray(3).also { android.graphics.Color.colorToHSV(initialArgb, it) }
}
var hue by remember(initialColorHex) { mutableFloatStateOf(initialHsv[0]) }
var saturation by remember(initialColorHex) { mutableFloatStateOf(initialHsv[1]) }
var brightness by remember(initialColorHex) { mutableFloatStateOf(initialHsv[2]) }
val selectedColor = remember(hue, saturation, brightness) {
Color(android.graphics.Color.HSVToColor(floatArrayOf(hue, saturation, brightness)))
}
fun emitColor(h: Float, s: Float, v: Float) {
onColorChange(hsvToHex(h, s, v))
}
Column(
modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Box(
Modifier
.size(56.dp)
.clip(CircleShape)
.background(selectedColor)
.border(2.dp, MatchColors.Outline, CircleShape),
)
SaturationValuePicker(
hue = hue,
saturation = saturation,
brightness = brightness,
onChange = { s, v ->
saturation = s
brightness = v
emitColor(hue, s, v)
},
)
HueSlider(
hue = hue,
onHueChange = { newHue ->
hue = newHue
emitColor(newHue, saturation, brightness)
},
)
}
}
@Composable
private fun SaturationValuePicker(
hue: Float,
saturation: Float,
brightness: Float,
onChange: (saturation: Float, brightness: Float) -> Unit,
) {
val baseHue = Color(android.graphics.Color.HSVToColor(floatArrayOf(hue, 1f, 1f)))
Box(
Modifier
.fillMaxWidth()
.height(140.dp)
.clip(RoundedCornerShape(10.dp))
.border(1.dp, MatchColors.Outline, RoundedCornerShape(10.dp))
.pointerInput(hue) {
detectTapGestures { offset ->
onChange(
(offset.x / size.width).coerceIn(0f, 1f),
1f - (offset.y / size.height).coerceIn(0f, 1f),
)
}
}
.pointerInput(hue) {
detectDragGestures { change, _ ->
change.consume()
onChange(
(change.position.x / size.width).coerceIn(0f, 1f),
1f - (change.position.y / size.height).coerceIn(0f, 1f),
)
}
},
) {
Canvas(Modifier.fillMaxSize()) {
drawRect(Color.White)
drawRect(Brush.horizontalGradient(listOf(Color.White, baseHue)))
drawRect(Brush.verticalGradient(listOf(Color.Transparent, Color.Black)))
val cx = saturation * size.width
val cy = (1f - brightness) * size.height
drawCircle(
color = Color.White,
radius = 10f,
center = Offset(cx, cy),
style = Stroke(width = 3f),
)
drawCircle(
color = Color.Black.copy(alpha = 0.35f),
radius = 10f,
center = Offset(cx, cy),
style = Stroke(width = 1.5f),
)
}
}
}
@Composable
private fun HueSlider(
hue: Float,
onHueChange: (Float) -> Unit,
) {
Column(Modifier.fillMaxWidth()) {
Text(
"Tonalità",
style = MaterialTheme.typography.labelMedium,
color = MatchColors.TextSecondary,
)
Spacer(Modifier.height(4.dp))
Box(
Modifier
.fillMaxWidth()
.height(16.dp)
.clip(RoundedCornerShape(8.dp))
.background(
Brush.horizontalGradient(
listOf(
Color.Red,
Color.Yellow,
Color.Green,
Color.Cyan,
Color.Blue,
Color.Magenta,
Color.Red,
),
),
),
)
Slider(
value = hue,
onValueChange = onHueChange,
valueRange = 0f..360f,
colors = SliderDefaults.colors(
thumbColor = Color.White,
activeTrackColor = Color.Transparent,
inactiveTrackColor = Color.Transparent,
),
)
}
}

View File

@@ -21,6 +21,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
private val stepTitles = listOf(
@@ -54,6 +56,20 @@ fun WizardStepIndicator(currentStep: Int, modifier: Modifier = Modifier) {
fun wizardStepTitle(step: Int): String =
stepTitles[(step.coerceIn(1, 3) - 1)]
@Composable
fun WizardChoiceButton(
label: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
if (selected) {
MatchPrimaryButton(label = label, onClick = onClick, modifier = modifier)
} else {
MatchSecondaryButton(label = label, onClick = onClick, modifier = modifier)
}
}
@Composable
fun WizardReadOnlyField(label: String, value: String) {
Column(