Completa app Android nativa e rimuove il client Flutter.

Il monorepo punta a native/android (login, wizard, diretta RTMP, tabellone WebSocket, pausa/telemetria) con encode 16:9 e orientamento via sensore; eliminati mobile/ e gli script Flutter obsoleti.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-06 17:38:56 +02:00
parent 499aee8988
commit 68b4390282
262 changed files with 4860 additions and 15162 deletions

View File

@@ -0,0 +1,163 @@
package com.matchlivetv.match_live_tv
import android.content.Intent
import android.view.KeyEvent
import android.os.SystemClock
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.Until
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
/**
* E2E sul simulatore: login → nuova partita → wizard 3 step → schermata diretta.
*/
@RunWith(AndroidJUnit4::class)
class E2EWizardFlowTest {
private lateinit var device: UiDevice
private val pkg = "com.matchlivetv.match_live_tv"
@Before
fun setUp() {
device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
grantRuntimePermissions()
launchApp()
}
@Test
fun login_newMatch_wizard_reachesBroadcastScreen() {
waitForAnyText("Email", "ACCEDI", timeoutMs = 45_000)
fillLogin()
waitForText("NUOVA PARTITA", timeoutMs = 45_000)
tapClickableText("NUOVA PARTITA")
waitForText("Avvia subito", timeoutMs = 15_000)
tapClickableText("Avvia subito")
waitForText("01 · Partita", timeoutMs = 45_000)
scrollDown()
tapClickableText("AVANTI >")
waitForText("02 · Trasmissione", timeoutMs = 45_000)
waitForText("Piattaforma", timeoutMs = 30_000)
scrollDown()
tapClickableText("AVANTI >")
waitForText("03 · Test rete", timeoutMs = 45_000)
waitForText("AVVIA TEST RETE", timeoutMs = 30_000)
tapClickableText("AVVIA TEST RETE")
waitForText("INIZIA >", timeoutMs = 30_000)
waitUntilEnabled("INIZIA >", timeoutMs = 25_000)
scrollDown()
tapClickableText("INIZIA >")
val diretta = waitForText("Diretta", timeoutMs = 60_000)
assertNotNull(diretta)
assertNotNull(waitForText("TERMINA DIRETTA", timeoutMs = 30_000))
assertNotNull(waitForText("CHIUDI SET", timeoutMs = 20_000))
}
private fun grantRuntimePermissions() {
listOf(
"android.permission.CAMERA",
"android.permission.RECORD_AUDIO",
"android.permission.POST_NOTIFICATIONS",
).forEach { permission ->
device.executeShellCommand("pm grant $pkg $permission")
}
}
private fun launchApp() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val intent = context.packageManager.getLaunchIntentForPackage(pkg)?.apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
} ?: error("Launch intent mancante per $pkg")
context.startActivity(intent)
device.wait(Until.hasObject(By.pkg(pkg).depth(0)), 15_000)
}
private fun fillLogin() {
val fields = device.wait(Until.findObjects(By.clazz("android.widget.EditText")), 15_000)
if (fields.size < 2) error("Campi login non trovati (${fields.size})")
pasteIntoField(fields[0], "coach@matchlivetv.test")
pasteIntoField(fields[1], "password123")
device.pressKeyCode(KeyEvent.KEYCODE_ENTER)
device.waitForIdle()
}
private fun pasteIntoField(field: UiObject2, value: String) {
field.click()
device.waitForIdle()
field.clear()
field.text = value
device.waitForIdle()
}
private fun waitForText(text: String, timeoutMs: Long): UiObject2 {
val obj = device.wait(Until.findObject(By.text(text)), timeoutMs)
assertNotNull("Testo non trovato entro ${timeoutMs}ms: $text", obj)
return obj!!
}
private fun waitForAnyText(vararg texts: String, timeoutMs: Long) {
val deadline = SystemClock.elapsedRealtime() + timeoutMs
while (SystemClock.elapsedRealtime() < deadline) {
for (text in texts) {
if (device.hasObject(By.text(text))) return
}
SystemClock.sleep(250)
}
error("Nessuno dei testi trovato: ${texts.joinToString()}")
}
private fun tapClickableText(text: String) {
device.findObject(By.text(text).clickable(true))?.let { node ->
node.click()
device.waitForIdle()
return
}
val label = device.wait(Until.findObject(By.text(text)), 15_000)
?: error("Testo non trovato: $text")
var node: UiObject2? = label
for (i in 0 until 6) {
val current = node ?: break
if (current.isClickable) {
current.click()
device.waitForIdle()
return
}
node = current.parent
}
label.click()
device.waitForIdle()
}
private fun waitUntilEnabled(text: String, timeoutMs: Long) {
val deadline = SystemClock.elapsedRealtime() + timeoutMs
while (SystemClock.elapsedRealtime() < deadline) {
val label = device.findObject(By.text(text))
if (label != null) {
var node: UiObject2? = label
for (i in 0 until 6) {
val current = node ?: break
if (current.isClickable && current.isEnabled) return
node = current.parent
}
}
SystemClock.sleep(500)
}
error("Pulsante non abilitato entro timeout: $text")
}
private fun scrollDown(steps: Int = 2) {
val centerX = device.displayWidth / 2
val startY = (device.displayHeight * 0.75).toInt()
val endY = (device.displayHeight * 0.25).toInt()
repeat(steps) {
device.swipe(centerX, startY, centerX, endY, 24)
device.waitForIdle()
SystemClock.sleep(300)
}
}
}

View File

@@ -0,0 +1,43 @@
package com.matchlivetv.match_live_tv
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.matchlivetv.match_live_tv.data.AppContainer
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ReleaseApiSmokeTest {
private lateinit var container: AppContainer
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
container = AppContainer(context)
}
@Test
fun login_parsesResponse() = runBlocking {
val session = container.authRepository.login(
email = "coach@matchlivetv.test",
password = "password123",
)
assertEquals("coach@matchlivetv.test", session.user.email)
assertTrue(session.accessToken.isNotBlank())
}
@Test
fun fetchMatches_afterLogin() = runBlocking {
container.authRepository.login(
email = "coach@matchlivetv.test",
password = "password123",
)
val matches = container.matchRepository.fetchMatches()
assertTrue(matches.isNotEmpty())
}
}

View File

@@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
@@ -16,7 +17,7 @@
<application
android:name=".MatchLiveTvApplication"
android:allowBackup="true"
android:icon="@drawable/ic_launcher"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.MatchLiveTv"

View File

@@ -15,6 +15,7 @@ class MainActivity : ComponentActivity() {
override fun onConfigurationChanged(newConfig: Configuration) {
container.broadcastCoordinator.pauseForConfigurationChange()
super.onConfigurationChanged(newConfig)
container.broadcastCoordinator.resumeAfterConfigurationChange()
}
override fun onCreate(savedInstanceState: Bundle?) {

View File

@@ -0,0 +1,31 @@
package com.matchlivetv.match_live_tv.core
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.BatteryManager
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 networkType(context: Context): String = runCatching {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = cm.activeNetwork ?: return "Sconosciuto"
val caps = cm.getNetworkCapabilities(network) ?: return "Sconosciuto"
when {
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "WiFi"
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "4G"
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "Ethernet"
else -> "Sconosciuto"
}
}.getOrDefault("Sconosciuto")
}

View File

@@ -4,13 +4,19 @@ import android.content.Context
import com.matchlivetv.match_live_tv.core.AppConfig
import com.matchlivetv.match_live_tv.core.TokenStore
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
import com.matchlivetv.match_live_tv.data.cable.SessionCableService
import com.matchlivetv.match_live_tv.data.repository.AuthRepository
import com.matchlivetv.match_live_tv.data.repository.MatchRepository
import com.matchlivetv.match_live_tv.data.repository.ScoreRepository
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.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.Dispatchers
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
@@ -69,6 +75,16 @@ class AppContainer(context: Context) {
val sessionRepository = SessionRepository(api)
val scoreRepository = ScoreRepository(api)
val sessionCable = SessionCableService(moshi)
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val scoreController = ScoreController(scoreRepository, sessionCable, appScope)
val wizardSession = WizardSessionHolder()
val broadcastCoordinator = LiveBroadcastCoordinator(appContext)
suspend fun bootstrapAuth() {

View File

@@ -0,0 +1,20 @@
package com.matchlivetv.match_live_tv.data
import com.matchlivetv.match_live_tv.domain.Match
import com.matchlivetv.match_live_tv.domain.StreamSession
/** Stato condiviso del wizard setup (equivalente a sessionProvider Flutter). */
class WizardSessionHolder {
var match: Match? = null
var session: StreamSession? = null
fun setSession(value: StreamSession, matchValue: Match? = null) {
session = value
matchValue?.let { match = it }
}
fun clear() {
match = null
session = null
}
}

View File

@@ -2,6 +2,7 @@ package com.matchlivetv.match_live_tv.data.api
import com.matchlivetv.match_live_tv.domain.AuthTokens
import com.matchlivetv.match_live_tv.domain.Match
import com.matchlivetv.match_live_tv.domain.Recording
import com.matchlivetv.match_live_tv.domain.StreamSession
import com.matchlivetv.match_live_tv.domain.Team
import com.matchlivetv.match_live_tv.domain.User
@@ -34,7 +35,15 @@ data class TeamDto(
val name: String,
val sport: String? = null,
@Json(name = "can_stream") val canStream: Boolean? = true,
@Json(name = "club_name") val clubName: String? = null,
@Json(name = "youtube_enabled") val youtubeEnabled: Boolean? = false,
@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_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,
) {
@@ -43,7 +52,15 @@ data class TeamDto(
name = name,
sport = sport ?: "volleyball",
canStream = canStream ?: true,
clubName = clubName,
youtubeEnabled = youtubeEnabled ?: false,
youtubeConnected = youtubeConnected ?: false,
youtubeSelectable = youtubeSelectable ?: false,
youtubeChannelTitle = youtubeChannelTitle,
youtubeTeamChannelTitle = youtubeTeamChannelTitle,
planName = planName,
recordingsEnabled = recordingsEnabled ?: false,
phoneDownloadEnabled = phoneDownloadEnabled ?: false,
billingUrl = billingUrl,
staffManageUrl = staffManageUrl,
)
@@ -56,6 +73,10 @@ data class MatchDto(
@Json(name = "opponent_name") val opponentName: String,
val location: String? = null,
@Json(name = "scheduled_at") val scheduledAt: String? = null,
@Json(name = "sets_to_win") val setsToWin: Int? = null,
val category: String? = null,
val phase: String? = null,
@Json(name = "roster_numbers") val rosterNumbers: List<Int>? = null,
@Json(name = "active_session_id") val activeSessionId: String? = null,
@Json(name = "active_session_status") val activeSessionStatus: String? = null,
) {
@@ -66,6 +87,10 @@ data class MatchDto(
opponentName = opponentName,
location = location,
scheduledAt = scheduledAt,
setsToWin = setsToWin ?: 3,
category = category,
phase = phase,
rosterNumbers = rosterNumbers.orEmpty(),
activeSessionId = activeSessionId,
activeSessionStatus = activeSessionStatus,
)
@@ -80,9 +105,13 @@ data class StreamSessionDto(
@Json(name = "hls_playback_url") val hlsPlaybackUrl: String? = null,
@Json(name = "watch_page_url") val watchPageUrl: String? = null,
@Json(name = "share_url") val shareUrl: String? = null,
@Json(name = "youtube_watch_url") val youtubeWatchUrl: String? = null,
@Json(name = "youtube_ready") val youtubeReady: Boolean? = null,
@Json(name = "privacy_status") val privacyStatus: String? = null,
@Json(name = "target_bitrate") val targetBitrate: Int? = null,
@Json(name = "target_fps") val targetFps: Int? = null,
@Json(name = "started_at") val startedAt: String? = null,
val score: ScoreStateDto? = null,
) {
fun toDomain() = StreamSession(
id = id,
@@ -93,21 +122,150 @@ data class StreamSessionDto(
hlsPlaybackUrl = hlsPlaybackUrl,
watchPageUrl = watchPageUrl,
shareUrl = shareUrl,
youtubeWatchUrl = youtubeWatchUrl,
youtubeReady = youtubeReady ?: false,
privacyStatus = privacyStatus ?: "public",
targetBitrate = targetBitrate ?: 2_500_000,
targetFps = targetFps ?: 30,
startedAt = startedAt,
score = score?.toDomain(),
)
}
data class SetPartialDto(
val set: Int? = null,
val home: Int? = null,
val away: Int? = null,
)
data class ScoreStateDto(
@Json(name = "home_sets") val homeSets: Int? = null,
@Json(name = "away_sets") val awaySets: Int? = null,
@Json(name = "home_points") val homePoints: Int? = null,
@Json(name = "away_points") val awayPoints: Int? = null,
@Json(name = "current_set") val currentSet: Int? = null,
@Json(name = "set_partials") val setPartials: List<SetPartialDto>? = null,
@Json(name = "timeout_home") val timeoutHome: Boolean? = null,
@Json(name = "timeout_away") val timeoutAway: Boolean? = null,
) {
fun toDomain() = com.matchlivetv.match_live_tv.domain.ScoreState(
homeSets = homeSets ?: 0,
awaySets = awaySets ?: 0,
homePoints = homePoints ?: 0,
awayPoints = awayPoints ?: 0,
currentSet = currentSet ?: 1,
setPartials = setPartials.orEmpty().map {
com.matchlivetv.match_live_tv.domain.SetPartial(it.set ?: 1, it.home ?: 0, it.away ?: 0)
},
timeoutHome = timeoutHome ?: false,
timeoutAway = timeoutAway ?: false,
)
}
data class ScoreSyncRequest(
@Json(name = "home_sets") val homeSets: Int,
@Json(name = "away_sets") val awaySets: Int,
@Json(name = "home_points") val homePoints: Int,
@Json(name = "away_points") val awayPoints: Int,
@Json(name = "current_set") val currentSet: Int,
@Json(name = "set_partials") val setPartials: List<SetPartialDto>,
)
data class CreateSessionRequest(
val platform: String = "matchlivetv",
@Json(name = "privacy_status") val privacyStatus: String = "public",
@Json(name = "quality_preset") val qualityPreset: String = "720p_30_2.5mbps",
@Json(name = "target_bitrate") val targetBitrate: Int = 2_500_000,
@Json(name = "target_fps") val targetFps: Int = 30,
@Json(name = "youtube_channel") val youtubeChannel: String? = null,
)
data class CreateMatchRequest(val match: Map<String, String>)
data class UpdateMatchBody(
@Json(name = "opponent_name") val opponentName: String? = null,
val location: String? = null,
@Json(name = "scheduled_at") val scheduledAt: String? = null,
@Json(name = "sets_to_win") val setsToWin: Int? = null,
val category: String? = null,
val phase: String? = null,
@Json(name = "roster_numbers") val rosterNumbers: List<Int>? = null,
)
data class UpdateMatchRequest(val match: UpdateMatchBody)
data class ScoringRulesBody(
@Json(name = "points_per_set") val pointsPerSet: Int,
@Json(name = "points_deciding_set") val pointsDecidingSet: Int,
@Json(name = "min_point_lead") val minPointLead: Int,
)
data class UpdateMatchBodyFull(
@Json(name = "opponent_name") val opponentName: String,
val location: String? = null,
@Json(name = "scheduled_at") val scheduledAt: String? = null,
@Json(name = "sets_to_win") val setsToWin: Int,
val category: String? = null,
val phase: String? = null,
@Json(name = "roster_numbers") val rosterNumbers: List<Int>? = null,
@Json(name = "scoring_rules") val scoringRules: ScoringRulesBody? = null,
)
data class UpdateMatchRequestFull(val match: UpdateMatchBodyFull)
data class NetworkTestResponse(
val ready: Boolean,
@Json(name = "target_upload_mbps") val targetUploadMbps: Double? = null,
)
data class RecordingDto(
val id: String,
val title: String? = null,
@Json(name = "opponent_name") val opponentName: String? = null,
@Json(name = "team_name") val teamName: String? = null,
val status: String? = null,
@Json(name = "status_label") val statusLabel: String? = null,
@Json(name = "recorded_at") val recordedAt: String? = null,
@Json(name = "ended_at") val endedAt: String? = null,
@Json(name = "duration_label") val durationLabel: String? = null,
@Json(name = "views_label") val viewsLabel: String? = null,
@Json(name = "replay_url") val replayUrl: String? = null,
@Json(name = "thumbnail_url") val thumbnailUrl: String? = null,
@Json(name = "download_enabled") val downloadEnabled: Boolean? = null,
@Json(name = "youtube_watch_url") val youtubeWatchUrl: String? = null,
@Json(name = "expires_at") val expiresAt: String? = null,
@Json(name = "title_or_default") val titleOrDefault: String? = null,
) {
fun toDomain() = Recording(
id = id,
title = title ?: titleOrDefault ?: "Replay",
opponentName = opponentName.orEmpty(),
teamName = teamName.orEmpty(),
status = status ?: "processing",
statusLabel = statusLabel ?: status.orEmpty(),
recordedAt = recordedAt,
endedAt = endedAt,
durationLabel = durationLabel,
viewsLabel = viewsLabel,
replayUrl = replayUrl,
thumbnailUrl = thumbnailUrl,
downloadEnabled = downloadEnabled ?: false,
youtubeWatchUrl = youtubeWatchUrl,
expiresAt = expiresAt,
)
}
data class RegiaLinkResponse(
@Json(name = "regia_url") val regiaUrl: String,
)
data class CreateMatchBody(
@Json(name = "opponent_name") val opponentName: String,
val sport: String = "volleyball",
@Json(name = "sets_to_win") val setsToWin: Int = 3,
@Json(name = "scheduled_at") val scheduledAt: String? = null,
val location: String? = null,
)
data class CreateMatchRequest(val match: CreateMatchBody)
data class NetworkTestRequest(
@Json(name = "download_mbps") val downloadMbps: Double,
@@ -115,3 +273,13 @@ data class NetworkTestRequest(
@Json(name = "latency_ms") val latencyMs: Int,
@Json(name = "network_type") val networkType: String,
)
data class TelemetryRequest(
@Json(name = "device_role") val deviceRole: String,
@Json(name = "battery_level") val batteryLevel: Int? = null,
@Json(name = "network_type") val networkType: String? = null,
@Json(name = "signal_strength") val signalStrength: Int? = null,
@Json(name = "current_bitrate") val currentBitrate: Int? = null,
@Json(name = "target_bitrate") val targetBitrate: Int? = null,
val fps: Int? = null,
)

View File

@@ -1,6 +1,7 @@
package com.matchlivetv.match_live_tv.data.api
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.PATCH
import retrofit2.http.POST
@@ -22,12 +23,30 @@ interface MatchLiveApi {
@GET("teams")
suspend fun teams(): List<TeamDto>
@GET("teams/{teamId}/recordings")
suspend fun recordings(@Path("teamId") teamId: String): List<RecordingDto>
@GET("teams/{teamId}/matches")
suspend fun matches(@Path("teamId") teamId: String): List<MatchDto>
@POST("teams/{teamId}/matches")
suspend fun createMatch(
@Path("teamId") teamId: String,
@Body body: CreateMatchRequest,
): MatchDto
@GET("matches/{matchId}")
suspend fun match(@Path("matchId") matchId: String): MatchDto
@PATCH("matches/{matchId}")
suspend fun updateMatch(
@Path("matchId") matchId: String,
@Body body: UpdateMatchRequestFull,
): MatchDto
@DELETE("matches/{matchId}")
suspend fun deleteMatch(@Path("matchId") matchId: String)
@POST("matches/{matchId}/sessions")
suspend fun createSession(
@Path("matchId") matchId: String,
@@ -53,5 +72,23 @@ interface MatchLiveApi {
suspend fun networkTest(
@Path("sessionId") sessionId: String,
@Body body: NetworkTestRequest,
): NetworkTestResponse
@POST("sessions/{sessionId}/regia_link")
suspend fun regiaLink(@Path("sessionId") sessionId: String): RegiaLinkResponse
@PATCH("sessions/{sessionId}/score")
suspend fun syncScore(
@Path("sessionId") sessionId: String,
@Body body: ScoreSyncRequest,
): StreamSessionDto
@POST("sessions/{sessionId}/telemetry")
suspend fun postTelemetry(
@Path("sessionId") sessionId: String,
@Body body: TelemetryRequest,
)
@GET("recordings/{recordingId}/download")
suspend fun recordingDownload(@Path("recordingId") recordingId: String): Map<String, String>
}

View File

@@ -0,0 +1,141 @@
package com.matchlivetv.match_live_tv.data.cable
import android.util.Log
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.json.JSONObject
import java.util.concurrent.TimeUnit
class ActionCableClient(
private val cableUrl: String,
private val accessToken: String,
moshi: Moshi,
) {
interface Listener {
fun onConnected() {}
fun onDisconnected() {}
fun onChannelMessage(payload: Map<String, Any?>) {}
}
private val envelopeAdapter: JsonAdapter<Map<String, Any?>> =
moshi.adapter(Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java))
private val client = OkHttpClient.Builder()
.readTimeout(0, TimeUnit.MILLISECONDS)
.build()
private var webSocket: WebSocket? = null
private var channelIdentifier: String? = null
private var listener: Listener? = null
fun connect(
channel: String,
params: Map<String, String>,
listener: Listener,
) {
disconnect()
this.listener = listener
channelIdentifier = JSONObject().apply {
put("channel", channel)
params.forEach { (key, value) -> put(key, value) }
}.toString()
val request = Request.Builder()
.url(cableUrl)
.header("Authorization", "Bearer $accessToken")
.build()
webSocket = client.newWebSocket(
request,
object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
sendSubscribe()
}
override fun onMessage(webSocket: WebSocket, text: String) {
handleIncoming(text)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
listener.onDisconnected()
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.w(TAG, "ActionCable failure", t)
listener.onDisconnected()
}
},
)
}
fun performReceive(payload: Map<String, Any?>) {
val identifier = channelIdentifier ?: return
val dataJson = JSONObject(payload).apply {
put("action", "receive")
}.toString()
send(
mapOf(
"command" to "message",
"identifier" to identifier,
"data" to dataJson,
),
)
}
fun disconnect() {
webSocket?.close(1000, "bye")
webSocket = null
channelIdentifier = null
listener = null
}
private fun sendSubscribe() {
val identifier = channelIdentifier ?: return
send(mapOf("command" to "subscribe", "identifier" to identifier))
}
private fun send(payload: Map<String, Any?>) {
val json = envelopeAdapter.toJson(payload)
webSocket?.send(json)
}
private fun handleIncoming(raw: String) {
val json = runCatching { JSONObject(raw) }.getOrNull() ?: return
when (json.optString("type")) {
"ping" -> {
val message = json.opt("message")
webSocket?.send("""{"type":"pong","message":$message}""")
}
"confirm_subscription" -> listener?.onConnected()
"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?>)
}
}
}
}
}
private fun messageToMap(json: JSONObject): Map<String, Any?> {
val map = mutableMapOf<String, Any?>()
json.keys().forEach { key ->
map[key] = json.get(key)
}
return map
}
companion object {
private const val TAG = "ActionCableClient"
}
}

View File

@@ -0,0 +1,86 @@
package com.matchlivetv.match_live_tv.data.cable
import com.matchlivetv.match_live_tv.core.AppConfig
import com.matchlivetv.match_live_tv.domain.ScoreState
import com.squareup.moshi.Moshi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class SessionCableService(private val moshi: Moshi) {
private var client: ActionCableClient? = null
private val _connected = MutableStateFlow(false)
val connected: StateFlow<Boolean> = _connected.asStateFlow()
var onScoreUpdate: ((ScoreState) -> Unit)? = null
var onPauseStream: (() -> Unit)? = null
var onResumeStream: (() -> Unit)? = null
fun connect(sessionId: String, accessToken: String, deviceRole: String = "camera") {
disconnect()
client = ActionCableClient(AppConfig.cableUrl, accessToken, moshi).also { cable ->
cable.connect(
channel = "SessionChannel",
params = mapOf(
"session_id" to sessionId,
"device_role" to deviceRole,
),
listener = object : ActionCableClient.Listener {
override fun onConnected() {
_connected.value = true
}
override fun onDisconnected() {
_connected.value = false
}
override fun onChannelMessage(payload: Map<String, Any?>) {
dispatch(payload)
}
},
)
}
}
fun sendScoreUpdate(score: ScoreState) {
client?.performReceive(score.cablePayload())
}
fun disconnect() {
client?.disconnect()
client = null
_connected.value = false
}
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) {
"paused" -> onPauseStream?.invoke()
"resumed" -> onResumeStream?.invoke()
}
}
}
private fun parseScore(data: Map<String, Any?>): ScoreState {
@Suppress("UNCHECKED_CAST")
val partialsRaw = data["set_partials"] as? List<Map<String, Any?>> ?: emptyList()
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 {
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,
)
},
timeoutHome = data["timeout_home"] as? Boolean ?: false,
timeoutAway = data["timeout_away"] as? Boolean ?: false,
)
}
}

