Stabilizza regia, live web e sync punteggi app nativa.

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

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

View File

@@ -91,7 +91,7 @@
<% if @active_sessions.any? %>
<table class="admin-table">
<thead>
<tr><th>Partita</th><th>Stato</th><th>Inizio</th><th></th></tr>
<tr><th>Partita</th><th>Stato</th><th>Inizio</th><th>Link</th><th></th></tr>
</thead>
<tbody id="active-sessions-body">
<% @active_sessions.each do |s| %>
@@ -99,6 +99,12 @@
<td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td>
<td><span class="badge badge--<%= s.status == 'live' ? 'live' : (s.status == 'paused' ? 'paused' : 'connecting') %>"><%= s.status %></span></td>
<td class="muted"><%= s.started_at&.strftime("%d/%m %H:%M") || "—" %></td>
<td class="admin-link-compact">
<% admin_session_watch_links(s).each do |link| %>
<%= link_to link[:label], link[:url], target: "_blank", rel: "noopener", class: "admin-link-chip" %>
<% end %>
<%= link_to "Regia", admin_session_path(s), class: "admin-link-chip admin-link-chip--regia" %>
</td>
<td class="admin-actions">
<%= link_to "Dettaglio", admin_session_path(s) %>
<%= button_to "Termina", stop_admin_session_path(s), method: :post, class: "admin-btn admin-btn--danger admin-btn--sm", form: { data: { turbo_confirm: "Terminare questa sessione?" } } %>

View File

@@ -0,0 +1,41 @@
<% watch_links = admin_session_watch_links(session) %>
<section class="admin-links-panel">
<h3>Link diretta</h3>
<% if watch_links.any? %>
<ul class="admin-link-list">
<% watch_links.each do |link| %>
<li>
<span class="admin-link-label"><%= link[:label] %></span>
<%= link_to link[:url], link[:url], target: "_blank", rel: "noopener", class: "admin-link-url" %>
</li>
<% end %>
</ul>
<% else %>
<p class="muted">Nessun link video disponibile per questa sessione.</p>
<% end %>
<h3>Link regia</h3>
<% if session.terminal? %>
<p class="muted">Sessione terminata — link regia non disponibile.</p>
<% else %>
<% if flash[:regia_url].present? %>
<p class="admin-link-generated">
<span class="admin-link-label">Regia</span>
<%= link_to flash[:regia_url], flash[:regia_url], target: "_blank", rel: "noopener", class: "admin-link-url" %>
</p>
<p><code class="admin-link-code"><%= flash[:regia_url] %></code></p>
<% if (expires = admin_regia_expires_label(flash[:regia_expires_at])) %>
<p class="muted">Valido fino a <%= expires %></p>
<% end %>
<% elsif session.regia_token_active? %>
<p class="muted">Esiste già un link regia attivo (lURL non è recuperabile). Generane uno nuovo se serve condividerlo di nuovo.</p>
<% else %>
<p class="muted">Nessun link regia attivo. Genera un link da condividere con chi gestisce il punteggio.</p>
<% end %>
<%= button_to "Genera link regia",
regia_link_admin_session_path(session),
method: :post,
class: "admin-btn admin-btn--secondary admin-btn--sm",
form: { class: "admin-inline-form" } %>
<% end %>
</section>

View File

@@ -1,12 +1,18 @@
<h2>Stream Sessions</h2>
<table class="admin-table">
<thead><tr><th>Match</th><th>Status</th><th>Disconnects</th><th></th></tr></thead>
<thead><tr><th>Match</th><th>Status</th><th>Disconnects</th><th>Link</th><th></th></tr></thead>
<tbody>
<% @sessions.each do |s| %>
<tr>
<td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td>
<td><span class="badge <%= s.status == 'live' ? 'badge--live' : 'badge--paused' %>"><%= s.status %></span></td>
<td><%= s.disconnection_count %></td>
<td class="admin-link-compact">
<% admin_session_watch_links(s).each do |link| %>
<%= link_to link[:label], link[:url], target: "_blank", rel: "noopener", class: "admin-link-chip" %>
<% end %>
<%= link_to "Regia", admin_session_path(s), class: "admin-link-chip admin-link-chip--regia" %>
</td>
<td><%= link_to "Dettaglio", admin_session_path(s) %></td>
</tr>
<% end %>

View File

@@ -10,8 +10,11 @@
</p>
<% end %>
<p>Match: <%= @session.match.team.name %> vs <%= @session.match.opponent_name %></p>
<%= render "admin/sessions/links", session: @session %>
<% if @session.youtube_broadcast_id %>
<p>YouTube: <a href="https://studio.youtube.com/video/<%= @session.youtube_broadcast_id %>/livestreaming" target="_blank">Broadcast</a></p>
<p>YouTube Studio: <a href="https://studio.youtube.com/video/<%= @session.youtube_broadcast_id %>/livestreaming" target="_blank" rel="noopener">Broadcast</a></p>
<% end %>
<p>RTMP ingest: <code><%= @session.rtmp_ingest_url %></code></p>

