Initial commit: monorepo Match Live TV.
Rails API, app Flutter, infrastruttura Docker/MediaMTX, sito marketing e documentazione di deploy. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
76
backend/app/views/public/live/index.html.erb
Normal file
76
backend/app/views/public/live/index.html.erb
Normal file
@@ -0,0 +1,76 @@
|
||||
<% content_for :title do %>Dirette in corso — Match Live TV<% end %>
|
||||
|
||||
<div class="wrap">
|
||||
<h1>Dirette in corso</h1>
|
||||
<p class="results-hint">
|
||||
Cerca per nome squadra o avversario, poi apri la diretta per guardare lo stream.
|
||||
</p>
|
||||
|
||||
<%= form_with url: public_live_index_path, method: :get, local: true, class: "search-form" do %>
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
value="<%= @query %>"
|
||||
placeholder="Es. Tigers, Eagles, nome squadra…"
|
||||
aria-label="Cerca squadra"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button type="submit" class="btn btn-primary">Cerca</button>
|
||||
<% if @query.present? %>
|
||||
<%= link_to "Azzera", public_live_index_path, class: "btn btn-secondary" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if @query.present? %>
|
||||
<p class="results-hint">
|
||||
Risultati per «<%= h @query %>»: <%= @sessions.size %> diretta<%= @sessions.size == 1 ? "" : "e" %>
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<% if @sessions.any? %>
|
||||
<div class="live-grid">
|
||||
<% @sessions.each do |session| %>
|
||||
<% match = session.match %>
|
||||
<% on_air = @online_paths.include?(session.mediamtx_path_name) %>
|
||||
<article class="live-card">
|
||||
<h3><%= match.team.name %> vs <%= match.opponent_name %></h3>
|
||||
<p class="meta">
|
||||
<% if match.location.present? %><%= match.location %> · <% end %>
|
||||
<%= session.platform == "matchlivetv" ? "Match Live TV" : session.platform.capitalize %>
|
||||
</p>
|
||||
<% if session.score_state %>
|
||||
<p class="card-score">
|
||||
<span class="card-sets"><%= live_score_sets_label(session.score_state) %></span>
|
||||
<% if live_score_partials_label(session.score_state).present? %>
|
||||
<span class="card-partials">Parziali: <%= live_score_partials_label(session.score_state) %></span>
|
||||
<% end %>
|
||||
<span class="card-points"><%= session.score_state.home_points %> - <%= session.score_state.away_points %></span>
|
||||
</p>
|
||||
<% end %>
|
||||
<div class="badges">
|
||||
<% if on_air %>
|
||||
<span class="badge badge-on-air">In onda</span>
|
||||
<% elsif session.status == "live" %>
|
||||
<span class="badge badge-live">Live</span>
|
||||
<% elsif session.status == "reconnecting" %>
|
||||
<span class="badge badge-connecting">Riconnessione</span>
|
||||
<% else %>
|
||||
<span class="badge badge-wait">In avvio</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= link_to "Guarda diretta →", public_live_path(session), class: "btn-watch" %>
|
||||
</article>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="empty-state">
|
||||
<% if @query.present? %>
|
||||
<p>Nessuna diretta attiva per «<%= h @query %>».</p>
|
||||
<p>Prova un altro nome squadra o <%= link_to "mostra tutte le dirette", public_live_index_path %>.</p>
|
||||
<% else %>
|
||||
<p>Al momento non ci sono dirette attive.</p>
|
||||
<p>Quando una squadra avvia lo streaming dall’app, la partita comparirà qui.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
227
backend/app/views/public/live/show.html.erb
Normal file
227
backend/app/views/public/live/show.html.erb
Normal file
@@ -0,0 +1,227 @@
|
||||
<% content_for :title do %><%= @match.team.name %> vs <%= @match.opponent_name %> — Match Live TV<% end %>
|
||||
|
||||
<% content_for :head do %>
|
||||
<% unless @stream_closed %>
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.7"></script>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="wrap">
|
||||
<%= link_to "← Tutte le dirette", public_live_index_path, class: "back-link" %>
|
||||
|
||||
<h1><%= @match.team.name %> vs <%= @match.opponent_name %></h1>
|
||||
<p>
|
||||
<% if @stream_closed %>
|
||||
<span id="status-badge" class="badge badge-ended">Diretta chiusa</span>
|
||||
<% elsif @on_air %>
|
||||
<span id="status-badge" class="badge badge-on-air">In onda</span>
|
||||
<% else %>
|
||||
<span id="status-badge" class="badge badge-wait">In attesa</span>
|
||||
<% end %>
|
||||
</p>
|
||||
|
||||
<div id="scoreboard" class="scoreboard">
|
||||
<p id="sets-line" class="sets-line"><%= live_score_sets_label(@session.score_state) || "—" %></p>
|
||||
<p id="partials-line" class="partials-line"<%= " hidden" unless live_score_partials_label(@session.score_state).present? %>>
|
||||
Parziali: <%= live_score_partials_label(@session.score_state) %>
|
||||
</p>
|
||||
<p id="score-line" class="score"><%= live_score_points_label(@match, @session.score_state) %></p>
|
||||
</div>
|
||||
|
||||
<% if @stream_closed %>
|
||||
<div id="stream-ended" class="stream-ended" role="status" aria-live="polite">
|
||||
<div class="stream-ended-icon" aria-hidden="true">📡</div>
|
||||
<h2>Diretta terminata</h2>
|
||||
<p>Lo streaming di questa partita è stato chiuso.</p>
|
||||
<% if @session.ended_at %>
|
||||
<p class="stream-ended-meta">Chiusa il <%= l(@session.ended_at.in_time_zone, format: :long) %></p>
|
||||
<% end %>
|
||||
<p class="stream-ended-hint">Il punteggio finale resta visibile sopra.</p>
|
||||
<%= link_to "Tutte le dirette", public_live_index_path, class: "btn btn-secondary stream-ended-btn" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<video id="player" controls playsinline muted autoplay></video>
|
||||
<p id="offline-msg" style="display:none;color:#aaa;">La diretta non è ancora disponibile. Si aggiorna automaticamente quando torna in onda.</p>
|
||||
|
||||
<script>
|
||||
const sessionId = "<%= @session.id %>";
|
||||
const hlsUrl = "<%= j @session.hls_playback_url %>";
|
||||
const video = document.getElementById("player");
|
||||
const badge = document.getElementById("status-badge");
|
||||
const setsLine = document.getElementById("sets-line");
|
||||
const partialsLine = document.getElementById("partials-line");
|
||||
const scoreLine = document.getElementById("score-line");
|
||||
const offlineMsg = document.getElementById("offline-msg");
|
||||
|
||||
let hls = null;
|
||||
let wasOnAir = <%= @on_air ? "true" : "false" %>;
|
||||
let reloadTimer = null;
|
||||
let stallPolls = 0;
|
||||
let streamClosed = false;
|
||||
|
||||
function setBadge(status, live, onAir, closed) {
|
||||
if (closed) {
|
||||
badge.textContent = "Diretta chiusa";
|
||||
badge.className = "badge badge-ended";
|
||||
return;
|
||||
}
|
||||
if (onAir) {
|
||||
badge.textContent = "In onda";
|
||||
badge.className = "badge badge-on-air";
|
||||
} else if (live) {
|
||||
badge.textContent = "LIVE";
|
||||
badge.className = "badge badge-live";
|
||||
} else if (status === "reconnecting") {
|
||||
badge.textContent = "Riconnessione";
|
||||
badge.className = "badge badge-wait";
|
||||
} else {
|
||||
badge.textContent = status;
|
||||
badge.className = "badge badge-wait";
|
||||
}
|
||||
}
|
||||
|
||||
function formatSets(score) {
|
||||
if (!score) return "—";
|
||||
return `Set ${score.current_set} · Set vinti ${score.home_sets}-${score.away_sets}`;
|
||||
}
|
||||
|
||||
function formatPartials(score) {
|
||||
const partials = score?.set_partials;
|
||||
if (!partials?.length) return "";
|
||||
return partials
|
||||
.map((p) => `Set ${p.set} ${p.home}-${p.away}`)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
function formatPoints(data) {
|
||||
if (!data.score || !data.home_name || !data.away_name) return "—";
|
||||
const s = data.score;
|
||||
return `${data.home_name} ${s.home_points} - ${s.away_points} ${data.away_name}`;
|
||||
}
|
||||
|
||||
function updateScoreboard(data) {
|
||||
const score = data.score;
|
||||
setsLine.textContent = formatSets(score);
|
||||
const partials = formatPartials(score);
|
||||
if (partials) {
|
||||
partialsLine.textContent = `Parziali: ${partials}`;
|
||||
partialsLine.hidden = false;
|
||||
} else {
|
||||
partialsLine.hidden = true;
|
||||
}
|
||||
scoreLine.textContent = formatPoints(data);
|
||||
}
|
||||
|
||||
function showStreamEnded() {
|
||||
if (streamClosed) return;
|
||||
streamClosed = true;
|
||||
destroyPlayer();
|
||||
video.style.display = "none";
|
||||
offlineMsg.style.display = "none";
|
||||
const panel = document.createElement("div");
|
||||
panel.id = "stream-ended";
|
||||
panel.className = "stream-ended";
|
||||
panel.setAttribute("role", "status");
|
||||
panel.innerHTML = `
|
||||
<div class="stream-ended-icon" aria-hidden="true">📡</div>
|
||||
<h2>Diretta terminata</h2>
|
||||
<p>Lo streaming di questa partita è stato chiuso.</p>
|
||||
<p class="stream-ended-hint">Il punteggio finale resta visibile sopra.</p>
|
||||
<a href="<%= public_live_index_path %>" class="btn btn-secondary stream-ended-btn">Tutte le dirette</a>
|
||||
`;
|
||||
video.parentNode.insertBefore(panel, video.nextSibling);
|
||||
}
|
||||
|
||||
function hlsUrlFresh() {
|
||||
const sep = hlsUrl.includes("?") ? "&" : "?";
|
||||
return `${hlsUrl}${sep}_ts=${Date.now()}`;
|
||||
}
|
||||
|
||||
function destroyPlayer() {
|
||||
if (hls) {
|
||||
hls.destroy();
|
||||
hls = null;
|
||||
}
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
}
|
||||
|
||||
function startPlayer() {
|
||||
destroyPlayer();
|
||||
const url = hlsUrlFresh();
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls({ lowLatencyMode: true, enableWorker: true });
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play().catch(() => {}));
|
||||
hls.on(Hls.Events.ERROR, (_, data) => {
|
||||
if (data.fatal) {
|
||||
scheduleReload(2500);
|
||||
}
|
||||
});
|
||||
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
video.src = url;
|
||||
video.addEventListener("loadedmetadata", () => video.play().catch(() => {}), { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReload(delayMs) {
|
||||
if (reloadTimer) return;
|
||||
reloadTimer = setTimeout(() => {
|
||||
reloadTimer = null;
|
||||
pollStatus();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
async function pollStatus() {
|
||||
try {
|
||||
const res = await fetch(`/live/${sessionId}/status.json`);
|
||||
const data = await res.json();
|
||||
setBadge(data.status, data.live, data.on_air, data.stream_closed);
|
||||
updateScoreboard(data);
|
||||
|
||||
if (data.stream_closed) {
|
||||
showStreamEnded();
|
||||
return;
|
||||
}
|
||||
|
||||
const onAir = !!data.on_air;
|
||||
|
||||
if (onAir) {
|
||||
offlineMsg.style.display = "none";
|
||||
if (!wasOnAir) {
|
||||
startPlayer();
|
||||
stallPolls = 0;
|
||||
} else if (video.readyState < 2) {
|
||||
stallPolls += 1;
|
||||
if (stallPolls >= 2) {
|
||||
startPlayer();
|
||||
stallPolls = 0;
|
||||
}
|
||||
} else {
|
||||
stallPolls = 0;
|
||||
}
|
||||
} else {
|
||||
offlineMsg.style.display = "block";
|
||||
destroyPlayer();
|
||||
stallPolls = 0;
|
||||
}
|
||||
|
||||
wasOnAir = onAir;
|
||||
} catch (_) {
|
||||
scheduleReload(5000);
|
||||
}
|
||||
}
|
||||
|
||||
if (wasOnAir) {
|
||||
startPlayer();
|
||||
} else {
|
||||
offlineMsg.style.display = "block";
|
||||
}
|
||||
|
||||
pollStatus();
|
||||
setInterval(pollStatus, 3000);
|
||||
</script>
|
||||
<% end %>
|
||||
</div>
|
||||
Reference in New Issue
Block a user