View File

@@ -1,10 +1,17 @@
package com.matchlivetv.match_live_tv.data.repository
import com.matchlivetv.match_live_tv.core.TokenStore
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.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.domain.Match
import com.matchlivetv.match_live_tv.domain.Recording
import com.matchlivetv.match_live_tv.domain.Team
import kotlinx.coroutines.flow.first
import java.time.Instant
class MatchRepository(
private val api: MatchLiveApi,
@@ -13,15 +20,98 @@ class MatchRepository(
suspend fun fetchTeams(): List<Team> =
api.teams().map { it.toDomain() }
suspend fun fetchMatches(): List<Match> {
val teamId = tokenStore.activeTeamIdFlow.first()
?: fetchTeams().firstOrNull()?.id
?: return emptyList()
tokenStore.saveActiveTeamId(teamId)
return api.matches(teamId).map { it.toDomain() }
suspend fun fetchTeam(teamId: String): Team? =
fetchTeams().firstOrNull { it.id == teamId }
suspend fun resolveActiveTeam(teams: List<Team>): Team? {
if (teams.isEmpty()) return null
val savedId = tokenStore.activeTeamIdFlow.first()
val selected = teams.firstOrNull { it.id == savedId } ?: teams.first()
tokenStore.saveActiveTeamId(selected.id)
return selected
}
suspend fun fetchMatch(matchId: String): Match =
api.match(matchId).toDomain()
suspend fun fetchMatchesForTeam(teamId: String): List<Match> =
api.matches(teamId)
.map { it.toDomain() }
.sortedWith(matchComparator)
suspend fun fetchMatches(): List<Match> {
val teams = fetchTeams()
val team = resolveActiveTeam(teams) ?: return emptyList()
return fetchMatchesForTeam(team.id)
}
suspend fun fetchRecordings(teamId: String): List<Recording> =
api.recordings(teamId).map { it.toDomain() }
suspend fun selectTeam(teamId: String) {
tokenStore.saveActiveTeamId(teamId)
}
suspend fun createQuickMatch(teamId: String): Match =
api.createMatch(
teamId,
CreateMatchRequest(match = CreateMatchBody(opponentName = "Avversario")),
).toDomain()
suspend fun createScheduledMatch(
teamId: String,
opponentName: String,
scheduledAt: Instant,
location: String?,
): Match = api.createMatch(
teamId,
CreateMatchRequest(
match = CreateMatchBody(
opponentName = opponentName,
scheduledAt = scheduledAt.toString(),
location = location?.takeIf { it.isNotBlank() },
),
),
).toDomain()
suspend fun updateMatch(
matchId: String,
opponentName: String,
location: String?,
scheduledAt: String?,
setsToWin: Int,
category: String?,
phase: String?,
rosterNumbers: List<Int>,
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() },
phase = phase?.takeIf { it.isNotBlank() },
rosterNumbers = rosterNumbers.takeIf { it.isNotEmpty() },
scoringRules = scoringRules,
),
),
).toDomain()
suspend fun deleteMatch(matchId: String) {
api.deleteMatch(matchId)
}
suspend fun recordingDownloadUrl(recordingId: String): String {
val body = api.recordingDownload(recordingId)
return body["download_url"] ?: error("URL download mancante")
}
companion object {
private val matchComparator = compareBy<Match, Instant?>(nullsLast()) { match ->
match.scheduledAt?.let { runCatching { Instant.parse(it) }.getOrNull() }
}.thenByDescending { it.id }
}
}

View File

@@ -0,0 +1,15 @@
package com.matchlivetv.match_live_tv.data.repository
import com.matchlivetv.match_live_tv.domain.Match
import com.matchlivetv.match_live_tv.domain.StreamSession
object MatchSessionLauncher {
/** Riprende una diretta già attiva (camera live / paused / reconnecting). */
suspend fun resumeBroadcastSession(
match: Match,
sessionRepository: SessionRepository,
): StreamSession {
val sessionId = match.activeSessionId ?: error("Sessione mancante")
return sessionRepository.fetchSession(sessionId)
}
}

View File

@@ -0,0 +1,27 @@
package com.matchlivetv.match_live_tv.data.repository
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
import com.matchlivetv.match_live_tv.data.api.ScoreSyncRequest
import com.matchlivetv.match_live_tv.data.api.SetPartialDto
import com.matchlivetv.match_live_tv.domain.ScoreState
class ScoreRepository(
private val api: MatchLiveApi,
) {
suspend fun syncScore(sessionId: String, score: ScoreState): ScoreState? {
val response = api.syncScore(
sessionId,
ScoreSyncRequest(
homeSets = score.homeSets,
awaySets = score.awaySets,
homePoints = score.homePoints,
awayPoints = score.awayPoints,
currentSet = score.currentSet,
setPartials = score.setPartials.map {
SetPartialDto(set = it.set, home = it.home, away = it.away)
},
),
)
return response.score?.toDomain()
}
}

View File

