Compare commits

..

3 Commits

Author SHA1 Message Date
f3ff657fc2 Sposta overlay video sull'app Android e rimuove ffmpeg server-side.
Il tabellone stile SportCam e il watermark Match Live TV sono bruciati in GPU via RootEncoder; sul backend spariscono OverlayRelay, job Sidekiq e volume stream_overlays.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 18:12:35 +02:00
68b4390282 Completa app Android nativa e rimuove il client Flutter.
Il monorepo punta a native/android (login, wizard, diretta RTMP, tabellone WebSocket, pausa/telemetria) con encode 16:9 e orientamento via sensore; eliminati mobile/ e gli script Flutter obsoleti.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 17:38:56 +02:00
499aee8988 Aggiunge app Android nativa da zero su branch dedicato.
Nuovo progetto Compose in native/android con API, auth, streaming RTMP e schermate base; nessun codice copiato dalla app Flutter.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 15:36:36 +02:00
301 changed files with 7315 additions and 15819 deletions

View File

@@ -2,15 +2,15 @@
Piattaforma mobile-first per streaming di partite sportive giovanili — MVP su **Match Live TV** (HLS sul nostro sito); YouTube/Facebook/Twitch come integrazioni premium. Piattaforma mobile-first per streaming di partite sportive giovanili — MVP su **Match Live TV** (HLS sul nostro sito); YouTube/Facebook/Twitch come integrazioni premium.
**Slogan:** *Lo streaming che non muore* **Slogan:** *Ogni partita, ogni evento, per i tuoi tifosi.*
## Struttura monorepo ## Struttura monorepo
``` ```
├── backend/ # Rails 7.2 API + Action Cable + Sidekiq ├── backend/ # Rails 7.2 API + Action Cable + Sidekiq
├── mobile/ # Flutter app (Camera + Regia) ├── native/android/ # App Android nativa (Kotlin + Compose)
├── infra/ # Docker Compose, MediaMTX, Nginx, Kamal ├── infra/ # Docker Compose, MediaMTX, Nginx, Kamal
└── docs/ # Documentazione └── docs/ # Documentazione
``` ```
## Deploy produzione (server 192.168.1.146) ## Deploy produzione (server 192.168.1.146)
@@ -45,15 +45,16 @@ email: coach@matchlivetv.test
password: password123 password: password123
``` ```
## Mobile ## App Android nativa
```bash ```bash
cd mobile ./scripts/build_native_android_apk_prod.sh
flutter pub get adb install -r native/android/app/build/outputs/apk/release/app-release.apk
flutter run
``` ```
Configura `API_BASE_URL` in `lib/core/config.dart` (default `http://10.0.2.2:3000` per emulatore Android). API produzione: `https://www.matchlivetv.it` (override con `-PAPI_BASE_URL=...` in Gradle).
Vedi [native/android/README.md](native/android/README.md).
## Architettura ## Architettura
@@ -62,9 +63,9 @@ Configura `API_BASE_URL` in `lib/core/config.dart` (default `http://10.0.2.2:300
- Controllo: Phone ↔ Action Cable ↔ Rails ↔ PostgreSQL - Controllo: Phone ↔ Action Cable ↔ Rails ↔ PostgreSQL
- Disconnessioni: MediaMTX `alwaysAvailable` slate + timeout 5 min (Sidekiq) - Disconnessioni: MediaMTX `alwaysAvailable` slate + timeout 5 min (Sidekiq)
### Mobile produzione ### App Android produzione
```bash ```bash
./scripts/run_mobile_android_prod.sh ./scripts/build_native_android_apk_prod.sh
# API: https://www.matchlivetv.it # API: https://www.matchlivetv.it
``` ```

View File

@@ -174,7 +174,7 @@ module Api
} }
end end
# L'app invia fps ogni ~10s; senza poll status.json l'overlay YouTube non partiva. # L'app invia fps ogni ~10s; poll status.json per allineare stato sessione e relay YouTube.
def sync_publisher_when_streaming!(fps) def sync_publisher_when_streaming!(fps)
return if fps < 1 return if fps < 1
return if @session.terminal? || @session.paused? return if @session.terminal? || @session.paused?

View File

@@ -1,32 +0,0 @@
class OverlayRefreshJob < ApplicationJob
queue_as :default
INTERVAL = 2.seconds
REDIS_CHAIN_KEY = "overlay_refresh:chain:%s"
def self.schedule_chain(session_id)
redis.set(format(REDIS_CHAIN_KEY, session_id), "1", ex: 48.hours.to_i)
set(wait: INTERVAL).perform_later(session_id)
end
def self.cancel_chain(session_id)
redis.del(format(REDIS_CHAIN_KEY, session_id))
end
def self.redis
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
end
def perform(session_id)
return unless self.class.redis.get(format(REDIS_CHAIN_KEY, session_id))
session = StreamSession.find_by(id: session_id)
return self.class.cancel_chain(session_id) unless session
return self.class.cancel_chain(session_id) if session.terminal?
return self.class.cancel_chain(session_id) unless Streams::OverlayRelay.running?(session_id)
Streams::Overlay::Refresh.call(session)
Mediamtx::PublisherSync.new(session).call if session.status.in?(%w[connecting reconnecting live paused])
self.class.set(wait: INTERVAL).perform_later(session_id)
end
end

View File

@@ -1,80 +0,0 @@
# Avvia overlay ffmpeg solo in Sidekiq (un solo job per sessione alla volta).
class OverlayRelayEnsureJob < ApplicationJob
queue_as :default
LOCK_KEY = "overlay_ensure:lock:%s"
LOCK_TTL = 55
RETRY_WAIT = 5.seconds
class << self
def enqueue_unique(session_id, attempt: 1, force_restart: false, wait: 0.seconds)
key = format(LOCK_KEY, session_id)
r = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
return unless r.set(key, "1", nx: true, ex: LOCK_TTL)
set(wait: wait).perform_later(session_id, attempt, force_restart: force_restart)
end
end
def perform(session_id, attempt = 1, force_restart: false)
session = StreamSession.find_by(id: session_id)
unless session && !session.terminal?
clear_lock(session_id)
return
end
return unless session.status.in?(%w[live connecting reconnecting paused])
begin
run_ensure(session, session_id, attempt, force_restart)
ensure
clear_lock(session_id)
end
end
private
def run_ensure(session, session_id, attempt, force_restart)
if Mediamtx::PublisherOnline.video_publishing?(session) && !session.paused?
session.go_live! if session.may_go_live?
session.reconnect! if session.reconnecting? && session.may_reconnect?
Mediamtx::Client.new.set_always_available(session, enabled: false)
end
if force_restart
Streams::OverlayRelay.stop(session)
Streams::OverlayRelay.terminate_all_session_processes!(session.id)
end
Streams::OverlayRelay.ensure_publishing!(session)
if Streams::OverlayRelay.healthy?(session)
Youtube::LivePipeline.schedule_activate!(session, force: true, wait: 5.seconds)
return
end
return if Streams::OverlayRelay.running?(session.id)
Rails.logger.info("[OverlayRelayEnsureJob] overlay not running, retry session=#{session_id} attempt=#{attempt}")
reschedule(session_id, attempt, false) if attempt < 30
rescue Mediamtx::Client::Error => e
Rails.logger.warn("[OverlayRelayEnsureJob] session=#{session_id}: #{e.message}")
end
def reschedule(session_id, attempt, force_restart)
clear_lock(session_id)
self.class.enqueue_unique(
session_id,
attempt: attempt + 1,
force_restart: force_restart,
wait: RETRY_WAIT
)
end
def clear_lock(session_id)
redis.del(format(LOCK_KEY, session_id))
end
def redis
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
end
end

View File

@@ -1,12 +0,0 @@
# Aggiorna tabellone overlay in background (evita blocchi HTTP al +1 punto).
class OverlayScoreRefreshJob < ApplicationJob
queue_as :default
def perform(session_id)
session = StreamSession.find_by(id: session_id)
return unless session
return if session.terminal?
Streams::Overlay::Refresh.call(session)
end
end

View File

@@ -12,21 +12,6 @@ module Mediamtx
end end
end end
def create_overlay_path(session)
path = session.mediamtx_overlay_path_name
body = {
source: "publisher",
overridePublisher: true,
record: false
}
response = @conn.post("/v3/config/paths/add/#{CGI.escape(path)}", body)
unless response.success?
err = response.body.is_a?(Hash) ? response.body["error"] : response.body
raise Error, "MediaMTX overlay path create failed: #{response.status} #{err}"
end
true
end
def create_path(session) def create_path(session)
path = session.mediamtx_path_name path = session.mediamtx_path_name
# record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe # record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe

View File

@@ -1,46 +0,0 @@
module Streams
module Overlay
module_function
def overlay_dir(session_id)
Rails.root.join("tmp/stream_overlays", session_id.to_s)
end
def overlay_png_path(session)
overlay_dir(session.id).join("overlay.png")
end
def overlay_pipe_path(session)
overlay_dir(session.id).join("overlay.pipe")
end
# Logo chiaro watermark (brand/logo-white-m.png → branding/ in deploy).
def brand_logo_path
candidates = [
Rails.root.join("branding/logo-white-m.png"),
Rails.root.join("brand/logo-white-m.png"),
Rails.root.join("public/logo-white.png"),
Rails.root.join("public/logo.png")
]
candidates.find(&:file?) || candidates.first
end
def badge_away_color(team)
club = team.club
candidate = club&.effective_secondary_color.presence || "#1565c0"
return candidate unless light_hex_color?(candidate)
"#1565c0"
end
def light_hex_color?(hex)
h = hex.to_s.delete_prefix("#")
return false unless h.match?(/\A\h{6}\z/i)
r = h[0..1].to_i(16)
g = h[2..3].to_i(16)
b = h[4..5].to_i(16)
(0.299 * r + 0.587 * g + 0.114 * b) > 200
end
end
end

View File

@@ -1,49 +0,0 @@
module Streams
module Overlay
# Etichetta stato broadcast (stessa logica della pagina live / badge HTML).
class BadgeLabel
Result = Struct.new(:text, :background, :foreground, keyword_init: true)
def self.for(session)
new(session).call
end
def initialize(session)
@session = session
end
def call
return Result.new(text: "DIRETTA CHIUSA", background: "#374151", foreground: "#dddddd") if @session.terminal?
return Result.new(text: "IN PAUSA", background: "#455a64", foreground: "#ffffff") if @session.paused?
if Mediamtx::PublisherOnline.active?(@session) || (@session.live? && !@session.paused?)
return Result.new(text: "IN ONDA", background: "#2e7d32", foreground: "#ffffff")
end
if path_has_output?
if @session.reconnecting?
return Result.new(text: "RICONNESSIONE", background: "#f57c00", foreground: "#ffffff")
end
if @session.connecting?
return Result.new(text: "CONNECTING", background: "#f57c00", foreground: "#ffffff")
end
# Slate alwaysAvailable senza telefono: non bruciare «COPERTINA» su YouTube.
return Result.new(text: "IN ATTESA", background: "#455a64", foreground: "#ffffff")
end
Result.new(text: "IN ATTESA", background: "#455a64", foreground: "#ffffff")
end
private
def path_info
@path_info ||= Mediamtx::PublisherOnline.path_info(@session)
end
def path_has_output?
info = path_info
info && (info["ready"] || info["available"] || info["online"])
end
end
end
end

View File

@@ -1,50 +0,0 @@
module Streams
module Overlay
class Refresh
class Error < StandardError; end
def self.call(session)
new(session).call
end
def initialize(session)
@session = session.reload
end
def call
return false if @session.terminal?
dir = Streams::Overlay.overlay_dir(@session.id)
FileUtils.mkdir_p(dir)
badge = BadgeLabel.for(@session)
svg = SvgBuilder.new(@session, badge: badge).to_svg
svg_path = dir.join("overlay.svg")
png_path = Streams::Overlay.overlay_png_path(@session)
atomic_write(svg_path, svg)
render_png!(svg_path, png_path)
true
rescue StandardError => e
Rails.logger.warn("[Overlay::Refresh] session=#{@session.id} #{e.class}: #{e.message}")
false
end
private
def atomic_write(path, content)
tmp = "#{path}.tmp"
File.write(tmp, content)
File.rename(tmp, path)
end
def render_png!(svg_path, png_path)
tmp = "#{png_path}.tmp"
unless system("rsvg-convert", "-w", "1280", "-h", "720", "-o", tmp, svg_path.to_s, out: File::NULL, err: File::NULL)
raise Error, "rsvg-convert fallito per session #{@session.id}"
end
File.rename(tmp, png_path)
FileUtils.touch(png_path)
end
end
end
end

View File

@@ -1,162 +0,0 @@
module Streams
module Overlay
# PNG 1280×720 trasparente: tabellone alto-sinistra, badge alto-destra, logo chiaro (logo-white-m) basso-destra.
class SvgBuilder
CANVAS_W = 1280
CANVAS_H = 720
def initialize(session, badge:)
@session = session
@match = session.match
@team = @match.team
@score = session.score_state
@badge = badge
end
def to_svg
cols = score_columns
<<~SVG
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="#{CANVAS_W}" height="#{CANVAS_H}" viewBox="0 0 #{CANVAS_W} #{CANVAS_H}">
#{scorebug_svg(cols)}
#{badge_svg}
#{brand_watermark_svg}
</svg>
SVG
end
private
def score_columns
score = @score
unless score
return {
labels: ["1"], home: ["0"], away: ["0"], home_name: abbrev(@match.team.name),
away_name: abbrev(@match.opponent_name), sets_won: "0-0", current_set: 1
}
end
partials = Array(score.set_partials)
labels = partials.map { |p| p["set"].to_s }
home = partials.map { |p| p["home"].to_s }
away = partials.map { |p| p["away"].to_s }
labels << score.current_set.to_s
home << score.home_points.to_s
away << score.away_points.to_s
{
labels: labels,
home: home,
away: away,
home_name: abbrev(@match.team.name),
away_name: abbrev(@match.opponent_name),
sets_won: "#{score.home_sets}-#{score.away_sets}",
current_set: score.current_set
}
end
def scorebug_svg(cols)
x0 = 12
y0 = 12
col_w = 26
team_w = 118
row_h = 18
head_h = 16
pad = 8
n = cols[:labels].length
box_w = team_w + n * col_w + pad * 2
box_h = head_h + row_h * 2 + 22 + pad * 2
live_i = n - 1
home_primary = @team.effective_primary_color
away_primary = Overlay.badge_away_color(@team)
header_cells = cols[:labels].map.with_index do |label, i|
cx = x0 + pad + team_w + i * col_w + col_w / 2
"<text x=\"#{cx}\" y=\"#{y0 + pad + 12}\" text-anchor=\"middle\" font-family=\"DejaVu Sans, sans-serif\" font-size=\"10\" font-weight=\"700\" fill=\"#666666\">#{escape(label)}</text>"
end.join
home_pts = cols[:home].map.with_index do |val, i|
cx = x0 + pad + team_w + i * col_w + col_w / 2
y = y0 + pad + head_h + 14
fill = i == live_i ? home_primary : "#111111"
weight = i == live_i ? "900" : "800"
bg = i == live_i ? "<rect x=\"#{cx - 12}\" y=\"#{y - 13}\" width=\"24\" height=\"16\" rx=\"3\" fill=\"#{home_primary}\" fill-opacity=\"0.14\"/>" : ""
"#{bg}<text x=\"#{cx}\" y=\"#{y}\" text-anchor=\"middle\" font-family=\"DejaVu Sans, sans-serif\" font-size=\"13\" font-weight=\"#{weight}\" fill=\"#{fill}\">#{escape(val)}</text>"
end.join
away_pts = cols[:away].map.with_index do |val, i|
cx = x0 + pad + team_w + i * col_w + col_w / 2
y = y0 + pad + head_h + row_h + 14
fill = i == live_i ? away_primary : "#111111"
weight = i == live_i ? "900" : "800"
bg = i == live_i ? "<rect x=\"#{cx - 12}\" y=\"#{y - 13}\" width=\"24\" height=\"16\" rx=\"3\" fill=\"#{away_primary}\" fill-opacity=\"0.14\"/>" : ""
"#{bg}<text x=\"#{cx}\" y=\"#{y}\" text-anchor=\"middle\" font-family=\"DejaVu Sans, sans-serif\" font-size=\"13\" font-weight=\"#{weight}\" fill=\"#{fill}\">#{escape(val)}</text>"
end.join
<<~SVG
<g id="scorebug">
<rect x="#{x0}" y="#{y0}" width="#{box_w}" height="#{box_h}" rx="6" fill="#ffffff" fill-opacity="0.94"/>
<line x1="#{x0 + pad}" y1="#{y0 + pad + head_h}" x2="#{x0 + box_w - pad}" y2="#{y0 + pad + head_h}" stroke="#000000" stroke-opacity="0.12"/>
#{header_cells}
<text x="#{x0 + pad}" y="#{y0 + pad + head_h + 14}" font-family="DejaVu Sans, sans-serif" font-size="12" font-weight="700" fill="#{home_primary}">#{escape(cols[:home_name])}</text>
#{home_pts}
<text x="#{x0 + pad}" y="#{y0 + pad + head_h + row_h + 14}" font-family="DejaVu Sans, sans-serif" font-size="12" font-weight="700" fill="#{away_primary}">#{escape(cols[:away_name])}</text>
#{away_pts}
<rect x="#{x0 + pad}" y="#{y0 + box_h - pad - 18}" width="#{box_w - pad * 2}" height="16" rx="4" fill="url(#brandGrad)"/>
<text x="#{x0 + box_w / 2}" y="#{y0 + box_h - pad - 6}" text-anchor="middle" font-family="DejaVu Sans, sans-serif" font-size="9" font-weight="800" fill="#ffffff" letter-spacing="1.2">MATCH <tspan fill="#e53935">LIVE TV</tspan></text>
</g>
<defs>
<linearGradient id="brandGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#14141c"/>
<stop offset="55%" stop-color="#2a1214"/>
<stop offset="100%" stop-color="#1a1a24"/>
</linearGradient>
</defs>
SVG
end
def brand_watermark_svg
path = Overlay.brand_logo_path
return "" unless path.file?
w = 108
h = 108
x = CANVAS_W - 14 - w
y = CANVAS_H - 14 - h
uri = "file://#{path.expand_path}"
<<~SVG
<g id="brand-watermark" opacity="0.94">
<image xlink:href="#{escape(uri)}" x="#{x}" y="#{y}" width="#{w}" height="#{h}" preserveAspectRatio="xMidYMid meet"/>
</g>
SVG
end
def badge_svg
text = @badge.text
bg = @badge.background
fg = @badge.foreground
pad_x = 12
pad_y = 6
fs = 11
tw = text.length * 6.5 + pad_x * 2
th = fs + pad_y * 2
x = CANVAS_W - 12 - tw
y = 12
<<~SVG
<g id="badge">
<rect x="#{x}" y="#{y}" width="#{tw}" height="#{th}" rx="#{th / 2}" fill="#{bg}"/>
<text x="#{x + tw / 2}" y="#{y + th - pad_y - 1}" text-anchor="middle" font-family="DejaVu Sans, sans-serif" font-size="#{fs}" font-weight="700" fill="#{fg}" letter-spacing="0.5">#{escape(text)}</text>
</g>
SVG
end
def abbrev(name, max = 16)
n = name.to_s
n.length <= max ? n : "#{n[0, max - 1]}…"
end
def escape(str)
str.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
end
end
end
end

View File

@@ -1,460 +0,0 @@
module Streams
# Transcodifica path grezzo → path _air con overlay PNG (tabellone + badge) bruciato nel video.
class OverlayRelay
class Error < StandardError; end
REDIS_KEY = "overlay_relay:pid:%s"
LIVE_INTAKE_KEY = "overlay_relay:live_intake:%s"
MODE_KEY = "overlay_relay:mode:%s"
START_LOCK_KEY = "overlay_relay:start_lock:%s"
MODE_LIVE = "live".freeze
MODE_COVER = "cover".freeze
class << self
def start(session, mode: nil)
return if session.terminal?
return unless acquire_start_lock!(session.id)
begin
terminate_all_session_processes!(session.id)
Streams::Overlay::Refresh.call(session)
dir = Streams::Overlay.overlay_dir(session.id)
FileUtils.mkdir_p(dir)
png = Streams::Overlay.overlay_png_path(session)
pipe = Streams::Overlay.overlay_pipe_path(session)
ensure_pipe!(pipe)
log_path = log_file(session)
FileUtils.mkdir_p(File.dirname(log_path))
mode = normalize_mode(mode || desired_mode(session))
ffmpeg_pid = Process.spawn(*ffmpeg_command(session, pipe, mode), %i[out err] => log_path, pgroup: true)
spawn_png_feeder!(ffmpeg_pid, pipe, png, log_path)
Process.detach(ffmpeg_pid)
store_pid(session.id, ffmpeg_pid)
store_mode(session.id, mode)
mode == MODE_LIVE ? mark_live_intake!(session.id) : clear_live_intake!(session.id)
OverlayRefreshJob.schedule_chain(session.id)
if session.platform == "youtube" && session.stream_key.present?
Streams::YoutubeRelay.stop(session)
Youtube::LivePipeline.schedule_activate!(session, force: true, wait: 20.seconds)
end
Rails.logger.info("[OverlayRelay] started pid=#{ffmpeg_pid} mode=#{mode} session=#{session.id} youtube_tee=#{session.platform == "youtube"}")
ffmpeg_pid
ensure
release_start_lock!(session.id)
end
rescue Errno::ENOENT => e
raise Error, "ffmpeg non disponibile: #{e.message}"
end
def stop(session)
pids = session_process_pids(session.id)
redis_pid = pid_for(session.id).to_i
pids << redis_pid if redis_pid > 1
pids = pids.uniq.select { |p| p > 1 }
return false if pids.empty?
pids.each { |p| terminate_pid(p) }
clear_pid(session.id)
clear_mode(session.id)
clear_live_intake!(session.id)
pipe = Streams::Overlay.overlay_pipe_path(session)
FileUtils.rm_f(pipe) if pipe.exist?
OverlayRefreshJob.cancel_chain(session.id)
Rails.logger.info("[OverlayRelay] stopped pids=#{pids.join(",")} session=#{session.id}")
true
end
def running?(session_id)
return false unless overlay_relay_process_check_local?
alive = session_process_pids(session_id).select { |p| process_alive?(p) }
return true if alive.any?
redis_pid = pid_for(session_id).to_i
return false if redis_pid <= 1
process_alive?(redis_pid)
end
# Overlay ffmpeg vivo e path _air riceve dati.
def healthy?(session)
running?(session.id) && overlay_path_publishing?(session)
end
# Riavvia il relay se il processo è morto o _air non pubblica (es. feeder bloccato).
def ensure_publishing!(session)
return if session.terminal?
restart_if_missing_youtube_tee!(session)
wanted_mode = desired_mode(session)
pids = session_process_pids(session.id)
if pids.any? && pids.none? { |p| process_alive?(p) }
terminate_all_session_processes!(session.id)
end
if healthy?(session) && current_mode(session.id) == wanted_mode
Youtube::LivePipeline.schedule_activate!(session) if session.platform == "youtube"
return
end
if running?(session.id) && current_mode(session.id) != wanted_mode
return unless mode_switch_allowed?(session.id, wanted_mode)
Rails.logger.info("[OverlayRelay] switch mode #{current_mode(session.id)}#{wanted_mode} session=#{session.id}")
stop(session)
end
if running?(session.id) && overlay_path_publishing?(session) && current_mode(session.id) == wanted_mode
return
end
if running?(session.id)
misses = redis.incr(overlay_air_miss_key(session.id)).to_i
redis.expire(overlay_air_miss_key(session.id), 120)
return if misses < 4
redis.del(overlay_air_miss_key(session.id))
Rails.logger.warn("[OverlayRelay] _air non attivo dopo #{misses} tentativi, restart session=#{session.id}")
stop(session)
else
redis.del(overlay_air_miss_key(session.id))
end
start(session, mode: wanted_mode)
rescue Error => e
Rails.logger.warn("[OverlayRelay] ensure_publishing session=#{session.id}: #{e.message}")
end
def desired_mode(session)
return MODE_COVER if session.paused?
return MODE_LIVE if Mediamtx::PublisherOnline.video_publishing?(session)
# RTMP a tratti: resta su live finché c'era segnale telefono (evita flip copertina↔live).
return MODE_LIVE if session.reconnecting? && live_intake?(session.id)
return MODE_LIVE if session.status.in?(%w[live connecting reconnecting]) && recent_phone_bytes?(session)
MODE_COVER
end
def terminate_all_session_processes!(session_id)
session_process_pids(session_id).each do |pid|
terminate_pid(pid)
end
redis_pid = pid_for(session_id).to_i
terminate_pid(redis_pid) if redis_pid > 1
clear_pid(session_id)
clear_mode(session_id)
end
def session_process_pids(session_id)
tag = session_match_tag(session_id)
pids = []
each_overlay_process do |pid, cmdline|
pids << pid if cmdline.include?(tag)
end
pids.uniq
end
def session_match_tag(session_id)
"match_#{session_id}"
end
def each_overlay_process
return unless overlay_relay_process_check_local?
Dir.glob("/proc/[0-9]*").each do |proc_dir|
pid = proc_dir.split("/").last.to_i
next if pid <= 1
cmdline = File.read("#{proc_dir}/cmdline").tr("\0", " ")
next unless cmdline.include?("ffmpeg") || cmdline.include?("overlay_png_feeder")
next unless cmdline.include?("match_")
yield pid, cmdline
rescue Errno::ENOENT, Errno::EPERM
nil
end
end
private
def mediamtx_rtmp_url
ENV.fetch("MEDIAMTX_INTERNAL_RTMP_URL", "rtmp://mediamtx:1935").chomp("/")
end
def ffmpeg_command(session, pipe, mode)
mode == MODE_LIVE ? live_command(session, pipe) : cover_command(session, pipe)
end
def live_command(session, pipe)
intake = "#{mediamtx_rtmp_url}/#{session.mediamtx_path_name}"
[
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
"-fflags", "+genpts+discardcorrupt",
"-analyzeduration", "20000000", "-probesize", "20000000",
"-rw_timeout", "15000000",
"-noautorotate",
"-i", intake,
"-thread_queue_size", "512",
"-f", "image2pipe", "-framerate", overlay_input_fps.to_s, "-i", pipe.to_s,
*common_encode_args(session, live_filter, "0:a?")
]
end
def cover_command(session, pipe)
cover = cover_image_path
raise Error, "copertina fallback non trovata: #{cover}" unless File.exist?(cover)
[
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
"-re", "-loop", "1", "-framerate", output_fps.to_s, "-i", cover,
"-thread_queue_size", "512",
"-f", "image2pipe", "-framerate", overlay_input_fps.to_s, "-i", pipe.to_s,
"-f", "lavfi", "-i", "anullsrc=channel_layout=mono:sample_rate=48000",
*common_encode_args(session, cover_filter, "2:a")
]
end
def common_encode_args(session, filter, audio_map)
fps = output_fps
[
"-filter_complex", filter,
"-map", "[vout]", "-map", audio_map,
"-fps_mode", "cfr", "-r", fps.to_s,
"-c:v", "libx264", "-preset", x264_preset, "-bf", "0",
"-profile:v", "high", "-pix_fmt", "yuv420p",
"-b:v", bitrate_for(session), "-maxrate", maxrate_for(session), "-bufsize", bufsize_for(session),
"-g", fps.to_s, "-keyint_min", fps.to_s,
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1",
*output_args(session)
]
end
# Crop center 16:9 a tutto schermo (niente bande nere su TV/web).
def live_filter
fps = output_fps
"[1:v]format=rgba[ol];" \
"[0:v]scale=1280:720:force_original_aspect_ratio=increase,crop=1280:720,setsar=1,fps=#{fps},format=yuv420p[vid];" \
"[vid][ol]overlay=0:0:format=auto[vout]"
end
def cover_filter
fps = output_fps
"[1:v]format=rgba[ol];" \
"[0:v]scale=1280:720:force_original_aspect_ratio=increase,crop=1280:720,setsar=1,fps=#{fps},format=yuv420p[vid];" \
"[vid][ol]overlay=0:0:format=auto[vout]"
end
def cover_image_path
ENV.fetch(
"OVERLAY_COVER_IMAGE",
Rails.root.join("branding", "CopertinaCanale_6.png").to_s
)
end
# Un solo encode → MediaMTX _air + YouTube RTMPS (evita seconda lettura RTMP senza SPS).
def output_args(session)
air = "#{mediamtx_rtmp_url}/#{session.mediamtx_overlay_path_name}"
if session.platform == "youtube" && session.stream_key.present?
yt = "rtmps://a.rtmps.youtube.com/live2/#{session.stream_key}"
tee = "[f=flv:onfail=ignore]#{air}|[f=flv:onfail=ignore:use_fifo=1]#{yt}"
["-f", "tee", tee]
else
["-f", "flv", air]
end
end
# Ricodifica con overlay: serve più budget della sorgente (generational loss).
def output_video_kbps(session)
override = ENV["OVERLAY_RELAY_VIDEO_KBPS"].presence&.to_i
return override if override&.positive?
source_kbps = [(session.target_bitrate.to_i / 1000), 3000].max
[(source_kbps * 2.0).to_i, 5500].max
end
def bitrate_for(session)
"#{output_video_kbps(session)}k"
end
def maxrate_for(session)
kbps = (output_video_kbps(session) * 1.15).to_i
"#{kbps}k"
end
def bufsize_for(session)
kbps = output_video_kbps(session) * 2
"#{kbps}k"
end
def output_fps
ENV.fetch("OVERLAY_RELAY_FPS", "30").to_i
end
def x264_preset
ENV.fetch("OVERLAY_RELAY_X264_PRESET", "fast")
end
def overlay_input_fps
ENV.fetch("OVERLAY_INPUT_FPS", "2").to_i
end
def ensure_pipe!(path)
FileUtils.rm_f(path) if path.exist? && !path.pipe?
system("mkfifo", path.to_s) unless path.exist?
raise Error, "impossibile creare fifo overlay #{path}" unless path.pipe?
end
def spawn_png_feeder!(ffmpeg_pid, pipe, png, log_path)
feeder = Rails.root.join("bin/overlay_png_feeder.rb")
poll = (1.0 / overlay_input_fps).round(2)
Process.spawn(
RbConfig.ruby, feeder.to_s, pipe.to_s, png.to_s, poll.to_s,
%i[out err] => log_path,
pgroup: ffmpeg_pid
)
end
def overlay_relay_process_check_local?
ENV["OVERLAY_RELAY_PROCESS_CHECK"] != "false"
end
def overlay_path_publishing?(session)
info = Mediamtx::Client.new.list_paths.find { |i| i["name"] == session.mediamtx_overlay_path_name }
info && info["online"] && info["bytesReceived"].to_i > 100_000
end
def acquire_start_lock!(session_id)
redis.set(format(START_LOCK_KEY, session_id), "1", nx: true, ex: 30)
end
def release_start_lock!(session_id)
redis.del(format(START_LOCK_KEY, session_id))
end
def publisher_has_video?(session)
info = Mediamtx::PublisherOnline.path_info(session)
return false unless Mediamtx::PublisherOnline.active_path?(info)
(info["tracks2"] || []).any? do |track|
track["codec"] == "H264" && track.dig("codecProps", "width").to_i.positive?
end
end
def live_intake?(session_id)
redis.get(format(LIVE_INTAKE_KEY, session_id)) == "1"
end
def current_mode(session_id)
redis.get(format(MODE_KEY, session_id))
end
def normalize_mode(mode)
mode == MODE_LIVE ? MODE_LIVE : MODE_COVER
end
def mark_live_intake!(session_id)
redis.set(format(LIVE_INTAKE_KEY, session_id), "1", ex: 48.hours.to_i)
end
def clear_live_intake!(session_id)
redis.del(format(LIVE_INTAKE_KEY, session_id))
end
def store_mode(session_id, mode)
redis.set(format(MODE_KEY, session_id), normalize_mode(mode), ex: 48.hours.to_i)
end
def clear_mode(session_id)
redis.del(format(MODE_KEY, session_id))
end
def log_file(session)
Rails.root.join("log", "overlay_relay_#{session.id}.log").to_s
end
def redis
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
end
def store_pid(session_id, pid)
redis.set(format(REDIS_KEY, session_id), pid, ex: 48.hours.to_i)
end
def clear_pid(session_id)
redis.del(format(REDIS_KEY, session_id))
end
def pid_for(session_id)
redis.get(format(REDIS_KEY, session_id))
end
def overlay_air_miss_key(session_id)
format("overlay_relay:air_miss:%s", session_id)
end
def recent_phone_bytes?(session)
info = Mediamtx::PublisherOnline.path_info(session)
info && info["bytesReceived"].to_i > 1_000_000
end
def publishing_to_youtube?(session)
return false unless session.platform == "youtube" && session.stream_key.present?
session_process_pids(session.id).any? do |pid|
cmdline = File.read("/proc/#{pid}/cmdline").tr("\0", " ")
cmdline.include?("rtmps://") || cmdline.include?("youtube.com/live2")
rescue Errno::ENOENT, Errno::EPERM
false
end
end
def restart_if_missing_youtube_tee!(session)
return unless session.platform == "youtube" && session.stream_key.present?
return unless running?(session.id)
return if publishing_to_youtube?(session)
Rails.logger.warn("[OverlayRelay] restart overlay: manca tee YouTube session=#{session.id}")
stop(session)
end
def mode_switch_allowed?(session_id, wanted_mode)
key = format("overlay_relay:mode_want:%s", session_id)
prev = redis.get(key)
redis.set(key, wanted_mode, ex: 120)
return true if prev.blank? || prev == wanted_mode
ticks = redis.incr(format("overlay_relay:mode_ticks:%s", session_id)).to_i
redis.expire(format("overlay_relay:mode_ticks:%s", session_id), 60)
if ticks >= 3
redis.del(format("overlay_relay:mode_ticks:%s", session_id))
return true
end
false
end
def process_alive?(pid)
stat = File.read("/proc/#{pid.to_i}/stat")
return false if stat.split[2] == "Z"
Process.kill(0, pid.to_i)
true
rescue Errno::ESRCH, Errno::EPERM, Errno::ENOENT
false
end
def terminate_pid(pid)
Process.kill("TERM", -pid.to_i)
sleep 0.5
rescue Errno::ESRCH
nil
else
begin
Process.kill("KILL", -pid.to_i)
rescue Errno::ESRCH
nil
end
end
end
end
end

View File

@@ -116,7 +116,7 @@ module Streams
raise Error, "ffmpeg non disponibile: #{e.message}" raise Error, "ffmpeg non disponibile: #{e.message}"
end end
# _air RTMP (copy) se pronto; altrimenti HLS via proxy Rails. # RTMP grezzo da telefono (overlay bruciato lato app); fallback HLS via proxy Rails.
def youtube_ffmpeg_args(intake_source, output) def youtube_ffmpeg_args(intake_source, output)
mode, url = intake_source mode, url = intake_source
if mode == :rtmp if mode == :rtmp

View File

@@ -61,7 +61,7 @@
<div class="live-grid"> <div class="live-grid">
<% @sessions.each do |session| %> <% @sessions.each do |session| %>
<% match = session.match %> <% match = session.match %>
<% on_air = @online_paths.include?(session.mediamtx_overlay_path_name) || @online_paths.include?(session.mediamtx_path_name) %> <% on_air = @online_paths.include?(session.mediamtx_path_name) %>
<article class="live-card"> <article class="live-card">
<%= live_match_card_heading(match) %> <%= live_match_card_heading(match) %>
<p class="meta"> <p class="meta">

View File

@@ -41,7 +41,7 @@
<% else %> <% else %>
<div class="live-player-wrap"> <div class="live-player-wrap">
<video id="player" controls playsinline muted autoplay poster="/images/copertina-canale.png"></video> <video id="player" controls playsinline muted autoplay poster="/images/copertina-canale.png"></video>
<%# Tabellone, badge stato e brand sono bruciati nel video (Streams::OverlayRelay). %> <%# Tabellone e watermark sono bruciati nel video dall'app mobile (overlay client-side). %>
<button type="button" id="play-hint" class="live-play-hint" hidden> <button type="button" id="play-hint" class="live-play-hint" hidden>
▶ Avvia la diretta ▶ Avvia la diretta
</button> </button>

View File

@@ -1,43 +0,0 @@
#!/usr/bin/env ruby
# Invia overlay.png aggiornata a ffmpeg via image2pipe (FIFO).
# ffmpeg con -loop 1 legge il file una sola volta: questo processo rilegge il PNG
# quando cambia mtime (punteggio, badge stato, …).
require "fileutils"
pipe_path = ARGV.fetch(0)
png_path = ARGV.fetch(1)
poll_sec = (ARGV[2] || ENV.fetch("OVERLAY_PIPE_POLL_SEC", "0.4")).to_f
abort "pipe mancante: #{pipe_path}" unless File.exist?(pipe_path)
abort "png mancante: #{png_path}" unless File.exist?(png_path)
stop = false
trap("TERM") { stop = true }
trap("INT") { stop = true }
# image2pipe richiede frame continui: inviamo la PNG a ritmo fisso ( anche se invariata ).
last_bytes = nil
File.open(pipe_path, "wb") do |pipe|
loop do
break if stop
if File.exist?(png_path)
bytes = File.binread(png_path)
# Evita frame corrotti durante rsvg-convert (file parziale prima del rename atomico).
next if bytes.bytesize < 512
if bytes != last_bytes
pipe.write(bytes)
pipe.flush
last_bytes = bytes
elsif last_bytes
pipe.write(last_bytes)
pipe.flush
end
end
sleep poll_sec
end
rescue Errno::EPIPE
# ffmpeg chiuso
end

View File

@@ -1,18 +0,0 @@
namespace :stream_overlay do
desc "Avvia path _air e OverlayRelay per sessioni attive (dopo deploy overlay server)"
task start_active: :environment do
sessions = StreamSession.where(status: %w[live connecting reconnecting paused])
mtx = Mediamtx::Client.new
sessions.find_each do |session|
begin
mtx.create_overlay_path(session)
rescue Mediamtx::Client::Error => e
Rails.logger.info("[stream_overlay:start_active] path #{session.id}: #{e.message}")
end
Streams::OverlayRelay.start(session)
puts "Overlay avviato per sessione #{session.id}"
rescue StandardError => e
warn "Sessione #{session.id}: #{e.message}"
end
end
end

View File

@@ -156,7 +156,7 @@ Dalla macchina di sviluppo:
```bash ```bash
# Tar + scp (rsync non installato sul server) # Tar + scp (rsync non installato sul server)
tar -C /path/to/MatchLiveTV/src -czf /tmp/matchlivetv-deploy.tar.gz \ tar -C /path/to/MatchLiveTV/src -czf /tmp/matchlivetv-deploy.tar.gz \
--exclude='./mobile/build' --exclude='./backend/log' --exclude='./.git' . --exclude='./native/android/build' --exclude='./backend/log' --exclude='./.git' .
scp /tmp/matchlivetv-deploy.tar.gz eminux@192.168.1.146:~/ scp /tmp/matchlivetv-deploy.tar.gz eminux@192.168.1.146:~/
ssh eminux@192.168.1.146 'tar -xzf ~/matchlivetv-deploy.tar.gz -C ~/matchlivetv' ssh eminux@192.168.1.146 'tar -xzf ~/matchlivetv-deploy.tar.gz -C ~/matchlivetv'

View File

@@ -115,7 +115,6 @@ services:
condition: service_started condition: service_started
volumes: volumes:
- recordings:/recordings - recordings:/recordings
- stream_overlays:/app/tmp/stream_overlays
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:3000/up"] test: ["CMD", "curl", "-f", "http://127.0.0.1:3000/up"]
interval: 15s interval: 15s
@@ -163,7 +162,6 @@ services:
REPLAY_STORAGE_ACCESS_KEY_ID: ${REPLAY_STORAGE_ACCESS_KEY_ID:-} REPLAY_STORAGE_ACCESS_KEY_ID: ${REPLAY_STORAGE_ACCESS_KEY_ID:-}
REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-} REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-}
REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true} REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true}
OVERLAY_RELAY_PROCESS_CHECK: "true"
depends_on: depends_on:
rails: rails:
condition: service_healthy condition: service_healthy
@@ -171,7 +169,6 @@ services:
condition: service_started condition: service_started
volumes: volumes:
- recordings:/recordings - recordings:/recordings
- stream_overlays:/app/tmp/stream_overlays
garage: garage:
image: dxflrs/garage:v1.0.1 image: dxflrs/garage:v1.0.1
@@ -187,6 +184,5 @@ volumes:
postgres_data: postgres_data:
redis_data: redis_data:
recordings: recordings:
stream_overlays:
garage_meta: garage_meta:
garage_data: garage_data:

45
mobile/.gitignore vendored
View File

@@ -1,45 +0,0 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

View File

@@ -1,45 +0,0 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "559ffa3f75e7402d65a8def9c28389a9b2e6fe42"
channel: "[user-branch]"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
- platform: android
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
- platform: ios
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
- platform: linux
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
- platform: macos
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
- platform: web
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
- platform: windows
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

View File

@@ -1,17 +0,0 @@
# match_live_tv
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

View File

@@ -1,28 +0,0 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

View File

@@ -1,14 +0,0 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@@ -1,59 +0,0 @@
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.matchlivetv.match_live_tv"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.matchlivetv.match_live_tv"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = maxOf(flutter.minSdkVersion, 21)
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}
dependencies {
val cameraxVersion = "1.4.1"
implementation("androidx.camera:camera-core:$cameraxVersion")
implementation("androidx.camera:camera-camera2:$cameraxVersion")
implementation("androidx.camera:camera-lifecycle:$cameraxVersion")
implementation("androidx.camera:camera-view:$cameraxVersion")
implementation("com.github.pedroSG94.RootEncoder:library:2.5.5")
}

View File

@@ -1,2 +0,0 @@
# RootEncoder / SLF4J (R8 release)
-dontwarn org.slf4j.impl.StaticLoggerBinder

View File

@@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -1,27 +0,0 @@
package com.matchlivetv.match_live_tv
import android.content.res.Configuration
import android.os.Bundle
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterFragmentActivity() {
override fun onConfigurationChanged(newConfig: Configuration) {
StreamingEngineHolder.engine?.pauseGlDuringRotation()
super.onConfigurationChanged(newConfig)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Termina eventuale diretta rimasta attiva da sessione precedente
StreamingEngineHolder.engine?.stopStream()
StreamingEngineHolder.release()
startService(StreamingForegroundService.stopIntent(this))
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(StreamingPlugin())
}
}

View File

@@ -1,9 +0,0 @@
package com.matchlivetv.match_live_tv
import com.pedro.library.view.OpenGlView
/** Riferimento condiviso alla surface di preview tra PlatformView e StreamingEngine. */
object StreamPreviewHolder {
@Volatile
var openGlView: OpenGlView? = null
}

View File

@@ -1,22 +0,0 @@
package com.matchlivetv.match_live_tv
import android.content.Context
/** Motore streaming condiviso tra preview UI e RTMP (deve restare legato all'Activity). */
object StreamingEngineHolder {
@Volatile
var engine: StreamingEngine? = null
fun getOrCreate(context: Context): StreamingEngine {
val existing = engine
if (existing != null) {
return existing
}
return StreamingEngine(context.applicationContext).also { engine = it }
}
fun release() {
engine?.release()
engine = null
}
}

View File

@@ -1,243 +0,0 @@
package com.matchlivetv.match_live_tv
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Binder
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.PowerManager
import android.os.SystemClock
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
/** Mantiene in foreground la diretta (notifica + wake lock). Il motore RTMP vive nel plugin. */
class StreamingForegroundService : Service() {
private val binder = LocalBinder()
private val mainHandler = Handler(Looper.getMainLooper())
private var wakeLock: PowerManager.WakeLock? = null
private var sessionStartedAtMs: Long = 0L
private var autoStopTriggered = false
private val sessionTimeoutRunnable = Runnable {
if (!autoStopTriggered) {
autoStopTriggered = true
ServiceEventBus.emit(
mapOf(
"type" to "stopped",
"reason" to "session_timeout",
"durationMs" to elapsedMs(),
),
)
stopForegroundInternal("session_timeout")
}
}
inner class LocalBinder : Binder() {
fun getService(): StreamingForegroundService = this@StreamingForegroundService
}
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> startForegroundSession()
ACTION_STOP -> stopForegroundInternal("user_stop")
}
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder = binder
override fun onDestroy() {
stopForegroundInternal("service_destroy")
super.onDestroy()
}
fun getMetrics(): Map<String, Any?> =
StreamingEngineHolder.engine?.getMetrics()?.toMap() ?: emptyMap()
private fun startForegroundSession() {
autoStopTriggered = false
sessionStartedAtMs = SystemClock.elapsedRealtime()
acquireWakeLock()
promoteToForeground(getString(R.string.streaming_notification_active))
mainHandler.removeCallbacks(sessionTimeoutRunnable)
mainHandler.postDelayed(sessionTimeoutRunnable, SESSION_MAX_MS)
}
private fun stopForegroundInternal(reason: String) {
mainHandler.removeCallbacks(sessionTimeoutRunnable)
releaseWakeLock()
if (sessionStartedAtMs > 0L) {
ServiceEventBus.emit(
mapOf(
"type" to "stopped",
"reason" to reason,
"durationMs" to elapsedMs(),
),
)
}
sessionStartedAtMs = 0L
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
private fun elapsedMs(): Long {
return if (sessionStartedAtMs > 0L) {
SystemClock.elapsedRealtime() - sessionStartedAtMs
} else {
0L
}
}
private fun promoteToForeground(content: String) {
val notification = buildNotification(content)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA or
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE,
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
fun updateNotificationForState(state: StreamingState) {
val content = when (state) {
StreamingState.CONNECTING -> getString(R.string.streaming_notification_connecting)
StreamingState.STREAMING -> getString(R.string.streaming_notification_streaming)
StreamingState.RECONNECTING -> getString(R.string.streaming_notification_reconnecting)
StreamingState.ERROR -> getString(R.string.streaming_notification_error)
else -> getString(R.string.streaming_notification_active)
}
val manager = getSystemService(NotificationManager::class.java)
manager.notify(NOTIFICATION_ID, buildNotification(content))
}
private fun buildNotification(content: String): Notification {
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
val contentIntent = PendingIntent.getActivity(
this,
0,
launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val stopIntent = PendingIntent.getService(
this,
1,
Companion.stopIntent(this),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.streaming_notification_title))
.setContentText(content)
.setSmallIcon(R.mipmap.ic_launcher)
.setOngoing(true)
.setContentIntent(contentIntent)
.addAction(
android.R.drawable.ic_media_pause,
getString(R.string.streaming_notification_stop),
stopIntent,
)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val manager = getSystemService(NotificationManager::class.java)
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.streaming_notification_channel),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = getString(R.string.streaming_notification_channel_desc)
}
manager.createNotificationChannel(channel)
}
private fun acquireWakeLock() {
if (wakeLock?.isHeld == true) {
return
}
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"$packageName:StreamingWakeLock",
).apply {
setReferenceCounted(false)
acquire(SESSION_MAX_MS + 60_000L)
}
}
private fun releaseWakeLock() {
wakeLock?.let { lock ->
if (lock.isHeld) {
lock.release()
}
}
wakeLock = null
}
companion object {
const val ACTION_START = "com.matchlivetv.match_live_tv.action.START_STREAM"
const val ACTION_STOP = "com.matchlivetv.match_live_tv.action.STOP_STREAM"
private const val CHANNEL_ID = "match_live_tv_streaming"
private const val NOTIFICATION_ID = 1001
private val SESSION_MAX_MS = 90L * 60L * 1000L
fun startIntent(context: Context): Intent {
return Intent(context, StreamingForegroundService::class.java).apply {
action = ACTION_START
}
}
fun stopIntent(context: Context): Intent {
return Intent(context, StreamingForegroundService::class.java).apply {
action = ACTION_STOP
}
}
}
}
object ServiceEventBus {
private val listeners = mutableSetOf<(Map<String, Any?>) -> Unit>()
fun addListener(listener: (Map<String, Any?>) -> Unit) {
synchronized(listeners) {
listeners.add(listener)
}
}
fun removeListener(listener: (Map<String, Any?>) -> Unit) {
synchronized(listeners) {
listeners.remove(listener)
}
}
fun emit(event: Map<String, Any?>) {
val snapshot = synchronized(listeners) { listeners.toList() }
snapshot.forEach { it(event) }
}
}

View File

@@ -1,30 +0,0 @@
package com.matchlivetv.match_live_tv
import android.content.Context
import android.util.AttributeSet
import android.view.SurfaceHolder
import com.pedro.library.view.OpenGlView
/**
* OpenGlView che non distrugge encoder/GL quando la surface viene persa in rotazione.
* La SurfaceView standard di Pedro chiama stop() in surfaceDestroyed e uccide tutto il pipeline.
*/
class StreamingOpenGlView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : OpenGlView(context, attrs) {
@Volatile
var preservePipelineOnSurfaceLoss: Boolean = false
var onPreviewSurfaceLost: (() -> Unit)? = null
override fun surfaceDestroyed(holder: SurfaceHolder) {
if (preservePipelineOnSurfaceLoss && isRunning) {
setForceRender(false)
onPreviewSurfaceLost?.invoke()
return
}
super.surfaceDestroyed(holder)
}
}

View File

@@ -1,365 +0,0 @@
package com.matchlivetv.match_live_tv
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Build
import android.os.IBinder
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventChannel.StreamHandler {
private lateinit var appContext: Context
private lateinit var methodChannel: MethodChannel
private lateinit var eventChannel: EventChannel
private var activityBinding: ActivityPluginBinding? = null
private var eventSink: EventChannel.EventSink? = null
private var boundService: StreamingForegroundService? = null
private var serviceBound = false
private val engineListener = object : StreamingEngineListener {
override fun onStateChanged(state: StreamingState, message: String?) {
ServiceEventBus.emit(
mapOf(
"type" to "state",
"state" to state.name.lowercase(),
"message" to message,
),
)
boundService?.updateNotificationForState(state)
}
override fun onMetricsUpdated(metrics: StreamingMetrics) {
ServiceEventBus.emit(
mapOf(
"type" to "metrics",
"metrics" to metrics.toMap(),
),
)
}
override fun onError(message: String) {
ServiceEventBus.emit(
mapOf(
"type" to "error",
"message" to message,
),
)
}
}
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val binder = service as? StreamingForegroundService.LocalBinder ?: return
boundService = binder.getService()
serviceBound = true
}
override fun onServiceDisconnected(name: ComponentName?) {
boundService = null
serviceBound = false
}
}
private val busListener: (Map<String, Any?>) -> Unit = { event ->
eventSink?.success(event)
}
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
appContext = binding.applicationContext
methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL)
methodChannel.setMethodCallHandler(this)
eventChannel = EventChannel(binding.binaryMessenger, EVENT_CHANNEL)
eventChannel.setStreamHandler(this)
binding.platformViewRegistry.registerViewFactory(
PREVIEW_VIEW_TYPE,
StreamingPreviewFactory(
onSurfaceReady = { glView ->
android.os.Handler(android.os.Looper.getMainLooper()).post {
val engine = getOrCreateEngine()
engine.setupPreviewGlView(glView)
engine.onPlatformSurfaceReady(glView)
}
},
onSurfaceDestroyed = {
android.os.Handler(android.os.Looper.getMainLooper()).post {
getEngine()?.onPlatformSurfaceDestroyed()
}
},
),
)
ServiceEventBus.addListener(busListener)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel.setMethodCallHandler(null)
eventChannel.setStreamHandler(null)
ServiceEventBus.removeListener(busListener)
releaseEngine()
unbindServiceIfNeeded()
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activityBinding = binding
}
override fun onDetachedFromActivityForConfigChanges() {
activityBinding = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activityBinding = binding
}
private fun attachPreviewIfPossible() {
val glView = StreamPreviewHolder.openGlView as? StreamingOpenGlView ?: return
val engine = getOrCreateEngine()
engine.setupPreviewGlView(glView)
engine.bindStreamPreview(glView)
}
private fun previewIsReady(): Boolean {
val engine = getEngine() ?: return false
val metrics = engine.getMetrics()
return metrics.isPreviewActive
}
override fun onDetachedFromActivity() {
activityBinding = null
unbindServiceIfNeeded()
}
override fun onMethodCall(call: MethodCall, result: Result) {
when (call.method) {
"startStream" -> startStream(call, result)
"resumeStream" -> resumeStream(call, result)
"stopStream" -> stopStream(result)
"pauseStream" -> pauseStream(result)
"getMetrics" -> getMetrics(result)
"startPreview" -> startPreview(result)
"stopPreview" -> stopPreview(result)
"bindPreview" -> bindPreview(result)
"syncOrientation" -> syncOrientation(result)
"unbindPreview" -> unbindPreview(result)
else -> result.notImplemented()
}
}
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
eventSink = events
}
override fun onCancel(arguments: Any?) {
eventSink = null
}
private fun startStream(call: MethodCall, result: Result) {
if (activityBinding?.activity == null) {
result.error("no_activity", "Activity non disponibile per lo streaming", null)
return
}
val args = call.arguments as? Map<*, *>
val rtmpUrl = args?.get("rtmpUrl") as? String
if (rtmpUrl.isNullOrBlank()) {
result.error("invalid_args", "rtmpUrl is required", null)
return
}
val argWidth = (args["width"] as? Number)?.toInt() ?: 1280
val argHeight = (args["height"] as? Number)?.toInt() ?: 720
val portrait = args["portrait"] as? Boolean ?: (argHeight > argWidth)
val config = StreamingConfig(
rtmpUrl = rtmpUrl,
width = (args["width"] as? Number)?.toInt() ?: if (portrait) 720 else 1280,
height = (args["height"] as? Number)?.toInt() ?: if (portrait) 1280 else 720,
videoBitrate = (args["videoBitrate"] as? Number)?.toInt() ?: 2_500_000,
audioBitrate = (args["audioBitrate"] as? Number)?.toInt() ?: 128_000,
fps = (args["fps"] as? Number)?.toInt() ?: 30,
rotation = (args["rotation"] as? Number)?.toInt() ?: 0,
portrait = portrait,
maxReconnectAttempts = (args["maxReconnectAttempts"] as? Number)?.toInt() ?: 10,
reconnectDelayMs = (args["reconnectDelayMs"] as? Number)?.toLong() ?: 5_000L,
)
val engine = getOrCreateEngine()
engine.removeListener(engineListener)
engine.addListener(engineListener)
attachPreviewIfPossible()
val intent = StreamingForegroundService.startIntent(appContext)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
appContext.startForegroundService(intent)
} else {
appContext.startService(intent)
}
bindServiceIfNeeded()
try {
engine.startStream(config)
result.success(true)
} catch (exception: Exception) {
appContext.startService(StreamingForegroundService.stopIntent(appContext))
result.error("start_failed", exception.message, null)
}
}
private fun resumeStream(call: MethodCall, result: Result) {
if (activityBinding?.activity == null) {
result.error("no_activity", "Activity non disponibile per lo streaming", null)
return
}
val args = call.arguments as? Map<*, *>
val rtmpUrl = args?.get("rtmpUrl") as? String
if (rtmpUrl.isNullOrBlank()) {
result.error("invalid_args", "rtmpUrl is required", null)
return
}
val argWidth = (args["width"] as? Number)?.toInt() ?: 1280
val argHeight = (args["height"] as? Number)?.toInt() ?: 720
val portrait = args["portrait"] as? Boolean ?: (argHeight > argWidth)
val config = StreamingConfig(
rtmpUrl = rtmpUrl,
width = (args["width"] as? Number)?.toInt() ?: if (portrait) 720 else 1280,
height = (args["height"] as? Number)?.toInt() ?: if (portrait) 1280 else 720,
videoBitrate = (args["videoBitrate"] as? Number)?.toInt() ?: 2_500_000,
audioBitrate = (args["audioBitrate"] as? Number)?.toInt() ?: 128_000,
fps = (args["fps"] as? Number)?.toInt() ?: 30,
rotation = (args["rotation"] as? Number)?.toInt() ?: 0,
portrait = portrait,
maxReconnectAttempts = (args["maxReconnectAttempts"] as? Number)?.toInt() ?: 10,
reconnectDelayMs = (args["reconnectDelayMs"] as? Number)?.toLong() ?: 5_000L,
)
val engine = getOrCreateEngine()
engine.removeListener(engineListener)
engine.addListener(engineListener)
attachPreviewIfPossible()
val intent = StreamingForegroundService.startIntent(appContext)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
appContext.startForegroundService(intent)
} else {
appContext.startService(intent)
}
bindServiceIfNeeded()
try {
engine.resumeStream(config)
result.success(true)
} catch (exception: Exception) {
appContext.startService(StreamingForegroundService.stopIntent(appContext))
result.error("resume_failed", exception.message, null)
}
}
private fun stopStream(result: Result) {
appContext.startService(StreamingForegroundService.stopIntent(appContext))
getEngine()?.let { engine ->
engine.removeListener(engineListener)
engine.stopStream()
}
unbindServiceIfNeeded()
result.success(true)
}
private fun pauseStream(result: Result) {
getEngine()?.pauseStream()
result.success(true)
}
private fun getMetrics(result: Result) {
val metrics = boundService?.getMetrics()
?: getEngine()?.getMetrics()?.toMap()
?: emptyMap()
result.success(metrics)
}
private fun bindPreview(result: Result) {
attachPreviewIfPossible()
result.success(previewIsReady())
}
private fun syncOrientation(result: Result) {
getEngine()?.applyOrientationFromSensor()
result.success(true)
}
private fun unbindPreview(result: Result) {
getEngine()?.unbindStreamPreview()
result.success(true)
}
private fun startPreview(result: Result) {
attachPreviewIfPossible()
result.success(StreamPreviewHolder.openGlView != null)
}
private fun stopPreview(result: Result) {
getEngine()?.unbindStreamPreview()
result.success(true)
}
private fun getOrCreateEngine(): StreamingEngine {
val context = activityBinding?.activity ?: appContext
return StreamingEngineHolder.getOrCreate(context).also { engine ->
engine.removeListener(engineListener)
engine.addListener(engineListener)
}
}
private fun getEngine(): StreamingEngine? = StreamingEngineHolder.engine
private fun releaseEngine() {
getEngine()?.let { engine ->
engine.removeListener(engineListener)
engine.stopStream()
}
StreamingEngineHolder.release()
}
private fun bindServiceIfNeeded() {
if (serviceBound) {
return
}
val intent = Intent(appContext, StreamingForegroundService::class.java)
appContext.bindService(
intent,
serviceConnection,
Context.BIND_AUTO_CREATE,
)
}
private fun unbindServiceIfNeeded() {
if (!serviceBound) {
return
}
try {
appContext.unbindService(serviceConnection)
} catch (_: IllegalArgumentException) {
}
serviceBound = false
boundService = null
}
companion object {
private const val METHOD_CHANNEL = "com.matchlivetv.match_live_tv/streaming"
private const val EVENT_CHANNEL = "com.matchlivetv.match_live_tv/streaming_events"
const val PREVIEW_VIEW_TYPE = "match_live_tv/streaming_preview"
}
}

View File

@@ -1,95 +0,0 @@
package com.matchlivetv.match_live_tv
import android.content.Context
import android.view.SurfaceHolder
import android.view.View
import android.widget.FrameLayout
import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory
class StreamingPreviewPlatformView(
context: Context,
private val onSurfaceReady: (StreamingOpenGlView) -> Unit,
private val onSurfaceDestroyed: () -> Unit,
) : PlatformView {
private val openGlView = StreamingOpenGlView(context).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
)
}
private var lastNotifiedWidth = 0
private var lastNotifiedHeight = 0
private val notifyReadyRunnable = Runnable {
if (openGlView.width <= 0 || openGlView.height <= 0) {
return@Runnable
}
val sizeChanged = openGlView.width != lastNotifiedWidth ||
openGlView.height != lastNotifiedHeight
if (!sizeChanged && lastNotifiedWidth > 0) {
return@Runnable
}
lastNotifiedWidth = openGlView.width
lastNotifiedHeight = openGlView.height
onSurfaceReady(openGlView)
}
init {
StreamPreviewHolder.openGlView = openGlView
openGlView.holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
StreamPreviewHolder.openGlView = openGlView
scheduleReadyDebounced()
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
StreamPreviewHolder.openGlView = openGlView
if (width > 0 && height > 0) {
scheduleReadyDebounced()
}
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
openGlView.removeCallbacks(notifyReadyRunnable)
lastNotifiedWidth = 0
lastNotifiedHeight = 0
onSurfaceDestroyed()
}
})
}
private fun scheduleReadyDebounced() {
openGlView.removeCallbacks(notifyReadyRunnable)
openGlView.postDelayed(notifyReadyRunnable, SURFACE_NOTIFY_DEBOUNCE_MS)
}
override fun getView(): View = openGlView
override fun dispose() {
openGlView.removeCallbacks(notifyReadyRunnable)
lastNotifiedWidth = 0
lastNotifiedHeight = 0
onSurfaceDestroyed()
if (StreamPreviewHolder.openGlView === openGlView) {
StreamPreviewHolder.openGlView = null
}
}
companion object {
private const val SURFACE_NOTIFY_DEBOUNCE_MS = 600L
}
}
class StreamingPreviewFactory(
private val onSurfaceReady: (StreamingOpenGlView) -> Unit,
private val onSurfaceDestroyed: () -> Unit,
) : PlatformViewFactory(StandardMessageCodec.INSTANCE) {
override fun create(context: Context, viewId: Int, args: Any?): PlatformView {
return StreamingPreviewPlatformView(context, onSurfaceReady, onSurfaceDestroyed)
}
}

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Match Live TV</string>
<string name="streaming_notification_channel">Streaming live</string>
<string name="streaming_notification_channel_desc">Notifiche durante lo streaming Match Live TV</string>
<string name="streaming_notification_title">Match Live TV</string>
<string name="streaming_notification_active">Streaming attivo</string>
<string name="streaming_notification_connecting">Connessione al server RTMP…</string>
<string name="streaming_notification_streaming">Trasmissione in corso</string>
<string name="streaming_notification_reconnecting">Riconnessione in corso…</string>
<string name="streaming_notification_error">Errore di streaming</string>
<string name="streaming_notification_stop">Stop</string>
</resources>

View File

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Dev: API Rails su host (emulatore → 10.0.2.2) -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">10.0.2.2</domain>
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
</domain-config>
</network-security-config>

View File

@@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -1,25 +0,0 @@
allprojects {
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@@ -1,6 +0,0 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false

View File

@@ -1,35 +0,0 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.PREFER_PROJECT)
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}
include(":app")

34
mobile/ios/.gitignore vendored
View File

@@ -1,34 +0,0 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3

View File

@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>

View File

@@ -1 +0,0 @@
#include "Generated.xcconfig"

View File

@@ -1 +0,0 @@
#include "Generated.xcconfig"

View File

@@ -1,644 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.matchLiveTv;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.matchLiveTv.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.matchLiveTv.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.matchLiveTv.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.matchLiveTv;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.matchLiveTv;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@@ -1,119 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@@ -1,16 +0,0 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}

View File

@@ -1,122 +0,0 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,23 +0,0 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

View File

@@ -1,5 +0,0 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.

View File

@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>

View File

@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

View File

@@ -1,70 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Match Live TV</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>match_live_tv</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View File

@@ -1 +0,0 @@
#import "GeneratedPluginRegistrant.h"

View File

@@ -1,6 +0,0 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}

View File

@@ -1,12 +0,0 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}

View File

@@ -1,84 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../features/auth/login_screen.dart';
import '../features/auth/splash_screen.dart';
import '../features/camera_mode/camera_screen.dart';
import '../features/archive/archive_screen.dart';
import '../features/matches/matches_screen.dart';
import '../features/setup_wizard/wizard_shell.dart';
import '../providers/auth_provider.dart';
/// Notifica GoRouter solo su cambi auth rilevanti (non su ogni loading tick).
class _AuthRefreshNotifier extends ChangeNotifier {
_AuthRefreshNotifier(this._ref) {
_ref.listen<AuthState>(authProvider, (prev, next) {
final authChanged = prev?.isAuthenticated != next.isAuthenticated;
if (authChanged) notifyListeners();
});
}
final Ref _ref;
}
final routerProvider = Provider<GoRouter>((ref) {
final refresh = _AuthRefreshNotifier(ref);
return GoRouter(
initialLocation: '/',
refreshListenable: refresh,
redirect: (context, state) {
final auth = ref.read(authProvider);
final loggingIn = state.matchedLocation == '/login';
final onSplash = state.matchedLocation == '/';
if (onSplash) return null;
if (!auth.isAuthenticated && !loggingIn) {
return '/login';
}
if (auth.isAuthenticated && loggingIn) {
return '/matches';
}
return null;
},
routes: [
GoRoute(
path: '/',
builder: (context, state) => const SplashScreen(),
),
GoRoute(
path: '/login',
builder: (context, state) => LoginScreen(
inviteToken: state.uri.queryParameters['invite'],
),
),
GoRoute(
path: '/matches',
builder: (context, state) => const MatchesScreen(),
),
GoRoute(
path: '/archive',
builder: (context, state) => const ArchiveScreen(),
),
GoRoute(
path: '/setup/:matchId/:step',
builder: (context, state) {
final matchId = state.pathParameters['matchId']!;
final step = int.tryParse(state.pathParameters['step'] ?? '1') ?? 1;
return WizardShell(matchId: matchId, step: step);
},
),
GoRoute(
path: '/session/:id/camera',
builder: (context, state) {
final sessionId = state.pathParameters['id']!;
return CameraScreen(sessionId: sessionId);
},
),
],
);
});

View File

@@ -1,93 +0,0 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
/// Brand Match Live TV — dark theme, rosso #FF2D2D, Barlow Condensed.
class AppTheme {
AppTheme._();
static const Color primaryRed = Color(0xFFFF2D2D);
static const Color background = Color(0xFF0A0A0A);
static const Color surface = Color(0xFF1E1E1E);
static const Color surfaceElevated = Color(0xFF2A2A2A);
static const Color accentYellow = Color(0xFFF5C518);
static const Color successGreen = Color(0xFF22C55E);
static const Color textSecondary = Color(0xFF9CA3AF);
static TextTheme get _textTheme {
final base = GoogleFonts.barlowCondensedTextTheme(
ThemeData.dark().textTheme,
);
return base.copyWith(
displayLarge: base.displayLarge?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: -0.5,
),
displayMedium: base.displayMedium?.copyWith(fontWeight: FontWeight.w900),
displaySmall: base.displaySmall?.copyWith(fontWeight: FontWeight.w900),
headlineLarge: base.headlineLarge?.copyWith(fontWeight: FontWeight.w900),
headlineMedium: base.headlineMedium?.copyWith(fontWeight: FontWeight.w800),
headlineSmall: base.headlineSmall?.copyWith(fontWeight: FontWeight.w800),
titleLarge: base.titleLarge?.copyWith(fontWeight: FontWeight.w700),
titleMedium: base.titleMedium?.copyWith(fontWeight: FontWeight.w700),
labelLarge: base.labelLarge?.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: 1.2,
),
bodyLarge: base.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
bodyMedium: base.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: textSecondary,
),
);
}
static ThemeData get dark {
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: background,
colorScheme: const ColorScheme.dark(
primary: primaryRed,
secondary: accentYellow,
surface: surface,
error: primaryRed,
onPrimary: Colors.white,
onSecondary: Colors.black,
onSurface: Colors.white,
),
textTheme: _textTheme,
appBarTheme: AppBarTheme(
backgroundColor: background,
elevation: 0,
centerTitle: true,
titleTextStyle: _textTheme.titleLarge?.copyWith(color: Colors.white),
iconTheme: const IconThemeData(color: Colors.white),
),
cardTheme: CardThemeData(
color: surface,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: surfaceElevated,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
labelStyle: _textTheme.bodyMedium,
hintStyle: _textTheme.bodyMedium,
),
segmentedButtonTheme: SegmentedButtonThemeData(
style: ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return primaryRed;
return surfaceElevated;
}),
foregroundColor: WidgetStateProperty.all(Colors.white),
),
),
dividerColor: surfaceElevated,
);
}
}

View File

@@ -1,64 +0,0 @@
import 'package:shared_preferences/shared_preferences.dart';
import '../shared/models/user.dart';
class AuthStorage {
static const _keyAccess = 'auth_access_token';
static const _keyRefresh = 'auth_refresh_token';
static const _keyUserId = 'auth_user_id';
static const _keyUserEmail = 'auth_user_email';
static const _keyUserName = 'auth_user_name';
static const _keyUserRole = 'auth_user_role';
static Future<({User user, String accessToken, String refreshToken})?> load() async {
final prefs = await SharedPreferences.getInstance();
final access = prefs.getString(_keyAccess);
final refresh = prefs.getString(_keyRefresh);
final userId = prefs.getString(_keyUserId);
final email = prefs.getString(_keyUserEmail);
final name = prefs.getString(_keyUserName);
if (access == null ||
access.isEmpty ||
refresh == null ||
refresh.isEmpty ||
userId == null ||
email == null ||
name == null) {
return null;
}
return (
user: User(
id: userId,
email: email,
name: name,
role: prefs.getString(_keyUserRole) ?? 'coach',
),
accessToken: access,
refreshToken: refresh,
);
}
static Future<void> save({
required User user,
required String accessToken,
required String refreshToken,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyAccess, accessToken);
await prefs.setString(_keyRefresh, refreshToken);
await prefs.setString(_keyUserId, user.id);
await prefs.setString(_keyUserEmail, user.email);
await prefs.setString(_keyUserName, user.name);
await prefs.setString(_keyUserRole, user.role);
}
static Future<void> clear() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_keyAccess);
await prefs.remove(_keyRefresh);
await prefs.remove(_keyUserId);
await prefs.remove(_keyUserEmail);
await prefs.remove(_keyUserName);
await prefs.remove(_keyUserRole);
}
}

View File

@@ -1,24 +0,0 @@
/// Configurazione globale dell'app Match Live TV.
class AppConfig {
AppConfig._();
/// URL base API Rails. Default emulatore Android → host localhost.
static const String apiBaseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://10.0.2.2:3000',
);
static String get apiV1 => '$apiBaseUrl/api/v1';
static String get cableUrl {
final uri = Uri.parse(apiBaseUrl);
final wsScheme = uri.scheme == 'https' ? 'wss' : 'ws';
final defaultPort = uri.scheme == 'https' ? 443 : 80;
final port = uri.hasPort ? uri.port : defaultPort;
// Evita :443/:80 espliciti — alcuni client WebSocket/NPM li gestiscono male
if (port == defaultPort) {
return '$wsScheme://${uri.host}/cable';
}
return '$wsScheme://${uri.host}:$port/cable';
}
}

View File

@@ -1,77 +0,0 @@
import 'dart:async';
import 'package:app_links/app_links.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../features/matches/team_providers.dart';
import 'invite_storage.dart';
/// Gestisce link invito (join) e ritorno OAuth YouTube dall'app.
class DeepLinkListener extends ConsumerStatefulWidget {
const DeepLinkListener({super.key, required this.child});
final Widget child;
@override
ConsumerState<DeepLinkListener> createState() => _DeepLinkListenerState();
}
class _DeepLinkListenerState extends ConsumerState<DeepLinkListener> {
final _appLinks = AppLinks();
StreamSubscription<Uri>? _sub;
@override
void initState() {
super.initState();
_handleInitialLink();
_sub = _appLinks.uriLinkStream.listen(_onUri, onError: (_) {});
}
Future<void> _handleInitialLink() async {
final uri = await _appLinks.getInitialLink();
if (uri != null) await _onUri(uri);
}
Future<void> _onUri(Uri uri) async {
final inviteToken = _extractInviteToken(uri);
if (inviteToken != null) {
await InviteStorage.saveToken(inviteToken);
if (!mounted) return;
context.go('/login?invite=$inviteToken');
return;
}
if (uri.scheme == 'matchlivetv' && uri.host == 'youtube-connected') {
ref.invalidate(teamsProvider);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Canale YouTube collegato. Puoi selezionare YouTube Live.'),
duration: Duration(seconds: 4),
),
);
}
}
String? _extractInviteToken(Uri uri) {
if (uri.scheme == 'matchlivetv' && uri.host == 'join') {
final segment = uri.pathSegments.isNotEmpty ? uri.pathSegments.first : uri.path.replaceFirst('/', '');
return segment.isEmpty ? null : segment;
}
if (uri.pathSegments.length >= 2 && uri.pathSegments.first == 'join') {
return uri.pathSegments[1];
}
return null;
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
}

View File

@@ -1,20 +0,0 @@
import 'package:shared_preferences/shared_preferences.dart';
class InviteStorage {
static const _keyToken = 'pending_invite_token';
static Future<void> saveToken(String token) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyToken, token);
}
static Future<String?> readToken() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyToken);
}
static Future<void> clear() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_keyToken);
}
}

View File

@@ -1,22 +0,0 @@
import 'package:shared_preferences/shared_preferences.dart';
class TeamSelectionStorage {
static const _keySelectedTeamId = 'selected_team_id';
static Future<String?> load() async {
final prefs = await SharedPreferences.getInstance();
final id = prefs.getString(_keySelectedTeamId);
if (id == null || id.isEmpty) return null;
return id;
}
static Future<void> save(String teamId) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keySelectedTeamId, teamId);
}
static Future<void> clear() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_keySelectedTeamId);
}
}

View File

@@ -1,216 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../app/theme.dart';
import '../../shared/api_client.dart';
import '../../shared/models/recording_item.dart';
import '../../shared/premium_dialog.dart';
import '../matches/team_providers.dart';
class ArchiveScreen extends ConsumerWidget {
const ArchiveScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final teamAsync = ref.watch(activeTeamProvider);
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Replay'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/matches'),
),
),
body: teamAsync.when(
loading: () => const Center(
child: CircularProgressIndicator(color: AppTheme.primaryRed),
),
error: (e, _) => Center(child: Text('Errore: $e')),
data: (team) {
if (team == null) {
return const Center(child: Text('Nessuna squadra'));
}
if (!team.canUseRecordings) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Replay disponibili con Premium Light o Full',
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Light: 30 giorni · Full: 90 giorni',
style: theme.textTheme.bodyMedium?.copyWith(color: Colors.white70),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
FilledButton(
onPressed: () => showPremiumRequiredDialog(
context,
message: 'Salva e rivedi le gare con il piano Premium.',
billingUrl: team.billingUrl,
),
style: FilledButton.styleFrom(
backgroundColor: AppTheme.primaryRed,
),
child: const Text('Scopri Premium'),
),
],
),
),
);
}
final recsAsync = ref.watch(recordingsProvider(team.id));
return recsAsync.when(
loading: () => const Center(
child: CircularProgressIndicator(color: AppTheme.primaryRed),
),
error: (e, _) => Center(child: Text('$e')),
data: (recs) {
if (recs.isEmpty) {
return const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'Nessun replay ancora.\nAl termine delle dirette Premium le registrazioni compaiono qui.',
textAlign: TextAlign.center,
),
),
);
}
final df = DateFormat('d MMM yyyy · HH:mm', 'it_IT');
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: recs.length,
itemBuilder: (context, i) {
final r = recs[i];
final when = r.recordedAt ?? r.endedAt;
final subtitle = [
if (when != null) df.format(when.toLocal()),
if (r.durationLabel != null) r.durationLabel,
if (r.viewsLabel != null) r.viewsLabel,
r.statusLabel,
if (r.expiresAt != null)
'Scade ${DateFormat('d MMM', 'it_IT').format(r.expiresAt!.toLocal())}',
].whereType<String>().join(' · ');
return Card(
color: AppTheme.surface,
child: ListTile(
leading: r.thumbnailUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.network(
r.thumbnailUrl!,
width: 72,
height: 40,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
const Icon(Icons.movie),
),
)
: const Icon(Icons.movie_outlined, size: 40),
title: Text(r.title),
subtitle: Text(subtitle),
isThreeLine: true,
trailing: r.isProcessing
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: PopupMenuButton<String>(
onSelected: (action) => _onMenuAction(
context,
ref,
action,
r,
team.canDownloadOnPhone,
),
itemBuilder: (_) => [
if (r.isReady)
const PopupMenuItem(
value: 'play',
child: Text('Guarda replay'),
),
if (r.isReady && r.downloadEnabled && team.canDownloadOnPhone)
const PopupMenuItem(
value: 'download',
child: Text('Scarica MP4'),
),
if (r.youtubeWatchUrl != null)
const PopupMenuItem(
value: 'youtube',
child: Text('Apri su YouTube'),
),
],
),
onTap: r.isReady ? () => _openReplay(r.replayUrl) : null,
),
);
},
);
},
);
},
),
);
}
Future<void> _openReplay(String? url) async {
if (url == null) return;
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
}
}
Future<void> _onMenuAction(
BuildContext context,
WidgetRef ref,
String action,
RecordingItem r,
bool canDownload,
) async {
switch (action) {
case 'play':
await _openReplay(r.replayUrl);
break;
case 'download':
if (!canDownload) return;
try {
final url = await ref.read(apiClientProvider).fetchRecordingDownloadUrl(r.id);
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Download: $e')),
);
}
}
break;
case 'youtube':
final yt = r.youtubeWatchUrl;
if (yt == null) return;
final uri = Uri.parse(yt);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
break;
}
}
}

View File

@@ -1,267 +0,0 @@
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../app/theme.dart';
import '../../core/invite_storage.dart';
import '../../features/matches/team_providers.dart';
import '../../providers/auth_provider.dart';
import '../../shared/api_client.dart';
import '../../shared/models/invite_preview.dart';
import '../../shared/widgets/match_live_wordmark.dart';
import '../../shared/widgets/primary_cta.dart';
import '../../shared/widgets/screen_insets.dart';
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key, this.inviteToken});
final String? inviteToken;
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController(text: 'password123');
bool _obscure = true;
InvitePreview? _invite;
String? _inviteToken;
bool _loadingInvite = false;
@override
void initState() {
super.initState();
_bootstrapInvite();
}
Future<void> _bootstrapInvite() async {
_inviteToken = widget.inviteToken ?? await InviteStorage.readToken();
if (_inviteToken == null) return;
setState(() => _loadingInvite = true);
try {
final preview = await ApiClient().fetchInvitePreview(_inviteToken!);
if (!mounted) return;
setState(() {
_invite = preview;
if (preview.email.isNotEmpty) {
_emailController.text = preview.email;
}
});
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invito non valido o scaduto')),
);
}
await InviteStorage.clear();
} finally {
if (mounted) setState(() => _loadingInvite = false);
}
}
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _acceptInviteIfNeeded(ApiClient client) async {
final token = _inviteToken;
if (token == null) return;
try {
final message = await client.acceptInvitation(token);
await InviteStorage.clear();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
} on DioException catch (e) {
final err = e.response?.data;
final msg = err is Map ? (err['error'] as String?) : null;
if (mounted && msg != null) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
rethrow;
}
}
Future<void> _login() async {
if (!_formKey.currentState!.validate()) return;
final connectivity = await Connectivity().checkConnectivity();
if (connectivity.contains(ConnectivityResult.none)) {
ref.read(authProvider.notifier).setError(
'Nessuna connessione. Disattiva la modalità aereo e verifica WiFi.',
);
return;
}
ref.read(authProvider.notifier).setLoading(true);
try {
final client = ApiClient();
final result = await client.login(
email: _emailController.text.trim(),
password: _passwordController.text,
);
ref.read(authProvider.notifier).setSession(
user: result.user,
accessToken: result.tokens.accessToken,
refreshToken: result.tokens.refreshToken,
);
final authed = ApiClient(accessToken: result.tokens.accessToken);
if (_inviteToken != null) {
await _acceptInviteIfNeeded(authed);
}
ref.invalidate(teamsProvider);
ref.invalidate(matchesProvider);
ref.read(authProvider.notifier).setLoading(false);
if (mounted) context.go('/matches');
return;
} on DioException catch (e) {
if (e.response?.statusCode == 422) {
ref.read(authProvider.notifier).setLoading(false);
return;
}
final message = switch (e.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.receiveTimeout ||
DioExceptionType.connectionError =>
'Server non raggiungibile. Verifica che il backend sia avviato (docker compose up).',
DioExceptionType.badResponse when e.response?.statusCode == 401 =>
'Email o password non corretti',
_ => 'Errore di rete: ${e.message}',
};
ref.read(authProvider.notifier).setError(message);
} catch (e) {
ref.read(authProvider.notifier).setError('Errore imprevisto: $e');
}
ref.read(authProvider.notifier).setLoading(false);
}
@override
Widget build(BuildContext context) {
final auth = ref.watch(authProvider);
final theme = Theme.of(context);
return Scaffold(
body: SingleChildScrollView(
padding: ScreenInsets.contentPadding(context, horizontal: 24, vertical: 16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 32),
const Center(child: MatchLiveWordmark(showSlogan: true)),
const SizedBox(height: 48),
if (_loadingInvite)
const Padding(
padding: EdgeInsets.only(bottom: 16),
child: Center(
child: CircularProgressIndicator(color: AppTheme.primaryRed),
),
),
if (_invite != null) ...[
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.primaryRed.withValues(alpha: 0.35)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Invito staff',
style: theme.textTheme.titleMedium?.copyWith(color: AppTheme.primaryRed),
),
const SizedBox(height: 6),
Text(
'Entra in ${_invite!.teamName} come responsabile trasmissione.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 4),
Text(
'Accedi con ${_invite!.email}',
style: theme.textTheme.bodySmall,
),
],
),
),
const SizedBox(height: 20),
],
Text(
'ACCEDI',
style: theme.textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
_invite != null
? 'Usa l\'email dell\'invito, poi collega YouTube e vai in diretta'
: 'Gestisci le dirette della tua squadra',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
readOnly: _invite != null,
decoration: const InputDecoration(
labelText: 'Email',
hintText: 'coach@squadra.it',
),
validator: (v) =>
v == null || v.isEmpty ? 'Inserisci l\'email' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _obscure,
decoration: InputDecoration(
labelText: 'Password',
suffixIcon: IconButton(
icon: Icon(
_obscure ? Icons.visibility : Icons.visibility_off,
),
onPressed: () => setState(() => _obscure = !_obscure),
),
),
validator: (v) =>
v == null || v.isEmpty ? 'Inserisci la password' : null,
),
if (auth.error != null) ...[
const SizedBox(height: 12),
Text(
auth.error!,
style: theme.textTheme.bodyMedium?.copyWith(
color: AppTheme.primaryRed,
),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 32),
PrimaryCta(
label: _invite != null ? 'ACCEDI E ACCETTA INVITO' : 'ACCEDI',
loading: auth.loading,
onPressed: _login,
),
],
),
),
),
);
}
}

View File

@@ -1,68 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../app/theme.dart';
import '../../providers/auth_provider.dart';
import '../../shared/widgets/match_live_wordmark.dart';
import '../../shared/widgets/screen_insets.dart';
/// Splash con wordmark e slogan.
class SplashScreen extends ConsumerStatefulWidget {
const SplashScreen({super.key});
@override
ConsumerState<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends ConsumerState<SplashScreen> {
@override
void initState() {
super.initState();
_bootstrap();
}
Future<void> _bootstrap() async {
final notifier = ref.read(authProvider.notifier);
await notifier.restoreSession();
await Future<void>.delayed(const Duration(milliseconds: 400));
if (!mounted) return;
var auth = ref.read(authProvider);
if (auth.isAuthenticated) {
final ok = await notifier.validateOrRefreshSession();
if (!mounted) return;
auth = ref.read(authProvider);
if (!ok || !auth.isAuthenticated) {
context.go('/login');
return;
}
}
if (!mounted) return;
if (auth.isAuthenticated) {
context.go('/matches');
} else {
context.go('/login');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.background,
body: AppSafeArea(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const MatchLiveWordmark(showSlogan: true),
const SizedBox(height: 48),
const CircularProgressIndicator(color: AppTheme.primaryRed),
],
),
),
),
);
}
}

View File

@@ -1,923 +0,0 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:battery_plus/battery_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../../app/theme.dart';
import '../../platform/streaming_channel.dart';
import '../../platform/streaming_preview.dart';
import '../../providers/match_rules_provider.dart';
import '../../providers/score_provider.dart';
import '../../providers/score_sync_provider.dart';
import '../../shared/api_client.dart';
import '../../shared/regia_share.dart';
import '../../shared/watch_share.dart';
import '../../shared/models/score_state.dart';
import '../../providers/session_provider.dart';
import '../../shared/websocket_service.dart';
import '../../shared/widgets/camera_compact_metrics.dart';
import '../../shared/widgets/live_score_controls.dart';
import '../../shared/widgets/destructive_cta.dart';
import '../../shared/widgets/end_stream_dialog.dart';
import '../../shared/widgets/live_badge.dart';
import '../../shared/widgets/screen_insets.dart';
class CameraScreen extends ConsumerStatefulWidget {
const CameraScreen({super.key, required this.sessionId});
final String sessionId;
@override
ConsumerState<CameraScreen> createState() => _CameraScreenState();
}
class _CameraScreenState extends ConsumerState<CameraScreen> {
static const _previewKey = ValueKey<String>('camera_streaming_preview');
Timer? _elapsedTimer;
Timer? _sessionSyncTimer;
Timer? _telemetryTimer;
Timer? _statsTimer;
Timer? _scorePullTimer;
int _batteryLevel = 100;
int? _lastDelta;
int _prevHomePoints = 0;
int _prevAwayPoints = 0;
double _bitrateMbps = 0;
int _fps = 0;
String _networkType = '4G';
int _viewerCount = 0;
StreamSubscription<Map<String, dynamic>>? _metricsSub;
bool _pauseInFlight = false;
bool _resumeInFlight = false;
String? _lastStreamErrorShown;
@override
void initState() {
super.initState();
WakelockPlus.enable();
_lockOrientations();
_bootstrap();
_readBattery();
_elapsedTimer = Timer.periodic(const Duration(seconds: 1), (_) {
final session = ref.read(sessionProvider).session;
if (session?.startedAt != null) {
ref.read(sessionProvider.notifier).setElapsed(
DateTime.now().difference(session!.startedAt!),
);
} else {
ref.read(sessionProvider.notifier).setElapsed(Duration.zero);
}
});
_sessionSyncTimer = Timer.periodic(const Duration(seconds: 15), (_) {
unawaited(_syncSessionStartedAt());
});
_telemetryTimer = Timer.periodic(const Duration(seconds: 10), (_) => _sendTelemetry());
_statsTimer = Timer.periodic(const Duration(seconds: 30), (_) => _fetchStats());
_scorePullTimer = Timer.periodic(const Duration(seconds: 2), (_) {
unawaited(ref.read(scoreSyncProvider).pullFromServer());
});
}
Map<String, dynamic> _streamVideoParams(BuildContext context) {
final portrait = MediaQuery.orientationOf(context) == Orientation.portrait;
return {
'width': portrait ? 720 : 1280,
'height': portrait ? 1280 : 720,
'portrait': portrait,
};
}
Future<bool> _ensurePermissions() async {
final camera = await Permission.camera.request();
final mic = await Permission.microphone.request();
if (camera.isGranted && mic.isGranted) return true;
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Servono permessi fotocamera e microfono per la diretta'),
),
);
}
return false;
}
Future<bool> _bindPreviewWithRetry({int attempts = 12}) async {
for (var i = 0; i < attempts; i++) {
final ok = await StreamingChannel.bindPreview();
if (ok) return true;
await Future<void>.delayed(const Duration(milliseconds: 100));
}
return false;
}
Future<void> _bootstrap() async {
try {
if (!await _ensurePermissions()) return;
final client = ref.read(apiClientProvider);
var session = await client.fetchSession(widget.sessionId);
if (session.isTerminal) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Questa diretta è già terminata'),
),
);
context.go('/matches');
}
return;
}
final match = await client.fetchMatch(session.matchId);
final statusAtOpen = session.status;
ref.read(sessionProvider.notifier).setSession(session, match: match);
if (session.score != null) {
ref.read(scoreProvider.notifier).reset(session.score!);
}
// idle → startSession; wizard può aver già messo connecting prima della camera.
var justStartedSession = false;
if (session.status == 'idle') {
session = await client.startSession(widget.sessionId);
ref.read(sessionProvider.notifier).setSession(session, match: match);
justStartedSession = true;
}
final ws = ref.read(websocketServiceProvider);
await ws.connect(sessionId: widget.sessionId, deviceRole: 'camera');
await Future<void>.delayed(const Duration(milliseconds: 300));
final previewReady = await _bindPreviewWithRetry();
if (!previewReady && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Anteprima camera non pronta — video non disponibile. Riapri per riprovare.'),
duration: Duration(seconds: 4),
),
);
// Non return: sessione e score sono già settati, WebSocket connesso.
// L'utente può ancora gestire il punteggio anche senza preview video.
}
final fresh = await client.fetchSession(widget.sessionId);
ref.read(sessionProvider.notifier).setSession(fresh, match: match);
if (fresh.score != null) {
ref.read(scoreProvider.notifier).reset(fresh.score!);
}
if (!mounted) return;
// Avvio RTMP: wizard chiama startSession prima della camera → stato "connecting".
// In pausa all'apertura: niente auto-publish (serve "RIPRENDI DIRETTA").
final wasPausedAtOpen = statusAtOpen == 'paused';
final shouldAutoPublish = previewReady &&
fresh.rtmpIngestUrl != null &&
!wasPausedAtOpen &&
fresh.status != 'paused' &&
(justStartedSession ||
fresh.status == 'connecting' ||
fresh.status == 'live' ||
fresh.status == 'reconnecting');
if (shouldAutoPublish) {
// Reset motore nativo (sessione precedente / RECONNECTING blocca startStream).
try {
await StreamingChannel.stopStream();
} catch (_) {}
await Future<void>.delayed(const Duration(milliseconds: 400));
if (!mounted) return;
final video = _streamVideoParams(context);
await StreamingChannel.startStream(
rtmpUrl: fresh.rtmpIngestUrl!,
targetBitrate: fresh.targetBitrate,
targetFps: 30,
width: video['width'] as int,
height: video['height'] as int,
portrait: video['portrait'] as bool,
);
if (mounted && (justStartedSession || fresh.status == 'connecting')) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Diretta avviata — connessione al server…'),
duration: Duration(seconds: 3),
),
);
}
}
_metricsSub = StreamingChannel.metricsStream
.receiveBroadcastStream()
.map((e) => Map<String, dynamic>.from(e as Map))
.listen((event) {
if (event['type'] != 'metrics') return;
final metrics = Map<String, dynamic>.from(
(event['metrics'] as Map?)?.map((k, v) => MapEntry(k.toString(), v)) ?? {},
);
final streamState = metrics['state'] as String?;
final lastError = metrics['lastError'] as String?;
setState(() {
_bitrateMbps = (metrics['bitrateKbps'] as num? ?? 0) / 1000;
_fps = (metrics['fps'] as num?)?.toInt() ?? 0;
});
if (lastError != null &&
lastError.isNotEmpty &&
lastError != _lastStreamErrorShown &&
(streamState == 'ERROR' || streamState == 'RECONNECTING') &&
mounted) {
_lastStreamErrorShown = lastError;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Connessione RTMP: $lastError'),
duration: const Duration(seconds: 5),
),
);
}
});
} on PlatformException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Errore streaming: ${e.message ?? e.code}')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Errore avvio camera: $e')),
);
}
}
}
Future<void> _readBattery() async {
final level = await Battery().batteryLevel;
if (mounted) setState(() => _batteryLevel = level);
}
Future<void> _sendTelemetry() async {
try {
await ref.read(apiClientProvider).postTelemetry(
sessionId: widget.sessionId,
deviceRole: 'camera',
batteryLevel: _batteryLevel,
networkType: _networkType,
currentBitrate: (_bitrateMbps * 1_000_000).round(),
fps: _fps,
);
} catch (_) {}
}
Future<void> _fetchStats() async {
try {
final stats = await ref.read(apiClientProvider).youtubeStats(widget.sessionId);
if (mounted) setState(() => _viewerCount = stats.concurrentViewers);
} catch (_) {}
}
/// Il server imposta `started_at` solo al primo go_live; sincronizza per il timer LIVE.
Future<void> _syncSessionStartedAt() async {
final current = ref.read(sessionProvider).session;
if (current == null || current.startedAt != null || !current.isLive) return;
try {
final fresh = await ref.read(apiClientProvider).fetchSession(widget.sessionId);
if (!mounted || fresh.startedAt == null) return;
ref.read(sessionProvider.notifier).setSession(
fresh,
match: ref.read(sessionProvider).match,
);
} catch (_) {}
}
Future<void> _lockOrientations() async {
await SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
DeviceOrientation.portraitUp,
]);
}
Future<void> _unlockOrientation() async {
await SystemChrome.setPreferredOrientations(DeviceOrientation.values);
}
Future<void> _shareRegia() async {
final match = ref.read(sessionProvider).match;
await shareRegiaLink(
context,
ref,
sessionId: widget.sessionId,
shareSubject:
'Regia — ${match?.teamName ?? 'Casa'} vs ${match?.opponentName ?? 'Ospiti'}',
);
}
Future<void> _shareWatch() async {
final session = ref.read(sessionProvider).session;
final match = ref.read(sessionProvider).match;
if (session == null) return;
await shareWatchLink(
context,
session: session,
title:
'Diretta — ${match?.teamName ?? 'Casa'} vs ${match?.opponentName ?? 'Ospiti'}',
);
}
void _showShareMenu() {
final session = ref.read(sessionProvider).session;
if (session == null) return;
final isYoutube = session.platform == 'youtube';
showModalBottomSheet<void>(
context: context,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.live_tv),
title: Text(isYoutube ? 'Condividi link YouTube' : 'Condividi link diretta'),
onTap: () {
Navigator.pop(ctx);
_shareWatch();
},
),
ListTile(
leading: const Icon(Icons.scoreboard),
title: const Text('Condividi link regia (punteggio)'),
onTap: () {
Navigator.pop(ctx);
_shareRegia();
},
),
],
),
),
);
}
Future<void> _leaveSession() async {
try {
await StreamingChannel.stopStream();
} catch (_) {}
try {
await ref.read(websocketServiceProvider).disconnect();
} catch (_) {}
if (mounted) context.go('/matches');
}
/// Pausa temporanea: il server manda la copertina, la camera resta aperta.
Future<void> _pauseStream() async {
if (_pauseInFlight) return;
_pauseInFlight = true;
try {
final match = ref.read(sessionProvider).match;
final session =
await ref.read(apiClientProvider).pauseSession(widget.sessionId);
ref.read(sessionProvider.notifier).setSession(session, match: match);
await StreamingChannel.pauseStream();
if (mounted) {
setState(() {
_bitrateMbps = 0;
_fps = 0;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Diretta in pausa — gli spettatori vedono la copertina'),
duration: Duration(seconds: 3),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Pausa sessione: $e')),
);
}
} finally {
_pauseInFlight = false;
}
}
Future<void> _applyRemotePause() async {
try {
await StreamingChannel.pauseStream();
if (mounted) {
setState(() {
_bitrateMbps = 0;
_fps = 0;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Pausa dalla regia — trasmissione fermata'),
duration: Duration(seconds: 3),
),
);
}
} catch (_) {}
}
Future<void> _applyRemoteResume() async {
if (_resumeInFlight) return;
final session = ref.read(sessionProvider).session;
if (session == null || session.rtmpIngestUrl == null) return;
_resumeInFlight = true;
try {
final isPortrait = mounted &&
MediaQuery.orientationOf(context) == Orientation.portrait;
final video = _streamVideoParams(context);
await StreamingChannel.resumeStream(
rtmpUrl: session.rtmpIngestUrl!,
targetBitrate: session.targetBitrate,
targetFps: 30,
width: video['width'] as int,
height: video['height'] as int,
portrait: video['portrait'] as bool,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Ripresa dalla regia — di nuovo in onda'),
duration: Duration(seconds: 3),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ripresa RTMP: $e')),
);
}
} finally {
_resumeInFlight = false;
}
}
Future<void> _resumeStream() async {
if (_resumeInFlight) return;
_resumeInFlight = true;
try {
final match = ref.read(sessionProvider).match;
var session =
await ref.read(apiClientProvider).resumeSession(widget.sessionId);
ref.read(sessionProvider.notifier).setSession(session, match: match);
if (session.rtmpIngestUrl != null) {
if (!mounted) return;
final video = _streamVideoParams(context);
await StreamingChannel.resumeStream(
rtmpUrl: session.rtmpIngestUrl!,
targetBitrate: session.targetBitrate,
targetFps: 30,
width: video['width'] as int,
height: video['height'] as int,
portrait: video['portrait'] as bool,
);
}
session = await ref.read(apiClientProvider).fetchSession(widget.sessionId);
ref.read(sessionProvider.notifier).setSession(session, match: match);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Diretta ripresa — di nuovo in onda'),
duration: Duration(seconds: 3),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ripresa diretta: $e')),
);
}
} finally {
_resumeInFlight = false;
}
}
/// Chiusura definitiva: sessione ended, pagina web senza player.
Future<void> _closeStreamPermanently() async {
if (!mounted) return;
final ok = await confirmEndStream(context);
if (!ok || !mounted) return;
try {
await StreamingChannel.stopStream();
} catch (_) {}
try {
ref.read(websocketServiceProvider).sendCommand('stop_stream');
} catch (_) {}
try {
await ref.read(apiClientProvider).stopSession(widget.sessionId);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Chiusura sessione: $e')),
);
}
}
await _leaveSession();
}
void _trackScoreDelta(ScoreState score) {
if (score.homePoints != _prevHomePoints) {
_lastDelta = score.homePoints - _prevHomePoints;
} else if (score.awayPoints != _prevAwayPoints) {
_lastDelta = score.awayPoints - _prevAwayPoints;
}
_prevHomePoints = score.homePoints;
_prevAwayPoints = score.awayPoints;
}
@override
void dispose() {
_elapsedTimer?.cancel();
_sessionSyncTimer?.cancel();
_telemetryTimer?.cancel();
_statsTimer?.cancel();
_scorePullTimer?.cancel();
_metricsSub?.cancel();
unawaited(StreamingChannel.stopStream());
WakelockPlus.disable();
_unlockOrientation();
super.dispose();
}
@override
Widget build(BuildContext context) {
ref.listen<String?>(
sessionProvider.select((s) => s.session?.status),
(previous, next) {
if (next == 'paused' && previous != 'paused' && !_pauseInFlight) {
_applyRemotePause();
}
if (previous == 'paused' &&
(next == 'connecting' || next == 'live') &&
!_resumeInFlight) {
_applyRemoteResume();
}
if (next == 'ended' && previous != 'ended' && mounted) {
_leaveSession();
}
},
);
final sessionState = ref.watch(sessionProvider);
final score = ref.watch(scoreProvider);
_trackScoreDelta(score);
final match = sessionState.match;
final sessionStatus = sessionState.session?.status ?? 'live';
final isPaused = sessionStatus == 'paused';
final homeName = match?.teamName ?? 'HOME';
final awayName = match?.opponentName ?? 'AWAY';
final isLandscape =
MediaQuery.orientationOf(context) == Orientation.landscape;
final rules = ref.watch(matchScoringRulesProvider);
final pointsTarget = rules.pointsTarget(score.currentSet);
// Preview a tutto schermo; controlli in overlay sopra (AndroidView nel layout Flutter).
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: NativeStreamingPreview(
key: _previewKey,
showGrid: true,
platformLabel: isPaused ? 'PAUSA' : 'LIVE',
),
),
if (isPaused)
Positioned(
left: 20,
right: 20,
bottom: isLandscape ? 100 : 200,
child: Center(
child: FilledButton.icon(
onPressed: _resumeStream,
icon: const Icon(Icons.play_arrow, size: 28),
label: const Text(
'RIPRENDI DIRETTA',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
style: FilledButton.styleFrom(
backgroundColor: AppTheme.primaryRed,
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 16),
),
),
),
),
if (isLandscape)
_LandscapeOverlay(
elapsed: sessionState.elapsed,
batteryLevel: _batteryLevel,
homeName: homeName,
awayName: awayName,
score: score,
lastDelta: _lastDelta,
networkType: _networkType,
bitrateMbps: _bitrateMbps,
fps: _fps,
viewerCount: _viewerCount,
onShare: _showShareMenu,
isPaused: isPaused,
onPause: _pauseStream,
onResume: _resumeStream,
onCloseStream: _closeStreamPermanently,
pointsTarget: pointsTarget,
)
else
_PortraitOverlay(
elapsed: sessionState.elapsed,
batteryLevel: _batteryLevel,
homeName: homeName,
awayName: awayName,
score: score,
networkType: _networkType,
bitrateMbps: _bitrateMbps,
fps: _fps,
viewerCount: _viewerCount,
onShare: _showShareMenu,
isPaused: isPaused,
onPause: _pauseStream,
onResume: _resumeStream,
onCloseStream: _closeStreamPermanently,
pointsTarget: pointsTarget,
),
],
),
);
}
}
class _PortraitOverlay extends StatelessWidget {
const _PortraitOverlay({
required this.elapsed,
required this.batteryLevel,
required this.homeName,
required this.awayName,
required this.score,
required this.networkType,
required this.bitrateMbps,
required this.fps,
required this.viewerCount,
required this.onShare,
required this.isPaused,
required this.onPause,
required this.onResume,
required this.onCloseStream,
required this.pointsTarget,
});
final Duration elapsed;
final int batteryLevel;
final String homeName;
final String awayName;
final ScoreState score;
final String networkType;
final double bitrateMbps;
final int fps;
final int viewerCount;
final VoidCallback onShare;
final bool isPaused;
final Future<void> Function() onPause;
final Future<void> Function() onResume;
final Future<void> Function() onCloseStream;
final int pointsTarget;
@override
Widget build(BuildContext context) {
final inset = ScreenInsets.cameraOverlay(context);
final screenH = MediaQuery.sizeOf(context).height;
// Riserva almeno 35% dello schermo all'anteprima camera, il resto ai controlli.
final maxControlsH = screenH * 0.65;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── Barra superiore: badge live + metriche ──────────────────────────
Container(
color: Colors.black54,
padding: EdgeInsets.fromLTRB(inset.left, inset.top, inset.right, 6),
child: Row(
children: [
LiveBadge(elapsed: elapsed, paused: isPaused),
const Spacer(),
CameraCompactMetrics(
networkType: networkType,
bitrateMbps: bitrateMbps,
fps: fps,
batteryLevel: batteryLevel,
viewerCount: viewerCount > 0 ? viewerCount : null,
),
IconButton(
onPressed: onShare,
icon: const Icon(Icons.share, size: 20),
tooltip: 'Condividi link',
color: Colors.white70,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
],
),
),
// ── Area camera (AndroidView a tutto schermo sotto) ───────────────
const Expanded(child: IgnorePointer(child: SizedBox.expand())),
// ── Pannello controlli ───────────────────────────────────────────────
ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxControlsH),
child: Container(
color: AppTheme.background,
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
inset.left,
12,
inset.right,
math.max(inset.bottom, 16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Tabellone + pulsanti punteggio — primo widget, sempre visibile.
LiveScoreControls(
homeName: homeName,
awayName: awayName,
pointsTarget: pointsTarget,
onStopStream: onCloseStream,
),
const SizedBox(height: 12),
// Azioni stream: pausa/riprendi + chiudi.
Row(
children: [
Expanded(
child: isPaused
? FilledButton.icon(
onPressed: onResume,
icon: const Icon(Icons.play_arrow, size: 18),
label: const Text('Riprendi'),
style: FilledButton.styleFrom(
backgroundColor: AppTheme.primaryRed,
),
)
: OutlinedButton.icon(
onPressed: onPause,
icon: const Icon(Icons.pause, size: 18),
label: const Text('Pausa'),
),
),
const SizedBox(width: 8),
Expanded(
child: DestructiveCta(
label: 'Chiudi diretta',
onPressed: () => onCloseStream(),
),
),
],
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: onShare,
icon: const Icon(Icons.share, size: 16),
label: const Text('Condividi link diretta'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white70,
side: const BorderSide(color: Colors.white24),
),
),
],
),
),
),
),
],
);
}
}
class _LandscapeOverlay extends StatelessWidget {
const _LandscapeOverlay({
required this.elapsed,
required this.batteryLevel,
required this.homeName,
required this.awayName,
required this.score,
required this.lastDelta,
required this.networkType,
required this.bitrateMbps,
required this.fps,
required this.viewerCount,
required this.onShare,
required this.isPaused,
required this.onPause,
required this.onResume,
required this.onCloseStream,
required this.pointsTarget,
});
final Duration elapsed;
final int batteryLevel;
final String homeName;
final String awayName;
final ScoreState score;
final int? lastDelta;
final String networkType;
final double bitrateMbps;
final int fps;
final int viewerCount;
final VoidCallback onShare;
final bool isPaused;
final Future<void> Function() onPause;
final Future<void> Function() onResume;
final Future<void> Function() onCloseStream;
final int pointsTarget;
@override
Widget build(BuildContext context) {
final inset = ScreenInsets.cameraOverlay(context);
return Column(
children: [
Padding(
padding: EdgeInsets.fromLTRB(inset.left, inset.top, inset.right, 6),
child: Row(
children: [
LiveBadge(elapsed: elapsed, compact: true, paused: isPaused),
const Spacer(),
CameraCompactMetrics(
networkType: networkType,
bitrateMbps: bitrateMbps,
fps: fps,
batteryLevel: batteryLevel,
viewerCount: viewerCount > 0 ? viewerCount : null,
light: true,
),
],
),
),
const Expanded(child: IgnorePointer(child: SizedBox.expand())),
Container(
color: Colors.black.withValues(alpha: 0.82),
padding: EdgeInsets.fromLTRB(
inset.left,
10,
inset.right,
math.max(inset.bottom, 12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
LiveScoreControls(
compact: true,
homeName: homeName,
awayName: awayName,
lastDelta: lastDelta,
pointsTarget: pointsTarget,
onStopStream: onCloseStream,
),
const SizedBox(height: 8),
Row(
children: [
IconButton(
onPressed: onShare,
icon: const Icon(Icons.share, color: Colors.white),
tooltip: 'Condividi link',
),
Expanded(
child: isPaused
? FilledButton(
onPressed: onResume,
child: const Text('Riprendi', style: TextStyle(fontSize: 11)),
)
: OutlinedButton(
onPressed: onPause,
child: const Text('Pausa', style: TextStyle(fontSize: 11)),
),
),
const SizedBox(width: 8),
Expanded(
child: DestructiveCta(
label: 'Chiudi',
compact: true,
onPressed: () => onCloseStream(),
),
),
],
),
],
),
),
],
);
}
}

View File

@@ -1,134 +0,0 @@
import 'package:flutter/material.dart';
import '../../shared/models/score_state.dart';
import '../../shared/widgets/destructive_cta.dart';
import '../../shared/widgets/live_badge.dart';
import '../../shared/widgets/metric_card.dart';
import '../../shared/widgets/score_overlay_bar.dart';
import '../../platform/streaming_preview.dart';
class CameraScreenLandscape extends StatelessWidget {
const CameraScreenLandscape({
super.key,
required this.elapsed,
required this.batteryLevel,
required this.homeName,
required this.awayName,
required this.score,
required this.lastDelta,
required this.networkType,
required this.bitrateMbps,
required this.fps,
required this.viewerCount,
this.platformLabel = 'LIVE',
required this.onStop,
});
final Duration elapsed;
final int batteryLevel;
final String homeName;
final String awayName;
final ScoreState score;
final int? lastDelta;
final String networkType;
final double bitrateMbps;
final int fps;
final int viewerCount;
final String platformLabel;
final VoidCallback onStop;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [
NativeStreamingPreview(showGrid: false, platformLabel: platformLabel),
Positioned(
top: 12,
left: 12,
child: LiveBadge(elapsed: elapsed, compact: true),
),
Positioned(
top: 12,
right: 12,
child: Row(
children: [
Icon(Icons.battery_std, size: 16, color: Colors.white70),
const SizedBox(width: 4),
Text(
'$batteryLevel%',
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
],
),
),
Positioned(
bottom: 16,
left: 16,
child: ScoreOverlayBar(
homeName: homeName,
awayName: awayName,
homePoints: score.homePoints,
awayPoints: score.awayPoints,
currentSet: score.currentSet,
homeSets: score.homeSets,
awaySets: score.awaySets,
compact: true,
lastDelta: lastDelta,
),
),
Positioned(
bottom: 16,
left: 0,
right: 100,
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
MetricCard(
label: 'Segnale',
value: networkType,
icon: Icons.signal_cellular_alt,
expand: false,
),
const SizedBox(width: 6),
MetricCard(
label: 'Mbps',
value: bitrateMbps.toStringAsFixed(1),
icon: Icons.speed,
expand: false,
),
const SizedBox(width: 6),
MetricCard(
label: 'fps',
value: '$fps',
icon: Icons.movie,
expand: false,
),
const SizedBox(width: 6),
MetricCard(
label: 'Spett.',
value: viewerCount > 0 ? '$viewerCount' : 'LIVE',
icon: Icons.visibility,
expand: false,
),
],
),
),
),
Positioned(
bottom: 16,
right: 16,
child: DestructiveCta(
label: 'INTERROMPI',
compact: true,
onPressed: onStop,
),
),
],
),
);
}
}

View File

@@ -1,158 +0,0 @@
import 'package:flutter/material.dart';
import '../../app/theme.dart';
import '../../shared/models/score_state.dart';
import '../../shared/widgets/destructive_cta.dart';
import '../../shared/widgets/live_badge.dart';
import '../../shared/widgets/metric_card.dart';
import '../../shared/widgets/score_overlay_bar.dart';
import '../../platform/streaming_preview.dart';
class CameraScreenPortrait extends StatelessWidget {
const CameraScreenPortrait({
super.key,
required this.elapsed,
required this.batteryLevel,
required this.homeName,
required this.awayName,
required this.score,
required this.networkType,
required this.bitrateMbps,
required this.fps,
required this.viewerCount,
required this.sessionId,
this.platformLabel = 'LIVE',
required this.onStop,
});
final Duration elapsed;
final int batteryLevel;
final String homeName;
final String awayName;
final ScoreState score;
final String networkType;
final double bitrateMbps;
final int fps;
final int viewerCount;
final String sessionId;
final String platformLabel;
final VoidCallback onStop;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
backgroundColor: AppTheme.background,
body: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
LiveBadge(elapsed: elapsed),
const Spacer(),
const Icon(Icons.battery_std, size: 18, color: AppTheme.textSecondary),
const SizedBox(width: 4),
Text('$batteryLevel%', style: theme.textTheme.labelLarge),
],
),
),
Expanded(
flex: 3,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: NativeStreamingPreview(
showGrid: true,
platformLabel: platformLabel,
),
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ScoreOverlayBar(
homeName: homeName,
awayName: awayName,
homePoints: score.homePoints,
awayPoints: score.awayPoints,
currentSet: score.currentSet,
homeSets: score.homeSets,
awaySets: score.awaySets,
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
MetricCard(
label: 'Segnale',
value: networkType,
icon: Icons.signal_cellular_alt,
),
const SizedBox(width: 8),
MetricCard(
label: 'Mbps',
value: bitrateMbps.toStringAsFixed(1),
icon: Icons.speed,
),
const SizedBox(width: 8),
MetricCard(label: 'fps', value: '$fps', icon: Icons.movie),
],
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
const Icon(Icons.play_circle, color: AppTheme.primaryRed),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(platformLabel, style: theme.textTheme.titleMedium),
Text(
viewerCount > 0
? '$viewerCount spettatori'
: 'IN DIRETTA',
style: theme.textTheme.bodyMedium,
),
],
),
),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Text(
'Registrazione locale attiva: /sd/match_$sessionId',
style: theme.textTheme.bodyMedium?.copyWith(fontSize: 11),
textAlign: TextAlign.center,
),
),
Padding(
padding: const EdgeInsets.all(16),
child: DestructiveCta(label: 'INTERROMPI', onPressed: onStop),
),
],
),
),
);
}
}

View File

@@ -1,596 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../app/theme.dart';
import '../../core/config.dart';
import '../../providers/auth_provider.dart';
import '../../shared/api_client.dart';
import '../../shared/models/match.dart';
import '../../shared/widgets/match_live_wordmark.dart';
import '../../shared/widgets/screen_insets.dart';
import 'resume_session_sheet.dart';
import 'no_team_onboarding.dart';
import 'new_match_sheet.dart';
import 'schedule_match_sheet.dart';
import 'select_match_sheet.dart';
import 'team_picker.dart';
import 'team_providers.dart';
class MatchesScreen extends ConsumerWidget {
const MatchesScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final auth = ref.watch(authProvider);
final teamsAsync = ref.watch(teamsProvider);
final matchesAsync = ref.watch(matchesProvider);
final theme = Theme.of(context);
final dateFormat = DateFormat('EEE d MMM · HH:mm', 'it_IT');
final bottomInset = ScreenInsets.of(context).bottom;
return Scaffold(
appBar: AppBar(
title: const MatchLiveWordmark(compact: true),
actions: [
IconButton(
icon: const Icon(Icons.group_outlined),
tooltip: 'Gestisci staff',
onPressed: () async {
final team = await ref.read(activeTeamProvider.future);
final url = team?.staffManageUrl ?? team?.billingUrl;
if (url != null && await canLaunchUrl(Uri.parse(url))) {
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
},
),
IconButton(
icon: const Icon(Icons.video_library_outlined),
tooltip: 'Archivio',
onPressed: () => context.push('/archive'),
),
IconButton(
icon: const Icon(Icons.logout),
tooltip: 'Esci',
onPressed: () {
ref.read(authProvider.notifier).logout();
context.go('/login');
},
),
],
),
body: teamsAsync.when(
loading: () => const Center(
child: CircularProgressIndicator(color: AppTheme.primaryRed),
),
error: (e, stack) {
final msg = e.toString();
final unauthorized = msg.contains('401');
final timeout = msg.contains('timeout') || msg.contains('Timeout');
final offline = timeout ||
msg.contains('SocketException') ||
msg.contains('Failed host lookup');
String title = 'Errore nel caricamento squadre';
String hint = 'Controlla WiFi o dati mobili e riprova.';
if (unauthorized) {
title = 'Sessione scaduta';
hint = 'Accedi di nuovo con email e password.';
} else if (offline) {
title = 'Server non raggiungibile';
hint = timeout
? 'La connessione è troppo lenta o assente. Verifica la rete (serve almeno qualche Mbps).'
: 'Impossibile contattare ${AppConfig.apiBaseUrl}. Riprova tra poco.';
}
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
title,
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
hint,
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
if (unauthorized)
FilledButton(
onPressed: () {
ref.read(authProvider.notifier).logout();
context.go('/login');
},
child: const Text('VAI AL LOGIN'),
)
else
TextButton(
onPressed: () => ref.invalidate(teamsProvider),
child: const Text('RIPROVA'),
),
],
),
),
);
},
data: (teams) {
if (teams.isEmpty) {
return NoTeamOnboarding(theme: theme);
}
return matchesAsync.when(
loading: () => const Center(
child: CircularProgressIndicator(color: AppTheme.primaryRed),
),
error: (e, _) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Errore nel caricamento', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(
e.toString(),
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
TextButton(
onPressed: () {
ref.invalidate(teamsProvider);
ref.invalidate(matchesProvider);
},
child: const Text('RIPROVA'),
),
],
),
),
),
data: (matches) {
MatchModel? activeMatch;
for (final m in matches) {
if (m.hasActiveSession) {
activeMatch = m;
break;
}
}
return RefreshIndicator(
color: AppTheme.primaryRed,
onRefresh: () async {
ref.invalidate(teamsProvider);
ref.invalidate(matchesProvider);
await ref.read(matchesProvider.future);
},
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ciao, ${auth.user?.name ?? ''}',
style: theme.textTheme.headlineSmall,
),
const SizedBox(height: 4),
Text(
'Scegli una partita programmata o creane una nuova.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => _pickScheduledMatch(context, ref, matches),
icon: const Icon(Icons.playlist_play),
label: const Text('Partita\nprogrammata'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
alignment: Alignment.center,
),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.icon(
onPressed: () => _newMatch(context, ref),
icon: const Icon(Icons.add),
label: const Text('Nuova\npartita'),
style: FilledButton.styleFrom(
backgroundColor: AppTheme.primaryRed,
padding: const EdgeInsets.symmetric(vertical: 14),
alignment: Alignment.center,
),
),
),
],
),
const TeamPickerBar(),
if (activeMatch != null) ...[
const SizedBox(height: 12),
Material(
color: AppTheme.primaryRed.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: () => showResumeSessionSheet(
context,
ref,
match: activeMatch!,
),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
const Icon(
Icons.videocam,
color: AppTheme.primaryRed,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
'Riprendi diretta in corso',
style: theme.textTheme.titleSmall
?.copyWith(
color: AppTheme.primaryRed,
fontWeight: FontWeight.w700,
),
),
Text(
'${activeMatch.teamName} vs ${activeMatch.opponentName}',
style: theme.textTheme.bodySmall,
),
],
),
),
const Icon(
Icons.chevron_right,
color: AppTheme.primaryRed,
),
],
),
),
),
),
],
],
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
child: Text(
matches.isEmpty ? 'Calendario vuoto' : 'Calendario',
style: theme.textTheme.labelLarge?.copyWith(
color: AppTheme.textSecondary,
),
),
),
),
if (matches.isEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Text(
'Nessuna partita ancora. Usa «Nuova partita» per programmare o avviare subito.',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final match = matches[index];
return _MatchCard(
match: match,
dateFormat: dateFormat,
onTap: () => _openMatch(context, ref, match),
onDelete: match.canResumeCamera
? null
: () => _deleteMatch(context, ref, match),
);
},
childCount: matches.length,
),
),
SliverToBoxAdapter(
child: SizedBox(height: 20 + bottomInset),
),
],
),
);
},
);
},
),
);
}
Future<void> _deleteMatch(
BuildContext context,
WidgetRef ref,
MatchModel match,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppTheme.surface,
title: const Text('Elimina partita'),
content: Text(
'Eliminare «${match.teamName} vs ${match.opponentName}»?\n\n'
'L\'operazione non si può annullare.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annulla'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(backgroundColor: AppTheme.primaryRed),
child: const Text('Elimina'),
),
],
),
);
if (confirmed != true || !context.mounted) return;
try {
await ref.read(apiClientProvider).deleteMatch(match.id);
ref.invalidate(matchesProvider);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Partita eliminata')),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Impossibile eliminare: $e')),
);
}
}
}
void _openMatch(BuildContext context, WidgetRef ref, MatchModel match) {
if (match.hasActiveSession) {
showResumeSessionSheet(context, ref, match: match);
} else {
context.push('/setup/${match.id}/1');
}
}
Future<void> _pickScheduledMatch(
BuildContext context,
WidgetRef ref,
List<MatchModel> matches,
) async {
final selectable = matches.where((m) => !m.hasActiveSession).toList();
if (selectable.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Nessuna partita disponibile. Crea una nuova partita.'),
),
);
}
return;
}
final picked = await showSelectMatchSheet(context, matches: matches);
if (picked != null && context.mounted) {
context.push('/setup/${picked.id}/1');
}
}
Future<void> _newMatch(BuildContext context, WidgetRef ref) async {
final choice = await showNewMatchSheet(context);
if (choice == null || !context.mounted) return;
switch (choice) {
case NewMatchChoice.schedule:
await _scheduleMatch(context, ref);
case NewMatchChoice.quickStart:
await _createMatchQuick(context, ref);
}
}
Future<void> _scheduleMatch(BuildContext context, WidgetRef ref) async {
final team = await ref.read(activeTeamProvider.future);
if (team == null) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Nessuna squadra disponibile')),
);
}
return;
}
final data = await showScheduleMatchSheet(context, teamName: team.name);
if (data == null || !context.mounted) return;
try {
final client = ref.read(apiClientProvider);
final match = await client.createMatch(team.id, {
'opponent_name': data.opponentName,
'scheduled_at': data.scheduledAt.toUtc().toIso8601String(),
if (data.location != null) 'location': data.location,
'sport': 'volleyball',
'sets_to_win': 3,
});
ref.invalidate(matchesProvider);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Partita programmata — visibile sul sito')),
);
final configure = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppTheme.surface,
title: const Text('Configurare ora?'),
content: const Text(
'Puoi preparare la trasmissione subito, oppure tornare quando sei in palestra.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Più tardi'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(backgroundColor: AppTheme.primaryRed),
child: const Text('Configura'),
),
],
),
);
if (configure == true && context.mounted) {
context.push('/setup/${match.id}/1');
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Errore: $e')),
);
}
}
}
Future<void> _createMatchQuick(BuildContext context, WidgetRef ref) async {
final team = await ref.read(activeTeamProvider.future);
if (team == null) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Nessuna squadra disponibile')),
);
}
return;
}
final client = ref.read(apiClientProvider);
final match = await client.createMatch(team.id, {
'opponent_name': 'Avversario',
'sport': 'volleyball',
'sets_to_win': 3,
});
if (context.mounted) context.push('/setup/${match.id}/1');
}
}
String _matchStatusLabel(
MatchModel match, {
required bool canResume,
required bool hasSession,
}) {
if (canResume) return 'RIPRENDI';
if (hasSession) return 'IN CORSO';
final at = match.scheduledAt;
if (at != null && at.isAfter(DateTime.now())) return 'PROGRAMMATA';
return 'AVVIA';
}
class _MatchCard extends StatelessWidget {
const _MatchCard({
required this.match,
required this.dateFormat,
required this.onTap,
this.onDelete,
});
final MatchModel match;
final DateFormat dateFormat;
final VoidCallback onTap;
final VoidCallback? onDelete;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hasSession = match.hasActiveSession;
final canResume = match.canResumeCamera;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6),
child: Material(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${match.teamName} vs ${match.opponentName}',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 4),
if (match.scheduledAt != null)
Text(
dateFormat.format(match.scheduledAt!.toLocal()),
style: theme.textTheme.bodyMedium,
),
if (match.location != null && match.location!.isNotEmpty)
Text(
match.location!,
style: theme.textTheme.bodyMedium,
),
],
),
),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: hasSession
? AppTheme.primaryRed.withValues(alpha: 0.2)
: AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_matchStatusLabel(match, canResume: canResume, hasSession: hasSession),
style: theme.textTheme.labelLarge?.copyWith(
color: hasSession ? AppTheme.primaryRed : AppTheme.textSecondary,
fontSize: 11,
),
),
),
if (onDelete != null) ...[
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.delete_outline, color: AppTheme.textSecondary),
tooltip: 'Elimina partita',
onPressed: onDelete,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
],
],
),
),
),
),
);
}
}

View File

@@ -1,105 +0,0 @@
import 'package:flutter/material.dart';
import '../../app/theme.dart';
enum NewMatchChoice { schedule, quickStart }
Future<NewMatchChoice?> showNewMatchSheet(BuildContext context) {
return showModalBottomSheet<NewMatchChoice>(
context: context,
backgroundColor: AppTheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) {
final theme = Theme.of(ctx);
return Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 28),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: AppTheme.textSecondary.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
Text('Nuova partita', style: theme.textTheme.titleLarge),
const SizedBox(height: 6),
Text(
'Programma in anticipo o avvia la configurazione diretta subito.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 20),
_OptionTile(
icon: Icons.event_available,
title: 'Programma partita',
subtitle: 'Data, ora e avversario — visibile anche sul sito',
onTap: () => Navigator.pop(ctx, NewMatchChoice.schedule),
),
const SizedBox(height: 10),
_OptionTile(
icon: Icons.play_circle_outline,
title: 'Avvia subito',
subtitle: 'Crea la partita e passa al wizard senza orario',
onTap: () => Navigator.pop(ctx, NewMatchChoice.quickStart),
),
],
),
);
},
);
}
class _OptionTile extends StatelessWidget {
const _OptionTile({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
});
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
color: AppTheme.surfaceElevated,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(icon, color: AppTheme.primaryRed, size: 28),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(subtitle, style: theme.textTheme.bodySmall),
],
),
),
const Icon(Icons.chevron_right, color: AppTheme.textSecondary),
],
),
),
),
);
}
}

View File

@@ -1,56 +0,0 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../app/theme.dart';
import '../../core/config.dart';
import '../../shared/widgets/primary_cta.dart';
class NoTeamOnboarding extends StatelessWidget {
const NoTeamOnboarding({required this.theme});
final ThemeData theme;
String get _signupUrl {
final base = AppConfig.apiBaseUrl.replaceAll(RegExp(r'/api/v1/?$'), '');
return '$base/signup';
}
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(28),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.groups_outlined, size: 64, color: AppTheme.textSecondary),
const SizedBox(height: 16),
Text(
'Completa l\'iscrizione sul sito',
style: theme.textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
'Crea la tua squadra e scegli il piano Free o Premium da browser. '
'Poi torna qui con le stesse credenziali.',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
PrimaryCta(
label: 'VAI AL SITO',
icon: Icons.open_in_new,
onPressed: () async {
final uri = Uri.parse(_signupUrl);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
},
),
],
),
),
);
}
}

Some files were not shown because too many files have changed in this diff Show More