View File

@@ -7,7 +7,7 @@
<%= render "shared/meta_tags" %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="/marketing.css?v=36">
<link rel="stylesheet" href="/live.css?v=22">
<link rel="stylesheet" href="/live.css?v=23">
<%= yield :head %>
</head>
<body<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>

View File

@@ -0,0 +1,47 @@
<% score = @session.score_state %>
<% team = @match.team %>
<% cols = live_scorebug_columns(score) %>
<% home_color = team.effective_primary_color %>
<% away_color = live_scorebug_away_color(team) %>
<div class="live-player-overlays" id="live-overlays">
<div
id="live-scorebug"
class="live-scorebug"
style="--sb-home-primary: <%= home_color %>; --sb-away-primary: <%= away_color %>;"
data-home-name="<%= j live_scorebug_team_label(team.name) %>"
data-away-name="<%= j live_scorebug_team_label(@match.opponent_name) %>"
>
<table class="live-scorebug__table" aria-label="Tabellone">
<caption class="visually-hidden">Punteggio live</caption>
<thead>
<tr>
<th class="live-scorebug__corner" scope="col">Squadra</th>
<% cols[:labels].each do |label| %>
<th class="live-scorebug__col-set live-scorebug__col-h" scope="col"><%= label %></th>
<% end %>
</tr>
</thead>
<tbody>
<tr class="live-scorebug__row--home">
<th class="live-scorebug__team" scope="row"><%= live_scorebug_team_label(team.name) %></th>
<% cols[:home].each_with_index do |val, i| %>
<td class="live-scorebug__pts<%= " live-scorebug__pts--live-home" if i == cols[:live_index] %>"><%= val %></td>
<% end %>
</tr>
<tr class="live-scorebug__row--away">
<th class="live-scorebug__team" scope="row"><%= live_scorebug_team_label(@match.opponent_name) %></th>
<% cols[:away].each_with_index do |val, i| %>
<td class="live-scorebug__pts<%= " live-scorebug__pts--live-away" if i == cols[:live_index] %>"><%= val %></td>
<% end %>
</tr>
</tbody>
</table>
<div class="live-scorebug__brand" aria-hidden="true">
<span class="live-scorebug__brand-text">MATCH <span class="live-scorebug__brand-accent">LIVE TV</span></span>
</div>
</div>
<span
id="live-stream-badge"
class="live-ovl-badge live-ovl-badge--right <%= @stream_closed ? "badge-ended" : (@session.paused? ? "badge-wait" : (@on_air ? "badge-live" : "badge-wait")) %>"
><%= @stream_closed ? "Terminata" : (@session.paused? ? "In pausa" : (@on_air ? "LIVE" : "In attesa")) %></span>
</div>

View File