@@ -3,13 +3,25 @@ package com.matchlivetv.match_live_tv.data.repository
import com.matchlivetv.match_live_tv.data.api.CreateSessionRequest
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
import com.matchlivetv.match_live_tv.data.api.NetworkTestRequest
import com.matchlivetv.match_live_tv.data.api.NetworkTestResponse
import com.matchlivetv.match_live_tv.domain.StreamSession
class SessionRepository(
private val api: MatchLiveApi,
) {
suspend fun createSession(matchId: String): StreamSession =
api.createSession(matchId, CreateSessionRequest()).toDomain()
suspend fun createSession(
matchId: String,
platform: String = "matchlivetv",
privacyStatus: String = "public",
youtubeChannel: String? = null,
): StreamSession = api.createSession(
matchId,
CreateSessionRequest(
platform = platform,
privacyStatus = privacyStatus,
youtubeChannel = youtubeChannel,
),
).toDomain()
suspend fun fetchSession(sessionId: String): StreamSession =
api.session(sessionId).toDomain()
@@ -32,10 +44,35 @@ class SessionRepository(
uploadMbps: Double,
latencyMs: Int,
networkType: String,
): NetworkTestResponse = api.networkTest(
sessionId,
NetworkTestRequest(downloadMbps, uploadMbps, latencyMs, networkType),
)
suspend fun createRegiaLink(sessionId: String): String =
api.regiaLink(sessionId).regiaUrl
suspend fun postTelemetry(
sessionId: String,
deviceRole: String = "camera",
batteryLevel: Int? = null,
networkType: String? = null,
signalStrength: Int? = null,
currentBitrate: Int? = null,
targetBitrate: Int? = null,
fps: Int? = null,
) {
api.networkTest(
api.postTelemetry(
sessionId,
NetworkTestRequest(downloadMbps, uploadMbps, latencyMs, networkType),
com.matchlivetv.match_live_tv.data.api.TelemetryRequest(
deviceRole = deviceRole,
batteryLevel = batteryLevel,
networkType = networkType,
signalStrength = signalStrength,
currentBitrate = currentBitrate,
targetBitrate = targetBitrate,
fps = fps,
),
)
}
}

View File

@@ -0,0 +1,112 @@
package com.matchlivetv.match_live_tv.data.scoring
import com.matchlivetv.match_live_tv.data.cable.SessionCableService
import com.matchlivetv.match_live_tv.data.repository.ScoreRepository
import com.matchlivetv.match_live_tv.domain.ScoreState
import com.matchlivetv.match_live_tv.domain.SetPartial
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class ScoreController(
private val scoreRepository: ScoreRepository,
private val sessionCable: SessionCableService,
private val scope: CoroutineScope,
) {
private val _score = MutableStateFlow(ScoreState())
val score: StateFlow<ScoreState> = _score.asStateFlow()
private var sessionId: String? = null
private var suppressRemoteUntilMs: Long = 0
private val syncMutex = Mutex()
private var syncJob: Job? = null
fun bind(sessionId: String, initial: ScoreState?) {
this.sessionId = sessionId
if (initial != null) {
_score.value = initial
}
}
fun applyRemote(remote: ScoreState) {
val now = System.currentTimeMillis()
if (now < suppressRemoteUntilMs && remote.progressKey() < _score.value.progressKey()) {
return
}
_score.value = remote
}
fun incrementHome() {
_score.update { it.copy(homePoints = it.homePoints + 1) }
schedulePush()
}
fun incrementAway() {
_score.update { it.copy(awayPoints = it.awayPoints + 1) }
schedulePush()
}
fun decrementHome() {
_score.update { current ->
if (current.homePoints > 0) current.copy(homePoints = current.homePoints - 1) else current
}
schedulePush()
}
fun decrementAway() {
_score.update { current ->
if (current.awayPoints > 0) current.copy(awayPoints = current.awayPoints - 1) else current
}
schedulePush()
}
fun closeSet() {
val current = _score.value
val homeWon = current.homePoints > current.awayPoints
val partials = current.setPartials.toMutableList()
if (current.homePoints > 0 || current.awayPoints > 0) {
partials.add(
SetPartial(
set = current.currentSet,
home = current.homePoints,
away = current.awayPoints,
),
)
}
_score.value = current.copy(
homeSets = if (homeWon) current.homeSets + 1 else current.homeSets,
awaySets = if (homeWon) current.awaySets else current.awaySets + 1,
homePoints = 0,
awayPoints = 0,
currentSet = current.currentSet + 1,
setPartials = partials,
timeoutHome = false,
timeoutAway = false,
)
schedulePush()
}
private fun schedulePush() {
syncJob?.cancel()
syncJob = scope.launch {
syncMutex.withLock {
val id = sessionId ?: return@withLock
val local = _score.value
sessionCable.sendScoreUpdate(local)
runCatching { scoreRepository.syncScore(id, local) }
.onSuccess { remote ->
suppressRemoteUntilMs = System.currentTimeMillis() + 500
if (remote != null) {
_score.value = remote
}
}
}
}
}
}

View File

@@ -0,0 +1,40 @@
package com.matchlivetv.match_live_tv.domain
enum class ScoringSide {
HOME,
AWAY,
}
data class MatchScoringRules(
val setsToWin: Int,
val pointsPerSet: Int = 25,
val pointsDecidingSet: Int = 15,
val minPointLead: Int = 2,
) {
val maxSets: Int get() = (setsToWin * 2) - 1
fun pointsTarget(currentSet: Int): Int =
if (currentSet >= maxSets) pointsDecidingSet else pointsPerSet
fun setWinnerFromPoints(
homePoints: Int,
awayPoints: Int,
currentSet: Int,
): ScoringSide? {
val target = pointsTarget(currentSet)
val lead = minPointLead
if (homePoints >= target && homePoints - awayPoints >= lead) return ScoringSide.HOME
if (awayPoints >= target && awayPoints - homePoints >= lead) return ScoringSide.AWAY
return null
}
fun matchWinner(homeSets: Int, awaySets: Int): ScoringSide? = when {
homeSets >= setsToWin -> ScoringSide.HOME
awaySets >= setsToWin -> ScoringSide.AWAY
else -> null
}
companion object {
fun fromMatch(match: Match) = MatchScoringRules(setsToWin = match.setsToWin)
}
}

View File

@@ -16,11 +16,22 @@ data class Team(
val id: String,
val name: String,
val sport: String,
val clubName: String? = null,
val canStream: Boolean = true,
val youtubeEnabled: Boolean = false,
val youtubeConnected: Boolean = false,
val youtubeSelectable: Boolean = false,
val youtubeChannelTitle: String? = null,
val youtubeTeamChannelTitle: String? = null,
val planName: String? = null,
val recordingsEnabled: Boolean = false,
val phoneDownloadEnabled: Boolean = false,
val billingUrl: String? = null,
val staffManageUrl: String? = null,
)
) {
val canUseYoutube: Boolean get() = youtubeEnabled
val isYoutubeReady: Boolean get() = youtubeSelectable
}
data class Match(
val id: String,
@@ -29,9 +40,15 @@ data class Match(
val opponentName: String,
val location: String? = null,
val scheduledAt: String? = null,
val setsToWin: Int = 3,
val category: String? = null,
val phase: String? = null,
val rosterNumbers: List<Int> = emptyList(),
val activeSessionId: String? = null,
val activeSessionStatus: String? = null,
) {
val hasActiveSession: Boolean get() = activeSessionId != null
val canResumeCamera: Boolean
get() {
val status = activeSessionStatus ?: return false
@@ -49,9 +66,22 @@ data class StreamSession(
val hlsPlaybackUrl: String? = null,
val watchPageUrl: String? = null,
val shareUrl: String? = null,
val youtubeWatchUrl: String? = null,
val youtubeReady: Boolean = false,
val privacyStatus: String = "public",
val targetBitrate: Int = 2_500_000,
val targetFps: Int = 30,
val startedAt: String? = null,
val score: ScoreState? = null,
) {
val isLive: Boolean get() = status == "live" || status == "reconnecting"
val isPaused: Boolean get() = status == "paused"
fun watchShareUrl(): String? = when {
platform == "youtube" && !youtubeWatchUrl.isNullOrBlank() -> youtubeWatchUrl
!shareUrl.isNullOrBlank() -> shareUrl
!watchPageUrl.isNullOrBlank() -> watchPageUrl
else -> null
}
}

View File

@@ -0,0 +1,22 @@
package com.matchlivetv.match_live_tv.domain
data class Recording(
val id: String,
val title: String,
val opponentName: String,
val teamName: String,
val status: String,
val statusLabel: String,
val recordedAt: String? = null,
val endedAt: String? = null,
val durationLabel: String? = null,
val viewsLabel: String? = null,
val replayUrl: String? = null,
val thumbnailUrl: String? = null,
val downloadEnabled: Boolean = false,
val youtubeWatchUrl: String? = null,
val expiresAt: String? = null,
) {
val isReady: Boolean get() = status == "ready"
val isProcessing: Boolean get() = status == "processing"
}

View File

@@ -0,0 +1,33 @@
package com.matchlivetv.match_live_tv.domain
data class SetPartial(
val set: Int,
val home: Int,
val away: Int,
)
data class ScoreState(
val homeSets: Int = 0,
val awaySets: Int = 0,
val homePoints: Int = 0,
val awayPoints: Int = 0,
val currentSet: Int = 1,
val setPartials: List<SetPartial> = emptyList(),
val timeoutHome: Boolean = false,
val timeoutAway: Boolean = false,
) {
fun cablePayload(): Map<String, Any?> = mapOf(
"type" to "score_update",
"home_sets" to homeSets,
"away_sets" to awaySets,
"home_points" to homePoints,
"away_points" to awayPoints,
"current_set" to currentSet,
"set_partials" to setPartials.map {
mapOf("set" to it.set, "home" to it.home, "away" to it.away)
},
)
fun progressKey(): Int =
homeSets * 1_000_000 + awaySets * 100_000 + homePoints * 1_000 + awayPoints
}

View File

@@ -7,7 +7,10 @@ data class BroadcastConfig(
val videoBitrate: Int = 2_500_000,
val audioBitrate: Int = 128_000,
val fps: Int = 30,
val rotation: Int = 0,
val portrait: Boolean = false,
val maxReconnectAttempts: Int = 10,
val reconnectDelayMs: Long = 5_000L,
)
enum class BroadcastPhase {
@@ -15,6 +18,7 @@ enum class BroadcastPhase {
PREVIEW,
CONNECTING,
LIVE,
PAUSED,
RECONNECTING,
ERROR,
}

View File

@@ -24,6 +24,10 @@ class LiveBroadcastCoordinator(context: Context) {
engine.pauseForConfigurationChange()
}
fun resumeAfterConfigurationChange() {
engine.resumeAfterConfigurationChange()
}
fun startService() {
val intent = LiveBroadcastService.startIntent(appContext)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {

View File

@@ -4,14 +4,17 @@ import android.content.Context
import android.media.MediaCodecInfo
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import android.view.Surface
import android.view.WindowManager
import com.pedro.common.ConnectChecker
import com.pedro.library.generic.GenericStream
import com.pedro.library.util.SensorRotationManager
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
/**
* Motore RTMP nativo Match Live TV — implementazione dedicata app Android (non Flutter).
* Motore RTMP nativo Match Live TV (RootEncoder / GenericStream).
*/
class LiveBroadcastEngine(
private val appContext: Context,
@@ -24,11 +27,29 @@ class LiveBroadcastEngine(
private var phase = BroadcastPhase.IDLE
private var surfaceSuspended = false
private var rebindPending = false
private var lastCameraRotation = -1
private var lastAppliedRotation = -1
private var lastAppliedPortrait: Boolean? = null
private var pendingRotation: Int? = null
private var pendingIsPortrait: Boolean? = null
private var sensorManager: SensorRotationManager? = null
private var listener: ((BroadcastMetrics) -> Unit)? = null
private var streamStartPending = false
private var publishInProgress = false
private var currentBitrateKbps: Long = 0
private var currentFps: Int = 0
private val pausingIntentionally = AtomicBoolean(false)
private val reconnectAttempts = AtomicInteger(0)
private val rebindRunnable = Runnable { reattachPreviewIfNeeded() }
private val rebindRunnable = Runnable {
reattachPreviewIfNeeded()
tryStartPendingBroadcast()
}
private val orientationApplyRunnable = Runnable {
val rotation = pendingRotation ?: return@Runnable
val isPortrait = pendingIsPortrait ?: return@Runnable
applyGlOrientation(rotation, isPortrait, "sensorOrientation")
}
fun setMetricsListener(block: ((BroadcastMetrics) -> Unit)?) {
listener = block
@@ -47,10 +68,20 @@ class LiveBroadcastEngine(
} else {
ensurePreviewPrepared(view)
}
tryStartPendingBroadcast()
}
}
/** Dopo rotazione schermo: riallinea preview e orientamento encoder. */
fun resumeAfterConfigurationChange() {
mainHandler.post {
if (phase == BroadcastPhase.IDLE) return@post
resetOrientationTracking()
applyInitialOrientation()
refreshOrientationSensor()
}
}
/** Chiamato da Activity.onConfigurationChanged prima di super. */
fun pauseForConfigurationChange() {
mainHandler.post {
if (phase == BroadcastPhase.IDLE) return@post
@@ -72,6 +103,7 @@ class LiveBroadcastEngine(
view.onSurfaceLost = { onPreviewSurfaceLost() }
updatePipelinePreservationFlag()
scheduleRebind()
tryStartPendingBroadcast()
}
}
@@ -91,19 +123,86 @@ class LiveBroadcastEngine(
fun startBroadcast(broadcastConfig: BroadcastConfig) {
mainHandler.post {
config = broadcastConfig
val stream = obtainStream()
resetEncoder(stream, broadcastConfig)
attachPreviewInternal()
applyOrientation(broadcastConfig.portrait, broadcastConfig.portrait)
startSensor()
reconnectAttempts.set(0)
streamStartPending = true
publishInProgress = false
setPhase(BroadcastPhase.CONNECTING)
Log.i(TAG, "publish ${broadcastConfig.rtmpUrl}")
stream.startStream(broadcastConfig.rtmpUrl)
Log.i(TAG, "publish queued ${broadcastConfig.rtmpUrl}")
tryStartPendingBroadcast()
}
}
private fun tryStartPendingBroadcast() {
if (!streamStartPending || publishInProgress) return
val cfg = config ?: return
publishInProgress = true
waitForPreviewThenPublish(cfg, attempt = 0)
}
private fun waitForPreviewThenPublish(cfg: BroadcastConfig, attempt: Int) {
if (!streamStartPending) {
publishInProgress = false
return
}
if (!isSurfaceUsable(previewView)) {
if (attempt >= 40) {
publishInProgress = false
streamStartPending = false
setPhase(BroadcastPhase.ERROR, "preview_not_ready")
return
}
mainHandler.postDelayed({ waitForPreviewThenPublish(cfg, attempt + 1) }, 50L)
return
}
val stream = obtainStream(cfg)
if (!stream.isOnPreview) {
if (!preparePipeline(stream, cfg)) {
publishInProgress = false
streamStartPending = false
return
}
}
if (!stream.isOnPreview) {
if (attempt >= 40) {
publishInProgress = false
streamStartPending = false
setPhase(BroadcastPhase.ERROR, "preview_not_ready")
return
}
mainHandler.postDelayed({ waitForPreviewThenPublish(cfg, attempt + 1) }, 50L)
return
}
mainHandler.postDelayed({
if (!streamStartPending) {
publishInProgress = false
return@postDelayed
}
val activeStream = genericStream
if (activeStream == null) {
publishInProgress = false
return@postDelayed
}
if (activeStream.isStreaming) {
streamStartPending = false
publishInProgress = false
setPhase(BroadcastPhase.LIVE)
return@postDelayed
}
setPhase(BroadcastPhase.CONNECTING)
Log.i(TAG, "publish ${cfg.rtmpUrl}")
activeStream.startStream(cfg.rtmpUrl)
streamStartPending = false
publishInProgress = false
}, PRE_PUBLISH_DELAY_MS)
}
fun stopBroadcast() {
mainHandler.post {
streamStartPending = false
publishInProgress = false
stopSensor()
genericStream?.let { stream ->
if (stream.isStreaming) stream.stopStream()
@@ -112,10 +211,41 @@ class LiveBroadcastEngine(
genericStream = null
surfaceSuspended = false
rebindPending = false
lastAppliedRotation = -1
lastAppliedPortrait = null
currentBitrateKbps = 0
currentFps = 0
reconnectAttempts.set(0)
setPhase(BroadcastPhase.IDLE)
}
}
fun pauseBroadcast() {
mainHandler.post {
streamStartPending = false
publishInProgress = false
pausingIntentionally.set(true)
genericStream?.let { stream ->
if (stream.isStreaming) stream.stopStream()
}
pausingIntentionally.set(false)
currentBitrateKbps = 0
currentFps = 0
attachPreviewInternal()
setPhase(BroadcastPhase.PAUSED)
}
}
fun resumeBroadcast(broadcastConfig: BroadcastConfig) {
mainHandler.post {
config = broadcastConfig
reconnectAttempts.set(0)
streamStartPending = true
publishInProgress = false
mainHandler.postDelayed({ tryStartPendingBroadcast() }, 250L)
}
}
fun release() {
mainHandler.post {
stopBroadcast()
@@ -123,57 +253,86 @@ class LiveBroadcastEngine(
}
}
private fun obtainStream(): GenericStream {
private fun obtainStream(cfg: BroadcastConfig): GenericStream {
val existing = genericStream
if (existing != null) return existing
return GenericStream(appContext, this).also { stream ->
stream.getGlInterface().autoHandleOrientation = false
stream.getStreamClient().setReTries(10)
stream.getStreamClient().setReTries(cfg.maxReconnectAttempts)
genericStream = stream
}
}
private fun ensurePreviewPrepared(view: LivePreviewView) {
val stream = obtainStream()
val stream = obtainStream(config ?: BroadcastConfig(rtmpUrl = ""))
if (stream.isOnPreview) return
val cfg = config ?: BroadcastConfig(rtmpUrl = "")
resetEncoder(stream, cfg.copy(rtmpUrl = cfg.rtmpUrl.ifBlank { "rtmp://127.0.0.1/preview" }))
attachPreviewInternal()
val cfg = config ?: BroadcastConfig(rtmpUrl = "rtmp://127.0.0.1/preview")
preparePipeline(stream, cfg.copy(rtmpUrl = cfg.rtmpUrl.ifBlank { "rtmp://127.0.0.1/preview" }))
setPhase(BroadcastPhase.PREVIEW)
}
private fun resetEncoder(stream: GenericStream, cfg: BroadcastConfig) {
private fun preparePipeline(stream: GenericStream, cfg: BroadcastConfig): Boolean {
if (stream.isStreaming) stream.stopStream()
if (stream.isOnPreview) stream.stopPreview()
val rotation = if (cfg.portrait) 90 else 0
val ok = stream.prepareVideo(
width = cfg.width,
height = cfg.height,
bitrate = cfg.videoBitrate,
fps = cfg.fps,
iFrameInterval = 1,
rotation = rotation,
profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline,
level = MediaCodecInfo.CodecProfileLevel.AVCLevel31,
) && stream.prepareAudio(48_000, false, cfg.audioBitrate)
if (!ok) {
val prepared = runCatching {
stream.prepareVideo(
width = cfg.width,
height = cfg.height,
bitrate = cfg.videoBitrate,
fps = cfg.fps,
iFrameInterval = 1,
rotation = cfg.rotation,
profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline,
level = MediaCodecInfo.CodecProfileLevel.AVCLevel31,
) && stream.prepareAudio(48_000, false, cfg.audioBitrate)
}.getOrElse {
Log.w(TAG, "preparePipeline: ${it.message}")
false
}
if (!prepared) {
setPhase(BroadcastPhase.ERROR, "prepare_failed")
return false
}
if (!attachPreviewInternal()) {
scheduleRebind()
return false
}
resetOrientationTracking()
applyInitialOrientation()
startSensor()
return true
}
/** Stessa convenzione di SensorRotationManager (0° = portrait naturale). */
private fun orientationFromDisplayRotation(): Pair<Int, Boolean> {
@Suppress("DEPRECATION")
val displayRotation = (appContext.getSystemService(Context.WINDOW_SERVICE) as WindowManager)
.defaultDisplay.rotation
return when (displayRotation) {
Surface.ROTATION_90 -> 90 to false
Surface.ROTATION_180 -> 180 to true
Surface.ROTATION_270 -> 270 to false
else -> 0 to true
}
}
private fun attachPreviewInternal() {
val view = previewView ?: return
val stream = genericStream ?: return
if (!isSurfaceUsable(view)) {
scheduleRebind()
return
}
val surface = view.holder.surface ?: return
if (!surface.isValid) {
scheduleRebind()
return
}
runCatching {
private fun applyInitialOrientation() {
val (rotation, isPortrait) = orientationFromDisplayRotation()
applyGlOrientation(rotation, isPortrait, "initialOrientation")
}
private fun attachPreviewInternal(): Boolean {
val view = previewView ?: return false
val stream = genericStream ?: return false
if (!isSurfaceUsable(view)) return false
val surface = view.holder.surface ?: return false
if (!surface.isValid) return false
return runCatching {
val gl = stream.getGlInterface()
gl.setForceRender(false)
if (stream.isOnPreview) gl.deAttachPreview()
@@ -183,9 +342,10 @@ class LiveBroadcastEngine(
gl.setForceRender(true, 30)
surfaceSuspended = false
rebindPending = false
}.onFailure {
true
}.getOrElse {
Log.w(TAG, "attachPreviewInternal: ${it.message}")
scheduleRebind()
false
}
}
@@ -200,8 +360,13 @@ class LiveBroadcastEngine(
scheduleRebind()
return
}
attachPreviewInternal()
config?.let { applyOrientation(it.portrait, it.portrait) }
if (attachPreviewInternal()) {
applyInitialOrientation()
refreshOrientationSensor()
} else {
scheduleRebind()
}
tryStartPendingBroadcast()
}
private fun isSurfaceUsable(view: LivePreviewView?): Boolean {
@@ -218,37 +383,64 @@ class LiveBroadcastEngine(
previewView?.keepPipelineOnSurfaceLoss = keep
}
private fun applyOrientation(isPortrait: Boolean, forcePortrait: Boolean) {
private fun applyGlOrientation(rotation: Int, isPortrait: Boolean, logLabel: String) {
if (surfaceSuspended) {
pendingRotation = rotation
pendingIsPortrait = isPortrait
return
}
val stream = genericStream ?: return
if (surfaceSuspended) return
runCatching {
val gl = stream.getGlInterface()
gl.setForceRender(false)
gl.autoHandleOrientation = false
gl.setIsPortrait(isPortrait)
gl.setCameraOrientation(if (forcePortrait) 90 else 0)
gl.setCameraOrientation(rotation)
gl.setPreviewRotation(0)
gl.setStreamRotation(0)
gl.setForceRender(true, 30)
lastAppliedRotation = rotation
lastAppliedPortrait = isPortrait
pendingIsPortrait = isPortrait
Log.i(TAG, "$logLabel orientation rotation=$rotation portrait=$isPortrait phase=$phase")
}.getOrElse {
Log.w(TAG, "$logLabel orientation failed: ${it.message}")
}
}
private fun startSensor() {
if (sensorManager != null) return
sensorManager = SensorRotationManager(appContext, true, true) { rotation, portrait ->
sensorManager = SensorRotationManager(appContext, true, true) { rotation, isPortrait ->
mainHandler.post {
if (surfaceSuspended || phase == BroadcastPhase.IDLE) return@post
if (rotation == lastCameraRotation) return@post
lastCameraRotation = rotation
applyOrientation(portrait, portrait)
if (surfaceSuspended || phase == BroadcastPhase.IDLE || phase == BroadcastPhase.PAUSED) return@post
if (rotation == lastAppliedRotation && isPortrait == lastAppliedPortrait) return@post
pendingRotation = rotation
pendingIsPortrait = isPortrait
mainHandler.removeCallbacks(orientationApplyRunnable)
mainHandler.postDelayed(orientationApplyRunnable, ORIENTATION_DEBOUNCE_MS)
}
}.also { it.start() }
}
private fun refreshOrientationSensor() {
sensorManager?.stop()
sensorManager = null
resetOrientationTracking()
startSensor()
}
private fun resetOrientationTracking() {
lastAppliedRotation = -1
lastAppliedPortrait = null
pendingRotation = null
pendingIsPortrait = null
mainHandler.removeCallbacks(orientationApplyRunnable)
}
private fun stopSensor() {
sensorManager?.stop()
sensorManager = null
lastCameraRotation = -1
resetOrientationTracking()
}
private fun setPhase(newPhase: BroadcastPhase, error: String? = null) {
@@ -258,12 +450,16 @@ class LiveBroadcastEngine(
}
private fun emitMetrics(error: String? = null) {
val stream = genericStream
val cfg = config
listener?.invoke(
BroadcastMetrics(
phase = phase,
bitrateKbps = 0,
fps = 0,
bitrateKbps = currentBitrateKbps,
fps = if (phase == BroadcastPhase.LIVE) {
currentFps.takeIf { it > 0 } ?: cfg?.fps ?: 0
} else {
0
},
connected = phase == BroadcastPhase.LIVE,
lastError = error,
),
@@ -275,31 +471,72 @@ class LiveBroadcastEngine(
}
override fun onConnectionSuccess() {
mainHandler.post { setPhase(BroadcastPhase.LIVE) }
mainHandler.post {
reconnectAttempts.set(0)
setPhase(BroadcastPhase.LIVE)
}
}
override fun onConnectionFailed(reason: String) {
mainHandler.post { setPhase(BroadcastPhase.ERROR, reason) }
mainHandler.post {
if (pausingIntentionally.get()) return@post
Log.w(TAG, "onConnectionFailed reason=$reason phase=$phase attempts=${reconnectAttempts.get()}")
val stream = genericStream ?: return@post
val currentConfig = config ?: return@post
setPhase(BroadcastPhase.RECONNECTING, reason)
reconnectAttempts.incrementAndGet()
val retryScheduled = stream.getStreamClient().reTry(
currentConfig.reconnectDelayMs,
reason,
null,
)
if (!retryScheduled) {
stream.stopStream()
setPhase(BroadcastPhase.ERROR, userFacingError(reason))
}
}
}
override fun onNewBitrate(bitrate: Long) = Unit
override fun onNewBitrate(bitrate: Long) {
mainHandler.post {
currentBitrateKbps = bitrate / 1000
emitMetrics()
}
}
override fun onDisconnect() {
mainHandler.post {
if (phase == BroadcastPhase.LIVE) setPhase(BroadcastPhase.RECONNECTING)
if (pausingIntentionally.get()) return@post
if (phase == BroadcastPhase.LIVE || phase == BroadcastPhase.RECONNECTING) {
setPhase(BroadcastPhase.RECONNECTING)
}
}
}
override fun onAuthError() {
mainHandler.post { setPhase(BroadcastPhase.ERROR, "auth_error") }
mainHandler.post {
genericStream?.stopStream()
setPhase(BroadcastPhase.ERROR, "Autenticazione RTMP fallita")
}
}
override fun onAuthSuccess() {
mainHandler.post { setPhase(BroadcastPhase.LIVE) }
mainHandler.post {
reconnectAttempts.set(0)
setPhase(BroadcastPhase.LIVE)
}
}
private fun userFacingError(reason: String): String = when {
reason.contains("broken pipe", ignoreCase = true) ->
"Connessione persa — riprova a riavviare la diretta"
else -> reason
}
companion object {
private const val TAG = "LiveBroadcastEngine"
private const val SURFACE_DEBOUNCE_MS = 700L
private const val ORIENTATION_DEBOUNCE_MS = 120L
private const val PRE_PUBLISH_DELAY_MS = 350L
}
}

View File

@@ -0,0 +1,178 @@
package com.matchlivetv.match_live_tv.ui.archive
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.layout.Arrangement
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.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
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.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.data.AppContainer
import com.matchlivetv.match_live_tv.domain.Recording
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.MatchScreenScaffold
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ArchiveScreen(
container: AppContainer,
onBack: () -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var loading by remember { mutableStateOf(true) }
var team by remember { mutableStateOf<Team?>(null) }
var recordings by remember { mutableStateOf<List<Recording>>(emptyList()) }
var error by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
loading = true
runCatching {
val teams = container.matchRepository.fetchTeams()
val active = container.matchRepository.resolveActiveTeam(teams)
team = active
recordings = active?.let { container.matchRepository.fetchRecordings(it.id) }.orEmpty()
}.onFailure { error = it.message }
loading = false
}
MatchScreenScaffold(
topBar = {
TopAppBar(
title = { Text("Replay") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color.White)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MatchColors.Background,
titleContentColor = Color.White,
),
)
},
) {
when {
loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MatchColors.PrimaryRed)
}
error != null -> Column(
Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(error!!, color = MatchColors.PrimaryRed, textAlign = TextAlign.Center)
}
team != null && !team!!.recordingsEnabled -> Column(
Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Replay disponibili con Premium Light o Full",
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Text(
"Light: 30 giorni · Full: 90 giorni",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 8.dp),
)
team?.billingUrl?.let { url ->
MatchPrimaryButton(
label = "SCOPRI PREMIUM",
onClick = {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
},
modifier = Modifier.padding(top = 16.dp),
)
}
}
recordings.isEmpty() -> Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Text(
"Nessun replay ancora.\nAl termine delle dirette Premium le registrazioni compaiono qui.",
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
)
}
else -> LazyColumn(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(recordings, key = { it.id }) { recording ->
Card(
colors = CardDefaults.cardColors(containerColor = MatchColors.Surface),
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth(),
onClick = {
if (recording.isReady) {
recording.replayUrl?.let { url ->
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
}
}
},
) {
Column(Modifier.padding(16.dp)) {
Text(recording.title, style = MaterialTheme.typography.titleMedium)
Text(
listOfNotNull(
recording.durationLabel,
recording.viewsLabel,
recording.statusLabel,
).joinToString(" · "),
style = MaterialTheme.typography.bodyMedium,
)
if (recording.downloadEnabled && team?.phoneDownloadEnabled == true) {
MatchPrimaryButton(
label = "SCARICA MP4",
onClick = {
scope.launch {
runCatching {
container.matchRepository.recordingDownloadUrl(recording.id)
}.onSuccess { url ->
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(url)),
)
}
}
},
modifier = Modifier.padding(top = 8.dp),
)
}
}
}
}
}
}
}
}

View File

@@ -1,14 +1,25 @@
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.padding
import androidx.compose.material3.Button
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
@@ -18,19 +29,31 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.data.AppContainer
import com.matchlivetv.match_live_tv.domain.Match
import com.matchlivetv.match_live_tv.domain.MatchScoringRules
import com.matchlivetv.match_live_tv.domain.StreamSession
import com.matchlivetv.match_live_tv.streaming.BroadcastConfig
import com.matchlivetv.match_live_tv.streaming.BroadcastPhase
import com.matchlivetv.match_live_tv.streaming.LivePreviewView
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.theme.MatchColors
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BroadcastScreen(
container: AppContainer,
@@ -38,75 +61,326 @@ fun BroadcastScreen(
onFinished: () -> Unit,
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val snackbar = remember { SnackbarHostState() }
var session by remember { mutableStateOf<StreamSession?>(null) }
var match by remember { mutableStateOf<Match?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(true) }
var pauseInFlight by remember { mutableStateOf(false) }
var resumeInFlight by remember { mutableStateOf(false) }
val metrics by container.broadcastCoordinator.metrics.collectAsState()
val configuration = LocalConfiguration.current
val portrait = configuration.screenHeightDp > configuration.screenWidthDp
LaunchedEffect(sessionId) {
runCatching { container.sessionRepository.fetchSession(sessionId) }
.onSuccess { loaded ->
session = loaded
val url = loaded.rtmpIngestUrl
if (url.isNullOrBlank()) {
error = "URL RTMP mancante"
return@onSuccess
}
container.broadcastCoordinator.startService()
container.broadcastCoordinator.engine.startBroadcast(
BroadcastConfig(
rtmpUrl = url,
width = if (portrait) 720 else 1280,
height = if (portrait) 1280 else 720,
portrait = portrait,
videoBitrate = loaded.targetBitrate,
fps = loaded.targetFps,
),
)
}
.onFailure { error = it.message ?: "Sessione non disponibile" }
val score by container.scoreController.score.collectAsState()
val cableConnected by container.sessionCable.connected.collectAsState()
val permissions = rememberBroadcastPermissionsState()
val dialogHost = rememberLiveScoreDialogHost()
val scoringRules = remember(match) { match?.let { MatchScoringRules.fromMatch(it) } }
val scoreActions = remember(scoringRules, dialogHost) {
scoringRules?.let { LiveScoreActions(it, container.scoreController, dialogHost) }
}
DisposableEffect(Unit) {
fun broadcastConfig(loaded: StreamSession): BroadcastConfig = BroadcastConfig(
rtmpUrl = loaded.rtmpIngestUrl.orEmpty(),
width = 1280,
height = 720,
videoBitrate = loaded.targetBitrate,
fps = loaded.targetFps,
)
suspend fun stopStreamPermanently() {
runCatching { container.sessionRepository.stopSession(sessionId) }
container.sessionCable.disconnect()
container.broadcastCoordinator.engine.stopBroadcast()
container.broadcastCoordinator.stopService()
onFinished()
}
suspend fun pauseStream() {
if (pauseInFlight) return
pauseInFlight = true
try {
val updated = container.sessionRepository.pauseSession(sessionId)
session = updated
container.broadcastCoordinator.engine.pauseBroadcast()
snackbar.showSnackbar("Diretta in pausa — gli spettatori vedono la copertina")
} catch (e: Exception) {
snackbar.showSnackbar("Pausa sessione: ${e.message ?: "errore"}")
} finally {
pauseInFlight = false
}
}
suspend fun resumeStream() {
if (resumeInFlight) return
resumeInFlight = true
try {
var updated = container.sessionRepository.resumeSession(sessionId)
session = updated
val url = updated.rtmpIngestUrl
if (!url.isNullOrBlank()) {
container.broadcastCoordinator.engine.resumeBroadcast(broadcastConfig(updated))
}
updated = container.sessionRepository.fetchSession(sessionId)
session = updated
snackbar.showSnackbar("Diretta ripresa — di nuovo in onda")
} catch (e: Exception) {
snackbar.showSnackbar("Ripresa diretta: ${e.message ?: "errore"}")
} finally {
resumeInFlight = false
}
}
suspend fun applyRemotePause() {
container.broadcastCoordinator.engine.pauseBroadcast()
session = session?.copy(status = "paused")
snackbar.showSnackbar("Pausa dalla regia — trasmissione fermata")
}
suspend fun applyRemoteResume() {
if (resumeInFlight) return
val current = session ?: return
val url = current.rtmpIngestUrl ?: return
resumeInFlight = true
try {
container.broadcastCoordinator.engine.resumeBroadcast(broadcastConfig(current))
snackbar.showSnackbar("Ripresa dalla regia — di nuovo in onda")
} catch (e: Exception) {
snackbar.showSnackbar("Ripresa RTMP: ${e.message ?: "errore"}")
} finally {
resumeInFlight = false
}
}
LaunchedEffect(sessionId, permissions.granted) {
if (!permissions.granted) return@LaunchedEffect
loading = true
error = null
runCatching {
val loaded = container.sessionRepository.fetchSession(sessionId)
val matchData = container.matchRepository.fetchMatch(loaded.matchId)
session = loaded
match = matchData
container.scoreController.bind(sessionId, loaded.score)
val token = container.authRepository.currentSession()?.accessToken
if (!token.isNullOrBlank()) {
container.sessionCable.onScoreUpdate = { remote ->
container.scoreController.applyRemote(remote)
}
container.sessionCable.onPauseStream = {
if (!pauseInFlight) scope.launch { applyRemotePause() }
}
container.sessionCable.onResumeStream = {
if (!resumeInFlight) scope.launch { applyRemoteResume() }
}
container.sessionCable.connect(sessionId, token, deviceRole = "camera")
}
container.broadcastCoordinator.startService()
val url = loaded.rtmpIngestUrl
if (url.isNullOrBlank()) {
error("URL RTMP mancante")
} else if (!loaded.isPaused) {
container.broadcastCoordinator.engine.startBroadcast(broadcastConfig(loaded))
}
}.onFailure {
error = it.message ?: "Sessione non disponibile"
}
loading = false
}
LaunchedEffect(sessionId) {
while (true) {
delay(10_000)
val current = session ?: continue
if (current.isPaused) continue
runCatching {
container.sessionRepository.postTelemetry(
sessionId = sessionId,
deviceRole = "camera",
batteryLevel = DeviceTelemetry.batteryLevel(context),
networkType = DeviceTelemetry.networkType(context),
currentBitrate = (metrics.bitrateKbps * 1000).toInt().takeIf { it > 0 },
targetBitrate = current.targetBitrate,
fps = metrics.fps.takeIf { it > 0 },
)
}
}
}
DisposableEffect(sessionId) {
onDispose {
container.sessionCable.onPauseStream = null
container.sessionCable.onResumeStream = null
container.sessionCable.disconnect()
container.broadcastCoordinator.stopService()
container.broadcastCoordinator.engine.stopBroadcast()
}
}
Column(Modifier.fillMaxSize().background(MatchColors.Background)) {
Box(Modifier.weight(1f).fillMaxWidth()) {
CameraPreviewPanel(container)
Text(
text = when (metrics.phase) {
BroadcastPhase.LIVE -> "IN DIRETTA"
BroadcastPhase.CONNECTING -> "CONNESSIONE…"
BroadcastPhase.RECONNECTING -> "RICONNESSIONE…"
BroadcastPhase.ERROR -> metrics.lastError ?: "ERRORE"
else -> "PREVIEW"
},
color = if (metrics.phase == BroadcastPhase.LIVE) MatchColors.SuccessGreen else MatchColors.AccentYellow,
modifier = Modifier.padding(16.dp),
)
}
error?.let {
Text(it, color = MatchColors.PrimaryRed, modifier = Modifier.padding(16.dp))
}
Button(
onClick = {
scope.launch {
runCatching { container.sessionRepository.stopSession(sessionId) }
container.broadcastCoordinator.engine.stopBroadcast()
container.broadcastCoordinator.stopService()
onFinished()
val isPaused = session?.isPaused == true || metrics.phase == BroadcastPhase.PAUSED
val statusText = when {
isPaused -> "PAUSA"
metrics.phase == BroadcastPhase.LIVE -> "IN DIRETTA"
metrics.phase == BroadcastPhase.CONNECTING -> "CONNESSIONE…"
metrics.phase == BroadcastPhase.RECONNECTING -> "RICONNESSIONE…"
metrics.phase == BroadcastPhase.ERROR -> metrics.lastError ?: "ERRORE"
else -> "PREVIEW"
}
val statusColor = when {
isPaused -> MatchColors.AccentYellow
metrics.phase == BroadcastPhase.LIVE -> MatchColors.SuccessGreen
metrics.phase == BroadcastPhase.ERROR -> MatchColors.PrimaryRed
else -> MatchColors.AccentYellow
}
val pointsTarget = scoringRules?.pointsTarget(score.currentSet) ?: 25
match?.let { currentMatch ->
ScoreDialogRouter(
host = dialogHost,
homeName = currentMatch.teamName,
awayName = currentMatch.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)
}
},
) {
Column(Modifier.fillMaxSize()) {
Box(
Modifier
.weight(1f)
.fillMaxWidth(),
) {
if (loading) {
CircularProgressIndicator(
color = MatchColors.PrimaryRed,
modifier = Modifier.align(Alignment.Center),
)
} else {
CameraPreviewPanel(container)
}
},
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Text("TERMINA DIRETTA")
MatchStatusBadge(
text = statusText,
textColor = statusColor,
backgroundColor = MatchColors.Background.copy(alpha = 0.75f),
modifier = Modifier
.align(Alignment.TopStart)
.padding(16.dp),
)
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 {
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",
color = MatchColors.AccentYellow,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.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),
)
}
}
}
}

View File

@@ -0,0 +1,153 @@
package com.matchlivetv.match_live_tv.ui.broadcast
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 com.matchlivetv.match_live_tv.data.scoring.ScoreController
import com.matchlivetv.match_live_tv.domain.MatchScoringRules
import com.matchlivetv.match_live_tv.domain.ScoreState
import com.matchlivetv.match_live_tv.domain.ScoringSide
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
enum class ScoreDialogKind {
SET_WON,
CLOSE_SET_ANYWAY,
MATCH_WON,
}
data class ScoreDialogState(
val kind: ScoreDialogKind,
val winnerSide: ScoringSide? = null,
val homePoints: Int = 0,
val awayPoints: Int = 0,
val homeSets: Int = 0,
val awaySets: Int = 0,
)
class LiveScoreDialogHost {
private var dialogOpen = false
var pendingDialog by mutableStateOf<ScoreDialogState?>(null)
private set
private var pendingResult: ((Boolean) -> Unit)? = null
suspend fun show(state: ScoreDialogState): Boolean = suspendCancellableCoroutine { cont ->
if (dialogOpen) {
cont.resume(false)
return@suspendCancellableCoroutine
}
dialogOpen = true
pendingDialog = state
pendingResult = { value ->
if (cont.isActive) cont.resume(value)
}
cont.invokeOnCancellation {
pendingDialog = null
dialogOpen = false
pendingResult = null
}
}
fun resolve(result: Boolean) {
pendingDialog = null
dialogOpen = false
pendingResult?.invoke(result)
pendingResult = null
}
}
@Composable
fun rememberLiveScoreDialogHost(): LiveScoreDialogHost = remember { LiveScoreDialogHost() }
@Composable
fun ScoreDialogRouter(
host: LiveScoreDialogHost,
homeName: String,
awayName: String,
) {
val dialog = host.pendingDialog ?: return
when (dialog.kind) {
ScoreDialogKind.SET_WON -> {
val winner = dialog.winnerSide ?: return
SetWonDialog(
winnerName = scoringSideName(winner, homeName, awayName),
homePoints = dialog.homePoints,
awayPoints = dialog.awayPoints,
onDismiss = host::resolve,
)
}
ScoreDialogKind.CLOSE_SET_ANYWAY -> {
CloseSetAnywayDialog(onDismiss = host::resolve)
}
ScoreDialogKind.MATCH_WON -> {
val winner = dialog.winnerSide ?: return
MatchWonDialog(
winnerName = scoringSideName(winner, homeName, awayName),
homeSets = dialog.homeSets,
awaySets = dialog.awaySets,
onDismiss = host::resolve,
)
}
}
}
class LiveScoreActions(
private val rules: MatchScoringRules,
private val scoreController: ScoreController,
private val dialogHost: LiveScoreDialogHost,
) {
suspend fun afterPointChange(
score: ScoreState,
onStopStream: suspend () -> Unit = {},
) {
val winner = rules.setWinnerFromPoints(
homePoints = score.homePoints,
awayPoints = score.awayPoints,
currentSet = score.currentSet,
) ?: return
val close = dialogHost.show(
ScoreDialogState(
kind = ScoreDialogKind.SET_WON,
winnerSide = winner,
homePoints = score.homePoints,
awayPoints = score.awayPoints,
),
)
if (close) {
scoreController.closeSet()
afterCloseSet(onStopStream)
}
}
suspend fun requestCloseSet(onStopStream: suspend () -> Unit) {
val score = scoreController.score.value
val winner = rules.setWinnerFromPoints(
homePoints = score.homePoints,
awayPoints = score.awayPoints,
currentSet = score.currentSet,
)
if (winner == null) {
val ok = dialogHost.show(ScoreDialogState(kind = ScoreDialogKind.CLOSE_SET_ANYWAY))
if (!ok) return
}
scoreController.closeSet()
afterCloseSet(onStopStream)
}
private suspend fun afterCloseSet(onStopStream: suspend () -> Unit) {
val score = scoreController.score.value
val winner = rules.matchWinner(score.homeSets, score.awaySets) ?: return
val stop = dialogHost.show(
ScoreDialogState(
kind = ScoreDialogKind.MATCH_WON,
winnerSide = winner,
homeSets = score.homeSets,
awaySets = score.awaySets,
),
)
if (stop) onStopStream()
}
}

View File

@@ -0,0 +1,153 @@
package com.matchlivetv.match_live_tv.ui.broadcast
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
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.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.domain.ScoreState
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 LiveScoreControls(
homeName: String,
awayName: String,
score: ScoreState,
pointsTarget: Int = 25,
onPointHome: () -> Unit,
onPointAway: () -> Unit,
onMinusHome: () -> Unit,
onMinusAway: () -> Unit,
onCloseSet: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier
.fillMaxWidth()
.background(MatchColors.Surface, RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp))
.padding(16.dp),
) {
ScoreOverlayBar(
homeName = homeName,
awayName = awayName,
score = score,
pointsTarget = pointsTarget,
)
Spacer(Modifier.height(12.dp))
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
TeamScoreColumn(
label = homeName,
points = score.homePoints,
onPlus = onPointHome,
onMinus = onMinusHome,
modifier = Modifier.weight(1f),
)
Text(
"$pointsTarget pt",
style = MaterialTheme.typography.labelSmall,
color = MatchColors.TextSecondary,
modifier = Modifier.padding(top = 24.dp).padding(horizontal = 8.dp),
)
TeamScoreColumn(
label = awayName,
points = score.awayPoints,
onPlus = onPointAway,
onMinus = onMinusAway,
modifier = Modifier.weight(1f),
)
}
Spacer(Modifier.height(10.dp))
MatchSecondaryButton(
label = "CHIUDI SET",
onClick = onCloseSet,
)
}
}
@Composable
private fun TeamScoreColumn(
label: String,
points: Int,
onPlus: () -> Unit,
onMinus: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) {
Text(
label,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(4.dp))
Text(
"$points",
style = MaterialTheme.typography.headlineMedium,
color = MatchColors.PrimaryRed,
)
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
MatchPrimaryButton(
label = "+1",
onClick = onPlus,
modifier = Modifier.weight(1f),
)
OutlinedButton(onClick = onMinus) {
Text("", color = Color.White)
}
}
}
}
@Composable
private fun ScoreOverlayBar(
homeName: String,
awayName: String,
score: ScoreState,
pointsTarget: Int,
) {
Row(
Modifier
.fillMaxWidth()
.background(MatchColors.SurfaceElevated, RoundedCornerShape(10.dp))
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(homeName, style = MaterialTheme.typography.labelMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("${score.homeSets} set", style = MaterialTheme.typography.labelSmall, color = MatchColors.TextSecondary)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
"${score.homePoints} - ${score.awayPoints}",
style = MaterialTheme.typography.titleLarge,
)
Text(
"Set ${score.currentSet} · $pointsTarget pt",
style = MaterialTheme.typography.labelSmall,
color = MatchColors.TextSecondary,
)
}
Column(Modifier.weight(1f), horizontalAlignment = Alignment.End) {
Text(awayName, style = MaterialTheme.typography.labelMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.End)
Text("${score.awaySets} set", style = MaterialTheme.typography.labelSmall, color = MatchColors.TextSecondary, textAlign = TextAlign.End)
}
}
}

View File

@@ -0,0 +1,95 @@
package com.matchlivetv.match_live_tv.ui.broadcast
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import com.matchlivetv.match_live_tv.domain.ScoringSide
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
@Composable
fun SetWonDialog(
winnerName: String,
homePoints: Int,
awayPoints: Int,
onDismiss: (closeSet: Boolean) -> Unit,
) {
AlertDialog(
onDismissRequest = { onDismiss(false) },
containerColor = MatchColors.Surface,
title = { Text("Set concluso") },
text = {
Text(
"$winnerName vince il set $homePoints-$awayPoints.\n\n" +
"Chiudere il set e passare al successivo?",
)
},
confirmButton = {
FilledTonalButton(onClick = { onDismiss(true) }) {
Text("Chiudi set", color = Color.Black)
}
},
dismissButton = {
TextButton(onClick = { onDismiss(false) }) {
Text("Continua a segnare")
}
},
)
}
@Composable
fun CloseSetAnywayDialog(onDismiss: (confirmed: Boolean) -> Unit) {
AlertDialog(
onDismissRequest = { onDismiss(false) },
containerColor = MatchColors.Surface,
title = { Text("Chiudi set") },
text = {
Text("Il punteggio non soddisfa ancora le regole del torneo. Chiudere il set comunque?")
},
confirmButton = {
FilledTonalButton(onClick = { onDismiss(true) }) {
Text("Chiudi comunque")
}
},
dismissButton = {
TextButton(onClick = { onDismiss(false) }) {
Text("Annulla")
}
},
)
}
@Composable
fun MatchWonDialog(
winnerName: String,
homeSets: Int,
awaySets: Int,
onDismiss: (stopStream: Boolean) -> Unit,
) {
AlertDialog(
onDismissRequest = { onDismiss(false) },
containerColor = MatchColors.Surface,
title = { Text("Partita terminata") },
text = {
Text(
"$winnerName vince la partita ($homeSets-$awaySets set).\n\n" +
"Chiudere definitivamente la diretta?",
)
},
confirmButton = {
FilledTonalButton(onClick = { onDismiss(true) }) {
Text("Chiudi diretta", color = Color.White)
}
},
dismissButton = {
TextButton(onClick = { onDismiss(false) }) {
Text("Continua in onda")
}
},
)
}
fun scoringSideName(side: ScoringSide, homeName: String, awayName: String): String =
if (side == ScoringSide.HOME) homeName else awayName

View File

@@ -0,0 +1,86 @@
package com.matchlivetv.match_live_tv.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.matchlivetv.match_live_tv.R
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
@Composable
fun MatchLiveWordmark(
modifier: Modifier = Modifier,
compact: Boolean = false,
showSlogan: Boolean = false,
showLogo: Boolean = !compact,
) {
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
if (showLogo) {
Image(
painter = painterResource(R.drawable.logo_white),
contentDescription = stringResource(R.string.app_name),
modifier = Modifier
.size(if (compact) 40.dp else 80.dp)
.padding(bottom = 16.dp),
contentScale = ContentScale.Fit,
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "MATCH",
style = MaterialTheme.typography.displaySmall,
color = Color.White,
)
Spacer(Modifier.width(8.dp))
Text(
text = "LIVE",
modifier = Modifier
.background(MatchColors.PrimaryRed, RoundedCornerShape(6.dp))
.padding(
horizontal = if (compact) 8.dp else 12.dp,
vertical = if (compact) 2.dp else 4.dp,
),
style = MaterialTheme.typography.labelLarge.copy(
color = Color.White,
fontSize = if (compact) 14.sp else 18.sp,
),
)
Spacer(Modifier.width(8.dp))
Text(
text = "TV",
style = MaterialTheme.typography.displaySmall,
color = MatchColors.TextSecondary,
)
}
if (showSlogan) {
Spacer(Modifier.padding(top = 12.dp))
Text(
text = stringResource(R.string.app_slogan).uppercase(),
style = MaterialTheme.typography.labelLarge.copy(
color = MatchColors.TextSecondary,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
),
textAlign = TextAlign.Center,
)
}
}
}

View File

@@ -0,0 +1,47 @@
package com.matchlivetv.match_live_tv.ui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
@Composable
fun MatchPrimaryButton(
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
loading: Boolean = false,
) {
Button(
onClick = onClick,
enabled = enabled && !loading,
modifier = modifier
.fillMaxWidth()
.height(52.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MatchColors.PrimaryRed,
contentColor = Color.White,
disabledContainerColor = MatchColors.SurfaceElevated,
disabledContentColor = MatchColors.TextSecondary,
),
shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp),
) {
if (loading) {
CircularProgressIndicator(
color = Color.White,
strokeWidth = 2.dp,
modifier = Modifier.height(22.dp),
)
} else {
Text(label, style = androidx.compose.material3.MaterialTheme.typography.labelLarge)
}
}
}

View File

@@ -0,0 +1,32 @@
package com.matchlivetv.match_live_tv.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
@Composable
fun MatchScreenScaffold(
modifier: Modifier = Modifier,
topBar: @Composable () -> Unit = {},
content: @Composable BoxScope.() -> Unit,
) {
Scaffold(
modifier = modifier,
containerColor = MatchColors.Background,
contentColor = Color.White,
topBar = topBar,
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
content = content,
)
}
}

View File

@@ -0,0 +1,40 @@
package com.matchlivetv.match_live_tv.ui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.BorderStroke
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
@Composable
fun MatchSecondaryButton(
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
OutlinedButton(
onClick = onClick,
enabled = enabled,
modifier = modifier
.fillMaxWidth()
.height(52.dp),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = Color.White,
disabledContentColor = MatchColors.TextSecondary,
),
border = BorderStroke(
1.dp,
if (enabled) MatchColors.Outline else MatchColors.SurfaceElevated,
),
shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp),
) {
Text(label, style = androidx.compose.material3.MaterialTheme.typography.labelLarge)
}
}

View File

@@ -0,0 +1,29 @@
package com.matchlivetv.match_live_tv.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
@Composable
fun MatchStatusBadge(
text: String,
modifier: Modifier = Modifier,
backgroundColor: Color = MatchColors.SurfaceElevated,
textColor: Color = MatchColors.AccentYellow,
) {
Text(
text = text,
modifier = modifier
.background(backgroundColor, RoundedCornerShape(6.dp))
.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelLarge,
color = textColor,
)
}

View File

@@ -1,14 +1,21 @@
package com.matchlivetv.match_live_tv.ui.login
import androidx.compose.foundation.layout.Arrangement
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.padding
import androidx.compose.material3.Button
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -16,10 +23,20 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.data.AppContainer
import com.matchlivetv.match_live_tv.ui.components.MatchLiveWordmark
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.theme.MatchColors
import kotlinx.coroutines.launch
@@ -32,56 +49,128 @@ fun LoginScreen(
var password by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(false) }
var passwordVisible by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
) {
Text("MATCH LIVE TV", color = MatchColors.PrimaryRed)
Spacer(Modifier.height(24.dp))
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
)
error?.let {
Spacer(Modifier.height(8.dp))
Text(it, color = MatchColors.PrimaryRed)
}
Spacer(Modifier.height(20.dp))
Button(
onClick = {
loading = true
error = null
scope.launch {
runCatching {
container.authRepository.login(email.trim(), password)
}.onSuccess {
onLoggedIn()
}.onFailure {
error = it.message ?: "Login fallito"
}
loading = false
fun submitLogin() {
if (loading || email.isBlank() || password.isBlank()) return
loading = true
error = null
scope.launch {
runCatching {
container.authRepository.login(email.trim(), password)
}.onSuccess {
onLoggedIn()
}.onFailure {
error = when {
it.message?.contains("401") == true -> "Email o password non corretti"
it.message?.contains("timeout", ignoreCase = true) == true ->
"Server non raggiungibile. Verifica la connessione."
else -> it.message ?: "Login fallito"
}
},
enabled = !loading && email.isNotBlank() && password.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
}
loading = false
}
}
MatchScreenScaffold {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(if (loading) "Accesso…" else "ACCEDI")
MatchLiveWordmark(showSlogan = true)
Spacer(Modifier.height(48.dp))
Text(
text = "ACCEDI",
style = androidx.compose.material3.MaterialTheme.typography.headlineMedium,
)
Spacer(Modifier.height(8.dp))
Text(
text = "Gestisci le dirette della tua squadra",
style = androidx.compose.material3.MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(32.dp))
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") },
placeholder = { Text("coach@squadra.it") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next,
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) },
),
colors = matchTextFieldColors(),
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = if (passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) {
Icons.Filled.VisibilityOff
} else {
Icons.Filled.Visibility
},
contentDescription = null,
tint = MatchColors.TextSecondary,
)
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { submitLogin() }),
colors = matchTextFieldColors(),
)
error?.let {
Spacer(Modifier.height(12.dp))
Text(
text = it,
color = MatchColors.PrimaryRed,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(32.dp))
MatchPrimaryButton(
label = "ACCEDI",
loading = loading,
enabled = email.isNotBlank() && password.isNotBlank(),
onClick = { submitLogin() },
)
}
}
}
@Composable
private fun matchTextFieldColors() = OutlinedTextFieldDefaults.colors(
focusedTextColor = androidx.compose.ui.graphics.Color.White,
unfocusedTextColor = androidx.compose.ui.graphics.Color.White,
focusedBorderColor = MatchColors.PrimaryRed,
unfocusedBorderColor = MatchColors.Outline,
focusedLabelColor = MatchColors.PrimaryRed,
unfocusedLabelColor = MatchColors.TextSecondary,
cursorColor = MatchColors.PrimaryRed,
focusedPlaceholderColor = MatchColors.TextSecondary,
unfocusedPlaceholderColor = MatchColors.TextSecondary,
)

View File

@@ -0,0 +1,560 @@
package com.matchlivetv.match_live_tv.ui.matches
import android.app.DatePickerDialog
import android.app.TimePickerDialog
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarToday
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.EventAvailable
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.PlayCircleOutline
import androidx.compose.material.icons.filled.RadioButtonUnchecked
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.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
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.theme.MatchColors
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
enum class NewMatchChoice { Schedule, QuickStart }
data class ScheduleMatchInput(
val opponentName: String,
val scheduledAt: Instant,
val location: String?,
)
private val sheetDateFormat =
DateTimeFormatter.ofPattern("EEE d MMM yyyy · HH:mm", Locale.ITALY)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewMatchBottomSheet(
onDismiss: () -> Unit,
onChoice: (NewMatchChoice) -> Unit,
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
containerColor = MatchColors.Surface,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
Column(Modifier.padding(horizontal = 20.dp, vertical = 12.dp)) {
Text("Nuova partita", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(6.dp))
Text(
"Programma in anticipo o avvia la configurazione diretta subito.",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(20.dp))
SheetOptionTile(
icon = { Icon(Icons.Default.EventAvailable, null, tint = MatchColors.PrimaryRed) },
title = "Programma partita",
subtitle = "Data, ora e avversario — visibile anche sul sito",
onClick = { onChoice(NewMatchChoice.Schedule) },
)
Spacer(Modifier.height(10.dp))
SheetOptionTile(
icon = { Icon(Icons.Default.PlayCircleOutline, null, tint = MatchColors.PrimaryRed) },
title = "Avvia subito",
subtitle = "Crea la partita e passa al wizard senza orario",
onClick = { onChoice(NewMatchChoice.QuickStart) },
)
Spacer(Modifier.height(24.dp))
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ScheduleMatchBottomSheet(
teamName: String?,
onDismiss: () -> Unit,
onSubmit: (ScheduleMatchInput) -> Unit,
) {
val context = LocalContext.current
var opponent by remember { mutableStateOf("") }
var location by remember { mutableStateOf("") }
var scheduledAt by remember {
mutableStateOf(
LocalDateTime.now().plusHours(2).withMinute(0).withSecond(0).withNano(0),
)
}
fun pickDateTime() {
DatePickerDialog(
context,
{ _, year, month, day ->
TimePickerDialog(
context,
{ _, hour, minute ->
scheduledAt = LocalDateTime.of(year, month + 1, day, hour, minute)
},
scheduledAt.hour,
scheduledAt.minute,
true,
).show()
},
scheduledAt.year,
scheduledAt.monthValue - 1,
scheduledAt.dayOfMonth,
).show()
}
ModalBottomSheet(
onDismissRequest = onDismiss,
containerColor = MatchColors.Surface,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
Column(Modifier.padding(horizontal = 20.dp, vertical = 12.dp)) {
Text("Programma partita", style = MaterialTheme.typography.headlineMedium)
if (!teamName.isNullOrBlank()) {
Spacer(Modifier.height(4.dp))
Text(teamName, color = MatchColors.PrimaryRed, style = MaterialTheme.typography.titleMedium)
}
Spacer(Modifier.height(6.dp))
Text(
"La diretta non parte ora: comparirà sul sito con data e ora. " +
"Avvierai lo streaming dall'app quando sei in palestra.",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(20.dp))
MatchOutlinedField(
value = opponent,
onValueChange = { opponent = it },
label = "Avversario",
)
Spacer(Modifier.height(12.dp))
MatchOutlinedField(
value = location,
onValueChange = { location = it },
label = "Luogo (opzionale)",
)
Spacer(Modifier.height(12.dp))
Row(
Modifier
.fillMaxWidth()
.clickable { pickDateTime() }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text("Data e ora", style = MaterialTheme.typography.bodyMedium)
Text(
scheduledAt.format(sheetDateFormat),
style = MaterialTheme.typography.titleMedium,
)
}
Icon(Icons.Default.CalendarToday, null, tint = MatchColors.PrimaryRed)
}
Spacer(Modifier.height(20.dp))
MatchPrimaryButton(
label = "SALVA IN PROGRAMMA",
onClick = {
val name = opponent.trim()
if (name.isEmpty()) return@MatchPrimaryButton
onSubmit(
ScheduleMatchInput(
opponentName = name,
scheduledAt = scheduledAt.atZone(ZoneId.systemDefault()).toInstant(),
location = location.trim().ifBlank { null },
),
)
},
)
Spacer(Modifier.height(24.dp))
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SelectMatchBottomSheet(
matches: List<Match>,
onDismiss: () -> Unit,
onSelect: (Match) -> Unit,
) {
val selectable = matches.filter { !it.hasActiveSession }
val now = Instant.now()
val scheduled = selectable
.filter { it.scheduledAt != null }
.sortedBy { runCatching { Instant.parse(it.scheduledAt!!) }.getOrNull() }
val unscheduled = selectable.filter { it.scheduledAt == null }
ModalBottomSheet(
onDismissRequest = onDismiss,
containerColor = MatchColors.Surface,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
Column(Modifier.padding(horizontal = 20.dp, vertical = 12.dp)) {
Text("Scegli partita", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(6.dp))
Text(
"Partite già programmate sul sito o in app.",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(16.dp))
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.height(360.dp),
) {
if (scheduled.isNotEmpty()) {
item { Text("Programmate", style = MaterialTheme.typography.labelLarge) }
items(scheduled, key = { it.id }) { match ->
SelectMatchTile(match, now) { onSelect(match) }
}
}
if (unscheduled.isNotEmpty()) {
item {
Spacer(Modifier.height(8.dp))
Text("Senza orario", style = MaterialTheme.typography.labelLarge)
}
items(unscheduled, key = { it.id }) { match ->
SelectMatchTile(match, now) { onSelect(match) }
}
}
}
Spacer(Modifier.height(24.dp))
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TeamPickerBottomSheet(
teams: List<Team>,
selectedTeamId: String?,
onDismiss: () -> Unit,
onSelect: (String) -> Unit,
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
containerColor = MatchColors.Surface,
) {
Column(Modifier.padding(bottom = 24.dp)) {
Text(
"Scegli squadra",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp),
)
teams.forEach { team ->
val selected = team.id == selectedTeamId
Row(
Modifier
.fillMaxWidth()
.clickable { onSelect(team.id) }
.padding(horizontal = 20.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
if (selected) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked,
contentDescription = null,
tint = if (selected) MatchColors.PrimaryRed else MatchColors.TextSecondary,
)
Spacer(Modifier.width(16.dp))
Column {
Text(team.name, style = MaterialTheme.typography.titleMedium)
team.clubName?.takeIf { it.isNotBlank() }?.let {
Text(it, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ResumeSessionBottomSheet(
match: Match,
onDismiss: () -> Unit,
onResumeCamera: () -> Unit,
onContinueSetup: () -> Unit,
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
containerColor = MatchColors.Surface,
) {
Column(Modifier.padding(horizontal = 20.dp, vertical = 16.dp)) {
Text("Diretta in corso", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(4.dp))
Text(
"${match.teamName} vs ${match.opponentName}",
style = MaterialTheme.typography.bodyMedium,
)
match.activeSessionStatus?.let { status ->
Spacer(Modifier.height(8.dp))
Text("Stato: $status", color = MatchColors.PrimaryRed, style = MaterialTheme.typography.labelLarge)
}
Spacer(Modifier.height(20.dp))
if (match.canResumeCamera) {
MatchPrimaryButton(label = "RIPRENDI CAMERA E TRASMETTI", onClick = onResumeCamera)
Spacer(Modifier.height(8.dp))
}
if (match.activeSessionStatus == "idle") {
MatchSecondaryButton(label = "CONTINUA CONFIGURAZIONE", onClick = onContinueSetup)
Spacer(Modifier.height(8.dp))
}
TextButton(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) {
Text("Annulla", color = MatchColors.TextSecondary)
}
Spacer(Modifier.height(8.dp))
}
}
}
@Composable
fun ConfigureScheduledMatchDialog(
onLater: () -> Unit,
onConfigure: () -> Unit,
) {
AlertDialog(
onDismissRequest = onLater,
containerColor = MatchColors.Surface,
title = { Text("Configurare ora?") },
text = {
Text(
"Puoi preparare la trasmissione subito, oppure tornare quando sei in palestra.",
style = MaterialTheme.typography.bodyMedium,
)
},
confirmButton = {
TextButton(onClick = onConfigure) {
Text("Configura", color = MatchColors.PrimaryRed)
}
},
dismissButton = {
TextButton(onClick = onLater) {
Text("Più tardi", color = MatchColors.TextSecondary)
}
},
)
}
@Composable
fun DeleteMatchDialog(
match: Match,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
containerColor = MatchColors.Surface,
title = { Text("Elimina partita") },
text = {
Text(
"Eliminare «${match.teamName} vs ${match.opponentName}»?\n\n" +
"L'operazione non si può annullare.",
style = MaterialTheme.typography.bodyMedium,
)
},
confirmButton = {
TextButton(onClick = onConfirm) {
Text("Elimina", color = MatchColors.PrimaryRed)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Annulla", color = MatchColors.TextSecondary)
}
},
)
}
@Composable
private fun SheetOptionTile(
icon: @Composable () -> Unit,
title: String,
subtitle: String,
onClick: () -> Unit,
) {
Row(
Modifier
.fillMaxWidth()
.background(MatchColors.SurfaceElevated, RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
icon()
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp))
Text(subtitle, style = MaterialTheme.typography.bodyMedium)
}
Icon(Icons.Default.ChevronRight, null, tint = MatchColors.TextSecondary)
}
}
@Composable
private fun SelectMatchTile(match: Match, now: Instant, onClick: () -> Unit) {
val scheduledInstant = match.scheduledAt?.let { runCatching { Instant.parse(it) }.getOrNull() }
val isFuture = scheduledInstant?.isAfter(now) == true
Row(
Modifier
.fillMaxWidth()
.background(MatchColors.SurfaceElevated, RoundedCornerShape(10.dp))
.clickable(onClick = onClick)
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text("${match.teamName} vs ${match.opponentName}", style = MaterialTheme.typography.titleMedium)
scheduledInstant?.let {
Text(formatMatchDate(it), style = MaterialTheme.typography.bodyMedium)
}
match.location?.takeIf { it.isNotBlank() }?.let {
Text(it, style = MaterialTheme.typography.bodyMedium)
}
}
Text(
when {
scheduledInstant == null -> "BOZZA"
isFuture -> "PROGRAMMATA"
else -> "IN CALENDARIO"
},
style = MaterialTheme.typography.labelLarge,
color = if (isFuture) MatchColors.PrimaryRed else MatchColors.TextSecondary,
modifier = Modifier
.background(
if (isFuture) MatchColors.PrimaryRed.copy(alpha = 0.15f) else MatchColors.Surface,
RoundedCornerShape(6.dp),
)
.padding(horizontal = 8.dp, vertical = 4.dp),
)
}
}
@Composable
fun TeamPickerBar(
team: Team,
showPicker: Boolean,
onClick: () -> Unit,
) {
if (!showPicker) return
Row(
Modifier
.fillMaxWidth()
.background(MatchColors.Surface, RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Default.Groups, null, tint = MatchColors.PrimaryRed)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text("Squadra per la diretta", style = MaterialTheme.typography.bodyMedium)
Text(team.name, style = MaterialTheme.typography.titleMedium)
team.clubName?.takeIf { it.isNotBlank() }?.let {
Text(it, style = MaterialTheme.typography.bodyMedium)
}
}
Icon(Icons.Default.ChevronRight, null, tint = MatchColors.TextSecondary)
}
}
@Composable
fun ActiveSessionBanner(
match: Match,
onClick: () -> Unit,
) {
Row(
Modifier
.fillMaxWidth()
.background(MatchColors.PrimaryRed.copy(alpha = 0.15f), RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Default.Videocam, null, tint = MatchColors.PrimaryRed)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
"Riprendi diretta in corso",
color = MatchColors.PrimaryRed,
style = MaterialTheme.typography.titleMedium,
)
Text("${match.teamName} vs ${match.opponentName}", style = MaterialTheme.typography.bodyMedium)
}
Icon(Icons.Default.ChevronRight, null, tint = MatchColors.PrimaryRed)
}
}
@Composable
fun MatchOutlinedField(
value: String,
onValueChange: (String) -> Unit,
label: String,
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedBorderColor = MatchColors.PrimaryRed,
unfocusedBorderColor = MatchColors.Outline,
focusedLabelColor = MatchColors.PrimaryRed,
unfocusedLabelColor = MatchColors.TextSecondary,
cursorColor = MatchColors.PrimaryRed,
),
)
}
fun matchStatusLabel(match: Match): String {
if (match.canResumeCamera) return "RIPRENDI"
if (match.hasActiveSession) return "IN CORSO"
val at = match.scheduledAt?.let { runCatching { Instant.parse(it) }.getOrNull() }
if (at != null && at.isAfter(Instant.now())) return "PROGRAMMATA"
return "AVVIA"
}
fun formatMatchDate(instant: Instant): String =
sheetDateFormat.format(instant.atZone(ZoneId.systemDefault()))
fun formatMatchDate(iso: String?): String? =
iso?.let { runCatching { formatMatchDate(Instant.parse(it)) }.getOrNull() }

View File

@@ -1,32 +1,54 @@
package com.matchlivetv.match_live_tv.ui.matches
import androidx.compose.foundation.background
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.PaddingValues
import androidx.compose.foundation.layout.Row
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.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
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.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.data.AppContainer
import com.matchlivetv.match_live_tv.data.repository.MatchSessionLauncher
import com.matchlivetv.match_live_tv.domain.Match
import com.matchlivetv.match_live_tv.domain.StreamSession
import com.matchlivetv.match_live_tv.domain.Team
import com.matchlivetv.match_live_tv.ui.components.MatchLiveWordmark
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.theme.MatchColors
import kotlinx.coroutines.launch
@@ -34,99 +56,421 @@ import kotlinx.coroutines.launch
@Composable
fun MatchesScreen(
container: AppContainer,
onOpenSetup: (matchId: String) -> Unit,
onOpenBroadcast: (sessionId: String) -> Unit,
onOpenArchive: () -> Unit,
onLogout: () -> Unit,
) {
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var matches by remember { mutableStateOf<List<Match>>(emptyList()) }
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val session by container.authRepository.sessionFlow.collectAsState(initial = null)
var loading by remember { mutableStateOf(true) }
var actionLoading by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var teams by remember { mutableStateOf<List<Team>>(emptyList()) }
var activeTeam by remember { mutableStateOf<Team?>(null) }
var matches by remember { mutableStateOf<List<Match>>(emptyList()) }
var showNewMatchSheet by remember { mutableStateOf(false) }
var showScheduleSheet by remember { mutableStateOf(false) }
var showSelectMatchSheet by remember { mutableStateOf(false) }
var showTeamSheet by remember { mutableStateOf(false) }
var resumeMatch by remember { mutableStateOf<Match?>(null) }
var configureMatch by remember { mutableStateOf<Match?>(null) }
var deleteMatch by remember { mutableStateOf<Match?>(null) }
suspend fun showMessage(message: String) {
snackbarHostState.showSnackbar(message)
}
fun reload() {
loading = true
error = null
scope.launch {
runCatching { container.matchRepository.fetchMatches() }
.onSuccess { matches = it }
.onFailure { error = it.message ?: "Errore caricamento" }
runCatching {
val loadedTeams = container.matchRepository.fetchTeams()
teams = loadedTeams
val team = container.matchRepository.resolveActiveTeam(loadedTeams)
activeTeam = team
matches = team?.let { container.matchRepository.fetchMatchesForTeam(it.id) }.orEmpty()
}.onFailure {
error = it.message ?: "Errore caricamento"
}
loading = false
}
}
fun openSetup(match: Match) {
onOpenSetup(match.id)
}
fun resumeBroadcast(match: Match) {
if (actionLoading) return
actionLoading = true
scope.launch {
runCatching {
MatchSessionLauncher.resumeBroadcastSession(match, container.sessionRepository)
}.onSuccess { session ->
onOpenBroadcast(session.id)
}.onFailure {
showMessage(it.message ?: "Impossibile riprendere la diretta")
}
actionLoading = false
}
}
fun openMatch(match: Match) {
if (match.hasActiveSession) {
resumeMatch = match
} else {
openSetup(match)
}
}
LaunchedEffect(Unit) { reload() }
fun startMatch(match: Match) {
scope.launch {
runCatching {
val session = if (match.canResumeCamera && match.activeSessionId != null) {
container.sessionRepository.fetchSession(match.activeSessionId)
} else {
val created = container.sessionRepository.createSession(match.id)
container.sessionRepository.submitNetworkTest(
sessionId = created.id,
downloadMbps = 20.0,
uploadMbps = 5.0,
latencyMs = 50,
networkType = "WiFi",
)
container.sessionRepository.startSession(created.id)
}
session
}.onSuccess { session: StreamSession ->
onOpenBroadcast(session.id)
}.onFailure {
error = it.message ?: "Impossibile avviare la diretta"
}
}
}
val activeMatch = matches.firstOrNull { it.hasActiveSession }
Scaffold(
MatchScreenScaffold(
topBar = {
TopAppBar(
title = { Text("MATCH LIVE TV") },
title = { MatchLiveWordmark(compact = true) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MatchColors.Background,
titleContentColor = Color.White,
),
actions = {
IconButton(onClick = onOpenArchive) {
Text("Archivio", color = MatchColors.TextSecondary)
}
IconButton(onClick = {
scope.launch {
container.authRepository.logout()
onLogout()
}
}) {
Text("Esci")
Text("Esci", color = MatchColors.TextSecondary)
}
},
)
},
) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
) {
) {
Box(Modifier.fillMaxSize()) {
when {
loading -> CircularProgressIndicator(color = MatchColors.PrimaryRed)
error != null -> {
Text(error!!, color = MatchColors.PrimaryRed)
Button(onClick = { reload() }) { Text("Riprova") }
loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MatchColors.PrimaryRed)
}
matches.isEmpty() -> Text("Nessuna partita in programma")
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(matches, key = { it.id }) { match ->
Column(
Modifier
.fillMaxWidth()
.clickable { startMatch(match) }
.padding(12.dp),
) {
Text("${match.teamName} vs ${match.opponentName}")
teams.isEmpty() -> NoTeamContent(onRetry = { reload() })
error != null -> Column(
Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(error!!, color = MatchColors.PrimaryRed, textAlign = TextAlign.Center)
Spacer(Modifier.height(16.dp))
MatchPrimaryButton(label = "RIPROVA", onClick = { reload() })
}
else -> LazyColumn(
contentPadding = PaddingValues(bottom = 24.dp),
) {
item {
Column(Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) {
Text(
if (match.canResumeCamera) "Riprendi diretta" else "Vai in diretta",
color = MatchColors.AccentYellow,
"Ciao, ${session?.user?.name.orEmpty()}",
style = MaterialTheme.typography.headlineMedium,
)
Spacer(Modifier.height(4.dp))
Text(
"Scegli una partita programmata o creane una nuova.",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
MatchSecondaryButton(
label = "PARTITA PROGRAMMATA",
onClick = {
val selectable = matches.filter { !it.hasActiveSession }
if (selectable.isEmpty()) {
scope.launch {
showMessage("Nessuna partita disponibile. Crea una nuova partita.")
}
} else {
showSelectMatchSheet = true
}
},
modifier = Modifier.weight(1f),
)
MatchPrimaryButton(
label = "NUOVA PARTITA",
onClick = { showNewMatchSheet = true },
modifier = Modifier.weight(1f),
)
}
Spacer(Modifier.height(8.dp))
activeTeam?.let { team ->
TeamPickerBar(
team = team,
showPicker = teams.size > 1,
onClick = { showTeamSheet = true },
)
}
activeMatch?.let { match ->
Spacer(Modifier.height(12.dp))
ActiveSessionBanner(match) { resumeMatch = match }
}
}
}
item {
Text(
if (matches.isEmpty()) "Calendario vuoto" else "Calendario",
style = MaterialTheme.typography.labelLarge,
color = MatchColors.TextSecondary,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
)
}
if (matches.isEmpty()) {
item {
Text(
"Nessuna partita ancora. Usa «Nuova partita» per programmare o avviare subito.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp),
)
}
} else {
items(matches, key = { it.id }) { match ->
MatchListCard(
match = match,
onClick = { openMatch(match) },
onDelete = if (match.canResumeCamera) null else {
{ deleteMatch = match }
},
)
}
}
}
}
if (actionLoading) {
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.35f)),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = MatchColors.PrimaryRed)
}
}
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}
if (showNewMatchSheet) {
NewMatchBottomSheet(
onDismiss = { showNewMatchSheet = false },
onChoice = { choice ->
showNewMatchSheet = false
when (choice) {
NewMatchChoice.Schedule -> showScheduleSheet = true
NewMatchChoice.QuickStart -> {
val teamId = activeTeam?.id ?: return@NewMatchBottomSheet
actionLoading = true
scope.launch {
runCatching { container.matchRepository.createQuickMatch(teamId) }
.onSuccess { match ->
reload()
openSetup(match)
}
.onFailure {
showMessage(it.message ?: "Impossibile creare la partita")
}
actionLoading = false
}
}
}
},
)
}
if (showScheduleSheet) {
ScheduleMatchBottomSheet(
teamName = activeTeam?.name,
onDismiss = { showScheduleSheet = false },
onSubmit = { input ->
showScheduleSheet = false
val teamId = activeTeam?.id ?: return@ScheduleMatchBottomSheet
actionLoading = true
scope.launch {
runCatching {
container.matchRepository.createScheduledMatch(
teamId = teamId,
opponentName = input.opponentName,
scheduledAt = input.scheduledAt,
location = input.location,
)
}.onSuccess { match ->
reload()
showMessage("Partita programmata — visibile sul sito")
configureMatch = match
}.onFailure {
showMessage(it.message ?: "Errore creazione partita")
}
actionLoading = false
}
},
)
}
if (showSelectMatchSheet) {
SelectMatchBottomSheet(
matches = matches,
onDismiss = { showSelectMatchSheet = false },
onSelect = { match ->
showSelectMatchSheet = false
openMatch(match)
},
)
}
if (showTeamSheet) {
TeamPickerBottomSheet(
teams = teams,
selectedTeamId = activeTeam?.id,
onDismiss = { showTeamSheet = false },
onSelect = { teamId ->
showTeamSheet = false
scope.launch {
container.matchRepository.selectTeam(teamId)
reload()
}
},
)
}
resumeMatch?.let { match ->
ResumeSessionBottomSheet(
match = match,
onDismiss = { resumeMatch = null },
onResumeCamera = {
resumeMatch = null
resumeBroadcast(match)
},
onContinueSetup = {
resumeMatch = null
openSetup(match)
},
)
}
configureMatch?.let { match ->
ConfigureScheduledMatchDialog(
onLater = { configureMatch = null },
onConfigure = {
configureMatch = null
openMatch(match)
},
)
}
deleteMatch?.let { match ->
DeleteMatchDialog(
match = match,
onDismiss = { deleteMatch = null },
onConfirm = {
deleteMatch = null
scope.launch {
runCatching { container.matchRepository.deleteMatch(match.id) }
.onSuccess {
reload()
showMessage("Partita eliminata")
}
.onFailure {
showMessage(it.message ?: "Impossibile eliminare")
}
}
},
)
}
}
@Composable
private fun NoTeamContent(onRetry: () -> Unit) {
Column(
Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Nessuna squadra assegnata",
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(8.dp))
Text(
"Per trasmettere devi essere responsabile della trasmissione di almeno una squadra. " +
"Chiedi al tuo club di aggiungerti come staff trasmissione.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(16.dp))
MatchPrimaryButton(label = "RIPROVA", onClick = onRetry)
}
}
@Composable
private fun MatchListCard(
match: Match,
onClick: () -> Unit,
onDelete: (() -> Unit)?,
) {
val status = matchStatusLabel(match)
val hasSession = match.hasActiveSession
Row(
Modifier
.padding(horizontal = 20.dp, vertical = 6.dp)
.fillMaxWidth()
.background(MatchColors.Surface, RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
"${match.teamName} vs ${match.opponentName}",
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.height(4.dp))
formatMatchDate(match.scheduledAt)?.let {
Text(it, style = MaterialTheme.typography.bodyMedium)
}
match.location?.takeIf { it.isNotBlank() }?.let {
Text(it, style = MaterialTheme.typography.bodyMedium)
}
}
Text(
status,
style = MaterialTheme.typography.labelLarge,
color = if (hasSession) MatchColors.PrimaryRed else MatchColors.TextSecondary,
modifier = Modifier
.background(
if (hasSession) MatchColors.PrimaryRed.copy(alpha = 0.2f) else MatchColors.SurfaceElevated,
RoundedCornerShape(8.dp),
)
.padding(horizontal = 10.dp, vertical = 4.dp),
)
if (onDelete != null) {
IconButton(onClick = onDelete) {
Icon(Icons.Default.DeleteOutline, null, tint = MatchColors.TextSecondary)
}
}
}
}

