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:
@@ -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")
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user