diff --git a/backend/app/controllers/api/v1/sports_controller.rb b/backend/app/controllers/api/v1/sports_controller.rb index 2f9a57a..ecf6475 100644 --- a/backend/app/controllers/api/v1/sports_controller.rb +++ b/backend/app/controllers/api/v1/sports_controller.rb @@ -2,7 +2,7 @@ module Api module V1 class SportsController < BaseController def index - render json: Sports::Catalog.as_api_list + render json: ::Sports::Catalog.as_api_list end end end diff --git a/backend/app/controllers/api/v1/stream_sessions_controller.rb b/backend/app/controllers/api/v1/stream_sessions_controller.rb index 25f1399..e9a55f9 100644 --- a/backend/app/controllers/api/v1/stream_sessions_controller.rb +++ b/backend/app/controllers/api/v1/stream_sessions_controller.rb @@ -39,6 +39,20 @@ module Api render json: session_json(@session, detail: true) end + def score_action + action = params[:score_action].presence + raise ArgumentError, "Azione mancante" if action.blank? + + result = Scoring::ApplyAction.new(session: @session, action: action).call + render json: session_json(@session, detail: true).merge( + set_won: result.set_won, + match_won: result.match_won, + winner: result.winner&.to_s + ) + rescue ArgumentError => e + render json: { error: e.message }, status: :unprocessable_entity + end + def events events = @session.stream_events.recent.limit(100) render json: events.map { |e| event_json(e) } diff --git a/backend/app/controllers/api/v1/teams_controller.rb b/backend/app/controllers/api/v1/teams_controller.rb index 89d0773..28ea697 100644 --- a/backend/app/controllers/api/v1/teams_controller.rb +++ b/backend/app/controllers/api/v1/teams_controller.rb @@ -21,7 +21,7 @@ module Api }, status: :unprocessable_entity end - sport_key = Sports::Catalog.normalize_key(team_params[:sport_key] || team_params[:sport] || "pallavolo") + sport_key = ::Sports::Catalog.normalize_key(team_params[:sport_key] || team_params[:sport] || "pallavolo") club = Club.create!(name: team_params[:name], sport: sport_key, primary_color: "#e53935", secondary_color: "#ffffff") ClubMembership.create!(user: current_user, club: club, role: "owner") diff --git a/backend/app/controllers/public/clubs_controller.rb b/backend/app/controllers/public/clubs_controller.rb index 5c3bf6a..3a113a2 100644 --- a/backend/app/controllers/public/clubs_controller.rb +++ b/backend/app/controllers/public/clubs_controller.rb @@ -190,6 +190,7 @@ module Public ) p[:primary_color] = normalize_hex_color(p[:primary_color], "#e53935") p[:secondary_color] = normalize_hex_color(p[:secondary_color], "#ffffff") + p[:sport] = Sports::Catalog.normalize_key(p[:sport]) if p[:sport].present? p end diff --git a/backend/app/controllers/public/teams_controller.rb b/backend/app/controllers/public/teams_controller.rb index 9c651e0..474424a 100644 --- a/backend/app/controllers/public/teams_controller.rb +++ b/backend/app/controllers/public/teams_controller.rb @@ -165,7 +165,12 @@ module Public end def team_params - p = params.require(:team).permit(:name, :sport, :description, :logo_url, :primary_color, :secondary_color) + p = params.require(:team).permit(:name, :sport, :sport_key, :description, :logo_url, :primary_color, :secondary_color) + if p[:sport].present? && p[:sport_key].blank? + p[:sport_key] = p.delete(:sport) + elsif p[:sport_key].present? + p.delete(:sport) + end p[:primary_color] = p[:primary_color].presence p[:secondary_color] = p[:secondary_color].presence if p[:primary_color].present? diff --git a/backend/app/helpers/application_helper.rb b/backend/app/helpers/application_helper.rb index be90c6c..201bda6 100644 --- a/backend/app/helpers/application_helper.rb +++ b/backend/app/helpers/application_helper.rb @@ -16,4 +16,9 @@ module ApplicationHelper value = date_or_time.respond_to?(:in_time_zone) ? date_or_time.in_time_zone : date_or_time I18n.with_locale(:it) { I18n.l(value, format: format) } end + + def sport_catalog_options(selected = nil) + options = Sports::Catalog.as_api_list.map { |entry| [entry[:label], entry[:key]] } + options_for_select(options, Sports::Catalog.normalize_key(selected)) + end end diff --git a/backend/app/services/scoring/engines/period_engine_mixin.rb b/backend/app/services/scoring/engines/period_engine_mixin.rb index b5005e1..5149b0f 100644 --- a/backend/app/services/scoring/engines/period_engine_mixin.rb +++ b/backend/app/services/scoring/engines/period_engine_mixin.rb @@ -1,6 +1,8 @@ module Scoring module Engines module PeriodEngineMixin + Result = Scoring::ApplyAction::Result + private def period_rules diff --git a/backend/app/views/public/clubs/edit.html.erb b/backend/app/views/public/clubs/edit.html.erb index 4006e17..f4f4bfa 100644 --- a/backend/app/views/public/clubs/edit.html.erb +++ b/backend/app/views/public/clubs/edit.html.erb @@ -8,7 +8,7 @@ <%= label_tag "club[name]", "Nome società" %> <%= text_field_tag "club[name]", @club.name, required: true %> <%= label_tag "club[sport]", "Sport principale" %> - <%= select_tag "club[sport]", options_for_select([["Pallavolo", "volleyball"], ["Calcio", "football"], ["Basket", "basketball"]], @club.sport) %> + <%= select_tag "club[sport]", sport_catalog_options(@club.sport) %> <%= render "shared/branding_fields", record: @club, legend: "Logo e colori societari" %> <%= render "shared/billing_profile_fields", record: @club %> <%= submit_tag "Salva", class: "btn btn-primary" %> diff --git a/backend/app/views/public/clubs/new.html.erb b/backend/app/views/public/clubs/new.html.erb index b8d0257..ce7c3bd 100644 --- a/backend/app/views/public/clubs/new.html.erb +++ b/backend/app/views/public/clubs/new.html.erb @@ -11,7 +11,7 @@ <%= label_tag "club[name]", "Nome società / club" %> <%= text_field_tag "club[name]", params.dig(:club, :name), required: true, placeholder: "es. Crazy Volley Rozzano" %> <%= label_tag "club[sport]", "Sport principale" %> - <%= select_tag "club[sport]", options_for_select([["Pallavolo", "volleyball"], ["Calcio", "football"], ["Basket", "basketball"]], params.dig(:club, :sport) || "volleyball") %> + <%= select_tag "club[sport]", sport_catalog_options(params.dig(:club, :sport) || "pallavolo") %> <%= render "shared/branding_fields", record: Club.new(primary_color: "#e53935", secondary_color: "#ffffff"), legend: "Logo e colori societari" %> <%= render "shared/billing_profile_fields", record: Club.new(billing_country: "IT") %> diff --git a/backend/app/views/public/teams/edit.html.erb b/backend/app/views/public/teams/edit.html.erb index 162f2a0..94dd175 100644 --- a/backend/app/views/public/teams/edit.html.erb +++ b/backend/app/views/public/teams/edit.html.erb @@ -9,7 +9,7 @@ <%= label_tag "team[name]", "Nome squadra" %> <%= text_field_tag "team[name]", @team.name, required: true %> <%= label_tag "team[sport]", "Sport" %> - <%= select_tag "team[sport]", options_for_select([["Pallavolo", "volleyball"], ["Calcio", "football"], ["Basket", "basketball"]], @team.sport) %> + <%= select_tag "team[sport]", sport_catalog_options(@team.sport_key) %> <%= label_tag "team[description]", "Descrizione squadra" %> <%= text_area_tag "team[description]", @team.description, rows: 5, placeholder: "Presentazione della squadra, palmarès, obiettivi della stagione…" %> diff --git a/backend/app/views/public/teams/new.html.erb b/backend/app/views/public/teams/new.html.erb index 29b3ed4..1195bf6 100644 --- a/backend/app/views/public/teams/new.html.erb +++ b/backend/app/views/public/teams/new.html.erb @@ -9,7 +9,7 @@ <%= label_tag "team[name]", "Nome squadra" %> <%= text_field_tag "team[name]", nil, required: true, placeholder: "es. Under 13, Serie C" %> <%= label_tag "team[sport]", "Sport" %> - <%= select_tag "team[sport]", options_for_select([["Pallavolo", "volleyball"], ["Calcio", "football"], ["Basket", "basketball"]], @club.sport) %> + <%= select_tag "team[sport]", sport_catalog_options(@club.sport) %> <%= render "shared/branding_fields", record: Team.new(club: @club), show_inherit_hint: true, legend: "Override branding (opzionale)" %> <%= submit_tag "Aggiungi squadra", class: "btn btn-primary" %> <% end %> diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 7f949dd..ec5577e 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -43,6 +43,7 @@ Rails.application.routes.draw do patch :pause patch :resume patch :score + post :score_action get :events post :telemetry post :pairing_token diff --git a/backend/db/schema.rb b/backend/db/schema.rb index aeed289..47a2f72 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_06_03_121000) do +ActiveRecord::Schema[7.2].define(version: 2026_06_09_100000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -147,7 +147,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_03_121000) do t.string "opponent_name", null: false t.string "location" t.datetime "scheduled_at" - t.string "sport", default: "volleyball" + t.string "sport_key", default: "pallavolo" t.integer "sets_to_win", default: 3 t.jsonb "roster_numbers", default: [] t.string "category" @@ -155,6 +155,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_03_121000) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.jsonb "scoring_rules", default: {}, null: false + t.string "opponent_primary_color" + t.string "overlay_kind" t.index ["team_id"], name: "index_matches_on_team_id" end @@ -217,6 +219,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_03_121000) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.jsonb "set_partials", default: [], null: false + t.string "board_type", default: "volley", null: false + t.jsonb "data", default: {}, null: false t.index ["stream_session_id"], name: "index_score_states_on_stream_session_id", unique: true end @@ -319,7 +323,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_03_121000) do create_table "teams", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "name", null: false - t.string "sport", default: "volleyball" + t.string "sport_key", default: "pallavolo" t.string "logo_url" t.datetime "created_at", null: false t.datetime "updated_at", null: false diff --git a/backend/spec/domain/sports/catalog_spec.rb b/backend/spec/domain/sports/catalog_spec.rb index 348000d..9b4259c 100644 --- a/backend/spec/domain/sports/catalog_spec.rb +++ b/backend/spec/domain/sports/catalog_spec.rb @@ -8,7 +8,7 @@ RSpec.describe Sports::Catalog do entry = described_class.find("pallavolo") expect(entry[:board]).to eq("volley") expect(entry[:overlay]).to eq("volley") - expect(entry[:default_rules]["sets_to_win"]).to eq(3) + expect(entry[:default_rules][:sets_to_win]).to eq(3) end it "mappa legacy volleyball" do diff --git a/native/android/app/build.gradle.kts b/native/android/app/build.gradle.kts index f44fa99..b52e663 100644 --- a/native/android/app/build.gradle.kts +++ b/native/android/app/build.gradle.kts @@ -12,7 +12,7 @@ android { applicationId = "com.matchlivetv.match_live_tv" minSdk = 24 targetSdk = 35 - versionCode = 16 + versionCode = 17 versionName = "2.0.0-native" val apiBaseUrl = project.findProperty("API_BASE_URL") as String? diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt index 00a4d4c..f35c77e 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt @@ -195,6 +195,7 @@ data class SetPartialDto( ) data class ScoreStateDto( + @Json(name = "board_type") val boardType: String? = null, @Json(name = "home_sets") val homeSets: Int? = null, @Json(name = "away_sets") val awaySets: Int? = null, @Json(name = "home_points") val homePoints: Int? = null, @@ -203,6 +204,9 @@ data class ScoreStateDto( @Json(name = "set_partials") val setPartials: List? = null, @Json(name = "timeout_home") val timeoutHome: Boolean? = null, @Json(name = "timeout_away") val timeoutAway: Boolean? = null, + val period: String? = null, + @Json(name = "clock_secs") val clockSecs: Int? = null, + @Json(name = "clock_running") val clockRunning: Boolean? = null, ) { fun toDomain() = com.matchlivetv.match_live_tv.domain.ScoreState( homeSets = homeSets ?: 0, @@ -215,9 +219,18 @@ data class ScoreStateDto( }, timeoutHome = timeoutHome ?: false, timeoutAway = timeoutAway ?: false, + boardType = boardType ?: "volley", + period = currentSet ?: 1, + periodLabel = period, + clockSecs = clockSecs ?: 0, + clockRunning = clockRunning ?: false, ) } +data class ScoreActionRequest( + @Json(name = "score_action") val scoreAction: String, +) + data class ScoreSyncRequest( @Json(name = "home_sets") val homeSets: Int, @Json(name = "away_sets") val awaySets: Int, diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt index 8c32c16..8832028 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt @@ -120,6 +120,12 @@ interface MatchLiveApi { @Body body: ScoreSyncRequest, ): StreamSessionDto + @POST("sessions/{sessionId}/score_action") + suspend fun applyScoreAction( + @Path("sessionId") sessionId: String, + @Body body: ScoreActionRequest, + ): StreamSessionDto + @POST("sessions/{sessionId}/telemetry") suspend fun postTelemetry( @Path("sessionId") sessionId: String, diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/SessionCableService.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/SessionCableService.kt index 90bd151..8d5bc14 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/SessionCableService.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/SessionCableService.kt @@ -126,35 +126,7 @@ class SessionCableService( return nested as? Map ?: raw } - private fun parseScore(data: Map): ScoreState { - val partialsRaw = data["set_partials"] as? List<*> ?: data["setPartials"] as? List<*> ?: emptyList() - return ScoreState( - homeSets = intField(data, "home_sets", "homeSets"), - awaySets = intField(data, "away_sets", "awaySets"), - homePoints = intField(data, "home_points", "homePoints"), - awayPoints = intField(data, "away_points", "awayPoints"), - currentSet = intField(data, "current_set", "currentSet").coerceAtLeast(1), - setPartials = partialsRaw.mapNotNull { item -> - val row = item as? Map<*, *> ?: return@mapNotNull null - com.matchlivetv.match_live_tv.domain.SetPartial( - set = (row["set"] as? Number)?.toInt() ?: 1, - home = (row["home"] as? Number)?.toInt() ?: 0, - away = (row["away"] as? Number)?.toInt() ?: 0, - ) - }, - timeoutHome = data["timeout_home"] as? Boolean ?: false, - timeoutAway = data["timeout_away"] as? Boolean ?: false, - ) - } - - private fun intField(data: Map, snake: String, camel: String): Int { - val raw = data[snake] ?: data[camel] - return when (raw) { - is Number -> raw.toInt() - is String -> raw.toIntOrNull() ?: 0 - else -> 0 - } - } + private fun parseScore(data: Map): ScoreState = ScoreState.fromCablePayload(data) companion object { private const val TAG = "SessionCableService" diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/ScoreRepository.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/ScoreRepository.kt index cb1d898..e1eb2a3 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/ScoreRepository.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/ScoreRepository.kt @@ -1,6 +1,7 @@ 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.ScoreActionRequest 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 @@ -24,4 +25,9 @@ class ScoreRepository( ) return response.score?.toDomain() } + + suspend fun applyScoreAction(sessionId: String, action: String): ScoreState? { + val response = api.applyScoreAction(sessionId, ScoreActionRequest(action)) + return response.score?.toDomain() + } } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/scoring/ScoreController.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/scoring/ScoreController.kt index 875ac52..d118c71 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/scoring/ScoreController.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/scoring/ScoreController.kt @@ -74,6 +74,27 @@ class ScoreController( schedulePush() } + fun applyAction(action: String) { + scheduleAction(action) + } + + private fun scheduleAction(action: String) { + syncJob?.cancel() + syncJob = scope.launch { + syncMutex.withLock { + val id = sessionId ?: return@withLock + runCatching { scoreRepository.applyScoreAction(id, action) } + .onSuccess { remote -> + suppressRemoteUntilMs = System.currentTimeMillis() + 500 + if (remote != null) { + _score.value = remote + sessionCable.sendScoreUpdate(remote) + } + } + } + } + } + fun closeSet() { val current = _score.value val homeWon = current.homePoints > current.awayPoints diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/ScoreState.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/ScoreState.kt index 9f07ede..2f7a16b 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/ScoreState.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/ScoreState.kt @@ -15,23 +15,107 @@ data class ScoreState( val setPartials: List = emptyList(), val timeoutHome: Boolean = false, val timeoutAway: Boolean = false, + val boardType: String = "volley", + val period: Int = 1, + val periodLabel: String? = null, + val clockSecs: Int = 0, + val clockRunning: Boolean = false, + val overtime: Boolean = false, ) { - fun cablePayload(): Map = 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 cablePayload(): Map { + val payload = mutableMapOf( + "type" to "score_update", + "board_type" to boardType, + "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) + }, + "timeout_home" to timeoutHome, + "timeout_away" to timeoutAway, + ) + if (boardType in PERIOD_BOARDS) { + payload["period"] = periodLabel ?: period.toString() + payload["clock_secs"] = clockSecs + payload["clock_running"] = clockRunning + payload["home_score"] = homePoints + payload["away_score"] = awayPoints + payload["overtime"] = overtime + } + if (boardType == "timer") { + payload["clock_secs"] = clockSecs + payload["clock_running"] = clockRunning + } + return payload + } fun progressKey(): Long = currentSet * 10_000_000_000L + homeSets * 1_000_000L + awaySets * 100_000L + homePoints * 1_000L + - awayPoints + awayPoints + + clockSecs + + companion object { + private val PERIOD_BOARDS = setOf("basket", "timed") + + fun fromCablePayload(data: Map): ScoreState { + val boardType = data["board_type"] as? String ?: "volley" + @Suppress("UNCHECKED_CAST") + val nested = data["data"] as? Map + val partialsRaw = data["set_partials"] as? List<*> ?: data["setPartials"] as? List<*> ?: emptyList() + val periodNumber = intFrom(nested, "period") + .takeIf { it > 0 } + ?: intField(data, "current_set", "currentSet").coerceAtLeast(1) + return ScoreState( + homeSets = intField(data, "home_sets", "homeSets"), + awaySets = intField(data, "away_sets", "awaySets"), + homePoints = intField(data, "home_points", "homePoints"), + awayPoints = intField(data, "away_points", "awayPoints"), + currentSet = intField(data, "current_set", "currentSet").coerceAtLeast(1), + setPartials = partialsRaw.mapNotNull { item -> + val row = item as? Map<*, *> ?: return@mapNotNull null + SetPartial( + set = (row["set"] as? Number)?.toInt() ?: 1, + home = (row["home"] as? Number)?.toInt() ?: 0, + away = (row["away"] as? Number)?.toInt() ?: 0, + ) + }, + timeoutHome = data["timeout_home"] as? Boolean ?: false, + timeoutAway = data["timeout_away"] as? Boolean ?: false, + boardType = boardType, + period = periodNumber, + periodLabel = data["period"] as? String, + clockSecs = intField(data, "clock_secs", "clockSecs") + .takeIf { it > 0 || data.containsKey("clock_secs") || data.containsKey("clockSecs") } + ?: intFrom(nested, "clock_secs"), + clockRunning = data["clock_running"] as? Boolean + ?: nested?.get("clock_running") as? Boolean + ?: false, + overtime = nested?.get("overtime") as? Boolean ?: false, + ) + } + + private fun intField(data: Map, snake: String, camel: String): Int { + val raw = data[snake] ?: data[camel] + return when (raw) { + is Number -> raw.toInt() + is String -> raw.toIntOrNull() ?: 0 + else -> 0 + } + } + + private fun intFrom(data: Map?, key: String): Int { + if (data == null) return 0 + return when (val raw = data[key]) { + is Number -> raw.toInt() + is String -> raw.toIntOrNull() ?: 0 + else -> 0 + } + } + } } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastControlsOverlay.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastControlsOverlay.kt index c6086f4..53a5b36 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastControlsOverlay.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastControlsOverlay.kt @@ -27,6 +27,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.SkipNext +import androidx.compose.material.icons.filled.Timer import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Visibility @@ -63,6 +65,7 @@ import com.matchlivetv.match_live_tv.core.resolveMediaUrl import com.matchlivetv.match_live_tv.core.DeviceHealthSnapshot import com.matchlivetv.match_live_tv.core.ThermalLevel import com.matchlivetv.match_live_tv.domain.ScoreState +import com.matchlivetv.match_live_tv.streaming.overlay.formatOverlayClock import com.matchlivetv.match_live_tv.ui.components.MatchStatusBadge import com.matchlivetv.match_live_tv.ui.theme.MatchColors @@ -85,12 +88,19 @@ fun BroadcastControlsOverlay( homeLogoUrl: String?, awayLogoUrl: String?, score: ScoreState, + boardType: String, pointsTarget: Int, onPointHome: () -> Unit, onPointAway: () -> Unit, onMinusHome: () -> Unit, onMinusAway: () -> Unit, - onCloseSet: () -> Unit, + onPoint2Home: (() -> Unit)? = null, + onPoint3Home: (() -> Unit)? = null, + onPoint2Away: (() -> Unit)? = null, + onPoint3Away: (() -> Unit)? = null, + onCloseSet: (() -> Unit)? = null, + onAdvancePeriod: (() -> Unit)? = null, + onClockToggle: (() -> Unit)? = null, onPauseOrResume: () -> Unit, onTerminate: () -> Unit, onShareLive: () -> Unit, @@ -181,11 +191,28 @@ fun BroadcastControlsOverlay( contentDescription = "Condividi link regia", onClick = onShareRegia, ) - SideIconButton( - icon = Icons.Default.Check, - contentDescription = "Chiudi set", - onClick = onCloseSet, - ) + if (onCloseSet != null) { + SideIconButton( + icon = Icons.Default.Check, + contentDescription = "Chiudi set", + onClick = onCloseSet, + ) + } + if (onAdvancePeriod != null) { + SideIconButton( + icon = Icons.Default.SkipNext, + contentDescription = "Periodo successivo", + onClick = onAdvancePeriod, + ) + } + if (onClockToggle != null) { + SideIconButton( + icon = Icons.Default.Timer, + contentDescription = if (score.clockRunning) "Ferma cronometro" else "Avvia cronometro", + onClick = onClockToggle, + highlighted = score.clockRunning, + ) + } } Column( @@ -223,34 +250,17 @@ fun BroadcastControlsOverlay( points = score.homePoints, onPlus = onPointHome, onMinus = onMinusHome, + onPlus2 = onPoint2Home, + onPlus3 = onPoint3Home, + showBasketButtons = boardType == "basket", alignEnd = false, modifier = Modifier.weight(1f), ) - Column( - Modifier - .padding(horizontal = 12.dp) - .padding(bottom = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - "${score.homePoints} - ${score.awayPoints}", - style = MaterialTheme.typography.titleLarge, - color = Color.White, - fontWeight = FontWeight.Bold, - ) - Text( - "Set ${score.currentSet} · $pointsTarget pt", - style = MaterialTheme.typography.labelSmall, - color = MatchColors.TextSecondary, - ) - if (score.homeSets > 0 || score.awaySets > 0) { - Text( - "Set vinti ${score.homeSets}-${score.awaySets}", - style = MaterialTheme.typography.labelSmall, - color = MatchColors.TextSecondary, - ) - } - } + ScoreCenterPanel( + score = score, + boardType = boardType, + pointsTarget = pointsTarget, + ) TeamScoreColumn( teamLabel = "OSPITE", teamName = awayName, @@ -259,6 +269,9 @@ fun BroadcastControlsOverlay( points = score.awayPoints, onPlus = onPointAway, onMinus = onMinusAway, + onPlus2 = onPoint2Away, + onPlus3 = onPoint3Away, + showBasketButtons = boardType == "basket", alignEnd = true, modifier = Modifier.weight(1f), ) @@ -365,6 +378,63 @@ private fun formatBitrateKbps(kbps: Long): String { return "$kbps kbps" } +@Composable +private fun ScoreCenterPanel( + score: ScoreState, + boardType: String, + pointsTarget: Int, +) { + Column( + Modifier + .padding(horizontal = 12.dp) + .padding(bottom = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "${score.homePoints} - ${score.awayPoints}", + style = MaterialTheme.typography.titleLarge, + color = Color.White, + fontWeight = FontWeight.Bold, + ) + when (boardType) { + "basket", "timed" -> { + Text( + score.periodLabel ?: if (boardType == "basket") "Q${score.period}" else "${score.period}° tempo", + style = MaterialTheme.typography.labelSmall, + color = MatchColors.TextSecondary, + ) + Text( + formatOverlayClock(score.clockSecs), + style = MaterialTheme.typography.labelMedium, + color = if (score.clockRunning) MatchColors.SuccessGreen else MatchColors.TextSecondary, + ) + } + "timer" -> { + Text( + formatOverlayClock(score.clockSecs, countUp = true), + style = MaterialTheme.typography.labelMedium, + color = if (score.clockRunning) MatchColors.SuccessGreen else MatchColors.TextSecondary, + ) + } + "generic" -> Unit + else -> { + Text( + "Set ${score.currentSet} · $pointsTarget pt", + style = MaterialTheme.typography.labelSmall, + color = MatchColors.TextSecondary, + ) + if (score.homeSets > 0 || score.awaySets > 0) { + Text( + "Set vinti ${score.homeSets}-${score.awaySets}", + style = MaterialTheme.typography.labelSmall, + color = MatchColors.TextSecondary, + ) + } + } + } + } +} + @Composable private fun TeamScoreColumn( teamLabel: String, @@ -374,6 +444,9 @@ private fun TeamScoreColumn( points: Int, onPlus: () -> Unit, onMinus: () -> Unit, + onPlus2: (() -> Unit)? = null, + onPlus3: (() -> Unit)? = null, + showBasketButtons: Boolean = false, alignEnd: Boolean, modifier: Modifier = Modifier, ) { @@ -407,11 +480,19 @@ private fun TeamScoreColumn( ) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { if (alignEnd) { + if (showBasketButtons) { + onPlus3?.let { + ScoreIconButton(label = "+3", tooltip = "+3 $teamSide", onClick = it, primary = true) + } + onPlus2?.let { + ScoreIconButton(label = "+2", tooltip = "+2 $teamSide", onClick = it, primary = true) + } + } ScoreIconButton( label = "+1", tooltip = "Aggiungi punto $teamSide", onClick = onPlus, - primary = true, + primary = !showBasketButtons, ) ScoreIconButton(label = "−", tooltip = "Togli punto $teamSide", onClick = onMinus) } else { @@ -420,8 +501,16 @@ private fun TeamScoreColumn( label = "+1", tooltip = "Aggiungi punto $teamSide", onClick = onPlus, - primary = true, + primary = !showBasketButtons, ) + if (showBasketButtons) { + onPlus2?.let { + ScoreIconButton(label = "+2", tooltip = "+2 $teamSide", onClick = it, primary = true) + } + onPlus3?.let { + ScoreIconButton(label = "+3", tooltip = "+3 $teamSide", onClick = it, primary = true) + } + } } } } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt index a724165..a7e78b6 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt @@ -233,7 +233,10 @@ fun BroadcastScreen( ) OverlayKind.TIMER -> OverlayState( overlayKind = overlayKind, - timerText = formatOverlayClock(score.homePoints, countUp = true), + timerText = formatOverlayClock( + if (score.boardType == "timer") score.clockSecs else score.homePoints, + countUp = true, + ), watermarkVisible = true, broadcastStatus = metrics.phase.toOverlayStatus(), ) @@ -244,7 +247,11 @@ fun BroadcastScreen( awayTeamName = currentMatch.opponentName, homeScore = score.homePoints, awayScore = score.awayPoints, - periodLabel = "${score.currentSet}°", + periodLabel = score.periodLabel ?: when (overlayKind) { + OverlayKind.BASKET -> "Q${score.period}" + else -> "${score.period}°" + }, + clockText = formatOverlayClock(score.clockSecs), homeAccentColor = homeColor, awayAccentColor = awayColor, ), @@ -286,6 +293,16 @@ fun BroadcastScreen( } } + LaunchedEffect(sessionId) { + while (true) { + delay(1_000) + val current = container.scoreController.score.value + if (current.clockRunning && current.boardType in setOf("basket", "timed", "timer")) { + container.scoreController.applyAction("clock_tick") + } + } + } + LaunchedEffect(sessionId) { while (true) { delay(4_000) @@ -346,6 +363,12 @@ fun BroadcastScreen( val pointsTarget = scoringRules?.pointsTarget(score.currentSet) ?: 25 val currentSession = session val currentMatch = match + val boardType = currentMatch?.boardType?.ifBlank { score.boardType } ?: score.boardType + val usesActionScoring = boardType in setOf("basket", "timed", "timer") + + fun applyScoreAction(action: String) { + container.scoreController.applyAction(action) + } val shareUrl = currentSession?.watchShareUrl() val shareSubject = currentMatch?.let { "${it.teamName} vs ${it.opponentName}" } ?: "Diretta Match Live TV" @@ -419,29 +442,76 @@ fun BroadcastScreen( homeLogoUrl = currentMatch.homeLogoUrl, awayLogoUrl = currentMatch.opponentLogoUrl, score = score, + boardType = boardType, pointsTarget = pointsTarget, onPointHome = { scope.launch { - container.scoreController.incrementHome() - scoreActions.afterPointChange(container.scoreController.score.value) { - stopStreamPermanently() + if (usesActionScoring) { + applyScoreAction("home_point") + } else { + container.scoreController.incrementHome() + scoreActions.afterPointChange(container.scoreController.score.value) { + stopStreamPermanently() + } } } }, onPointAway = { scope.launch { - container.scoreController.incrementAway() - scoreActions.afterPointChange(container.scoreController.score.value) { - stopStreamPermanently() + if (usesActionScoring) { + applyScoreAction("away_point") + } else { + container.scoreController.incrementAway() + scoreActions.afterPointChange(container.scoreController.score.value) { + stopStreamPermanently() + } } } }, - onMinusHome = { container.scoreController.decrementHome() }, - onMinusAway = { container.scoreController.decrementAway() }, - onCloseSet = { - scope.launch { - scoreActions.requestCloseSet { stopStreamPermanently() } + onMinusHome = { + if (usesActionScoring) applyScoreAction("home_undo") else container.scoreController.decrementHome() + }, + onMinusAway = { + if (usesActionScoring) applyScoreAction("away_undo") else container.scoreController.decrementAway() + }, + onPoint2Home = if (boardType == "basket") { + { applyScoreAction("home_point_2") } + } else { + null + }, + onPoint3Home = if (boardType == "basket") { + { applyScoreAction("home_point_3") } + } else { + null + }, + onPoint2Away = if (boardType == "basket") { + { applyScoreAction("away_point_2") } + } else { + null + }, + onPoint3Away = if (boardType == "basket") { + { applyScoreAction("away_point_3") } + } else { + null + }, + onCloseSet = if (boardType in setOf("volley", "racket")) { + { + scope.launch { + scoreActions.requestCloseSet { stopStreamPermanently() } + } } + } else { + null + }, + onAdvancePeriod = if (boardType in setOf("basket", "timed")) { + { applyScoreAction("advance_period") } + } else { + null + }, + onClockToggle = if (boardType in setOf("basket", "timed", "timer")) { + { applyScoreAction("clock_toggle") } + } else { + null }, onPauseOrResume = { scope.launch { diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/LiveScoreActions.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/LiveScoreActions.kt index 389aa0a..4aa7cfe 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/LiveScoreActions.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/LiveScoreActions.kt @@ -98,10 +98,15 @@ class LiveScoreActions( private val scoreController: ScoreController, private val dialogHost: LiveScoreDialogHost, ) { + private val usesSetLogic: Boolean + get() = rules.boardType in setOf("volley", "racket") + suspend fun afterPointChange( score: ScoreState, onStopStream: suspend () -> Unit = {}, ) { + if (!usesSetLogic) return + val winner = rules.setWinnerFromPoints( homePoints = score.homePoints, awayPoints = score.awayPoints, @@ -123,6 +128,8 @@ class LiveScoreActions( } suspend fun requestCloseSet(onStopStream: suspend () -> Unit) { + if (!usesSetLogic) return + val score = scoreController.score.value val winner = rules.setWinnerFromPoints( homePoints = score.homePoints, diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepMatchScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepMatchScreen.kt index 478c990..6b22161 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepMatchScreen.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepMatchScreen.kt @@ -45,15 +45,27 @@ private const val DEFAULT_SETS_TO_WIN = 3 private const val DEFAULT_POINTS_PER_SET = 25 private const val DEFAULT_POINTS_DECIDING_SET = 15 private const val DEFAULT_MIN_POINT_LEAD = 2 +private const val DEFAULT_PERIODS = 4 +private const val DEFAULT_PERIOD_DURATION_SECS = 600 +private const val DEFAULT_OVERTIME_DURATION_SECS = 300 private const val DEFAULT_OPPONENT_COLOR = "#1E3A8A" -/** True solo se la partita ha regole non standard (non i default FIPAV). */ -private fun matchHasCustomScoring(match: Match): Boolean { - if (match.setsToWin != DEFAULT_SETS_TO_WIN) return true +/** True se la partita ha regole non standard rispetto ai default del board. */ +private fun matchHasCustomScoring(match: Match, boardType: String): Boolean { val rules = match.scoringRules ?: return false - return rules.pointsPerSet != DEFAULT_POINTS_PER_SET || - rules.pointsDecidingSet != DEFAULT_POINTS_DECIDING_SET || - rules.minPointLead != DEFAULT_MIN_POINT_LEAD + return when (boardType) { + "basket", "timed" -> { + rules.periods != DEFAULT_PERIODS || + rules.periodDurationSecs != DEFAULT_PERIOD_DURATION_SECS || + rules.overtimeDurationSecs != DEFAULT_OVERTIME_DURATION_SECS + } + else -> { + if (match.setsToWin != DEFAULT_SETS_TO_WIN) return true + rules.pointsPerSet != DEFAULT_POINTS_PER_SET || + rules.pointsDecidingSet != DEFAULT_POINTS_DECIDING_SET || + rules.minPointLead != DEFAULT_MIN_POINT_LEAD + } + } } @Composable @@ -102,7 +114,7 @@ fun StepMatchScreen( ) { uri -> opponentLogoUri = uri } val existingRules = match.scoringRules - var customRules by remember { mutableStateOf(matchHasCustomScoring(match)) } + var customRules by remember { mutableStateOf(false) } var setsToWin by remember { mutableIntStateOf(match.setsToWin.coerceIn(2, 3)) } var pointsPerSet by remember { mutableIntStateOf(existingRules?.pointsPerSet ?: DEFAULT_POINTS_PER_SET) @@ -112,6 +124,15 @@ fun StepMatchScreen( } var pointsPerSetText by remember { mutableStateOf(pointsPerSet.toString()) } var pointsDecidingSetText by remember { mutableStateOf(pointsDecidingSet.toString()) } + var periods by remember { mutableIntStateOf(existingRules?.periods ?: DEFAULT_PERIODS) } + var periodDurationMins by remember { + mutableIntStateOf((existingRules?.periodDurationSecs ?: DEFAULT_PERIOD_DURATION_SECS) / 60) + } + var overtimeDurationMins by remember { + mutableIntStateOf((existingRules?.overtimeDurationSecs ?: DEFAULT_OVERTIME_DURATION_SECS) / 60) + } + var periodDurationText by remember { mutableStateOf(periodDurationMins.toString()) } + var overtimeDurationText by remember { mutableStateOf(overtimeDurationMins.toString()) } var saving by remember { mutableStateOf(false) } var sports by remember { mutableStateOf>(emptyList()) } var selectedSportKey by remember { mutableStateOf(match.sportKey) } @@ -127,6 +148,10 @@ fun StepMatchScreen( val allowedOverlays = currentSport?.allowedOverlays.orEmpty() val boardType = currentSport?.board ?: homeTeam?.boardType ?: match.boardType + LaunchedEffect(boardType, match.id) { + customRules = matchHasCustomScoring(match, boardType) + } + Column( Modifier .verticalScroll(rememberScrollState()) @@ -227,10 +252,15 @@ fun StepMatchScreen( Column(Modifier.weight(1f)) { Text("Regole punteggio personalizzate") Text( - if (customRules) { - "Torneo non standard: imposta set e punteggi." - } else { - "Standard FIPAV: 3 set per vincere, set a 25, tie-break a 15." + when { + !customRules && boardType in setOf("basket", "timed") -> + "Regole standard dello sport selezionato." + customRules && boardType in setOf("basket", "timed") -> + "Torneo non standard: tempi e periodi personalizzati." + customRules -> + "Torneo non standard: imposta set e punteggi." + else -> + "Standard FIPAV: 3 set per vincere, set a 25, tie-break a 15." }, style = MaterialTheme.typography.bodyMedium, color = MatchColors.TextSecondary, @@ -239,6 +269,51 @@ fun StepMatchScreen( Switch(checked = customRules, onCheckedChange = { customRules = it }) } + if (customRules && boardType in setOf("basket", "timed")) { + Spacer(Modifier.height(16.dp)) + Text( + if (boardType == "basket") "Quarti" else "Tempi", + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf(2, 4).forEach { value -> + val selected = periods == value + if (selected) { + MatchPrimaryButton( + label = value.toString(), + onClick = { periods = value }, + modifier = Modifier.weight(1f), + ) + } else { + MatchSecondaryButton( + label = value.toString(), + onClick = { periods = value }, + modifier = Modifier.weight(1f), + ) + } + } + } + Spacer(Modifier.height(12.dp)) + MatchOutlinedField( + value = periodDurationText, + onValueChange = { text -> + periodDurationText = text.filter { it.isDigit() }.take(3) + periodDurationText.toIntOrNull()?.let { periodDurationMins = it.coerceIn(1, 120) } + }, + label = if (boardType == "basket") "Minuti per quarto" else "Minuti per tempo", + ) + Spacer(Modifier.height(12.dp)) + MatchOutlinedField( + value = overtimeDurationText, + onValueChange = { text -> + overtimeDurationText = text.filter { it.isDigit() }.take(3) + overtimeDurationText.toIntOrNull()?.let { overtimeDurationMins = it.coerceIn(1, 60) } + }, + label = "Minuti supplementari", + ) + } + if (customRules && boardType in setOf("volley", "racket")) { Spacer(Modifier.height(16.dp)) Text("Set da vincere la partita", style = MaterialTheme.typography.bodyMedium) @@ -290,7 +365,7 @@ fun StepMatchScreen( onError("Inserisci il nome avversario") return@MatchPrimaryButton } - if (customRules) { + if (customRules && boardType in setOf("volley", "racket")) { val perSet = pointsPerSetText.toIntOrNull() val deciding = pointsDecidingSetText.toIntOrNull() if (perSet == null || perSet < 1) { @@ -304,17 +379,39 @@ fun StepMatchScreen( pointsPerSet = perSet pointsDecidingSet = deciding } + if (customRules && boardType in setOf("basket", "timed")) { + val periodMins = periodDurationText.toIntOrNull() + val overtimeMins = overtimeDurationText.toIntOrNull() + if (periodMins == null || periodMins < 1) { + onError("Inserisci la durata del periodo in minuti") + return@MatchPrimaryButton + } + if (overtimeMins == null || overtimeMins < 1) { + onError("Inserisci la durata dei supplementari in minuti") + return@MatchPrimaryButton + } + periodDurationMins = periodMins + overtimeDurationMins = overtimeMins + } - val resolvedSets = if (customRules) setsToWin else DEFAULT_SETS_TO_WIN - val resolvedRules: ScoringRulesBody? = if (customRules && boardType in setOf("volley", "racket")) { - ScoringRulesBody( + val resolvedSets = if (customRules && boardType in setOf("volley", "racket")) { + setsToWin + } else { + DEFAULT_SETS_TO_WIN + } + val resolvedRules: ScoringRulesBody? = when { + customRules && boardType in setOf("volley", "racket") -> ScoringRulesBody( setsToWin = resolvedSets, pointsPerSet = pointsPerSet, pointsDecidingSet = pointsDecidingSet, minPointLead = existingRules?.minPointLead ?: DEFAULT_MIN_POINT_LEAD, ) - } else { - null + customRules && boardType in setOf("basket", "timed") -> ScoringRulesBody( + periods = periods, + periodDurationSecs = periodDurationMins * 60, + overtimeDurationSecs = overtimeDurationMins * 60, + ) + else -> null } val initialHomeColor = normalizeHexColor(