View File

@@ -7,10 +7,12 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.matchlivetv.match_live_tv.data.AppContainer
import com.matchlivetv.match_live_tv.ui.archive.ArchiveScreen
import com.matchlivetv.match_live_tv.ui.broadcast.BroadcastScreen
import com.matchlivetv.match_live_tv.ui.login.LoginScreen
import com.matchlivetv.match_live_tv.ui.matches.MatchesScreen
import com.matchlivetv.match_live_tv.ui.splash.SplashScreen
import com.matchlivetv.match_live_tv.ui.wizard.WizardShellScreen
@Composable
fun AppNavHost(container: AppContainer) {
@@ -42,9 +44,15 @@ fun AppNavHost(container: AppContainer) {
composable(Routes.Matches) {
MatchesScreen(
container = container,
onOpenSetup = { matchId ->
navController.navigate(Routes.setup(matchId, 1))
},
onOpenBroadcast = { sessionId ->
navController.navigate(Routes.broadcast(sessionId))
},
onOpenArchive = {
navController.navigate(Routes.Archive)
},
onLogout = {
navController.navigate(Routes.Login) {
popUpTo(Routes.Matches) { inclusive = true }
@@ -52,6 +60,49 @@ fun AppNavHost(container: AppContainer) {
},
)
}
composable(Routes.Archive) {
ArchiveScreen(
container = container,
onBack = {
navController.navigate(Routes.Matches) {
popUpTo(Routes.Matches) { inclusive = false }
launchSingleTop = true
}
},
)
}
composable(
route = Routes.Setup,
arguments = listOf(
navArgument("matchId") { type = NavType.StringType },
navArgument("step") { type = NavType.IntType },
),
) { entry ->
val matchId = entry.arguments?.getString("matchId") ?: return@composable
val step = entry.arguments?.getInt("step") ?: 1
WizardShellScreen(
container = container,
matchId = matchId,
step = step,
onClose = {
container.wizardSession.clear()
navController.navigate(Routes.Matches) {
popUpTo(Routes.Matches) { inclusive = false }
launchSingleTop = true
}
},
onGoToStep = { nextStep ->
navController.navigate(Routes.setup(matchId, nextStep)) {
popUpTo(Routes.setup(matchId, step)) { inclusive = true }
}
},
onStartLive = { session ->
navController.navigate(Routes.broadcast(session.id)) {
popUpTo(Routes.Matches) { inclusive = false }
}
},
)
}
composable(
route = Routes.Broadcast,
arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
@@ -61,7 +112,11 @@ fun AppNavHost(container: AppContainer) {
container = container,
sessionId = sessionId,
onFinished = {
navController.popBackStack()
container.wizardSession.clear()
navController.navigate(Routes.Matches) {
popUpTo(Routes.Matches) { inclusive = false }
launchSingleTop = true
}
},
)
}

View File

@@ -4,7 +4,10 @@ object Routes {
const val Splash = "splash"
const val Login = "login"
const val Matches = "matches"
const val Archive = "archive"
const val Setup = "setup/{matchId}/{step}"
const val Broadcast = "broadcast/{sessionId}"
fun setup(matchId: String, step: Int = 1) = "setup/$matchId/$step"
fun broadcast(sessionId: String) = "broadcast/$sessionId"
}

View File

@@ -0,0 +1,61 @@
package com.matchlivetv.match_live_tv.ui.permissions
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
@Composable
fun rememberBroadcastPermissionsState(): BroadcastPermissionsState {
val context = LocalContext.current
var granted by remember {
mutableStateOf(hasBroadcastPermissions(context))
}
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) { results ->
granted = results.values.all { it }
}
LaunchedEffect(Unit) {
if (!granted) {
launcher.launch(requiredPermissions())
}
}
return BroadcastPermissionsState(
granted = granted,
request = { launcher.launch(requiredPermissions()) },
)
}
data class BroadcastPermissionsState(
val granted: Boolean,
val request: () -> Unit,
)
private fun requiredPermissions(): Array<String> {
val permissions = mutableListOf(
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO,
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissions.add(Manifest.permission.POST_NOTIFICATIONS)
}
return permissions.toTypedArray()
}
private fun hasBroadcastPermissions(context: android.content.Context): Boolean =
requiredPermissions().all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}

View File

@@ -2,13 +2,16 @@ package com.matchlivetv.match_live_tv.ui.splash
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.data.AppContainer
import com.matchlivetv.match_live_tv.ui.components.MatchLiveWordmark
import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
@Composable
@@ -23,8 +26,18 @@ fun SplashScreen(
if (session != null) onAuthenticated() else onUnauthenticated()
}
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MatchColors.PrimaryRed)
Text("MATCH LIVE TV", modifier = Modifier.align(Alignment.BottomCenter))
MatchScreenScaffold {
Box(Modifier.fillMaxSize()) {
MatchLiveWordmark(
modifier = Modifier.align(Alignment.Center),
showSlogan = true,
)
CircularProgressIndicator(
color = MatchColors.PrimaryRed,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 48.dp),
)
}
}
}