@@ -1,7 +1,7 @@
<% club_name = @match.team.club&.name %>
<% content_for :title do %><%= club_name %> · <%= @match.team.name %> vs <%= @match.opponent_name %> — Diretta live<% end %>
<% content_for :meta_description do %>Segui in diretta live <%= club_name %> — <%= @match.team.name %> vs <%= @match.opponent_name %><% if @match.location.present? %> — <%= @match.location %><% end %>. Guarda dal browser senza installare app.<% end %>
<% content_for :robots, "noindex, follow" %>
<% content_for :robots, @session.publicly_listed? ? "index, follow" : "noindex, nofollow" %>
<% content_for :head do %>
<% unless @stream_closed || @session.platform == "youtube" %>
@@ -41,7 +41,7 @@
<% else %>
<div class="live-player-wrap">
<video id="player" controls playsinline muted autoplay poster="/images/copertina-canale.png"></video>
<%# Tabellone e watermark sono bruciati nel video dall'app mobile (overlay client-side). %>
<%= render "public/live/scorebug", match: @match, session: @session %>
<button type="button" id="play-hint" class="live-play-hint" hidden>
▶ Avvia la diretta
</button>
@@ -58,6 +58,88 @@
const pausedMsg = document.getElementById("paused-msg");
const awaitingMsg = document.getElementById("awaiting-msg");
const playHint = document.getElementById("play-hint");
const scorebug = document.getElementById("live-scorebug");
const streamBadge = document.getElementById("live-stream-badge");
const homeName = "<%= j @match.team.name %>";
const awayName = "<%= j @match.opponent_name %>";
function abbrevName(name, max = 16) {
const n = String(name || "");
return n.length <= max ? n : `${n.slice(0, max - 1)}…`;
}
function scoreColumnsFromPayload(score) {
if (!score) return { labels: ["1"], home: ["0"], away: ["0"], liveIndex: 0 };
const partials = score.set_partials || score.setPartials || [];
const labels = partials.map((p) => String(p.set ?? p["set"] ?? ""));
const home = partials.map((p) => String(p.home ?? p["home"] ?? "0"));
const away = partials.map((p) => String(p.away ?? p["away"] ?? "0"));
const currentSet = score.current_set ?? score.currentSet ?? 1;
labels.push(String(currentSet));
home.push(String(score.home_points ?? score.homePoints ?? 0));
away.push(String(score.away_points ?? score.awayPoints ?? 0));
return { labels, home, away, liveIndex: labels.length - 1 };
}
function renderLiveScorebug(score) {
if (!scorebug) return;
const cols = scoreColumnsFromPayload(score);
const homeLabel = abbrevName(scorebug.dataset.homeName || homeName);
const awayLabel = abbrevName(scorebug.dataset.awayName || awayName);
const headCells = cols.labels
.map((label) => `<th class="live-scorebug__col-set live-scorebug__col-h" scope="col">${label}</th>`)
.join("");
const homeCells = cols.home
.map((val, i) => {
const live = i === cols.liveIndex ? " live-scorebug__pts--live-home" : "";
return `<td class="live-scorebug__pts${live}">${val}</td>`;
})
.join("");
const awayCells = cols.away
.map((val, i) => {
const live = i === cols.liveIndex ? " live-scorebug__pts--live-away" : "";
return `<td class="live-scorebug__pts${live}">${val}</td>`;
})
.join("");
scorebug.innerHTML = `
<table class="live-scorebug__table" aria-label="Tabellone">
<caption class="visually-hidden">Punteggio live</caption>
<thead><tr>
<th class="live-scorebug__corner" scope="col">Squadra</th>${headCells}
</tr></thead>
<tbody>
<tr class="live-scorebug__row--home">
<th class="live-scorebug__team" scope="row">${homeLabel}</th>${homeCells}
</tr>
<tr class="live-scorebug__row--away">
<th class="live-scorebug__team" scope="row">${awayLabel}</th>${awayCells}
</tr>
</tbody>
</table>
<div class="live-scorebug__brand" aria-hidden="true">
<span class="live-scorebug__brand-text">MATCH <span class="live-scorebug__brand-accent">LIVE TV</span></span>
</div>`;
}
function syncStreamBadge(data) {
if (!streamBadge) return;
if (data.stream_closed) {
streamBadge.textContent = "Terminata";
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-ended";
return;
}
const paused = !!(data.paused || data.status === "paused");
if (paused) {
streamBadge.textContent = "In pausa";
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-wait";
} else if (data.on_air) {
streamBadge.textContent = "LIVE";
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-live";
} else {
streamBadge.textContent = "In attesa";
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-wait";
}
}
let hls = null;
let playerStarted = false;
@@ -247,6 +329,9 @@
return;
}
renderLiveScorebug(data.score);
syncStreamBadge(data);
const onAir = !!data.on_air;
const sessionLive = !!data.live;

View File

@@ -12,6 +12,7 @@
data-status-url="<%= j public_regia_status_path(params[:token]) %>"
data-score-url="<%= j public_regia_score_path(params[:token]) %>"
data-pause-url="<%= j public_regia_pause_path(params[:token]) %>"
data-resume-url="<%= j public_regia_resume_path(params[:token]) %>"
data-stop-url="<%= j public_regia_stop_path(params[:token]) %>"
data-cable-url="<%= j "/cable?regia_token=#{params[:token]}" %>"
data-hls-url="<%= j @session.hls_playback_url %>"
@@ -20,13 +21,14 @@
data-share-url="<%= j @share_url %>"
data-share-title="<%= j @share_title %>"
data-points-target="<%= Scoring::Rules.from_match(@match).points_target(@score.current_set) %>"
data-stream-closed="<%= @stream_closed %>">
data-stream-closed="<%= @stream_closed %>"
data-initial-paused="<%= @session.paused? %>">
<header class="regia-header">
<h1><%= @team.name %> vs <%= @match.opponent_name %></h1>
<p>Regia · Set <%= @score.current_set %></p>
<span id="regia-badge" class="regia-badge <%= @on_air ? 'regia-badge--live' : (@stream_closed ? 'regia-badge--ended' : 'regia-badge--wait') %>">
<%= @stream_closed ? "Terminata" : (@on_air ? "In onda" : "In attesa") %>
<span id="regia-badge" class="regia-badge <%= @stream_closed ? 'regia-badge--ended' : (@session.paused? ? 'regia-badge--wait' : (@on_air ? 'regia-badge--live' : 'regia-badge--wait')) %>">
<%= @stream_closed ? "Terminata" : (@session.paused? ? "In pausa" : (@on_air ? "In onda" : "In attesa")) %>
</span>
</header>
@@ -76,7 +78,7 @@
</section>
<% unless @stream_closed %>
<button type="button" class="regia-btn regia-btn--outline" id="btn-pause">Metti in pausa</button>
<button type="button" class="regia-btn regia-btn--outline" id="btn-pause"><%= @session.paused? ? "Riprendi diretta" : "Metti in pausa" %></button>
<button type="button" class="regia-btn regia-btn--danger" id="btn-stop">Chiudi diretta</button>
<% end %>
</div>
@@ -94,4 +96,4 @@
</div>
</div>
<script src="/regia.js?v=2"></script>
<script src="/regia.js?v=4"></script>