<% @active_sessions.each do |s| %>
@@ -99,6 +99,12 @@
| <%= s.match.team.name %> vs <%= s.match.opponent_name %> |
<%= s.status %> |
<%= s.started_at&.strftime("%d/%m %H:%M") || "—" %> |
+
+ <% 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" %>
+ |
<%= 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?" } } %>
diff --git a/backend/app/views/admin/sessions/_links.html.erb b/backend/app/views/admin/sessions/_links.html.erb
new file mode 100644
index 0000000..baee4cc
--- /dev/null
+++ b/backend/app/views/admin/sessions/_links.html.erb
@@ -0,0 +1,41 @@
+<% watch_links = admin_session_watch_links(session) %>
+
+ Link diretta
+ <% if watch_links.any? %>
+
+ <% watch_links.each do |link| %>
+ -
+ <%= link[:label] %>
+ <%= link_to link[:url], link[:url], target: "_blank", rel: "noopener", class: "admin-link-url" %>
+
+ <% end %>
+
+ <% else %>
+ Nessun link video disponibile per questa sessione.
+ <% end %>
+
+ Link regia
+ <% if session.terminal? %>
+ Sessione terminata — link regia non disponibile.
+ <% else %>
+ <% if flash[:regia_url].present? %>
+
+ Regia
+ <%= link_to flash[:regia_url], flash[:regia_url], target: "_blank", rel: "noopener", class: "admin-link-url" %>
+
+ <%= flash[:regia_url] %>
+ <% if (expires = admin_regia_expires_label(flash[:regia_expires_at])) %>
+ Valido fino a <%= expires %>
+ <% end %>
+ <% elsif session.regia_token_active? %>
+ Esiste già un link regia attivo (l’URL non è recuperabile). Generane uno nuovo se serve condividerlo di nuovo.
+ <% else %>
+ Nessun link regia attivo. Genera un link da condividere con chi gestisce il punteggio.
+ <% 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 %>
+
diff --git a/backend/app/views/admin/sessions/index.html.erb b/backend/app/views/admin/sessions/index.html.erb
index 20b7fca..9c48299 100644
--- a/backend/app/views/admin/sessions/index.html.erb
+++ b/backend/app/views/admin/sessions/index.html.erb
@@ -1,12 +1,18 @@
Stream Sessions
- | Match | Status | Disconnects | |
+ | Match | Status | Disconnects | Link | |
<% @sessions.each do |s| %>
| <%= s.match.team.name %> vs <%= s.match.opponent_name %> |
<%= s.status %> |
<%= s.disconnection_count %> |
+
+ <% 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" %>
+ |
<%= link_to "Dettaglio", admin_session_path(s) %> |
<% end %>
diff --git a/backend/app/views/admin/sessions/show.html.erb b/backend/app/views/admin/sessions/show.html.erb
index ff039ee..964b70a 100644
--- a/backend/app/views/admin/sessions/show.html.erb
+++ b/backend/app/views/admin/sessions/show.html.erb
@@ -10,8 +10,11 @@
<% end %>
Match: <%= @session.match.team.name %> vs <%= @session.match.opponent_name %>
+
+<%= render "admin/sessions/links", session: @session %>
+
<% if @session.youtube_broadcast_id %>
- YouTube: Broadcast
+ YouTube Studio: Broadcast
<% end %>
RTMP ingest: <%= @session.rtmp_ingest_url %>
diff --git a/backend/app/views/layouts/marketing_live.html.erb b/backend/app/views/layouts/marketing_live.html.erb
index c403298..50e3372 100644
--- a/backend/app/views/layouts/marketing_live.html.erb
+++ b/backend/app/views/layouts/marketing_live.html.erb
@@ -7,7 +7,7 @@
<%= render "shared/meta_tags" %>
-
+
<%= yield :head %>
data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
diff --git a/backend/app/views/public/live/_scorebug.html.erb b/backend/app/views/public/live/_scorebug.html.erb
new file mode 100644
index 0000000..ec430c2
--- /dev/null
+++ b/backend/app/views/public/live/_scorebug.html.erb
@@ -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) %>
+
+
+
+ Punteggio live
+
+
+ | Squadra |
+ <% cols[:labels].each do |label| %>
+ <%= label %> |
+ <% end %>
+
+
+
+
+ | <%= live_scorebug_team_label(team.name) %> |
+ <% cols[:home].each_with_index do |val, i| %>
+ "><%= val %> |
+ <% end %>
+
+
+ | <%= live_scorebug_team_label(@match.opponent_name) %> |
+ <% cols[:away].each_with_index do |val, i| %>
+ "><%= val %> |
+ <% end %>
+
+
+
+
+ MATCH LIVE TV
+
+
+ "
+ ><%= @stream_closed ? "Terminata" : (@session.paused? ? "In pausa" : (@on_air ? "LIVE" : "In attesa")) %>
+
diff --git a/backend/app/views/public/live/show.html.erb b/backend/app/views/public/live/show.html.erb
index 65209ca..c0b96cf 100644
--- a/backend/app/views/public/live/show.html.erb
+++ b/backend/app/views/public/live/show.html.erb
@@ -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 %>
- <%# Tabellone e watermark sono bruciati nel video dall'app mobile (overlay client-side). %>
+ <%= render "public/live/scorebug", match: @match, session: @session %>
@@ -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) => ` ${label} | `)
+ .join("");
+ const homeCells = cols.home
+ .map((val, i) => {
+ const live = i === cols.liveIndex ? " live-scorebug__pts--live-home" : "";
+ return ` ${val} | `;
+ })
+ .join("");
+ const awayCells = cols.away
+ .map((val, i) => {
+ const live = i === cols.liveIndex ? " live-scorebug__pts--live-away" : "";
+ return ` ${val} | `;
+ })
+ .join("");
+ scorebug.innerHTML = `
+
+ Punteggio live
+
+ | Squadra | ${headCells}
+
+
+
+ | ${homeLabel} | ${homeCells}
+
+
+ | ${awayLabel} | ${awayCells}
+
+
+
+
+ MATCH LIVE TV
+ `;
+ }
+
+ 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;
diff --git a/backend/app/views/public/regia/show.html.erb b/backend/app/views/public/regia/show.html.erb
index 966add4..42ef9a3 100644
--- a/backend/app/views/public/regia/show.html.erb
+++ b/backend/app/views/public/regia/show.html.erb
@@ -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? %>">
@@ -76,7 +78,7 @@
<% unless @stream_closed %>
-
+
<% end %>
@@ -94,4 +96,4 @@
-
+
diff --git a/backend/config/routes.rb b/backend/config/routes.rb
index 4bf7ca4..7f4e58d 100644
--- a/backend/config/routes.rb
+++ b/backend/config/routes.rb
@@ -85,7 +85,10 @@ Rails.application.routes.draw do
resources :billing_invoices, only: %i[index new create edit update], controller: "billing_invoices"
end
resources :sessions, only: %i[index show] do
- member { post :stop }
+ member do
+ post :stop
+ post :regia_link
+ end
end
get "youtube/platform", to: "youtube#platform", as: :youtube_platform
end
@@ -105,6 +108,7 @@ Rails.application.routes.draw do
get "regia/:token/status.json", to: "public/regia#status", as: :public_regia_status
patch "regia/:token/score", to: "public/regia#score", as: :public_regia_score
post "regia/:token/pause", to: "public/regia#pause", as: :public_regia_pause
+ post "regia/:token/resume", to: "public/regia#resume", as: :public_regia_resume
post "regia/:token/stop", to: "public/regia#stop", as: :public_regia_stop
root to: "public/pages#home"
diff --git a/backend/db/migrate/20260606200000_add_opponent_branding_to_matches.rb b/backend/db/migrate/20260606200000_add_opponent_branding_to_matches.rb
new file mode 100644
index 0000000..2bfc7fc
--- /dev/null
+++ b/backend/db/migrate/20260606200000_add_opponent_branding_to_matches.rb
@@ -0,0 +1,5 @@
+class AddOpponentBrandingToMatches < ActiveRecord::Migration[7.2]
+ def change
+ add_column :matches, :opponent_primary_color, :string
+ end
+end
diff --git a/backend/public/admin.css b/backend/public/admin.css
index fc59c29..d235823 100644
--- a/backend/public/admin.css
+++ b/backend/public/admin.css
@@ -233,6 +233,90 @@ body.admin-body {
align-items: center;
}
+.admin-links-panel {
+ margin: 1.25rem 0 1.5rem;
+ padding: 1rem 1.1rem;
+ background: var(--card-bg, #14141c);
+ border: 1px solid var(--card-border, #2a2a36);
+ border-radius: 8px;
+}
+
+.admin-links-panel h3 {
+ margin: 0 0 0.6rem;
+ font-size: 0.95rem;
+}
+
+.admin-links-panel h3 + h3 {
+ margin-top: 1.1rem;
+}
+
+.admin-link-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.admin-link-list li,
+.admin-link-generated {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem 0.75rem;
+ align-items: baseline;
+ margin-bottom: 0.45rem;
+}
+
+.admin-link-label {
+ min-width: 6.5rem;
+ color: var(--muted, #9ca3af);
+ font-size: 0.85rem;
+}
+
+.admin-link-url {
+ color: var(--red, #e53935);
+ word-break: break-all;
+}
+
+.admin-link-code {
+ display: block;
+ margin: 0.35rem 0 0.5rem;
+ padding: 0.45rem 0.6rem;
+ background: #0a0a0e;
+ border-radius: 6px;
+ font-size: 0.78rem;
+ word-break: break-all;
+}
+
+.admin-link-compact {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+}
+
+.admin-link-chip {
+ display: inline-block;
+ padding: 0.15rem 0.45rem;
+ border-radius: 999px;
+ background: #2a2a36;
+ color: #eee;
+ font-size: 0.72rem;
+ text-decoration: none;
+}
+
+.admin-link-chip:hover {
+ background: #3a3a48;
+ color: #fff;
+}
+
+.admin-link-chip--regia {
+ background: #3d2a00;
+ color: #f9a825;
+}
+
+.admin-inline-form {
+ display: inline-block;
+ margin-top: 0.35rem;
+}
+
.admin-input {
padding: 0.45rem 0.6rem;
border: 1px solid #444;
diff --git a/backend/public/regia.js b/backend/public/regia.js
index c2d11da..e8aba6a 100644
--- a/backend/public/regia.js
+++ b/backend/public/regia.js
@@ -7,6 +7,7 @@
statusUrl: root.dataset.statusUrl,
scoreUrl: root.dataset.scoreUrl,
pauseUrl: root.dataset.pauseUrl,
+ resumeUrl: root.dataset.resumeUrl,
stopUrl: root.dataset.stopUrl,
cableUrl: root.dataset.cableUrl,
hlsUrl: root.dataset.hlsUrl,
@@ -30,13 +31,15 @@
modalConfirm: document.getElementById("modal-confirm"),
modalCancel: document.getElementById("modal-cancel"),
preview: document.getElementById("regia-preview"),
- previewPlaceholder: document.getElementById("regia-preview-placeholder")
+ previewPlaceholder: document.getElementById("regia-preview-placeholder"),
+ btnPause: document.getElementById("btn-pause")
};
let pendingAction = null;
let hls = null;
let previewStarted = false;
let wasPausedPreview = false;
+ let streamPaused = root.dataset.initialPaused === "true";
function hlsUrlFresh() {
const sep = cfg.hlsUrl.includes("?") ? "&" : "?";
@@ -108,7 +111,15 @@
}
}
+ function syncPauseButton(data) {
+ if (!els.btnPause) return;
+ const paused = !!(data.paused || data.status === "paused");
+ streamPaused = paused;
+ els.btnPause.textContent = paused ? "Riprendi diretta" : "Metti in pausa";
+ }
+
function setBadge(data) {
+ syncPauseButton(data);
if (data.stream_closed) {
els.badge.textContent = "Terminata";
els.badge.className = "regia-badge regia-badge--ended";
@@ -139,6 +150,8 @@
}
async function handleAction(action) {
+ const prevHome = els.homePoints?.textContent;
+ const prevAway = document.getElementById("away-points")?.textContent;
try {
const data = await postScore(action);
applyScorePayload(data);
@@ -152,6 +165,9 @@
openModal("Partita vinta", `${winner} ha vinto la partita. Chiudere la diretta?`, () => stopStream());
}
} catch (e) {
+ if (prevHome != null && els.homePoints) els.homePoints.textContent = prevHome;
+ const awayEl = document.getElementById("away-points");
+ if (prevAway != null && awayEl) awayEl.textContent = prevAway;
toast(e.message || "Errore");
}
}
@@ -284,10 +300,19 @@
showPreviewPlaceholder("Diretta terminata");
}
- async function pauseStream() {
- await fetch(cfg.pauseUrl, { method: "POST", headers: { Accept: "application/json" } });
- toast("Diretta in pausa — copertina in onda");
- pollStatus();
+ async function togglePauseStream() {
+ const url = streamPaused ? cfg.resumeUrl : cfg.pauseUrl;
+ const res = await fetch(url, { method: "POST", headers: { Accept: "application/json" } });
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ throw new Error(body.error || (streamPaused ? "Errore ripresa diretta" : "Errore pausa diretta"));
+ }
+ const data = await res.json();
+ const wasPaused = streamPaused;
+ applyScorePayload(data);
+ setBadge(data);
+ syncPreviewFromStatus(data);
+ toast(wasPaused ? "Diretta ripresa" : "Diretta in pausa — copertina in onda");
}
async function stopStream() {
@@ -302,7 +327,7 @@
return data;
}
- document.getElementById("btn-pause")?.addEventListener("click", () => pauseStream().catch((e) => toast(e.message)));
+ els.btnPause?.addEventListener("click", () => togglePauseStream().catch((e) => toast(e.message)));
document.getElementById("btn-stop")?.addEventListener("click", () => stopStream().catch((e) => toast(e.message)));
async function shareLink() {
diff --git a/backend/spec/requests/public/regia_spec.rb b/backend/spec/requests/public/regia_spec.rb
index 3213853..cdf0823 100644
--- a/backend/spec/requests/public/regia_spec.rb
+++ b/backend/spec/requests/public/regia_spec.rb
@@ -22,4 +22,24 @@ RSpec.describe "Public regia", type: :request do
expect(response).to have_http_status(:ok)
expect(session.score_state.reload.home_points).to eq(1)
end
+
+ it "mette in pausa e riprende la diretta" do
+ token = Sessions::RegiaAccess.new(session).issue_token!
+ post public_regia_pause_path(token)
+ expect(response).to have_http_status(:ok)
+ expect(session.reload.status).to eq("paused")
+
+ post public_regia_resume_path(token)
+ expect(response).to have_http_status(:ok)
+ expect(session.reload.status).to eq("connecting")
+ end
+
+ it "emette token valido anche oltre le 8 ore dalla partenza" do
+ session.update!(started_at: 9.hours.ago)
+ token = Sessions::RegiaAccess.new(session).issue_token!
+ session.reload
+ expect(session.regia_token_expires_at).to be > Time.current
+ get public_regia_path(token)
+ expect(response).to have_http_status(:ok)
+ end
end
diff --git a/native/android/app/build.gradle.kts b/native/android/app/build.gradle.kts
index 25eac58..77b4df1 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 = 14
+ versionCode = 15
versionName = "2.0.0-native"
val apiBaseUrl = project.findProperty("API_BASE_URL") as String?
@@ -74,6 +74,8 @@ dependencies {
testImplementation("junit:junit:4.13.2")
+ implementation("io.coil-kt:coil-compose:2.7.0")
+
implementation("com.github.pedroSG94.RootEncoder:library:2.5.5")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
diff --git a/native/android/app/src/main/AndroidManifest.xml b/native/android/app/src/main/AndroidManifest.xml
index 4545d0b..272221b 100644
--- a/native/android/app/src/main/AndroidManifest.xml
+++ b/native/android/app/src/main/AndroidManifest.xml
@@ -28,6 +28,7 @@
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
+ android:screenOrientation="portrait"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/ColorHex.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/ColorHex.kt
new file mode 100644
index 0000000..7c2b39e
--- /dev/null
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/ColorHex.kt
@@ -0,0 +1,40 @@
+package com.matchlivetv.match_live_tv.core
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+
+private val HEX_PATTERN = Regex("^#?[0-9A-Fa-f]{6}$")
+
+fun normalizeHexColor(input: String?, fallback: String = "#E53935"): String {
+ val raw = input?.trim().orEmpty()
+ if (raw.isEmpty()) return fallback
+ val withHash = if (raw.startsWith("#")) raw else "#$raw"
+ return if (HEX_PATTERN.matches(withHash)) withHash.uppercase() else fallback
+}
+
+fun parseColorHex(hex: String?, fallbackArgb: Int): Int {
+ val normalized = normalizeHexColor(hex, "")
+ if (normalized.isEmpty()) return fallbackArgb
+ return runCatching {
+ Color(android.graphics.Color.parseColor(normalized)).toArgb()
+ }.getOrDefault(fallbackArgb)
+}
+
+fun colorToHex(color: Color): String {
+ val argb = color.toArgb()
+ return String.format("#%06X", 0xFFFFFF and argb)
+}
+
+fun hsvToHex(h: Float, s: Float, v: Float): String =
+ colorToHex(Color(android.graphics.Color.HSVToColor(floatArrayOf(h, s, v))))
+
+private val PLACEHOLDER_TEAM_COLORS = listOf(
+ "#E53935", "#1E88E5", "#43A047", "#FB8C00",
+ "#8E24AA", "#00897B", "#5E35B1", "#F4511E",
+)
+
+/** Colore decorativo per squadre senza branding completo (logo + colore). */
+fun placeholderTeamColor(seed: String): String {
+ val index = kotlin.math.abs(seed.hashCode()) % PLACEHOLDER_TEAM_COLORS.size
+ return PLACEHOLDER_TEAM_COLORS[index]
+}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/DeviceTelemetry.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/DeviceTelemetry.kt
index 8d4fa78..5c07461 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/DeviceTelemetry.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/DeviceTelemetry.kt
@@ -6,16 +6,25 @@ import android.content.IntentFilter
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.BatteryManager
+import android.os.Build
+import android.os.PowerManager
+
+enum class ThermalLevel {
+ NORMAL,
+ WARM,
+ HOT,
+ CRITICAL,
+}
+
+data class DeviceHealthSnapshot(
+ val batteryPercent: Int,
+ val batteryTempC: Float?,
+ val thermalLevel: ThermalLevel,
+ val thermalLabel: String,
+)
object DeviceTelemetry {
- fun batteryLevel(context: Context): Int {
- val intent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
- ?: return 100
- val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
- val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
- if (level < 0 || scale <= 0) return 100
- return ((level * 100f) / scale).toInt().coerceIn(0, 100)
- }
+ fun batteryLevel(context: Context): Int = snapshot(context).batteryPercent
fun networkType(context: Context): String = runCatching {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
@@ -28,4 +37,67 @@ object DeviceTelemetry {
else -> "Sconosciuto"
}
}.getOrDefault("Sconosciuto")
+
+ fun snapshot(context: Context): DeviceHealthSnapshot {
+ val batteryIntent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
+ val batteryPercent = readBatteryPercent(batteryIntent)
+ val batteryTempC = readBatteryTemperatureC(batteryIntent)
+ val thermalLevel = resolveThermalLevel(context, batteryTempC)
+ return DeviceHealthSnapshot(
+ batteryPercent = batteryPercent,
+ batteryTempC = batteryTempC,
+ thermalLevel = thermalLevel,
+ thermalLabel = thermalLabel(thermalLevel),
+ )
+ }
+
+ private fun readBatteryPercent(intent: Intent?): Int {
+ if (intent == null) return 100
+ val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
+ val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
+ if (level < 0 || scale <= 0) return 100
+ return ((level * 100f) / scale).toInt().coerceIn(0, 100)
+ }
+
+ private fun readBatteryTemperatureC(intent: Intent?): Float? {
+ val raw = intent?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, -1) ?: -1
+ if (raw <= 0) return null
+ return raw / 10f
+ }
+
+ private fun resolveThermalLevel(context: Context, batteryTempC: Float?): ThermalLevel {
+ val fromStatus = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
+ when (pm.currentThermalStatus) {
+ PowerManager.THERMAL_STATUS_NONE,
+ PowerManager.THERMAL_STATUS_LIGHT,
+ -> ThermalLevel.NORMAL
+ PowerManager.THERMAL_STATUS_MODERATE -> ThermalLevel.WARM
+ PowerManager.THERMAL_STATUS_SEVERE -> ThermalLevel.HOT
+ PowerManager.THERMAL_STATUS_CRITICAL,
+ PowerManager.THERMAL_STATUS_EMERGENCY,
+ PowerManager.THERMAL_STATUS_SHUTDOWN,
+ -> ThermalLevel.CRITICAL
+ else -> null
+ }
+ } else {
+ null
+ }
+ val fromTemp = batteryTempC?.let { tempFromBattery(it) }
+ return listOfNotNull(fromStatus, fromTemp).maxByOrNull { it.ordinal } ?: ThermalLevel.NORMAL
+ }
+
+ private fun tempFromBattery(tempC: Float): ThermalLevel = when {
+ tempC >= 45f -> ThermalLevel.CRITICAL
+ tempC >= 42f -> ThermalLevel.HOT
+ tempC >= 38f -> ThermalLevel.WARM
+ else -> ThermalLevel.NORMAL
+ }
+
+ private fun thermalLabel(level: ThermalLevel): String = when (level) {
+ ThermalLevel.NORMAL -> "OK"
+ ThermalLevel.WARM -> "Caldo"
+ ThermalLevel.HOT -> "Surriscaldamento"
+ ThermalLevel.CRITICAL -> "Troppo caldo"
+ }
}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/MediaUrl.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/MediaUrl.kt
new file mode 100644
index 0000000..57314cc
--- /dev/null
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/MediaUrl.kt
@@ -0,0 +1,11 @@
+package com.matchlivetv.match_live_tv.core
+
+fun resolveMediaUrl(path: String?): String? {
+ val value = path?.trim().orEmpty()
+ if (value.isEmpty()) return null
+ if (value.startsWith("http://", ignoreCase = true) || value.startsWith("https://", ignoreCase = true)) {
+ return value
+ }
+ val base = AppConfig.apiBaseUrl
+ return if (value.startsWith("/")) "$base$value" else "$base/$value"
+}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt
index 351fb14..e1450b1 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt
@@ -12,6 +12,7 @@ import com.matchlivetv.match_live_tv.data.repository.SessionRepository
import com.matchlivetv.match_live_tv.data.scoring.ScoreController
import com.matchlivetv.match_live_tv.BuildConfig
import com.matchlivetv.match_live_tv.streaming.LiveBroadcastCoordinator
+import com.matchlivetv.match_live_tv.streaming.overlay.OverlayLogoCache
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.CoroutineScope
@@ -69,18 +70,19 @@ class AppContainer(context: Context) {
val authRepository = AuthRepository(api, tokenStore) { token ->
accessToken = token
+ OverlayLogoCache.configure(token)
}
- val matchRepository = MatchRepository(api, tokenStore)
+ val matchRepository = MatchRepository(api, tokenStore, appContext)
val sessionRepository = SessionRepository(api)
val scoreRepository = ScoreRepository(api)
- val sessionCable = SessionCableService(moshi)
-
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+ val sessionCable = SessionCableService(moshi, appScope)
+
val scoreController = ScoreController(scoreRepository, sessionCable, appScope)
val wizardSession = WizardSessionHolder()
@@ -88,6 +90,9 @@ class AppContainer(context: Context) {
val broadcastCoordinator = LiveBroadcastCoordinator(appContext)
suspend fun bootstrapAuth() {
- authRepository.currentSession()?.let { accessToken = it.accessToken }
+ authRepository.currentSession()?.let {
+ accessToken = it.accessToken
+ OverlayLogoCache.configure(it.accessToken)
+ }
}
}
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 cb940b5..9454405 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
@@ -41,12 +41,16 @@ data class TeamDto(
@Json(name = "youtube_connected") val youtubeConnected: Boolean? = false,
@Json(name = "youtube_selectable") val youtubeSelectable: Boolean? = false,
@Json(name = "youtube_channel_title") val youtubeChannelTitle: String? = null,
+ @Json(name = "youtube_uses_platform_channel") val youtubeUsesPlatformChannel: Boolean? = null,
@Json(name = "youtube_team_channel_title") val youtubeTeamChannelTitle: String? = null,
@Json(name = "plan_name") val planName: String? = null,
@Json(name = "recordings_enabled") val recordingsEnabled: Boolean? = false,
@Json(name = "phone_download_enabled") val phoneDownloadEnabled: Boolean? = false,
@Json(name = "billing_url") val billingUrl: String? = null,
@Json(name = "staff_manage_url") val staffManageUrl: String? = null,
+ @Json(name = "logo_url") val logoUrl: String? = null,
+ @Json(name = "primary_color") val primaryColor: String? = null,
+ @Json(name = "secondary_color") val secondaryColor: String? = null,
) {
fun toDomain() = Team(
id = id,
@@ -54,10 +58,14 @@ data class TeamDto(
sport = sport ?: "volleyball",
canStream = canStream ?: true,
clubName = clubName,
+ logoUrl = logoUrl,
+ primaryColor = primaryColor,
+ secondaryColor = secondaryColor,
youtubeEnabled = youtubeEnabled ?: false,
youtubeConnected = youtubeConnected ?: false,
youtubeSelectable = youtubeSelectable ?: false,
youtubeChannelTitle = youtubeChannelTitle,
+ youtubeUsesPlatformChannel = youtubeUsesPlatformChannel ?: false,
youtubeTeamChannelTitle = youtubeTeamChannelTitle,
planName = planName,
recordingsEnabled = recordingsEnabled ?: false,
@@ -82,6 +90,11 @@ data class MatchDto(
@Json(name = "active_session_status") val activeSessionStatus: String? = null,
@Json(name = "stream_completed") val streamCompleted: Boolean? = null,
@Json(name = "coach_hub_visible") val coachHubVisible: Boolean? = null,
+ @Json(name = "home_primary_color") val homePrimaryColor: String? = null,
+ @Json(name = "home_secondary_color") val homeSecondaryColor: String? = null,
+ @Json(name = "home_logo_url") val homeLogoUrl: String? = null,
+ @Json(name = "opponent_primary_color") val opponentPrimaryColor: String? = null,
+ @Json(name = "opponent_logo_url") val opponentLogoUrl: String? = null,
) {
fun toDomain() = Match(
id = id,
@@ -97,6 +110,11 @@ data class MatchDto(
activeSessionStatus = activeSessionStatus,
streamCompleted = streamCompleted ?: false,
coachHubVisible = coachHubVisible,
+ homePrimaryColor = homePrimaryColor,
+ homeSecondaryColor = homeSecondaryColor,
+ homeLogoUrl = homeLogoUrl,
+ opponentPrimaryColor = opponentPrimaryColor,
+ opponentLogoUrl = opponentLogoUrl,
)
}
@@ -218,14 +236,25 @@ data class UpdateMatchBodyFull(
@Json(name = "scheduled_at") val scheduledAt: String? = null,
@Json(name = "sets_to_win") val setsToWin: Int,
val category: String? = null,
+ @Json(name = "opponent_primary_color") val opponentPrimaryColor: String? = null,
@Json(name = "scoring_rules") val scoringRules: ScoringRulesBody? = null,
)
+data class UpdateTeamRequest(val team: UpdateTeamBody)
+
+data class UpdateTeamBody(
+ @Json(name = "primary_color") val primaryColor: String? = null,
+ @Json(name = "secondary_color") val secondaryColor: String? = null,
+)
+
data class UpdateMatchRequestFull(val match: UpdateMatchBodyFull)
data class NetworkTestResponse(
val ready: Boolean,
@Json(name = "target_upload_mbps") val targetUploadMbps: Double? = null,
+ @Json(name = "quality_preset") val qualityPreset: String? = null,
+ @Json(name = "target_bitrate") val targetBitrate: Int? = null,
+ @Json(name = "target_fps") val targetFps: Int? = null,
)
data class RecordingDto(
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 fe2479d..8a68079 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
@@ -3,9 +3,12 @@ package com.matchlivetv.match_live_tv.data.api
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
+import retrofit2.http.Multipart
import retrofit2.http.PATCH
import retrofit2.http.POST
+import retrofit2.http.Part
import retrofit2.http.Path
+import okhttp3.MultipartBody
interface MatchLiveApi {
@POST("auth/login")
@@ -23,6 +26,24 @@ interface MatchLiveApi {
@GET("teams")
suspend fun teams(): List
+ @GET("teams/{teamId}")
+ suspend fun team(@Path("teamId") teamId: String): TeamDto
+
+ @PATCH("teams/{teamId}")
+ suspend fun updateTeam(
+ @Path("teamId") teamId: String,
+ @Body body: UpdateTeamRequest,
+ ): TeamDto
+
+ @Multipart
+ @PATCH("teams/{teamId}")
+ suspend fun updateTeamMultipart(
+ @Path("teamId") teamId: String,
+ @Part("team[primary_color]") primaryColor: okhttp3.RequestBody?,
+ @Part("team[secondary_color]") secondaryColor: okhttp3.RequestBody?,
+ @Part logoFile: MultipartBody.Part?,
+ ): TeamDto
+
@GET("teams/{teamId}/recordings")
suspend fun recordings(@Path("teamId") teamId: String): List
@@ -44,6 +65,22 @@ interface MatchLiveApi {
@Body body: UpdateMatchRequestFull,
): MatchDto
+ @Multipart
+ @PATCH("matches/{matchId}")
+ suspend fun updateMatchMultipart(
+ @Path("matchId") matchId: String,
+ @Part("match[opponent_name]") opponentName: okhttp3.RequestBody,
+ @Part("match[location]") location: okhttp3.RequestBody?,
+ @Part("match[scheduled_at]") scheduledAt: okhttp3.RequestBody?,
+ @Part("match[sets_to_win]") setsToWin: okhttp3.RequestBody,
+ @Part("match[category]") category: okhttp3.RequestBody?,
+ @Part("match[opponent_primary_color]") opponentPrimaryColor: okhttp3.RequestBody?,
+ @Part("match[scoring_rules][points_per_set]") pointsPerSet: okhttp3.RequestBody?,
+ @Part("match[scoring_rules][points_deciding_set]") pointsDecidingSet: okhttp3.RequestBody?,
+ @Part("match[scoring_rules][min_point_lead]") minPointLead: okhttp3.RequestBody?,
+ @Part opponentLogoFile: MultipartBody.Part?,
+ ): MatchDto
+
@DELETE("matches/{matchId}")
suspend fun deleteMatch(@Path("matchId") matchId: String)
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MultipartBodies.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MultipartBodies.kt
new file mode 100644
index 0000000..dfa885b
--- /dev/null
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MultipartBodies.kt
@@ -0,0 +1,29 @@
+package com.matchlivetv.match_live_tv.data.api
+
+import android.content.Context
+import android.net.Uri
+import android.webkit.MimeTypeMap
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.MultipartBody
+import okhttp3.RequestBody.Companion.asRequestBody
+import okhttp3.RequestBody.Companion.toRequestBody
+import java.io.File
+
+object MultipartBodies {
+ fun textPart(value: String) = value.toRequestBody("text/plain".toMediaTypeOrNull())
+
+ fun logoPart(context: Context, uri: Uri, fieldName: String): MultipartBody.Part {
+ val contentResolver = context.applicationContext.contentResolver
+ val mime = contentResolver.getType(uri) ?: "image/jpeg"
+ val ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "jpg"
+ val temp = File.createTempFile("upload_", ".$ext", context.cacheDir)
+ contentResolver.openInputStream(uri)?.use { input ->
+ temp.outputStream().use { output -> input.copyTo(output) }
+ }
+ return MultipartBody.Part.createFormData(
+ fieldName,
+ "logo.$ext",
+ temp.asRequestBody(mime.toMediaTypeOrNull()),
+ )
+ }
+}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/ActionCableClient.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/ActionCableClient.kt
index 5ffc9c2..e073f77 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/ActionCableClient.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/cable/ActionCableClient.kt
@@ -116,11 +116,16 @@ class ActionCableClient(
"welcome" -> Unit
else -> {
if (json.has("message")) {
- @Suppress("UNCHECKED_CAST")
val message = json.opt("message")
when (message) {
is JSONObject -> listener?.onChannelMessage(messageToMap(message))
- is Map<*, *> -> listener?.onChannelMessage(message as Map)
+ is String -> runCatching { JSONObject(message) }
+ .getOrNull()
+ ?.let { listener?.onChannelMessage(messageToMap(it)) }
+ is Map<*, *> -> {
+ @Suppress("UNCHECKED_CAST")
+ listener?.onChannelMessage(message as Map)
+ }
}
}
}
@@ -130,11 +135,20 @@ class ActionCableClient(
private fun messageToMap(json: JSONObject): Map {
val map = mutableMapOf()
json.keys().forEach { key ->
- map[key] = json.get(key)
+ map[key] = jsonValueToKotlin(json.get(key))
}
return map
}
+ private fun jsonValueToKotlin(value: Any?): Any? = when (value) {
+ is JSONObject -> messageToMap(value)
+ is org.json.JSONArray -> List(value.length()) { index ->
+ jsonValueToKotlin(value.get(index))
+ }
+ JSONObject.NULL -> null
+ else -> value
+ }
+
companion object {
private const val TAG = "ActionCableClient"
}
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 ab10c52..6474ad3 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
@@ -1,14 +1,30 @@
package com.matchlivetv.match_live_tv.data.cable
+import android.util.Log
import com.matchlivetv.match_live_tv.core.AppConfig
import com.matchlivetv.match_live_tv.domain.ScoreState
import com.squareup.moshi.Moshi
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlin.math.min
-class SessionCableService(private val moshi: Moshi) {
+class SessionCableService(
+ private val moshi: Moshi,
+ private val scope: CoroutineScope,
+) {
private var client: ActionCableClient? = null
+ private var reconnectJob: Job? = null
+ private var reconnectAttempt = 0
+ private var intentionalDisconnect = false
+
+ private var pendingSessionId: String? = null
+ private var pendingToken: String? = null
+ private var pendingDeviceRole: String = "camera"
private val _connected = MutableStateFlow(false)
val connected: StateFlow = _connected.asStateFlow()
@@ -16,23 +32,53 @@ class SessionCableService(private val moshi: Moshi) {
var onScoreUpdate: ((ScoreState) -> Unit)? = null
var onPauseStream: (() -> Unit)? = null
var onResumeStream: (() -> Unit)? = null
+ var onEndStream: (() -> Unit)? = null
fun connect(sessionId: String, accessToken: String, deviceRole: String = "camera") {
- disconnect()
- client = ActionCableClient(AppConfig.cableUrl, accessToken, moshi).also { cable ->
+ intentionalDisconnect = false
+ reconnectJob?.cancel()
+ reconnectAttempt = 0
+ pendingSessionId = sessionId
+ pendingToken = accessToken
+ pendingDeviceRole = deviceRole
+ openSocket()
+ }
+
+ fun sendScoreUpdate(score: ScoreState) {
+ client?.performReceive(score.cablePayload())
+ }
+
+ fun disconnect() {
+ intentionalDisconnect = true
+ reconnectJob?.cancel()
+ reconnectJob = null
+ pendingSessionId = null
+ pendingToken = null
+ client?.disconnect()
+ client = null
+ _connected.value = false
+ }
+
+ private fun openSocket() {
+ val sessionId = pendingSessionId ?: return
+ val token = pendingToken ?: return
+ client?.disconnect()
+ client = ActionCableClient(AppConfig.cableUrl, token, moshi).also { cable ->
cable.connect(
channel = "SessionChannel",
params = mapOf(
"session_id" to sessionId,
- "device_role" to deviceRole,
+ "device_role" to pendingDeviceRole,
),
listener = object : ActionCableClient.Listener {
override fun onConnected() {
+ reconnectAttempt = 0
_connected.value = true
}
override fun onDisconnected() {
_connected.value = false
+ scheduleReconnect()
}
override fun onChannelMessage(payload: Map) {
@@ -43,44 +89,65 @@ class SessionCableService(private val moshi: Moshi) {
}
}
- fun sendScoreUpdate(score: ScoreState) {
- client?.performReceive(score.cablePayload())
- }
-
- fun disconnect() {
- client?.disconnect()
- client = null
- _connected.value = false
+ private fun scheduleReconnect() {
+ if (intentionalDisconnect || pendingSessionId == null || pendingToken == null) return
+ reconnectJob?.cancel()
+ reconnectJob = scope.launch {
+ reconnectAttempt += 1
+ val delayMs = min(30_000L, 1_000L shl min(reconnectAttempt - 1, 4))
+ Log.i(TAG, "ActionCable reconnect in ${delayMs}ms (attempt $reconnectAttempt)")
+ delay(delayMs)
+ if (!intentionalDisconnect) openSocket()
+ }
}
private fun dispatch(data: Map) {
- when (data["type"] as? String) {
- "score_update" -> onScoreUpdate?.invoke(parseScore(data))
- "stream_event" -> when (data["event"] as? String) {
+ val payload = unwrapCablePayload(data)
+ when (payload["type"] as? String) {
+ "score_update" -> onScoreUpdate?.invoke(parseScore(payload))
+ "command" -> when (payload["action"] as? String) {
+ "pause_stream" -> onPauseStream?.invoke()
+ "resume_stream" -> onResumeStream?.invoke()
+ "stop_stream" -> onEndStream?.invoke()
+ }
+ "stream_event" -> when (payload["event"] as? String) {
"paused" -> onPauseStream?.invoke()
"resumed" -> onResumeStream?.invoke()
+ "ended" -> onEndStream?.invoke()
}
}
}
- private fun parseScore(data: Map): ScoreState {
+ /** Compat: messaggi legacy `{ method: receive_message, data: {...} }`. */
+ private fun unwrapCablePayload(raw: Map): Map {
+ if (raw["method"] != "receive_message") return raw
+ val nested = raw["data"] ?: return raw
@Suppress("UNCHECKED_CAST")
- val partialsRaw = data["set_partials"] as? List |