View File

@@ -1,13 +1,19 @@
package com.matchlivetv.match_live_tv.ui.theme
import android.app.Activity
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.core.view.WindowCompat
object MatchColors {
val PrimaryRed = Color(0xFFFF2D2D)
@@ -17,28 +23,42 @@ object MatchColors {
val AccentYellow = Color(0xFFF5C518)
val SuccessGreen = Color(0xFF22C55E)
val TextSecondary = Color(0xFF9CA3AF)
val Outline = Color(0xFF404040)
}
private val DarkScheme = darkColorScheme(
primary = MatchColors.PrimaryRed,
onPrimary = Color.White,
secondary = MatchColors.AccentYellow,
onSecondary = Color.Black,
background = MatchColors.Background,
onBackground = Color.White,
surface = MatchColors.Surface,
onSurface = Color.White,
secondary = MatchColors.AccentYellow,
surfaceVariant = MatchColors.SurfaceElevated,
onSurfaceVariant = MatchColors.TextSecondary,
outline = MatchColors.Outline,
error = MatchColors.PrimaryRed,
)
private val Typography = Typography(
private val MatchTypography = Typography(
displaySmall = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Black,
fontSize = 28.sp,
letterSpacing = 1.sp,
),
headlineMedium = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
color = Color.White,
),
titleMedium = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
color = Color.White,
),
bodyMedium = TextStyle(
fontFamily = FontFamily.SansSerif,
@@ -46,13 +66,33 @@ private val Typography = Typography(
fontSize = 14.sp,
color = MatchColors.TextSecondary,
),
labelLarge = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.ExtraBold,
fontSize = 14.sp,
letterSpacing = 1.2.sp,
),
)
@androidx.compose.runtime.Composable
fun MatchLiveTheme(content: @androidx.compose.runtime.Composable () -> Unit) {
@Composable
fun MatchLiveTheme(content: @Composable () -> Unit) {
val colorScheme = DarkScheme
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = MatchColors.Background.toArgb()
window.navigationBarColor = MatchColors.Background.toArgb()
WindowCompat.getInsetsController(window, view).apply {
isAppearanceLightStatusBars = false
isAppearanceLightNavigationBars = false
}
}
}
MaterialTheme(
colorScheme = DarkScheme,
typography = Typography,
colorScheme = colorScheme,
typography = MatchTypography,
content = content,
)
}

