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:
@@ -3,7 +3,7 @@ module CableBroadcastable
|
||||
|
||||
class_methods do
|
||||
def broadcast_message(session, data)
|
||||
broadcast_to(session, method: "receive_message", data: data)
|
||||
broadcast_to(session, data)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,5 +22,22 @@ module Admin
|
||||
Rails.logger.error("[admin] stop session #{@session.id}: #{e.class} #{e.message}")
|
||||
redirect_to admin_session_path(@session), alert: "Errore durante la chiusura: #{e.message}"
|
||||
end
|
||||
|
||||
def regia_link
|
||||
@session = StreamSession.find(params[:id])
|
||||
if @session.terminal?
|
||||
redirect_to admin_session_path(@session), alert: "Sessione terminata: impossibile generare il link regia."
|
||||
return
|
||||
end
|
||||
|
||||
access = Sessions::RegiaAccess.new(@session)
|
||||
token = access.issue_token!
|
||||
redirect_to admin_session_path(@session),
|
||||
notice: "Link regia generato.",
|
||||
flash: {
|
||||
regia_url: access.url(token),
|
||||
regia_expires_at: @session.regia_token_expires_at&.iso8601
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
module Api
|
||||
module V1
|
||||
class MatchesController < BaseController
|
||||
include Api::UrlHelper
|
||||
include BrandingAttachments
|
||||
|
||||
before_action :set_team, only: %i[index create]
|
||||
before_action :set_match, only: %i[show update destroy]
|
||||
|
||||
@@ -14,6 +17,7 @@ module Api
|
||||
|
||||
def create
|
||||
match = @team.matches.create!(match_params)
|
||||
attach_opponent_logo(match)
|
||||
render json: match_json(match), status: :created
|
||||
end
|
||||
|
||||
@@ -24,7 +28,9 @@ module Api
|
||||
def update
|
||||
attrs = match_params.to_h
|
||||
normalize_scoring_rules!(attrs)
|
||||
normalize_opponent_color!(attrs)
|
||||
@match.update!(attrs)
|
||||
attach_opponent_logo(@match)
|
||||
render json: match_json(@match)
|
||||
end
|
||||
|
||||
@@ -49,18 +55,17 @@ module Api
|
||||
|
||||
def set_match
|
||||
team_ids = current_user.streamable_teams.map(&:id)
|
||||
@match = Match.where(team_id: team_ids).find(params[:id])
|
||||
@match = Match.includes(:team).where(team_id: team_ids).find(params[:id])
|
||||
end
|
||||
|
||||
def match_params
|
||||
params.require(:match).permit(
|
||||
:opponent_name, :location, :scheduled_at, :sport, :sets_to_win,
|
||||
:category,
|
||||
:category, :opponent_primary_color,
|
||||
scoring_rules: %i[points_per_set points_deciding_set min_point_lead]
|
||||
)
|
||||
end
|
||||
|
||||
# Regolamento standard: scoring_rules assente o {} → default FIPAV lato Scoring::Rules.
|
||||
def normalize_scoring_rules!(attrs)
|
||||
return unless attrs.key?("scoring_rules")
|
||||
|
||||
@@ -68,12 +73,27 @@ module Api
|
||||
attrs["scoring_rules"] = {} if rules.blank?
|
||||
end
|
||||
|
||||
def normalize_opponent_color!(attrs)
|
||||
return unless attrs.key?("opponent_primary_color")
|
||||
|
||||
attrs["opponent_primary_color"] = normalize_hex_color(
|
||||
attrs["opponent_primary_color"],
|
||||
Match::DEFAULT_OPPONENT_COLOR
|
||||
)
|
||||
end
|
||||
|
||||
def attach_opponent_logo(match)
|
||||
file = params[:opponent_logo_file]
|
||||
match.opponent_logo_file.attach(file) if file.present?
|
||||
end
|
||||
|
||||
def match_json(match, detail: false)
|
||||
active = active_session_for(match)
|
||||
team = match.team
|
||||
{
|
||||
id: match.id,
|
||||
team_id: match.team_id,
|
||||
team_name: match.team.name,
|
||||
team_name: team.name,
|
||||
opponent_name: match.opponent_name,
|
||||
location: match.location,
|
||||
scheduled_at: match.scheduled_at,
|
||||
@@ -81,6 +101,11 @@ module Api
|
||||
sets_to_win: match.sets_to_win,
|
||||
scoring_rules: match.scoring_rules.presence,
|
||||
category: match.category,
|
||||
home_primary_color: team.effective_primary_color,
|
||||
home_secondary_color: team.effective_secondary_color,
|
||||
home_logo_url: api_absolute_url(team.effective_logo_url),
|
||||
opponent_primary_color: match.effective_opponent_primary_color,
|
||||
opponent_logo_url: api_absolute_url(match.opponent_logo_url),
|
||||
active_session_id: active&.id,
|
||||
active_session_status: active&.status,
|
||||
stream_completed: match.stream_completed?,
|
||||
|
||||
@@ -100,13 +100,31 @@ module Api
|
||||
end
|
||||
|
||||
def network_test
|
||||
upload_mbps = params[:upload_mbps].to_f
|
||||
preset = Sessions::SelectQuality.call(upload_mbps: upload_mbps)
|
||||
@session.update!(
|
||||
quality_preset: preset[:id],
|
||||
target_bitrate: preset[:target_bitrate],
|
||||
target_fps: preset[:target_fps]
|
||||
)
|
||||
@session.stream_events.create!(
|
||||
event_type: "network_test",
|
||||
metadata: params.permit(:download_mbps, :upload_mbps, :latency_ms, :network_type).to_h,
|
||||
metadata: params.permit(:download_mbps, :upload_mbps, :latency_ms, :network_type)
|
||||
.to_h.merge(
|
||||
"quality_preset" => preset[:id],
|
||||
"target_bitrate" => preset[:target_bitrate]
|
||||
),
|
||||
occurred_at: Time.current
|
||||
)
|
||||
ready = params[:upload_mbps].to_f >= (@session.target_bitrate / 1_000_000.0 * 0.8)
|
||||
render json: { ready: ready, target_upload_mbps: @session.target_bitrate / 1_000_000.0 }
|
||||
target_upload = preset[:target_bitrate] / 1_000_000.0
|
||||
ready = upload_mbps >= (target_upload * Sessions::SelectQuality::UPLOAD_HEADROOM)
|
||||
render json: {
|
||||
ready: ready,
|
||||
target_upload_mbps: target_upload,
|
||||
quality_preset: preset[:id],
|
||||
target_bitrate: preset[:target_bitrate],
|
||||
target_fps: preset[:target_fps]
|
||||
}
|
||||
end
|
||||
|
||||
def youtube_stats
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
module Api
|
||||
module V1
|
||||
class TeamsController < BaseController
|
||||
include Api::UrlHelper
|
||||
include BrandingAttachments
|
||||
|
||||
def index
|
||||
teams = current_user.streamable_teams
|
||||
render json: teams.map { |t| team_json(t) }
|
||||
@@ -48,6 +51,7 @@ module Api
|
||||
def update
|
||||
team = current_user.manageable_teams.find(params[:id])
|
||||
team.update!(team_params)
|
||||
attach_team_logo(team)
|
||||
render json: team_json(team)
|
||||
end
|
||||
|
||||
@@ -69,7 +73,19 @@ module Api
|
||||
private
|
||||
|
||||
def team_params
|
||||
params.require(:team).permit(:name, :sport, :logo_url)
|
||||
p = params.require(:team).permit(:name, :sport, :logo_url, :primary_color, :secondary_color)
|
||||
if p[:primary_color].present?
|
||||
p[:primary_color] = normalize_hex_color(p[:primary_color], p[:primary_color])
|
||||
end
|
||||
if p[:secondary_color].present?
|
||||
p[:secondary_color] = normalize_hex_color(p[:secondary_color], p[:secondary_color])
|
||||
end
|
||||
p
|
||||
end
|
||||
|
||||
def attach_team_logo(team)
|
||||
file = params[:logo_file]
|
||||
team.logo_file.attach(file) if file.present?
|
||||
end
|
||||
|
||||
def team_json(team, detail: false)
|
||||
@@ -80,7 +96,7 @@ module Api
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
sport: team.sport,
|
||||
logo_url: team.effective_logo_url,
|
||||
logo_url: api_absolute_url(team.effective_logo_url),
|
||||
primary_color: team.effective_primary_color,
|
||||
secondary_color: team.effective_secondary_color,
|
||||
club_id: team.club_id,
|
||||
|
||||
16
backend/app/controllers/concerns/api/url_helper.rb
Normal file
16
backend/app/controllers/concerns/api/url_helper.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
module Api
|
||||
module UrlHelper
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def api_absolute_url(path_or_url)
|
||||
return nil if path_or_url.blank?
|
||||
return path_or_url if path_or_url.match?(/\Ahttps?:\/\//i)
|
||||
|
||||
base = request.base_url
|
||||
path = path_or_url.start_with?("/") ? path_or_url : "/#{path_or_url}"
|
||||
"#{base}#{path}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -6,7 +6,7 @@ module Public
|
||||
layout "regia"
|
||||
|
||||
protect_from_forgery with: :null_session
|
||||
skip_before_action :verify_authenticity_token, only: %i[score pause stop]
|
||||
skip_before_action :verify_authenticity_token, only: %i[score pause resume stop]
|
||||
|
||||
before_action :set_session_from_token
|
||||
|
||||
@@ -44,6 +44,13 @@ module Public
|
||||
render json: status_payload
|
||||
end
|
||||
|
||||
def resume
|
||||
Sessions::Resume.new(@session).call
|
||||
render json: status_payload
|
||||
rescue Teams::EntitlementError => e
|
||||
render json: status_payload.merge(error: e.message), status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def stop
|
||||
Sessions::Stop.new(@session).call
|
||||
render json: status_payload
|
||||
@@ -55,8 +62,7 @@ module Public
|
||||
@session = Sessions::RegiaAccess.find_session_by_token(params[:token])
|
||||
return if @session
|
||||
|
||||
render file: Rails.root.join("public/404.html"), status: :not_found, layout: false
|
||||
throw :abort
|
||||
head :not_found
|
||||
end
|
||||
|
||||
def status_payload
|
||||
|
||||
@@ -19,4 +19,22 @@ module AdminHelper
|
||||
mins = minutes % 60
|
||||
hours.positive? ? "#{hours}h #{mins}m" : "#{mins} min"
|
||||
end
|
||||
|
||||
def admin_session_watch_links(session)
|
||||
links = []
|
||||
if session.matchlivetv_platform?
|
||||
links << { label: "Pagina live", url: session.watch_page_url }
|
||||
end
|
||||
youtube = session.youtube_watch_url
|
||||
links << { label: "YouTube", url: youtube } if youtube.present?
|
||||
links
|
||||
end
|
||||
|
||||
def admin_regia_expires_label(iso_time)
|
||||
return if iso_time.blank?
|
||||
|
||||
Time.zone.parse(iso_time).strftime("%d/%m/%Y %H:%M")
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -61,6 +61,36 @@ module Public
|
||||
"#{match.team.name} #{score_state.home_points} - #{score_state.away_points} #{match.opponent_name}"
|
||||
end
|
||||
|
||||
def live_scorebug_columns(score_state)
|
||||
unless score_state
|
||||
return {
|
||||
labels: ["1"],
|
||||
home: ["0"],
|
||||
away: ["0"],
|
||||
live_index: 0
|
||||
}
|
||||
end
|
||||
|
||||
partials = Array(score_state.set_partials)
|
||||
labels = partials.map { |p| (p["set"] || p[:set]).to_s }
|
||||
home = partials.map { |p| (p["home"] || p[:home]).to_s }
|
||||
away = partials.map { |p| (p["away"] || p[:away]).to_s }
|
||||
labels << score_state.current_set.to_s
|
||||
home << score_state.home_points.to_s
|
||||
away << score_state.away_points.to_s
|
||||
{
|
||||
labels: labels,
|
||||
home: home,
|
||||
away: away,
|
||||
live_index: labels.length - 1
|
||||
}
|
||||
end
|
||||
|
||||
def live_scorebug_team_label(name, max: 16)
|
||||
n = name.to_s
|
||||
n.length <= max ? n : "#{n[0, max - 1]}…"
|
||||
end
|
||||
|
||||
# Colore ospite sul tabellone: secondario società se leggibile su bianco, altrimenti blu visitatore.
|
||||
def live_scorebug_away_color(team)
|
||||
club = team.club
|
||||
|
||||
@@ -2,11 +2,16 @@ class Match < ApplicationRecord
|
||||
belongs_to :team
|
||||
has_many :stream_sessions, dependent: :destroy
|
||||
|
||||
has_one_attached :opponent_logo_file
|
||||
|
||||
ACTIVE_SESSION_STATUSES = %w[live connecting reconnecting paused].freeze
|
||||
DEFAULT_OPPONENT_COLOR = "#1E3A8A"
|
||||
|
||||
validates :opponent_name, presence: true
|
||||
validates :sets_to_win, numericality: { greater_than: 0, less_than: 6 }
|
||||
validates :opponent_primary_color, format: { with: Brandable::HEX_COLOR }, allow_blank: true
|
||||
validate :scoring_rules_shape, if: -> { scoring_rules.present? }
|
||||
validate :opponent_logo_file_type, if: -> { opponent_logo_file.attached? }
|
||||
|
||||
# category: campionato / descrizione torneo (facoltativo, es. "Serie C").
|
||||
|
||||
@@ -63,6 +68,16 @@ class Match < ApplicationRecord
|
||||
stream_sessions.where(status: %w[ended error]).exists?
|
||||
end
|
||||
|
||||
def opponent_logo_url
|
||||
return unless opponent_logo_file.attached?
|
||||
|
||||
Rails.application.routes.url_helpers.rails_blob_path(opponent_logo_file, only_path: true)
|
||||
end
|
||||
|
||||
def effective_opponent_primary_color
|
||||
opponent_primary_color.presence || DEFAULT_OPPONENT_COLOR
|
||||
end
|
||||
|
||||
# Hub app coach: dirette da riprendere o partite programmate future.
|
||||
# Le bozze «Avversario» abbandonate (senza sessione) non compaiono.
|
||||
def coach_hub_visible?
|
||||
@@ -77,6 +92,12 @@ class Match < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
def opponent_logo_file_type
|
||||
return if opponent_logo_file.content_type.in?(%w[image/png image/jpeg image/webp])
|
||||
|
||||
errors.add(:opponent_logo_file, "deve essere PNG, JPEG o WebP")
|
||||
end
|
||||
|
||||
def scoring_rules_shape
|
||||
rules = scoring_rules.is_a?(Hash) ? scoring_rules : {}
|
||||
%w[points_per_set points_deciding_set min_point_lead].each do |key|
|
||||
|
||||
@@ -131,6 +131,12 @@ class StreamSession < ApplicationRecord
|
||||
status.in?(%w[ended error])
|
||||
end
|
||||
|
||||
def regia_token_active?
|
||||
regia_token_digest.present? &&
|
||||
regia_token_expires_at&.future? &&
|
||||
!terminal?
|
||||
end
|
||||
|
||||
def resumable?
|
||||
status.in?(%w[connecting live reconnecting paused])
|
||||
end
|
||||
|
||||
@@ -13,7 +13,9 @@ module Sessions
|
||||
|
||||
def rotate!
|
||||
token = SecureRandom.urlsafe_base64(24)
|
||||
expires = [DEFAULT_TTL.from_now, session_end_cap].compact.min
|
||||
expires = DEFAULT_TTL.from_now
|
||||
cap = session_end_cap
|
||||
expires = [expires, cap].min if cap&.future?
|
||||
@session.update!(
|
||||
regia_token_digest: digest(token),
|
||||
regia_token_expires_at: expires
|
||||
|
||||
29
backend/app/services/sessions/select_quality.rb
Normal file
29
backend/app/services/sessions/select_quality.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
module Sessions
|
||||
# Sceglie il preset più alto supportato dall'upload misurato (test rete).
|
||||
class SelectQuality
|
||||
UPLOAD_HEADROOM = 0.8
|
||||
|
||||
PRESETS = [
|
||||
{ id: "1080p_30_4.5mbps", target_bitrate: 4_500_000, target_fps: 30 },
|
||||
{ id: "720p_30_4mbps", target_bitrate: 4_000_000, target_fps: 30 },
|
||||
{ id: "720p_30_2.5mbps", target_bitrate: 2_500_000, target_fps: 30 },
|
||||
{ id: "720p_30_1.5mbps", target_bitrate: 1_500_000, target_fps: 30 }
|
||||
].freeze
|
||||
|
||||
def self.call(upload_mbps:)
|
||||
new(upload_mbps: upload_mbps).call
|
||||
end
|
||||
|
||||
def initialize(upload_mbps:)
|
||||
@upload_mbps = upload_mbps.to_f
|
||||
end
|
||||
|
||||
def call
|
||||
PRESETS.find { |preset| upload_sufficient?(preset) } || PRESETS.last
|
||||
end
|
||||
|
||||
def upload_sufficient?(preset)
|
||||
@upload_mbps >= (preset[:target_bitrate] / 1_000_000.0 * UPLOAD_HEADROOM)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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?" } } %>
|
||||
|
||||
41
backend/app/views/admin/sessions/_links.html.erb
Normal file
41
backend/app/views/admin/sessions/_links.html.erb
Normal 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 (l’URL 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>
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 %>>
|
||||
|
||||
47
backend/app/views/public/live/_scorebug.html.erb
Normal file
47
backend/app/views/public/live/_scorebug.html.erb
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user