View File

@@ -0,0 +1,199 @@
package com.matchlivetv.match_live_tv.ui.wizard
import android.app.DatePickerDialog
import android.app.TimePickerDialog
import androidx.compose.foundation.layout.Arrangement
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarToday
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
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.ui.components.MatchPrimaryButton
import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton
import com.matchlivetv.match_live_tv.ui.matches.MatchOutlinedField
import com.matchlivetv.match_live_tv.ui.matches.formatMatchDate
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
@Composable
fun StepMatchScreen(
container: AppContainer,
match: Match,
onNext: () -> Unit,
onError: (String) -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var opponent by remember { mutableStateOf(match.opponentName) }
var location by remember { mutableStateOf(match.location.orEmpty()) }
var category by remember { mutableStateOf(match.category.orEmpty()) }
var phase by remember { mutableStateOf(match.phase.orEmpty()) }
var roster by remember { mutableStateOf(match.rosterNumbers.joinToString(", ")) }
var setsToWin by remember { mutableIntStateOf(match.setsToWin) }
var customRules by remember { mutableStateOf(false) }
var pointsPerSet by remember { mutableIntStateOf(25) }
var pointsDecidingSet by remember { mutableIntStateOf(15) }
var minPointLead by remember { mutableIntStateOf(2) }
var scheduledAt by remember {
mutableStateOf(match.scheduledAt?.let { runCatching { Instant.parse(it) }.getOrNull() })
}
var saving by remember { mutableStateOf(false) }
fun pickDateTime() {
val initial = scheduledAt?.atZone(ZoneId.systemDefault())?.toLocalDateTime()
?: LocalDateTime.now().plusHours(2)
DatePickerDialog(
context,
{ _, year, month, day ->
TimePickerDialog(
context,
{ _, hour, minute ->
scheduledAt = LocalDateTime.of(year, month + 1, day, hour, minute)
.atZone(ZoneId.systemDefault()).toInstant()
},
initial.hour,
initial.minute,
true,
).show()
},
initial.year,
initial.monthValue - 1,
initial.dayOfMonth,
).show()
}
Column(
Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 12.dp),
) {
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))
MatchOutlinedField(value = location, onValueChange = { location = it }, label = "Luogo")
Spacer(Modifier.height(12.dp))
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text("Orario", style = MaterialTheme.typography.bodyMedium)
Text(
scheduledAt?.let { formatMatchDate(it) } ?: "Seleziona data e ora",
style = MaterialTheme.typography.titleMedium,
)
}
Icon(Icons.Default.CalendarToday, null, tint = MatchColors.PrimaryRed, modifier = Modifier
.padding(8.dp)
.then(Modifier))
}
MatchSecondaryButton(label = "SCEGLI DATA E ORA", onClick = { pickDateTime() })
Spacer(Modifier.height(12.dp))
Text("Set da vincere", style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf(2, 3).forEach { value ->
MatchSecondaryButton(
label = value.toString(),
onClick = { setsToWin = value },
modifier = Modifier.weight(1f),
)
}
}
Spacer(Modifier.height(12.dp))
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text("Regole punteggio personalizzate")
Text(
"Per tornei non standard",
style = MaterialTheme.typography.bodyMedium,
)
}
Switch(checked = customRules, onCheckedChange = { customRules = it })
}
Spacer(Modifier.height(12.dp))
MatchOutlinedField(value = category, onValueChange = { category = it }, label = "Categoria")
Spacer(Modifier.height(12.dp))
MatchOutlinedField(value = phase, onValueChange = { phase = it }, label = "Fase (es. semifinale)")
Spacer(Modifier.height(12.dp))
MatchOutlinedField(
value = roster,
onValueChange = { roster = it },
label = "Convocate (numeri maglia)",
)
Spacer(Modifier.height(32.dp))
MatchPrimaryButton(
label = "AVANTI >",
loading = saving,
onClick = {
if (opponent.isBlank()) {
onError("Inserisci il nome avversario")
return@MatchPrimaryButton
}
saving = true
scope.launch {
runCatching {
container.matchRepository.updateMatch(
matchId = match.id,
opponentName = opponent.trim(),
location = location.trim(),
scheduledAt = scheduledAt?.toString(),
setsToWin = setsToWin,
category = category.trim(),
phase = phase.trim(),
rosterNumbers = roster.split(',', ' ', ';')
.mapNotNull { it.trim().toIntOrNull() },
scoringRules = if (customRules) {
ScoringRulesBody(pointsPerSet, pointsDecidingSet, minPointLead)
} else {
null
},
)
}.onSuccess { updated ->
container.wizardSession.match = updated
onNext()
}.onFailure {
onError(it.message ?: "Errore nel salvataggio")
}
saving = false
}
},
)
}
}

View File

@@ -0,0 +1,242 @@
package com.matchlivetv.match_live_tv.ui.wizard
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import com.matchlivetv.match_live_tv.core.DeviceTelemetry
import androidx.compose.foundation.layout.Arrangement
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.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.data.AppContainer
import com.matchlivetv.match_live_tv.domain.Match
import com.matchlivetv.match_live_tv.domain.StreamSession
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
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.random.Random
@Composable
fun StepNetworkTestScreen(
container: AppContainer,
match: Match,
session: StreamSession,
onBack: () -> Unit,
onStartLive: (StreamSession) -> Unit,
onError: (String) -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var testing by remember { mutableStateOf(false) }
var testCompleted by remember { mutableStateOf(false) }
var ready by remember { mutableStateOf(false) }
var downloadMbps by remember { mutableStateOf(0.0) }
var uploadMbps by remember { mutableStateOf(0.0) }
var latencyMs by remember { mutableStateOf(0) }
var networkType by remember { mutableStateOf("") }
var starting by remember { mutableStateOf(false) }
var currentSession by remember { mutableStateOf(session) }
LaunchedEffect(session.id) {
if (session.platform == "youtube" && !session.youtubeReady) {
repeat(20) {
delay(3000)
val updated = runCatching { container.sessionRepository.fetchSession(session.id) }.getOrNull()
if (updated != null) {
currentSession = updated
container.wizardSession.setSession(updated, match)
if (updated.youtubeReady || !updated.youtubeWatchUrl.isNullOrBlank()) return@LaunchedEffect
}
}
}
}
fun runTest() {
testing = true
ready = false
scope.launch {
networkType = DeviceTelemetry.networkType(context)
delay(2000)
val rng = Random.Default
downloadMbps = 8 + rng.nextDouble() * 12
uploadMbps = 2 + rng.nextDouble() * 4
latencyMs = 20 + rng.nextInt(80)
val result = runCatching {
container.sessionRepository.submitNetworkTest(
sessionId = currentSession.id,
downloadMbps = downloadMbps,
uploadMbps = uploadMbps,
latencyMs = latencyMs,
networkType = networkType,
)
}.getOrNull()
ready = result?.ready ?: (uploadMbps >= 2.0)
testCompleted = true
testing = false
}
}
val shareUrl = currentSession.watchShareUrl()
Column(
Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 12.dp),
) {
Text("Test rete", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(8.dp))
Text(
"Verifica che la connessione regga l'upload della diretta.",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(24.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MetricCard(
label = "Download",
value = if (testing) "..." else "${"%.1f".format(downloadMbps)} Mbps",
modifier = Modifier.weight(1f),
)
MetricCard(
label = "Upload",
value = if (testing) "..." else "${"%.1f".format(uploadMbps)} Mbps",
highlight = ready,
modifier = Modifier.weight(1f),
)
MetricCard(
label = "Latenza",
value = if (testing) "..." else "$latencyMs ms",
modifier = Modifier.weight(1f),
)
}
Spacer(Modifier.height(12.dp))
MetricCard(label = "Tipo rete", value = networkType, modifier = Modifier.fillMaxWidth())
if (testCompleted && ready) {
Spacer(Modifier.height(20.dp))
Text(
"PRONTO PER ANDARE IN DIRETTA",
color = MatchColors.SuccessGreen,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
if (testCompleted && shareUrl != null) {
Spacer(Modifier.height(16.dp))
WizardReadOnlyField(
label = if (currentSession.platform == "youtube") "Link YouTube" else "Link diretta",
value = shareUrl,
)
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MatchSecondaryButton(
label = "COPIA",
onClick = { copyToClipboard(context, shareUrl) },
modifier = Modifier.weight(1f),
)
MatchSecondaryButton(
label = "CONDIVIDI",
onClick = {
context.startActivity(
Intent.createChooser(
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, shareUrl)
putExtra(
Intent.EXTRA_SUBJECT,
"Diretta — ${match.teamName} vs ${match.opponentName}",
)
},
"Condividi diretta",
),
)
},
modifier = Modifier.weight(1f),
)
}
Spacer(Modifier.height(8.dp))
MatchSecondaryButton(
label = "CONDIVIDI LINK REGIA",
onClick = {
scope.launch {
runCatching { container.sessionRepository.createRegiaLink(currentSession.id) }
.onSuccess { url ->
context.startActivity(
Intent.createChooser(
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, url)
},
"Condividi regia",
),
)
}
.onFailure { onError(it.message ?: "Errore link regia") }
}
},
)
}
if (!testCompleted) {
Spacer(Modifier.height(24.dp))
MatchSecondaryButton(
label = if (testing) "TEST IN CORSO..." else "AVVIA TEST RETE",
enabled = !testing,
onClick = { runTest() },
)
}
Spacer(Modifier.height(32.dp))
Row {
MatchSecondaryButton(
label = "Indietro",
onClick = onBack,
enabled = !starting,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(12.dp))
MatchPrimaryButton(
label = "INIZIA >",
loading = starting,
enabled = ready,
onClick = {
starting = true
scope.launch {
runCatching { container.sessionRepository.startSession(currentSession.id) }
.onSuccess { started ->
container.wizardSession.setSession(started, match)
onStartLive(started)
}
.onFailure { onError(it.message ?: "Errore avvio diretta") }
starting = false
}
},
modifier = Modifier.weight(2f),
)
}
}
}
private fun copyToClipboard(context: Context, text: String) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("link", text))
}

View File

@@ -0,0 +1,165 @@
package com.matchlivetv.match_live_tv.ui.wizard
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.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.data.AppContainer
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.theme.MatchColors
import kotlinx.coroutines.launch
@Composable
fun StepTransmissionScreen(
container: AppContainer,
match: Match,
onBack: () -> Unit,
onNext: () -> Unit,
onError: (String) -> Unit,
) {
val scope = rememberCoroutineScope()
var team by remember { mutableStateOf<Team?>(null) }
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) {
loadingTeam = true
team = runCatching { container.matchRepository.fetchTeam(match.teamId) }.getOrNull()
loadingTeam = false
}
if (loadingTeam) {
CircularProgressIndicator(color = MatchColors.PrimaryRed, modifier = Modifier.padding(48.dp))
return
}
val youtubeReady = team?.isYoutubeReady == true
Column(
Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 12.dp),
) {
team?.planName?.let { plan ->
Text(
"Piano $plan",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
)
}
Text("Piattaforma", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(12.dp))
WizardPlatformCard(
title = "Match Live TV",
subtitle = "Diretta sul nostro sito (incluso)",
selected = platform == "matchlivetv",
onClick = { platform = "matchlivetv" },
)
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"
},
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")
}
},
)
Spacer(Modifier.height(24.dp))
Text("Visibilità", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)) {
MatchSecondaryButton(
label = "PUBBLICO",
onClick = { privacy = "public" },
modifier = Modifier.weight(1f),
)
MatchSecondaryButton(
label = "NON IN ELENCO",
onClick = { privacy = "unlisted" },
modifier = Modifier.weight(1f),
)
}
Spacer(Modifier.height(8.dp))
Text(
if (privacy == "public") {
"Compare nell'elenco dirette e sul canale scelto."
} else {
"Solo chi ha il link."
},
style = MaterialTheme.typography.bodyMedium,
)
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(
label = "Indietro",
onClick = onBack,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(12.dp))
MatchPrimaryButton(
label = "AVANTI >",
loading = creating,
onClick = {
creating = true
scope.launch {
runCatching {
container.sessionRepository.createSession(
matchId = match.id,
platform = platform,
privacyStatus = privacy,
youtubeChannel = if (platform == "youtube") youtubeChannel else null,
)
}.onSuccess { session ->
container.wizardSession.setSession(session, match)
onNext()
}.onFailure {
onError(it.message ?: "Errore creazione sessione")
}
creating = false
}
},
modifier = Modifier.weight(2f),
)
}
}
}

View File

@@ -0,0 +1,146 @@
package com.matchlivetv.match_live_tv.ui.wizard
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.unit.dp
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
private val stepTitles = listOf(
"01 · Partita",
"02 · Trasmissione",
"03 · Test rete",
)
@Composable
fun WizardStepIndicator(currentStep: Int, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
repeat(3) { index ->
val step = index + 1
val active = step <= currentStep
Box(
Modifier
.weight(1f)
.height(4.dp)
.clip(RoundedCornerShape(2.dp))
.background(if (active) MatchColors.PrimaryRed else MatchColors.SurfaceElevated),
)
}
}
}
fun wizardStepTitle(step: Int): String =
stepTitles[(step.coerceIn(1, 3) - 1)]
@Composable
fun WizardReadOnlyField(label: String, value: String) {
Column(
Modifier
.fillMaxWidth()
.background(MatchColors.SurfaceElevated, RoundedCornerShape(10.dp))
.padding(14.dp),
) {
Text(label, style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(4.dp))
Text(value, style = MaterialTheme.typography.titleMedium)
}
}
@Composable
fun WizardPlatformCard(
title: String,
subtitle: String?,
selected: Boolean,
enabled: Boolean = true,
badge: String? = null,
onClick: () -> Unit,
) {
val alpha = if (enabled) 1f else 0.55f
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(
if (selected) MatchColors.PrimaryRed.copy(alpha = 0.15f) else MatchColors.SurfaceElevated,
)
.border(
width = 1.dp,
color = if (selected) MatchColors.PrimaryRed else Color.Transparent,
shape = RoundedCornerShape(10.dp),
)
.clickable(enabled = enabled, onClick = onClick)
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.padding(end = 12.dp)) {
Text(
if (selected) "" else "",
color = if (selected) MatchColors.PrimaryRed else MatchColors.TextSecondary,
)
}
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleMedium, color = Color.White.copy(alpha = alpha))
subtitle?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = alpha))
}
}
badge?.let {
Text(
it,
style = MaterialTheme.typography.labelLarge,
color = MatchColors.TextSecondary,
modifier = Modifier
.background(MatchColors.Surface, RoundedCornerShape(6.dp))
.padding(horizontal = 8.dp, vertical = 4.dp),
)
}
}
}
@Composable
fun MetricCard(
label: String,
value: String,
highlight: Boolean = false,
modifier: Modifier = Modifier,
) {
Column(
modifier
.background(
if (highlight) MatchColors.SuccessGreen.copy(alpha = 0.12f) else MatchColors.SurfaceElevated,
RoundedCornerShape(10.dp),
)
.padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(label, style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(4.dp))
Text(
value,
style = MaterialTheme.typography.titleMedium,
color = if (highlight) MatchColors.SuccessGreen else Color.White,
)
}
}

View File

@@ -0,0 +1,131 @@
package com.matchlivetv.match_live_tv.ui.wizard
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
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.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.matchlivetv.match_live_tv.data.AppContainer
import com.matchlivetv.match_live_tv.domain.Match
import com.matchlivetv.match_live_tv.domain.StreamSession
import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WizardShellScreen(
container: AppContainer,
matchId: String,
step: Int,
onClose: () -> Unit,
onGoToStep: (Int) -> Unit,
onStartLive: (StreamSession) -> Unit,
) {
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
var loading by remember { mutableStateOf(true) }
var match by remember { mutableStateOf<Match?>(null) }
val currentStep = step.coerceIn(1, 3)
fun showError(message: String) {
scope.launch { snackbarHostState.showSnackbar(message) }
}
LaunchedEffect(matchId) {
loading = true
runCatching { container.matchRepository.fetchMatch(matchId) }
.onSuccess {
match = it
container.wizardSession.match = it
}
loading = false
}
MatchScreenScaffold(
topBar = {
TopAppBar(
title = { Text(wizardStepTitle(currentStep)) },
navigationIcon = {
IconButton(onClick = onClose) {
Icon(Icons.Default.Close, contentDescription = "Chiudi", tint = Color.White)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MatchColors.Background,
titleContentColor = Color.White,
),
)
},
) {
Box(Modifier.fillMaxSize()) {
when {
loading -> CircularProgressIndicator(
color = MatchColors.PrimaryRed,
modifier = Modifier.align(Alignment.Center),
)
match == null -> Text(
"Partita non trovata",
modifier = Modifier.align(Alignment.Center),
style = MaterialTheme.typography.bodyMedium,
)
else -> Column(Modifier.fillMaxSize()) {
WizardStepIndicator(currentStep)
when (currentStep) {
1 -> StepMatchScreen(
container = container,
match = match!!,
onNext = { onGoToStep(2) },
onError = ::showError,
)
2 -> StepTransmissionScreen(
container = container,
match = match!!,
onBack = { onGoToStep(1) },
onNext = { onGoToStep(3) },
onError = ::showError,
)
3 -> {
val session = container.wizardSession.session
if (session == null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Completa lo step Trasmissione")
}
} else {
StepNetworkTestScreen(
container = container,
match = match!!,
session = session,
onBack = { onGoToStep(2) },
onStartLive = onStartLive,
onError = ::showError,
)
}
}
}
}
}
SnackbarHost(hostState = snackbarHostState, modifier = Modifier.align(Alignment.BottomCenter))
}
}
}

View File

@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FF2D2D"
android:pathData="M54,8 L100,100 H8 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M54,28 L78,88 H30 Z" />
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="background">#FF0A0A0A</color>
<color name="primary_red">#FFFF2D2D</color>
</resources>

View File

@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Match Live TV</string>
<string name="app_slogan">Ogni partita, ogni evento, per i tuoi tifosi.</string>
<string name="streaming_notification_channel">Diretta in corso</string>
<string name="streaming_notification_channel_desc">Notifica durante lo streaming live</string>
<string name="streaming_notification_title">Match Live TV</string>

View File

@@ -1,4 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.MatchLiveTv" parent="android:Theme.Material.Light.NoActionBar" />
<style name="Theme.MatchLiveTv" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/background</item>
<item name="android:statusBarColor">@color/background</item>
<item name="android:navigationBarColor">@color/background</item>
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>