commit bba6df52c03a6c512d14480ff92655c78cb2d48a Author: Emiliano Frascaro Date: Tue May 26 17:45:37 2026 +0200 Initial commit: monorepo Match Live TV. Rails API, app Flutter, infrastruttura Docker/MediaMTX, sito marketing e documentazione di deploy. Co-authored-by: Cursor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac7cba2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Ruby +backend/.bundle +backend/vendor/bundle +backend/tmp/ +backend/log/ +backend/storage/ +backend/.env +backend/.env.* + +# Flutter +mobile/.dart_tool/ +mobile/.flutter-plugins +mobile/.flutter-plugins-dependencies +mobile/build/ +mobile/.packages +mobile/pubspec.lock + +# Infra +infra/certs/ +infra/recordings/ +infra/.env +infra/.env.production.generated + +# OS +.DS_Store +*.swp +.idea/ +.vscode/ + +# Secrets +.env +*.pem +credentials.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..d902b54 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# Match Live TV + +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* + +## Struttura monorepo + +``` +├── backend/ # Rails 7.2 API + Action Cable + Sidekiq +├── mobile/ # Flutter app (Camera + Regia) +├── infra/ # Docker Compose, MediaMTX, Nginx, Kamal +└── docs/ # Documentazione +``` + +## Deploy produzione (server 192.168.1.146) + +Vedi [docs/infrastructure/SERVER_DEPLOYMENT.md](docs/infrastructure/SERVER_DEPLOYMENT.md) per porte router, NPM e procedura completa. + +**Porte da aprire sul router:** `1935/TCP` (RTMP). Opzionale `8888/TCP` (HLS). HTTPS via Nginx Proxy Manager verso `http://192.168.1.146:3000`. + +**Sul server (una tantum, con sudo):** +```bash +sudo bash ~/matchlivetv/scripts/deploy/server_bootstrap.sh +bash ~/matchlivetv/scripts/deploy/post_bootstrap_deploy.sh +``` + +## Avvio rapido (locale) + +```bash +cd infra +cp .env.example .env +docker compose up -d --build +``` + +- API Rails: http://localhost:3000 +- MediaMTX RTMP: rtmp://localhost:1935 +- MediaMTX API: http://localhost:9997 +- Admin: http://localhost:3000/admin + +### Seed utente demo + +``` +email: coach@matchlivetv.test +password: password123 +``` + +## Mobile + +```bash +cd mobile +flutter pub get +flutter run +``` + +Configura `API_BASE_URL` in `lib/core/config.dart` (default `http://10.0.2.2:3000` per emulatore Android). + +## Architettura + +- Video (MVP): Phone → MediaMTX (RTMP) → HLS su `matchlivetv.*` (pagina `/live/:session_id`) +- Video (premium): stesso ingest + relay ffmpeg verso YouTube +- Controllo: Phone ↔ Action Cable ↔ Rails ↔ PostgreSQL +- Disconnessioni: MediaMTX `alwaysAvailable` slate + timeout 5 min (Sidekiq) + +### Mobile produzione + +```bash +./scripts/run_mobile_android_prod.sh +# API: https://matchlivetv.eminux.it +``` diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..7f5a804 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,42 @@ +# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files. + +# Ignore git directory. +/.git/ +/.gitignore + +# Ignore bundler config. +/.bundle + +# Ignore all environment files (except templates). +/.env* +!/.env*.erb + +# Ignore all default key files. +/config/master.key +/config/credentials/*.key + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/.keep + +# Ignore CI service files. +/.github + +# Ignore development files +/.devcontainer + +# Ignore Docker-related files +/.dockerignore +/Dockerfile* diff --git a/backend/.gitattributes b/backend/.gitattributes new file mode 100644 index 0000000..8dc4323 --- /dev/null +++ b/backend/.gitattributes @@ -0,0 +1,9 @@ +# See https://git-scm.com/docs/gitattributes for more about git attribute files. + +# Mark the database schema as having been generated. +db/schema.rb linguist-generated + +# Mark any vendored files as having been vendored. +vendor/* linguist-vendored +config/credentials/*.yml.enc diff=rails_credentials +config/credentials.yml.enc diff=rails_credentials diff --git a/backend/.github/dependabot.yml b/backend/.github/dependabot.yml new file mode 100644 index 0000000..f0527e6 --- /dev/null +++ b/backend/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: +- package-ecosystem: bundler + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 diff --git a/backend/.github/workflows/ci.yml b/backend/.github/workflows/ci.yml new file mode 100644 index 0000000..fb0f4ea --- /dev/null +++ b/backend/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + pull_request: + push: + branches: [ main ] + +jobs: + scan_ruby: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Scan for common Rails security vulnerabilities using static analysis + run: bin/brakeman --no-pager + + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Lint code for consistent style + run: bin/rubocop -f github + diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..5ba3862 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,33 @@ +# See https://help.github.com/articles/ignoring-files for more about ignoring files. +# +# Temporary files generated by your text editor or operating system +# belong in git's global ignore instead: +# `$XDG_CONFIG_HOME/git/ignore` or `~/.config/git/ignore` + +# Ignore bundler config. +/.bundle + +# Ignore all environment files (except templates). +/.env* +!/.env*.erb + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/ +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/ +!/tmp/storage/.keep + +# Ignore master key for decrypting credentials and more. +/config/master.key diff --git a/backend/.rubocop.yml b/backend/.rubocop.yml new file mode 100644 index 0000000..f9d86d4 --- /dev/null +++ b/backend/.rubocop.yml @@ -0,0 +1,8 @@ +# Omakase Ruby styling for Rails +inherit_gem: { rubocop-rails-omakase: rubocop.yml } + +# Overwrite or add rules to create your own house style +# +# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` +# Layout/SpaceInsideArrayLiteralBrackets: +# Enabled: false diff --git a/backend/.ruby-version b/backend/.ruby-version new file mode 100644 index 0000000..e41c6bf --- /dev/null +++ b/backend/.ruby-version @@ -0,0 +1 @@ +ruby-3.3.11 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..bb6d341 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,19 @@ +FROM ruby:3.3-bookworm + +RUN apt-get update -qq && \ + apt-get install -y --no-install-recommends build-essential libpq-dev curl ffmpeg && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +ENV BUNDLE_WITHOUT="development:test" +ENV BUNDLE_DEPLOYMENT="1" + +COPY Gemfile Gemfile.lock ./ +RUN bundle install + +COPY . . + +EXPOSE 3000 + +CMD ["bundle", "exec", "puma", "-C", "config/puma.rb", "-b", "tcp://0.0.0.0:3000"] diff --git a/backend/Gemfile b/backend/Gemfile new file mode 100644 index 0000000..fa5a506 --- /dev/null +++ b/backend/Gemfile @@ -0,0 +1,33 @@ +source "https://rubygems.org" + +gem "rails", "~> 7.2.2" +gem "pg", "~> 1.1" +gem "puma", ">= 5.0" +gem "bootsnap", require: false +gem "tzinfo-data", platforms: %i[ windows jruby ] + +gem "redis", ">= 4.0.1" +gem "bcrypt", "~> 3.1.7" +gem "rack-cors" +gem "jwt" +gem "sidekiq" +gem "rack-attack" +gem "aasm" +gem "attr_encrypted" +gem "google-apis-youtube_v3" +gem "faraday" +gem "stripe" +gem "kamal", require: false + +group :development, :test do + gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" + gem "brakeman", require: false + gem "rubocop-rails-omakase", require: false + gem "rspec-rails" + gem "factory_bot_rails" + gem "faker" +end + +group :development do + gem "letter_opener" +end diff --git a/backend/Gemfile.lock b/backend/Gemfile.lock new file mode 100644 index 0000000..2864ced --- /dev/null +++ b/backend/Gemfile.lock @@ -0,0 +1,443 @@ +GEM + remote: https://rubygems.org/ + specs: + aasm (5.5.2) + concurrent-ruby (~> 1.0) + actioncable (7.2.3.1) + actionpack (= 7.2.3.1) + activesupport (= 7.2.3.1) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (7.2.3.1) + actionpack (= 7.2.3.1) + activejob (= 7.2.3.1) + activerecord (= 7.2.3.1) + activestorage (= 7.2.3.1) + activesupport (= 7.2.3.1) + mail (>= 2.8.0) + actionmailer (7.2.3.1) + actionpack (= 7.2.3.1) + actionview (= 7.2.3.1) + activejob (= 7.2.3.1) + activesupport (= 7.2.3.1) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (7.2.3.1) + actionview (= 7.2.3.1) + activesupport (= 7.2.3.1) + cgi + nokogiri (>= 1.8.5) + racc + rack (>= 2.2.4, < 3.3) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (7.2.3.1) + actionpack (= 7.2.3.1) + activerecord (= 7.2.3.1) + activestorage (= 7.2.3.1) + activesupport (= 7.2.3.1) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (7.2.3.1) + activesupport (= 7.2.3.1) + builder (~> 3.1) + cgi + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (7.2.3.1) + activesupport (= 7.2.3.1) + globalid (>= 0.3.6) + activemodel (7.2.3.1) + activesupport (= 7.2.3.1) + activerecord (7.2.3.1) + activemodel (= 7.2.3.1) + activesupport (= 7.2.3.1) + timeout (>= 0.4.0) + activestorage (7.2.3.1) + actionpack (= 7.2.3.1) + activejob (= 7.2.3.1) + activerecord (= 7.2.3.1) + activesupport (= 7.2.3.1) + marcel (~> 1.0) + activesupport (7.2.3.1) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1, < 6) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + ast (2.4.3) + attr_encrypted (4.2.0) + encryptor (~> 3.0.0) + base64 (0.3.0) + bcrypt (3.1.22) + bcrypt_pbkdf (1.1.2) + bcrypt_pbkdf (1.1.2-arm64-darwin) + bcrypt_pbkdf (1.1.2-x86_64-darwin) + benchmark (0.5.0) + bigdecimal (4.1.2) + bootsnap (1.24.5) + msgpack (~> 1.2) + brakeman (8.0.4) + racc + builder (3.3.0) + cgi (0.5.1) + childprocess (5.1.0) + logger (~> 1.5) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) + crass (1.0.6) + date (3.5.1) + debug (1.11.1) + irb (~> 1.10) + reline (>= 0.3.8) + declarative (0.0.20) + diff-lcs (1.6.2) + dotenv (3.2.0) + drb (2.2.3) + ed25519 (1.4.0) + encryptor (3.0.0) + erb (6.0.4) + erubi (1.13.1) + factory_bot (6.6.0) + activesupport (>= 6.1.0) + factory_bot_rails (6.5.1) + factory_bot (~> 6.5) + railties (>= 6.1.0) + faker (3.8.0) + i18n (>= 1.8.11, < 2) + faraday (2.14.2) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-follow_redirects (0.5.0) + faraday (>= 1, < 3) + faraday-net_http (3.4.3) + net-http (~> 0.5) + globalid (1.3.0) + activesupport (>= 6.1) + google-apis-core (1.0.2) + addressable (~> 2.8, >= 2.8.7) + faraday (~> 2.13) + faraday-follow_redirects (~> 0.3) + googleauth (~> 1.14) + mini_mime (~> 1.1) + representable (~> 3.0) + retriable (~> 3.1) + google-apis-youtube_v3 (0.64.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-env (2.3.1) + base64 (~> 0.2) + faraday (>= 1.0, < 3.a) + google-logging-utils (0.2.0) + googleauth (1.16.2) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) + jwt (>= 1.4, < 4.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + i18n (1.14.8) + concurrent-ruby (~> 1.0) + io-console (0.8.2) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.19.5) + jwt (3.2.0) + base64 + kamal (2.11.0) + activesupport (>= 7.0) + base64 (~> 0.2) + bcrypt_pbkdf (~> 1.0) + concurrent-ruby (~> 1.2) + dotenv (~> 3.1) + ed25519 (~> 1.4) + net-ssh (~> 7.3) + sshkit (>= 1.23.0, < 2.0) + thor (~> 1.3) + zeitwerk (>= 2.6.18, < 3.0) + language_server-protocol (3.17.0.5) + launchy (3.1.1) + addressable (~> 2.8) + childprocess (~> 5.0) + logger (~> 1.6) + letter_opener (1.10.0) + launchy (>= 2.2, < 4) + lint_roller (1.1.0) + logger (1.7.0) + loofah (2.25.1) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.9.0) + logger + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.2.1) + mini_mime (1.1.5) + minitest (5.27.0) + msgpack (1.8.0) + multi_json (1.21.1) + net-http (0.9.1) + uri (>= 0.11.1) + net-imap (0.6.4) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-scp (4.1.0) + net-ssh (>= 2.6.5, < 8.0.0) + net-sftp (4.0.0) + net-ssh (>= 5.0.0, < 8.0.0) + net-smtp (0.5.1) + net-protocol + net-ssh (7.3.2) + nio4r (2.7.5) + nokogiri (1.19.3-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.3-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.19.3-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.3-arm-linux-musl) + racc (~> 1.4) + nokogiri (1.19.3-arm64-darwin) + racc (~> 1.4) + nokogiri (1.19.3-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.19.3-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.3-x86_64-linux-musl) + racc (~> 1.4) + os (1.1.4) + ostruct (0.6.3) + parallel (2.1.0) + parser (3.3.11.1) + ast (~> 2.4.1) + racc + pg (1.6.3) + pg (1.6.3-aarch64-linux) + pg (1.6.3-aarch64-linux-musl) + pg (1.6.3-arm64-darwin) + pg (1.6.3-x86_64-darwin) + pg (1.6.3-x86_64-linux) + pg (1.6.3-x86_64-linux-musl) + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + psych (5.3.1) + date + stringio + public_suffix (7.0.5) + puma (8.0.1) + nio4r (~> 2.0) + racc (1.8.1) + rack (3.2.6) + rack-attack (6.8.0) + rack (>= 1.0, < 4) + rack-cors (3.0.0) + logger + rack (>= 3.0.14) + rack-session (2.1.2) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.3.1) + rack (>= 3) + rails (7.2.3.1) + actioncable (= 7.2.3.1) + actionmailbox (= 7.2.3.1) + actionmailer (= 7.2.3.1) + actionpack (= 7.2.3.1) + actiontext (= 7.2.3.1) + actionview (= 7.2.3.1) + activejob (= 7.2.3.1) + activemodel (= 7.2.3.1) + activerecord (= 7.2.3.1) + activestorage (= 7.2.3.1) + activesupport (= 7.2.3.1) + bundler (>= 1.15.0) + railties (= 7.2.3.1) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.7.0) + loofah (~> 2.25) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (7.2.3.1) + actionpack (= 7.2.3.1) + activesupport (= 7.2.3.1) + cgi + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) + rainbow (3.1.1) + rake (13.4.2) + rdoc (7.2.0) + erb + psych (>= 4.0.0) + tsort + redis (5.4.1) + redis-client (>= 0.22.0) + redis-client (0.29.0) + connection_pool + regexp_parser (2.12.0) + reline (0.6.3) + io-console (~> 0.5) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.5.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-rails (8.0.4) + actionpack (>= 7.2) + activesupport (>= 7.2) + railties (>= 7.2) + rspec-core (>= 3.13.0, < 5.0.0) + rspec-expectations (>= 3.13.0, < 5.0.0) + rspec-mocks (>= 3.13.0, < 5.0.0) + rspec-support (>= 3.13.0, < 5.0.0) + rspec-support (3.13.7) + rubocop (1.86.2) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.1) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.35.2) + activesupport (>= 4.2.0) + lint_roller (~> 1.1) + rack (>= 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rails-omakase (1.1.0) + rubocop (>= 1.72) + rubocop-performance (>= 1.24) + rubocop-rails (>= 2.30) + ruby-progressbar (1.13.0) + securerandom (0.4.1) + sidekiq (8.1.5) + connection_pool (>= 3.0.0) + json (>= 2.16.0) + logger (>= 1.7.0) + rack (>= 3.2.0) + redis-client (>= 0.29.0) + signet (0.21.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 4.0) + multi_json (~> 1.10) + sshkit (1.25.0) + base64 + logger + net-scp (>= 1.1.2) + net-sftp (>= 2.1.2) + net-ssh (>= 2.8.0) + ostruct + stringio (3.2.0) + stripe (19.1.0) + bigdecimal + logger + thor (1.5.0) + timeout (0.6.1) + trailblazer-option (0.1.2) + tsort (0.2.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + uber (0.1.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) + useragent (0.16.11) + websocket-driver (0.8.0) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + zeitwerk (2.8.2) + +PLATFORMS + aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin + x86_64-darwin + x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + aasm + attr_encrypted + bcrypt (~> 3.1.7) + bootsnap + brakeman + debug + factory_bot_rails + faker + faraday + google-apis-youtube_v3 + jwt + kamal + letter_opener + pg (~> 1.1) + puma (>= 5.0) + rack-attack + rack-cors + rails (~> 7.2.2) + redis (>= 4.0.1) + rspec-rails + rubocop-rails-omakase + sidekiq + stripe + tzinfo-data + +BUNDLED WITH + 2.5.22 diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..7db80e4 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,24 @@ +# README + +This README would normally document whatever steps are necessary to get the +application up and running. + +Things you may want to cover: + +* Ruby version + +* System dependencies + +* Configuration + +* Database creation + +* Database initialization + +* How to run the test suite + +* Services (job queues, cache servers, search engines, etc.) + +* Deployment instructions + +* ... diff --git a/backend/Rakefile b/backend/Rakefile new file mode 100644 index 0000000..9a5ea73 --- /dev/null +++ b/backend/Rakefile @@ -0,0 +1,6 @@ +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative "config/application" + +Rails.application.load_tasks diff --git a/backend/app/channels/application_cable/channel.rb b/backend/app/channels/application_cable/channel.rb new file mode 100644 index 0000000..d672697 --- /dev/null +++ b/backend/app/channels/application_cable/channel.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end diff --git a/backend/app/channels/application_cable/connection.rb b/backend/app/channels/application_cable/connection.rb new file mode 100644 index 0000000..e1044a9 --- /dev/null +++ b/backend/app/channels/application_cable/connection.rb @@ -0,0 +1,17 @@ +module ApplicationCable + class Connection < ActionCable::Connection::Base + identified_by :current_user + + def connect + payload = JsonWebToken.decode(token_from_header) + self.current_user = User.find_by(id: payload[:user_id]) if payload + reject_unauthorized_connection unless current_user + end + + private + + def token_from_header + request.headers["Authorization"].to_s.delete_prefix("Bearer ").presence + end + end +end diff --git a/backend/app/channels/concerns/cable_broadcastable.rb b/backend/app/channels/concerns/cable_broadcastable.rb new file mode 100644 index 0000000..cf89fbb --- /dev/null +++ b/backend/app/channels/concerns/cable_broadcastable.rb @@ -0,0 +1,9 @@ +module CableBroadcastable + extend ActiveSupport::Concern + + class_methods do + def broadcast_message(session, data) + broadcast_to(session, method: "receive_message", data: data) + end + end +end diff --git a/backend/app/channels/session_channel.rb b/backend/app/channels/session_channel.rb new file mode 100644 index 0000000..2d81bea --- /dev/null +++ b/backend/app/channels/session_channel.rb @@ -0,0 +1,83 @@ +class SessionChannel < ApplicationCable::Channel + include CableBroadcastable + def subscribed + session = StreamSession.joins(match: :team) + .merge(current_user.teams) + .find(params[:session_id]) + stream_for session + session.device_states.find_or_create_by!(device_role: params[:device_role] || "controller") + end + + def unsubscribed + stop_all_streams + end + + def receive(data) + session = StreamSession.find(params[:session_id]) + handle_message(session, data) + end + + private + + def handle_message(session, data) + case data["type"] + when "command" + SessionChannel.broadcast_message(session, data) + execute_command(session, data) + when "score_update" + update_score(session, data) + when "timeout" + update_timeout(session, data) + when "device_state" + update_device(session, data) + end + end + + def execute_command(session, data) + case data["action"] + when "start_stream" + Sessions::Start.new(session).call + when "stop_stream" + Sessions::Stop.new(session).call + when "pause_stream" + Sessions::Pause.new(session).call + end + end + + def update_score(session, data) + score = session.score_state || session.create_score_state! + score.update!( + home_sets: data["home_sets"] || score.home_sets, + away_sets: data["away_sets"] || score.away_sets, + home_points: data["home_points"] || score.home_points, + away_points: data["away_points"] || score.away_points, + current_set: data["current_set"] || score.current_set, + set_partials: data.key?("set_partials") ? data["set_partials"] : score.set_partials + ) + SessionChannel.broadcast_message(session, score.as_cable_payload) + end + + def update_timeout(session, data) + score = session.score_state + return unless score + + if data["team"] == "home" + score.update!(timeout_home: true) + else + score.update!(timeout_away: true) + end + SessionChannel.broadcast_message(session, { type: "timeout", team: data["team"] }) + end + + def update_device(session, data) + state = session.device_states.find_or_initialize_by(device_role: data["device_role"] || "camera") + state.update!( + battery_level: data["battery"], + network_type: data["network"], + current_bitrate: data["bitrate"], + fps: data["fps"], + last_seen_at: Time.current + ) + SessionChannel.broadcast_message(session, state.as_cable_payload) + end +end diff --git a/backend/app/controllers/admin/base_controller.rb b/backend/app/controllers/admin/base_controller.rb new file mode 100644 index 0000000..ebf3d4c --- /dev/null +++ b/backend/app/controllers/admin/base_controller.rb @@ -0,0 +1,6 @@ +module Admin + class BaseController < ActionController::Base + layout "admin" + protect_from_forgery with: :null_session + end +end diff --git a/backend/app/controllers/admin/dashboard_controller.rb b/backend/app/controllers/admin/dashboard_controller.rb new file mode 100644 index 0000000..94dd830 --- /dev/null +++ b/backend/app/controllers/admin/dashboard_controller.rb @@ -0,0 +1,8 @@ +module Admin + class DashboardController < Admin::BaseController + def index + @teams = Team.includes(:matches).order(:name) + @active_sessions = StreamSession.where(status: %w[live reconnecting connecting]).includes(:match) + end + end +end diff --git a/backend/app/controllers/admin/sessions_controller.rb b/backend/app/controllers/admin/sessions_controller.rb new file mode 100644 index 0000000..cdf87e2 --- /dev/null +++ b/backend/app/controllers/admin/sessions_controller.rb @@ -0,0 +1,12 @@ +module Admin + class SessionsController < Admin::BaseController + def index + @sessions = StreamSession.includes(:match, :user).order(created_at: :desc).limit(50) + end + + def show + @session = StreamSession.find(params[:id]) + @events = @session.stream_events.recent.limit(50) + end + end +end diff --git a/backend/app/controllers/admin/teams_controller.rb b/backend/app/controllers/admin/teams_controller.rb new file mode 100644 index 0000000..fb09527 --- /dev/null +++ b/backend/app/controllers/admin/teams_controller.rb @@ -0,0 +1,12 @@ +module Admin + class TeamsController < Admin::BaseController + def index + @teams = Team.all.order(:name) + end + + def show + @team = Team.find(params[:id]) + @matches = @team.matches.order(scheduled_at: :desc) + end + end +end diff --git a/backend/app/controllers/api/v1/auth_controller.rb b/backend/app/controllers/api/v1/auth_controller.rb new file mode 100644 index 0000000..378e8d2 --- /dev/null +++ b/backend/app/controllers/api/v1/auth_controller.rb @@ -0,0 +1,65 @@ +module Api + module V1 + class AuthController < ApplicationController + skip_before_action :authenticate_request!, only: %i[login refresh register] + + def register + user = User.new( + email: params[:email]&.downcase, + name: params[:name], + password: params[:password], + role: "coach" + ) + if user.save + render json: token_response(user), status: :created + else + render json: { error: user.errors.full_messages.join(", ") }, status: :unprocessable_entity + end + end + + def login + user = User.find_by(email: params[:email]&.downcase) + if user&.authenticate(params[:password]) + render json: token_response(user), status: :ok + else + render json: { error: "Invalid credentials" }, status: :unauthorized + end + end + + def logout + render json: { message: "Logged out" } + end + + def refresh + payload = JsonWebToken.decode(params[:refresh_token] || bearer_token) + user = User.find_by(id: payload&.dig(:user_id)) + return render json: { error: "Invalid token" }, status: :unauthorized unless user + + render json: token_response(user) + end + + def me + render json: user_json(current_user) + end + + private + + def token_response(user) + { + user: user_json(user), + access_token: JsonWebToken.encode({ user_id: user.id, type: "access" }), + refresh_token: JsonWebToken.encode({ user_id: user.id, type: "refresh" }, exp: 30.days.from_now) + } + end + + def user_json(user) + { + id: user.id, + email: user.email, + name: user.name, + role: user.role + } + end + end + end +end diff --git a/backend/app/controllers/api/v1/base_controller.rb b/backend/app/controllers/api/v1/base_controller.rb new file mode 100644 index 0000000..95536eb --- /dev/null +++ b/backend/app/controllers/api/v1/base_controller.rb @@ -0,0 +1,17 @@ +module Api + module V1 + class BaseController < ApplicationController + rescue_from Teams::EntitlementError, with: :render_entitlement_error + + private + + def render_entitlement_error(error) + render json: { + error: error.message, + error_code: error.code, + billing_url: error.billing_url + }, status: :forbidden + end + end + end +end diff --git a/backend/app/controllers/api/v1/matches_controller.rb b/backend/app/controllers/api/v1/matches_controller.rb new file mode 100644 index 0000000..464ffd0 --- /dev/null +++ b/backend/app/controllers/api/v1/matches_controller.rb @@ -0,0 +1,85 @@ +module Api + module V1 + class MatchesController < BaseController + before_action :set_team, only: %i[index create] + before_action :set_match, only: %i[show update destroy] + + def index + matches = @team.matches + .includes(:team, :stream_sessions) + .order(scheduled_at: :desc) + render json: matches.map { |m| match_json(m) } + end + + def create + match = @team.matches.create!(match_params) + render json: match_json(match), status: :created + end + + def show + render json: match_json(@match, detail: true) + end + + def update + @match.update!(match_params) + render json: match_json(@match) + end + + def destroy + active = active_session_for(@match) + if active&.resumable? + return render json: { + error: "Chiudi la diretta prima di eliminare questa partita" + }, status: :unprocessable_entity + end + + @match.destroy! + head :no_content + end + + private + + def set_team + @team = current_user.teams.find(params[:team_id]) + end + + def set_match + @match = Match.joins(:team).merge(current_user.teams).find(params[:id]) + end + + def match_params + params.require(:match).permit( + :opponent_name, :location, :scheduled_at, :sport, :sets_to_win, + :category, :phase, roster_numbers: [], + scoring_rules: %i[points_per_set points_deciding_set min_point_lead] + ) + end + + def match_json(match, detail: false) + active = active_session_for(match) + { + id: match.id, + team_id: match.team_id, + team_name: match.team.name, + opponent_name: match.opponent_name, + location: match.location, + scheduled_at: match.scheduled_at, + sport: match.sport, + sets_to_win: match.sets_to_win, + scoring_rules: match.scoring_rules.presence, + roster_numbers: match.roster_numbers, + category: match.category, + phase: match.phase, + active_session_id: active&.id, + active_session_status: active&.status + } + end + + def active_session_for(match) + match.stream_sessions + .order(created_at: :desc) + .find { |s| !%w[ended error].include?(s.status) } + end + end + end +end diff --git a/backend/app/controllers/api/v1/stream_sessions_controller.rb b/backend/app/controllers/api/v1/stream_sessions_controller.rb new file mode 100644 index 0000000..2c191e9 --- /dev/null +++ b/backend/app/controllers/api/v1/stream_sessions_controller.rb @@ -0,0 +1,146 @@ +module Api + module V1 + class StreamSessionsController < BaseController + before_action :set_session, except: :create + + def create + match = Match.joins(:team).merge(current_user.teams).find(params[:match_id]) + session = Sessions::Create.new(user: current_user, match: match, params: session_params).call + render json: session_json(session), status: :created + end + + def show + render json: session_json(@session, detail: true) + end + + def start + Sessions::Start.new(@session).call + render json: session_json(@session) + end + + def stop + Sessions::Stop.new(@session).call + render json: session_json(@session) + end + + def pause + Sessions::Pause.new(@session).call + render json: session_json(@session) + end + + def events + events = @session.stream_events.recent.limit(100) + render json: events.map { |e| event_json(e) } + end + + def telemetry + role = params.require(:device_role) + state = @session.device_states.find_or_initialize_by(device_role: role) + state.update!( + battery_level: params[:battery_level], + network_type: params[:network_type], + signal_strength: params[:signal_strength], + current_bitrate: params[:current_bitrate], + target_bitrate: params[:target_bitrate], + fps: params[:fps], + last_seen_at: Time.current + ) + SessionChannel.broadcast_message(@session, state.as_cable_payload) + head :no_content + end + + def pairing_token + token = SecureRandom.urlsafe_base64(24) + @session.update!( + pairing_token_digest: Digest::SHA256.hexdigest(token), + pairing_token_expires_at: 15.minutes.from_now + ) + render json: { + pairing_token: token, + expires_at: @session.pairing_token_expires_at, + qr_payload: { + session_id: @session.id, + pairing_token: token, + api_url: request.base_url + } + } + end + + def claim_pairing + token = params.require(:pairing_token) + digest = Digest::SHA256.hexdigest(token) + unless @session.pairing_token_digest == digest && + @session.pairing_token_expires_at&.future? + return render json: { error: "Invalid or expired token" }, status: :unauthorized + end + + @session.device_states.find_or_create_by!(device_role: params[:device_role] || "controller") + render json: session_json(@session) + end + + def network_test + @session.stream_events.create!( + event_type: "network_test", + metadata: params.permit(:download_mbps, :upload_mbps, :latency_ms, :network_type).to_h, + occurred_at: Time.current + ) + ready = params[:upload_mbps].to_f >= (@session.target_bitrate / 1_000_000.0 * 0.8) + render json: { ready: ready, target_upload_mbps: @session.target_bitrate / 1_000_000.0 } + end + + def youtube_stats + count = if @session.platform == "youtube" && @session.youtube_broadcast_id.present? + Youtube::BroadcastService.new(@session.match.team) + .viewer_count(@session.youtube_broadcast_id) + else + 0 + end + render json: { concurrent_viewers: count, live: @session.live? } + end + + private + + def set_session + @session = StreamSession.joins(match: :team) + .merge(current_user.teams) + .find(params[:id]) + end + + def session_params + params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps) + end + + def session_json(session, detail: false) + data = { + id: session.id, + match_id: session.match_id, + status: session.status, + platform: session.platform, + rtmp_ingest_url: session.rtmp_ingest_url, + hls_playback_url: session.hls_playback_url, + watch_page_url: session.watch_page_url, + youtube_broadcast_id: session.youtube_broadcast_id, + privacy_status: session.privacy_status, + quality_preset: session.quality_preset, + target_bitrate: session.target_bitrate, + started_at: session.started_at, + disconnection_count: session.disconnection_count + } + if detail + data[:score] = session.score_state&.as_cable_payload + data[:devices] = session.device_states.map(&:as_cable_payload) + end + data + end + + def event_json(event) + { + id: event.id, + event_type: event.event_type, + metadata: event.metadata, + occurred_at: event.occurred_at + } + end + end + end +end diff --git a/backend/app/controllers/api/v1/teams_controller.rb b/backend/app/controllers/api/v1/teams_controller.rb new file mode 100644 index 0000000..656d4e6 --- /dev/null +++ b/backend/app/controllers/api/v1/teams_controller.rb @@ -0,0 +1,122 @@ +module Api + module V1 + class TeamsController < BaseController + def index + teams = current_user.teams.includes(:matches) + render json: teams.map { |t| team_json(t) } + end + + def show + team = current_user.teams.find(params[:id]) + render json: team_json(team, detail: true) + end + + def create + if current_user.user_teams.where(role: "owner").count >= 1 + return render json: { + error: "Puoi gestire una sola squadra. Usa il sito per inviti o contattaci." + }, status: :unprocessable_entity + end + + team = Team.create!(team_params) + UserTeam.create!(user: current_user, team: team, role: "owner") + Billing::AssignPlan.call(team: team, plan_slug: "free") + render json: team_json(team), status: :created + end + + def recordings + team = current_user.teams.find(params[:id]) + unless team.entitlements.can_access_recordings? + return render json: { + error: "Archivio gare disponibile con Premium Light o Full", + error_code: "premium_required", + billing_url: team.entitlements.billing_url + }, status: :forbidden + end + + recs = team.recordings.ready.includes(stream_session: :match).order(created_at: :desc).limit(50) + render json: recs.map { |r| recording_json(r) } + end + + def update + team = current_user.teams.find(params[:id]) + team.update!(team_params) + render json: team_json(team) + end + + def add_member + team = current_user.teams.find(params[:id]) + team.entitlements.assert_can_invite! + member = User.find(params[:user_id]) + UserTeam.find_or_create_by!(user: member, team: team) { |ut| ut.role = "member" } + head :no_content + end + + def remove_member + team = current_user.teams.find(params[:id]) + ut = team.user_teams.find_by!(user_id: params[:user_id], role: "member") + ut.destroy! + head :no_content + end + + private + + def team_params + params.require(:team).permit(:name, :sport, :logo_url) + end + + def team_json(team, detail: false) + ent = team.entitlements + data = { + id: team.id, + name: team.name, + sport: team.sport, + logo_url: team.logo_url, + youtube_connected: team.youtube_credential.present?, + plan_slug: ent.plan.slug, + plan_name: ent.plan.name, + premium_active: ent.premium_active?, + premium_full: ent.premium_full?, + subscription_status: ent.subscription.status, + features: ent.plan.features, + billing_url: ent.billing_url, + staff_manage_url: ent.staff_manage_url, + max_staff: ent.max_staff, + max_staff_transmission: ent.max_staff_transmission, + max_staff_regia: ent.max_staff_regia, + staff_used: ent.staff_count, + staff_transmission_used: ent.staff_count_for("transmission"), + staff_regia_used: ent.staff_count_for("regia"), + concurrent_streams_used: ent.concurrent_streams_used, + concurrent_streams_limit: ent.concurrent_streams_limit, + recordings_enabled: ent.can_access_recordings?, + phone_download_enabled: ent.phone_download_enabled?, + youtube_enabled: ent.youtube_enabled?, + youtube_mode: ent.plan.youtube_mode + } + if detail + data[:members] = team.user_teams.includes(:user).where.not(role: "owner").map do |ut| + { id: ut.user.id, name: ut.user.name, email: ut.user.email, role: ut.role } + end + end + data + end + + def recording_json(recording) + session = recording.stream_session + match = session.match + { + id: recording.id, + session_id: session.id, + match_id: match.id, + opponent_name: match.opponent_name, + team_name: match.team.name, + ended_at: session.ended_at, + replay_url: recording.replay_url, + download_url: recording.replay_url, + expires_at: recording.expires_at + } + end + end + end +end diff --git a/backend/app/controllers/api/v1/youtube_controller.rb b/backend/app/controllers/api/v1/youtube_controller.rb new file mode 100644 index 0000000..6268701 --- /dev/null +++ b/backend/app/controllers/api/v1/youtube_controller.rb @@ -0,0 +1,26 @@ +module Api + module V1 + class YoutubeController < BaseController + def authorize + team = current_user.teams.find(params[:team_id]) + team.entitlements.assert_can_connect_youtube! + url = Youtube::OauthUrl.build(team_id: team.id, redirect_uri: ENV.fetch("YOUTUBE_REDIRECT_URI")) + render json: { authorization_url: url } + end + + def callback + team = current_user.teams.find(params[:state]) + tokens = Youtube::OauthExchange.call(params[:code]) + cred = team.youtube_credential || team.build_youtube_credential + cred.update!( + access_token: tokens[:access_token], + refresh_token: tokens[:refresh_token], + expires_at: Time.current + tokens[:expires_in].seconds, + channel_id: tokens[:channel_id], + channel_title: tokens[:channel_title] + ) + redirect_to "/admin/teams/#{team.id}?youtube=connected" + end + end + end +end diff --git a/backend/app/controllers/application_controller.rb b/backend/app/controllers/application_controller.rb new file mode 100644 index 0000000..029725c --- /dev/null +++ b/backend/app/controllers/application_controller.rb @@ -0,0 +1,25 @@ +class ApplicationController < ActionController::API + include ActionController::HttpAuthentication::Token::ControllerMethods + + before_action :authenticate_request! + + attr_reader :current_user + + private + + def authenticate_request! + token = bearer_token + payload = JsonWebToken.decode(token) + @current_user = User.find_by(id: payload[:user_id]) if payload + render json: { error: "Unauthorized" }, status: :unauthorized unless @current_user + end + + def bearer_token + auth = request.headers["Authorization"].to_s + auth.start_with?("Bearer ") ? auth.split(" ", 2).last : nil + end + + def skip_auth? + false + end +end diff --git a/backend/app/controllers/concerns/.keep b/backend/app/controllers/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/controllers/hls_proxy_controller.rb b/backend/app/controllers/hls_proxy_controller.rb new file mode 100644 index 0000000..65d8dca --- /dev/null +++ b/backend/app/controllers/hls_proxy_controller.rb @@ -0,0 +1,44 @@ +class HlsProxyController < ActionController::Base + # Proxy pubblico verso MediaMTX HLS (NPM inoltra tutto a Rails :3000). + def show + upstream_path = params[:path].to_s + return head :not_found if upstream_path.blank? + + upstream = "#{MatchLiveTv.mediamtx_hls_url}/#{upstream_path}" + upstream = "#{upstream}?#{request.query_string}" if request.query_string.present? + cookie = request.headers["Cookie"].presence || "cookieCheck=1" + response = hls_conn.get(upstream) { |req| req.headers["Cookie"] = cookie } + + if response.status == 302 + location = response.headers["location"].to_s + follow_url = if location.start_with?("http") + location + else + "#{MatchLiveTv.mediamtx_hls_url.sub(%r{/$}, "")}#{location}" + end + set_cookie = response.headers["set-cookie"].to_s + cookie = set_cookie.presence || cookie + response = hls_conn.get(follow_url) { |req| req.headers["Cookie"] = cookie } + end + + response.headers.each do |key, value| + next if %w[transfer-encoding connection].include?(key.downcase) + + headers[key] = value + end + render body: response.body, status: response.status + rescue Faraday::Error => e + Rails.logger.warn("[HlsProxy] #{upstream_path}: #{e.message}") + head :bad_gateway + end + + private + + def hls_conn + @hls_conn ||= Faraday.new do |f| + f.adapter Faraday.default_adapter + f.options.timeout = 15 + f.options.open_timeout = 5 + end + end +end diff --git a/backend/app/controllers/public/invitations_controller.rb b/backend/app/controllers/public/invitations_controller.rb new file mode 100644 index 0000000..5710a1e --- /dev/null +++ b/backend/app/controllers/public/invitations_controller.rb @@ -0,0 +1,28 @@ +module Public + class InvitationsController < WebBaseController + def show + @token = params[:token] + @invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(@token.to_s)) + unless @invitation + redirect_to public_pricing_path, alert: "Invito non valido o scaduto" + end + end + + def accept + invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(params[:token].to_s)) + return redirect_to public_pricing_path, alert: "Invito non valido" unless invitation + + if logged_in? + if current_user.email.downcase != invitation.email.downcase + redirect_to public_pricing_path, alert: "Questo invito è per #{invitation.email}" + return + end + invitation.accept!(current_user) + redirect_to public_team_dashboard_path(invitation.team), notice: "Sei entrato nella squadra!" + else + session[:pending_invite_token] = params[:token] + redirect_to public_signup_path, notice: "Registrati con #{invitation.email} per accettare l'invito" + end + end + end +end diff --git a/backend/app/controllers/public/live_controller.rb b/backend/app/controllers/public/live_controller.rb new file mode 100644 index 0000000..0a1b85b --- /dev/null +++ b/backend/app/controllers/public/live_controller.rb @@ -0,0 +1,47 @@ +module Public + class LiveController < SiteBaseController + layout "marketing_live" + + ACTIVE_STATUSES = %w[live connecting reconnecting].freeze + + def index + @query = params[:q].to_s.strip + @sessions = StreamSession + .broadcasting + .includes(:score_state, match: :team) + .search_by_team_or_opponent(@query) + .order(Arel.sql("started_at DESC NULLS LAST"), created_at: :desc) + + @online_paths = Mediamtx::Client.new.online_path_names + end + + def show + @session = StreamSession.includes(match: :team).find(params[:id]) + @match = @session.match + @stream_closed = @session.terminal? + @on_air = !@stream_closed && mediamtx_online?(@session) + end + + def status + session = StreamSession.includes(match: :team).find(params[:id]) + closed = session.terminal? + render json: { + status: session.status, + stream_closed: closed, + live: !closed && (session.live? || mediamtx_online?(session)), + on_air: !closed && mediamtx_online?(session), + ended_at: session.ended_at, + home_name: session.match.team.name, + away_name: session.match.opponent_name, + score: session.score_state&.as_cable_payload + } + end + + private + + def mediamtx_online?(session) + @online_paths_cache ||= Mediamtx::Client.new.online_path_names + @online_paths_cache.include?(session.mediamtx_path_name) + end + end +end diff --git a/backend/app/controllers/public/pages_controller.rb b/backend/app/controllers/public/pages_controller.rb new file mode 100644 index 0000000..7e919b2 --- /dev/null +++ b/backend/app/controllers/public/pages_controller.rb @@ -0,0 +1,19 @@ +module Public + class PagesController < WebBaseController + def home + end + + def features + end + + def pricing + @plans = Plan.ordered + end + + def privacy + end + + def terms + end + end +end diff --git a/backend/app/controllers/public/registrations_controller.rb b/backend/app/controllers/public/registrations_controller.rb new file mode 100644 index 0000000..8e23d27 --- /dev/null +++ b/backend/app/controllers/public/registrations_controller.rb @@ -0,0 +1,33 @@ +module Public + class RegistrationsController < WebBaseController + def new + redirect_to public_team_dashboard_path(current_user.teams.first) if logged_in? && current_user.teams.any? + @user = User.new + end + + def create + @user = User.new(user_params.merge(role: "coach")) + if @user.save + session[:user_id] = @user.id + if session[:pending_invite_token].present? + token = session.delete(:pending_invite_token) + invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(token)) + if invitation && invitation.email.downcase == @user.email.downcase + invitation.accept!(@user) + return redirect_to public_team_dashboard_path(invitation.team), notice: "Benvenuto nella squadra!" + end + end + redirect_to new_public_team_path, notice: "Account creato. Ora crea la tua squadra." + else + flash.now[:alert] = @user.errors.full_messages.join(", ") + render :new, status: :unprocessable_entity + end + end + + private + + def user_params + params.require(:user).permit(:name, :email, :password, :password_confirmation) + end + end +end diff --git a/backend/app/controllers/public/replay_controller.rb b/backend/app/controllers/public/replay_controller.rb new file mode 100644 index 0000000..bc4c252 --- /dev/null +++ b/backend/app/controllers/public/replay_controller.rb @@ -0,0 +1,15 @@ +module Public + class ReplayController < SiteBaseController + layout "marketing_live" + + def show + @session = StreamSession.includes(match: :team).find(params[:id]) + @recording = Recording.ready.find_by(stream_session: @session) + @match = @session.match + + unless @recording + redirect_to public_live_path(@session), alert: "Replay non disponibile" + end + end + end +end diff --git a/backend/app/controllers/public/sessions_controller.rb b/backend/app/controllers/public/sessions_controller.rb new file mode 100644 index 0000000..3759be3 --- /dev/null +++ b/backend/app/controllers/public/sessions_controller.rb @@ -0,0 +1,24 @@ +module Public + class SessionsController < WebBaseController + def new + redirect_to public_team_dashboard_path(current_user.teams.first) if logged_in? && current_user.teams.any? + end + + def create + user = User.find_by(email: params[:email]&.downcase) + if user&.authenticate(params[:password]) + session[:user_id] = user.id + dest = user.teams.first ? public_team_dashboard_path(user.teams.first) : new_public_team_path + redirect_to dest, notice: "Bentornato!" + else + flash.now[:alert] = "Email o password non validi" + render :new, status: :unauthorized + end + end + + def destroy + reset_session + redirect_to public_pricing_path, notice: "Disconnesso" + end + end +end diff --git a/backend/app/controllers/public/site_base_controller.rb b/backend/app/controllers/public/site_base_controller.rb new file mode 100644 index 0000000..bd7ead4 --- /dev/null +++ b/backend/app/controllers/public/site_base_controller.rb @@ -0,0 +1,19 @@ +module Public + class SiteBaseController < ActionController::Base + layout "marketing" + + helper_method :current_user, :logged_in? + + private + + def current_user + return @current_user if defined?(@current_user) + + @current_user = User.find_by(id: session[:user_id]) if session[:user_id] + end + + def logged_in? + current_user.present? + end + end +end diff --git a/backend/app/controllers/public/teams_controller.rb b/backend/app/controllers/public/teams_controller.rb new file mode 100644 index 0000000..fd64d9b --- /dev/null +++ b/backend/app/controllers/public/teams_controller.rb @@ -0,0 +1,119 @@ +module Public + class TeamsController < WebBaseController + before_action :require_login! + before_action :set_team, only: %i[ + dashboard billing checkout portal invite create_invitation + remove_member destroy_invitation + ] + + def new + redirect_to public_team_dashboard_path(current_user.teams.first) if current_user.teams.any? + end + + def create + if current_user.user_teams.where(role: "owner").exists? + redirect_to public_team_dashboard_path(current_user.teams.first), alert: "Hai già una squadra" + return + end + + team = Team.create!(team_params) + UserTeam.create!(user: current_user, team: team, role: "owner") + plan = params[:plan].presence_in(%w[free premium_light premium_full]) || "free" + Billing::AssignPlan.call(team: team, plan_slug: plan) + + if plan.in?(%w[premium_light premium_full]) && MatchLiveTv.stripe_enabled? + redirect_to public_team_checkout_path(team, plan: plan) + else + redirect_to public_team_dashboard_path(team), notice: "Squadra creata con piano Free" + end + end + + def dashboard + require_team_owner!(@team) + @entitlements = @team.entitlements + @recordings = @team.recordings.ready.includes(stream_session: :match).order(created_at: :desc).limit(10) + @members = @team.user_teams.includes(:user).where.not(role: "owner") + @pending_invitations = @team.team_invitations.pending.order(created_at: :desc) + end + + def billing + require_team_owner!(@team) + @entitlements = @team.entitlements + @plans = Plan.ordered + end + + def checkout + require_team_owner!(@team) + unless MatchLiveTv.stripe_enabled? + redirect_to public_team_billing_path(@team), alert: "Pagamenti non ancora configurati sul server" + return + end + + plan_slug = params[:plan].presence_in(%w[premium_light premium_full]) || "premium_light" + url = Billing::Stripe::CheckoutSession.new(team: @team, user: current_user, plan_slug: plan_slug).url + redirect_to url, allow_other_host: true + end + + def portal + require_team_owner!(@team) + unless MatchLiveTv.stripe_enabled? + redirect_to public_team_billing_path(@team), alert: "Portale pagamenti non configurato" + return + end + + url = Billing::Stripe::CheckoutSession.new(team: @team, user: current_user).portal_url + redirect_to url, allow_other_host: true + end + + def invite + require_team_owner!(@team) + @entitlements = @team.entitlements + end + + def create_invitation + require_team_owner!(@team) + @entitlements = @team.entitlements + staff_kind = params[:staff_kind].presence_in(TeamInvitation::STAFF_KINDS) || "transmission" + @entitlements.assert_can_invite!(staff_kind: staff_kind) + + email = params[:email]&.downcase&.strip + token = TeamInvitation.generate_token + @team.team_invitations.create!( + email: email, + token_digest: Digest::SHA256.hexdigest(token), + role: "member", + staff_kind: staff_kind, + expires_at: 7.days.from_now + ) + @invite_url = join_public_invitation_url(token: token) + flash.now[:notice] = "Link invito generato (valido 7 giorni)" + render :invite + rescue Teams::EntitlementError => e + redirect_to public_team_invite_path(@team), alert: e.message + end + + def remove_member + require_team_owner!(@team) + ut = @team.user_teams.find_by!(user_id: params[:user_id], role: "member") + ut.destroy! + redirect_to public_team_dashboard_path(@team), notice: "Accesso revocato" + end + + def destroy_invitation + require_team_owner!(@team) + inv = @team.team_invitations.pending.find(params[:invitation_id]) + inv.destroy! + redirect_to public_team_dashboard_path(@team), notice: "Invito annullato" + end + + private + + def set_team + @team = current_user.teams.find(params[:id]) + end + + def team_params + params.require(:team).permit(:name, :sport, :logo_url) + end + end +end diff --git a/backend/app/controllers/public/web_base_controller.rb b/backend/app/controllers/public/web_base_controller.rb new file mode 100644 index 0000000..ccd966d --- /dev/null +++ b/backend/app/controllers/public/web_base_controller.rb @@ -0,0 +1,18 @@ +module Public + class WebBaseController < SiteBaseController + protect_from_forgery with: :exception + + private + + def require_login! + return if logged_in? + + redirect_to public_login_path, alert: "Accedi per continuare" + end + + def require_team_owner!(team) + membership = current_user.user_teams.find_by(team: team) + redirect_to public_prezzi_path, alert: "Accesso non consentito" unless membership&.role == "owner" + end + end +end diff --git a/backend/app/controllers/webhooks/mediamtx_controller.rb b/backend/app/controllers/webhooks/mediamtx_controller.rb new file mode 100644 index 0000000..a89fdea --- /dev/null +++ b/backend/app/controllers/webhooks/mediamtx_controller.rb @@ -0,0 +1,53 @@ +module Webhooks + class MediamtxController < ApplicationController + skip_before_action :authenticate_request! + + before_action :verify_signature! + + def connect + handle_event("connect") + end + + def disconnect + handle_event("disconnect") + end + + def ready + handle_event("ready") + end + + def validate_publish + session_id = params[:session_id] || extract_session_id(params[:path]) + token = params[:token] + session = StreamSession.find_by(id: session_id) + + valid = session && session.publish_token == token && !session.ended? + render json: { valid: valid }, status: valid ? :ok : :forbidden + end + + private + + def handle_event(event) + session_id = params[:session_id].presence || extract_session_id(params[:path]) + Webhooks::MediamtxHandler.new( + event: event, + session_id: session_id, + metadata: params.permit!.to_h.except("controller", "action") + ).call + head :ok + end + + def extract_session_id(path) + path.to_s[%r{match_([0-9a-f-]+)}, 1] + end + + def verify_signature! + signature = request.headers["X-MediaMTX-Signature"] + payload = request.raw_post.presence || request.query_string + return if Webhooks::Signature.valid?(payload, signature) + return if Rails.env.development? && signature.blank? + + head :unauthorized + end + end +end diff --git a/backend/app/controllers/webhooks/stripe_controller.rb b/backend/app/controllers/webhooks/stripe_controller.rb new file mode 100644 index 0000000..56fd09a --- /dev/null +++ b/backend/app/controllers/webhooks/stripe_controller.rb @@ -0,0 +1,32 @@ +module Webhooks + class StripeController < ActionController::API + def create + payload = request.body.read + sig_header = request.env["HTTP_STRIPE_SIGNATURE"] + + unless MatchLiveTv.stripe_webhook_secret.present? + Rails.logger.info("[Stripe webhook] ignored — STRIPE_WEBHOOK_SECRET not set") + return head :ok + end + + event = ::Stripe::Webhook.construct_event( + payload, sig_header, MatchLiveTv.stripe_webhook_secret + ) + + Billing::Stripe::WebhookHandler.call(event) + + head :ok + rescue JSON::ParserError, ::Stripe::SignatureVerificationError => e + Rails.logger.warn("[Stripe webhook] #{e.message}") + head :bad_request + end + + private + + def stripe_object_from_hash(hash) + OpenStruct.new(hash.transform_keys(&:to_s).transform_values do |v| + v.is_a?(Hash) ? stripe_object_from_hash(v) : v + end) + end + end +end diff --git a/backend/app/helpers/public/live_helper.rb b/backend/app/helpers/public/live_helper.rb new file mode 100644 index 0000000..8ad61e0 --- /dev/null +++ b/backend/app/helpers/public/live_helper.rb @@ -0,0 +1,35 @@ +module Public + module LiveHelper + def live_score_sets_label(score_state) + return nil unless score_state + + "Set #{score_state.current_set} · Set vinti #{score_state.home_sets}-#{score_state.away_sets}" + end + + def live_score_partials_label(score_state) + return nil unless score_state + + partials = Array(score_state.set_partials) + return nil if partials.empty? + + partials.map { |p| format_set_partial_entry(p) }.join(" · ") + end + + def live_score_points_label(match, score_state) + return "—" unless score_state + + "#{match.team.name} #{score_state.home_points} - #{score_state.away_points} #{match.opponent_name}" + end + + private + + def format_set_partial_entry(partial) + data = partial.respond_to?(:with_indifferent_access) ? partial.with_indifferent_access : partial + set_no = data[:set] || data["set"] + home = data[:home] || data["home"] + away = data[:away] || data["away"] + label = set_no.present? ? "Set #{set_no}" : "Set" + "#{label} #{home}-#{away}" + end + end +end diff --git a/backend/app/jobs/application_job.rb b/backend/app/jobs/application_job.rb new file mode 100644 index 0000000..d394c3d --- /dev/null +++ b/backend/app/jobs/application_job.rb @@ -0,0 +1,7 @@ +class ApplicationJob < ActiveJob::Base + # Automatically retry jobs that encountered a deadlock + # retry_on ActiveRecord::Deadlocked + + # Most jobs are safe to ignore if the underlying records are no longer available + # discard_on ActiveJob::DeserializationError +end diff --git a/backend/app/jobs/cleanup_expired_sessions_job.rb b/backend/app/jobs/cleanup_expired_sessions_job.rb new file mode 100644 index 0000000..b7d521f --- /dev/null +++ b/backend/app/jobs/cleanup_expired_sessions_job.rb @@ -0,0 +1,12 @@ +class CleanupExpiredSessionsJob + include Sidekiq::Job + + def perform + StreamSession.where(status: %w[connecting reconnecting]) + .where("updated_at < ?", 6.hours.ago) + .find_each do |session| + session.fail! if session.may_fail? + Mediamtx::Client.new.delete_path(session) + end + end +end diff --git a/backend/app/jobs/disconnection_timeout_job.rb b/backend/app/jobs/disconnection_timeout_job.rb new file mode 100644 index 0000000..1467bc5 --- /dev/null +++ b/backend/app/jobs/disconnection_timeout_job.rb @@ -0,0 +1,22 @@ +class DisconnectionTimeoutJob + include Sidekiq::Job + + def perform(session_id) + session = StreamSession.find_by(id: session_id) + return unless session&.reconnecting? + + session.finish! if session.may_finish? + session.update!(timeout_job_id: nil) + session.stream_events.create!( + event_type: "error", + metadata: { reason: "reconnect_timeout" }, + occurred_at: Time.current + ) + SessionChannel.broadcast_message(session, { + type: "stream_event", + event: "timeout_ended", + message: "Session ended after reconnect timeout" + }) + Sessions::Stop.new(session).call + end +end diff --git a/backend/app/jobs/notify_stream_health_job.rb b/backend/app/jobs/notify_stream_health_job.rb new file mode 100644 index 0000000..00320a5 --- /dev/null +++ b/backend/app/jobs/notify_stream_health_job.rb @@ -0,0 +1,17 @@ +class NotifyStreamHealthJob + include Sidekiq::Job + + def perform(session_id) + session = StreamSession.find_by(id: session_id) + return unless session&.live? + + camera = session.device_states.find_by(device_role: "camera") + return unless camera&.current_bitrate && camera.current_bitrate < session.target_bitrate * 0.5 + + SessionChannel.broadcast_message(session, { + type: "stream_event", + event: "quality_degraded", + bitrate: camera.current_bitrate + }) + end +end diff --git a/backend/app/jobs/stream_analytics_job.rb b/backend/app/jobs/stream_analytics_job.rb new file mode 100644 index 0000000..6b4e164 --- /dev/null +++ b/backend/app/jobs/stream_analytics_job.rb @@ -0,0 +1,16 @@ +class StreamAnalyticsJob + include Sidekiq::Job + + def perform(session_id) + session = StreamSession.find_by(id: session_id) + return unless session&.ended? + + duration = session.total_duration_secs + disconnects = session.disconnection_count + session.stream_events.create!( + event_type: "quality_changed", + metadata: { analytics: true, duration: duration, disconnects: disconnects }, + occurred_at: Time.current + ) + end +end diff --git a/backend/app/mailers/application_mailer.rb b/backend/app/mailers/application_mailer.rb new file mode 100644 index 0000000..3c34c81 --- /dev/null +++ b/backend/app/mailers/application_mailer.rb @@ -0,0 +1,4 @@ +class ApplicationMailer < ActionMailer::Base + default from: "from@example.com" + layout "mailer" +end diff --git a/backend/app/models/application_record.rb b/backend/app/models/application_record.rb new file mode 100644 index 0000000..b63caeb --- /dev/null +++ b/backend/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class +end diff --git a/backend/app/models/concerns/.keep b/backend/app/models/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/device_state.rb b/backend/app/models/device_state.rb new file mode 100644 index 0000000..922ec9c --- /dev/null +++ b/backend/app/models/device_state.rb @@ -0,0 +1,19 @@ +class DeviceState < ApplicationRecord + ROLES = %w[camera controller].freeze + + belongs_to :stream_session + + validates :device_role, inclusion: { in: ROLES } + validates :device_role, uniqueness: { scope: :stream_session_id } + + def as_cable_payload + { + type: "device_state", + battery: battery_level, + network: network_type, + bitrate: current_bitrate, + fps: fps, + status: stream_session.status + } + end +end diff --git a/backend/app/models/match.rb b/backend/app/models/match.rb new file mode 100644 index 0000000..cc55889 --- /dev/null +++ b/backend/app/models/match.rb @@ -0,0 +1,7 @@ +class Match < ApplicationRecord + belongs_to :team + has_many :stream_sessions, dependent: :destroy + + validates :opponent_name, presence: true + validates :sets_to_win, numericality: { greater_than: 0, less_than: 6 } +end diff --git a/backend/app/models/plan.rb b/backend/app/models/plan.rb new file mode 100644 index 0000000..00eb601 --- /dev/null +++ b/backend/app/models/plan.rb @@ -0,0 +1,86 @@ +class Plan < ApplicationRecord + SLUGS = %w[free premium_light premium_full].freeze + EXTERNAL_PLATFORMS = %w[youtube facebook twitch].freeze + PREMIUM_SLUGS = %w[premium_light premium_full].freeze + + has_many :subscriptions, dependent: :restrict_with_error + + validates :slug, presence: true, uniqueness: true, inclusion: { in: SLUGS } + validates :name, presence: true + + scope :ordered, -> { + order(Arel.sql("CASE slug WHEN 'free' THEN 0 WHEN 'premium_light' THEN 1 ELSE 2 END")) + } + + def self.[](slug) + find_by!(slug: slug.to_s) + end + + def allows_platform?(platform) + return false if platform.to_s == "youtube" && !youtube_enabled? + + Array(features["platforms"]).map(&:to_s).include?(platform.to_s) + end + + def feature(key) + features[key.to_s] + end + + def recordings_enabled? + feature("recordings_enabled") == true + end + + def recording_days + feature("recording_days").to_i + end + + def max_staff_transmission + val = feature("max_staff_transmission") + return legacy_max_staff if val.nil? && feature("max_staff").present? + + val.nil? ? nil : val.to_i + end + + def max_staff_regia + val = feature("max_staff_regia") + return legacy_max_staff if val.nil? && feature("max_staff").present? + + val.nil? ? nil : val.to_i + end + + def max_staff + t = max_staff_transmission + r = max_staff_regia + return legacy_max_staff if legacy_max_staff.positive? && t.nil? && r.nil? && feature("max_staff").present? + return nil if t.nil? && r.nil? + + (t || 0) + (r || 0) + end + + def youtube_mode + feature("youtube_mode")&.to_s + end + + def concurrent_streams_limit + val = feature("concurrent_streams_limit") + val.nil? ? nil : val.to_i + end + + def phone_download_enabled? + feature("phone_download_enabled") == true + end + + def youtube_enabled? + feature("youtube_enabled") == true + end + + def premium? + slug.in?(PREMIUM_SLUGS) + end + + private + + def legacy_max_staff + feature("max_staff").to_i + end +end diff --git a/backend/app/models/recording.rb b/backend/app/models/recording.rb new file mode 100644 index 0000000..edd5000 --- /dev/null +++ b/backend/app/models/recording.rb @@ -0,0 +1,21 @@ +class Recording < ApplicationRecord + STATUSES = %w[processing ready expired].freeze + + belongs_to :stream_session + belongs_to :team + + validates :status, inclusion: { in: STATUSES } + + scope :ready, -> { where(status: "ready").where("expires_at IS NULL OR expires_at > ?", Time.current) } + scope :for_team, ->(team) { where(team: team) } + + def replay_url + return nil unless ready? && stream_session_id.present? + + "#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{stream_session_id}" + end + + def ready? + status == "ready" && (expires_at.nil? || expires_at.future?) + end +end diff --git a/backend/app/models/score_state.rb b/backend/app/models/score_state.rb new file mode 100644 index 0000000..425a512 --- /dev/null +++ b/backend/app/models/score_state.rb @@ -0,0 +1,17 @@ +class ScoreState < ApplicationRecord + belongs_to :stream_session + + def as_cable_payload + { + type: "score_update", + home_sets: home_sets, + away_sets: away_sets, + home_points: home_points, + away_points: away_points, + current_set: current_set, + set_partials: set_partials || [], + timeout_home: timeout_home, + timeout_away: timeout_away + } + end +end diff --git a/backend/app/models/stream_event.rb b/backend/app/models/stream_event.rb new file mode 100644 index 0000000..4d8cdf5 --- /dev/null +++ b/backend/app/models/stream_event.rb @@ -0,0 +1,13 @@ +class StreamEvent < ApplicationRecord + EVENT_TYPES = %w[ + connected disconnected reconnected quality_changed error paused resumed + started ended network_test pairing + ].freeze + + belongs_to :stream_session + + validates :event_type, inclusion: { in: EVENT_TYPES } + validates :occurred_at, presence: true + + scope :recent, -> { order(occurred_at: :desc) } +end diff --git a/backend/app/models/stream_session.rb b/backend/app/models/stream_session.rb new file mode 100644 index 0000000..6b17f40 --- /dev/null +++ b/backend/app/models/stream_session.rb @@ -0,0 +1,150 @@ +class StreamSession < ApplicationRecord + include AASM + + PLATFORMS = %w[matchlivetv youtube facebook twitch].freeze + STATUSES = %w[idle connecting live reconnecting paused ended error].freeze + + belongs_to :match + belongs_to :user + has_many :stream_events, dependent: :destroy + has_one :score_state, dependent: :destroy + has_many :device_states, dependent: :destroy + + validates :platform, inclusion: { in: PLATFORMS } + validates :status, inclusion: { in: STATUSES } + + before_validation :ensure_publish_token, on: :create + + scope :broadcasting, -> { where(status: %w[live connecting reconnecting]) } + scope :search_by_team_or_opponent, lambda { |query| + q = query.to_s.strip + return all if q.blank? + + term = "%#{sanitize_sql_like(q)}%" + joins(match: :team).where( + "teams.name ILIKE :term OR matches.opponent_name ILIKE :term", + term: term + ) + } + + aasm column: :status do + state :idle, initial: true + state :connecting, :live, :reconnecting, :paused, :ended, :error + + event :begin_connect do + transitions from: %i[idle paused], to: :connecting + end + + event :go_live do + transitions from: %i[connecting reconnecting], to: :live + after { update!(started_at: Time.current) if started_at.nil? } + end + + event :lose_connection do + transitions from: :live, to: :reconnecting + after { increment!(:disconnection_count) } + end + + event :reconnect do + transitions from: :reconnecting, to: :live + end + + event :pause do + transitions from: :live, to: :paused + end + + event :resume do + transitions from: :paused, to: :live + end + + event :finish do + transitions from: %i[live reconnecting paused connecting], to: :ended + after { close_session! } + end + + event :fail do + transitions from: %i[connecting reconnecting idle], to: :error + end + end + + def rtmp_ingest_url + # RootEncoder richiede rtmp://host:port/app/stream (due segmenti). + # MediaMTX path = live/match_{uuid} (no ?token= nel path). + "#{MatchLiveTv.mediamtx_rtmp_url}/#{mediamtx_path_name}" + end + + def mediamtx_path_name + "live/match_#{id}" + end + + def matchlivetv_platform? + platform == "matchlivetv" + end + + def hls_playback_url + base = MatchLiveTv.hls_public_url.chomp("/") + "#{base}/#{mediamtx_path_name}/index.m3u8" + end + + def watch_page_url + "#{MatchLiveTv.app_public_url.chomp('/')}/live/#{id}" + end + + def terminal? + status.in?(%w[ended error]) + end + + def resumable? + status.in?(%w[connecting live reconnecting paused]) + end + + def end_stream! + return if terminal? + + if may_finish? + finish! + else + update!(status: "ended") + record_ended_timestamps! + end + end + + def stream_key + return @stream_key if instance_variable_defined?(:@stream_key) + return nil if stream_key_encrypted.blank? + + stream_key_encryptor.decrypt_and_verify(stream_key_encrypted) + rescue ActiveSupport::MessageEncryptor::InvalidMessage + nil + end + + def stream_key=(value) + @stream_key = value + self.stream_key_encrypted = + value.present? ? stream_key_encryptor.encrypt_and_sign(value) : nil + end + + private + + def stream_key_encryptor + key = ActiveSupport::KeyGenerator.new(Rails.application.secret_key_base) + .generate_key("stream_session_stream_key", 32) + ActiveSupport::MessageEncryptor.new(key) + end + + def ensure_publish_token + self.publish_token ||= SecureRandom.urlsafe_base64(32) + end + + def record_ended_timestamps! + now = Time.current + update!(ended_at: now) if ended_at.nil? + if started_at && total_duration_secs.to_i.zero? + update!(total_duration_secs: (now - started_at).to_i) + end + end + + def close_session! + record_ended_timestamps! + end +end diff --git a/backend/app/models/subscription.rb b/backend/app/models/subscription.rb new file mode 100644 index 0000000..1ad36cf --- /dev/null +++ b/backend/app/models/subscription.rb @@ -0,0 +1,28 @@ +class Subscription < ApplicationRecord + STATUSES = %w[active trialing past_due canceled incomplete].freeze + ACTIVE_STATUSES = %w[active trialing].freeze + + belongs_to :team + belongs_to :plan + + validates :status, inclusion: { in: STATUSES } + validates :team_id, uniqueness: true + + scope :active, -> { where(status: ACTIVE_STATUSES) } + + def active? + status.in?(ACTIVE_STATUSES) + end + + def premium? + active? && plan.premium? + end + + def premium_full? + active? && plan.slug == "premium_full" + end + + def free? + plan.slug == "free" + end +end diff --git a/backend/app/models/team.rb b/backend/app/models/team.rb new file mode 100644 index 0000000..f56281b --- /dev/null +++ b/backend/app/models/team.rb @@ -0,0 +1,19 @@ +class Team < ApplicationRecord + has_many :user_teams, dependent: :destroy + has_many :users, through: :user_teams + has_many :matches, dependent: :destroy + has_one :youtube_credential, dependent: :destroy + has_one :subscription, dependent: :destroy + has_many :recordings, dependent: :destroy + has_many :team_invitations, dependent: :destroy + + validates :name, presence: true + + def entitlements + @entitlements ||= Teams::Entitlements.new(self) + end + + def owner + user_teams.find_by(role: "owner")&.user + end +end diff --git a/backend/app/models/team_invitation.rb b/backend/app/models/team_invitation.rb new file mode 100644 index 0000000..846c5b0 --- /dev/null +++ b/backend/app/models/team_invitation.rb @@ -0,0 +1,28 @@ +class TeamInvitation < ApplicationRecord + STAFF_KINDS = %w[transmission regia].freeze + + belongs_to :team + + validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :token_digest, presence: true, uniqueness: true + validates :role, inclusion: { in: UserTeam::ROLES } + validates :staff_kind, inclusion: { in: STAFF_KINDS } + + scope :pending, -> { where(accepted_at: nil).where("expires_at > ?", Time.current) } + + def self.generate_token + SecureRandom.urlsafe_base64(32) + end + + def accept!(user) + ut = UserTeam.find_or_initialize_by(user: user, team: team) + ut.role = role + ut.staff_kind = staff_kind + ut.save! + update!(accepted_at: Time.current) + end + + def expired? + expires_at.past? + end +end diff --git a/backend/app/models/user.rb b/backend/app/models/user.rb new file mode 100644 index 0000000..5d6117f --- /dev/null +++ b/backend/app/models/user.rb @@ -0,0 +1,17 @@ +class User < ApplicationRecord + ROLES = %w[admin coach parent volunteer].freeze + + has_secure_password + + has_many :user_teams, dependent: :destroy + has_many :teams, through: :user_teams + has_many :stream_sessions, dependent: :nullify + + validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :name, presence: true + validates :role, inclusion: { in: ROLES } + + def admin? + role == "admin" + end +end diff --git a/backend/app/models/user_team.rb b/backend/app/models/user_team.rb new file mode 100644 index 0000000..1e7550c --- /dev/null +++ b/backend/app/models/user_team.rb @@ -0,0 +1,10 @@ +class UserTeam < ApplicationRecord + ROLES = %w[owner member].freeze + + belongs_to :user + belongs_to :team + + validates :role, inclusion: { in: ROLES } + validates :staff_kind, inclusion: { in: TeamInvitation::STAFF_KINDS }, allow_nil: true + validates :user_id, uniqueness: { scope: :team_id } +end diff --git a/backend/app/models/youtube_credential.rb b/backend/app/models/youtube_credential.rb new file mode 100644 index 0000000..db04251 --- /dev/null +++ b/backend/app/models/youtube_credential.rb @@ -0,0 +1,22 @@ +class YoutubeCredential < ApplicationRecord + belongs_to :team + + attr_encrypted :access_token, + key: :encryption_key, + attribute: "access_token_encrypted", + mode: :single_iv_salt + attr_encrypted :refresh_token, + key: :encryption_key, + attribute: "refresh_token_encrypted", + mode: :single_iv_salt + + def expired? + expires_at.present? && expires_at < Time.current + end + + private + + def encryption_key + Rails.application.secret_key_base[0, 32] + end +end diff --git a/backend/app/services/billing/assign_plan.rb b/backend/app/services/billing/assign_plan.rb new file mode 100644 index 0000000..0814181 --- /dev/null +++ b/backend/app/services/billing/assign_plan.rb @@ -0,0 +1,12 @@ +module Billing + class AssignPlan + def self.call(team:, plan_slug:, status: "active", stripe_attrs: {}) + plan = Plan[plan_slug] + sub = team.subscription || team.build_subscription + attrs = { plan: plan, status: status }.merge(stripe_attrs.symbolize_keys) + sub.assign_attributes(attrs) + sub.save! + sub + end + end +end diff --git a/backend/app/services/billing/stripe/checkout_session.rb b/backend/app/services/billing/stripe/checkout_session.rb new file mode 100644 index 0000000..ef80376 --- /dev/null +++ b/backend/app/services/billing/stripe/checkout_session.rb @@ -0,0 +1,79 @@ +module Billing + module Stripe + class CheckoutSession + VALID_PLANS = %w[premium_light premium_full].freeze + + def initialize(team:, user:, plan_slug: "premium_light") + @team = team + @user = user + @plan_slug = plan_slug.to_s + end + + def url + raise "Stripe non configurato" unless MatchLiveTv.stripe_enabled? + raise "Piano non valido" unless VALID_PLANS.include?(@plan_slug) + + plan = Plan[@plan_slug] + price_id = plan.stripe_price_id.presence || stripe_price_id_for(@plan_slug) + raise "Price ID Stripe mancante per #{@plan_slug}" if price_id.blank? + + customer_id = ensure_customer_id! + session = ::Stripe::Checkout::Session.create( + mode: "subscription", + customer: customer_id, + line_items: [{ price: price_id, quantity: 1 }], + success_url: success_url, + cancel_url: cancel_url, + metadata: { team_id: @team.id, user_id: @user.id, plan_slug: @plan_slug }, + subscription_data: { metadata: { team_id: @team.id, plan_slug: @plan_slug } } + ) + session.url + end + + def portal_url + raise "Stripe non configurato" unless MatchLiveTv.stripe_enabled? + + customer_id = ensure_customer_id! + session = ::Stripe::BillingPortal::Session.create( + customer: customer_id, + return_url: dashboard_url + ) + session.url + end + + private + + def stripe_price_id_for(slug) + case slug + when "premium_light" then MatchLiveTv.stripe_premium_light_price_id + when "premium_full" then MatchLiveTv.stripe_premium_full_price_id + end + end + + def ensure_customer_id! + sub = @team.subscription || @team.build_subscription(plan: Plan["free"], status: "active") + return sub.stripe_customer_id if sub.stripe_customer_id.present? + + customer = ::Stripe::Customer.create( + email: @user.email, + name: @team.name, + metadata: { team_id: @team.id } + ) + sub.update!(stripe_customer_id: customer.id) + customer.id + end + + def success_url + "#{dashboard_url}?checkout=success" + end + + def cancel_url + "#{dashboard_url}?checkout=canceled" + end + + def dashboard_url + "#{MatchLiveTv.app_public_url.chomp('/')}/teams/#{@team.id}/dashboard" + end + end + end +end diff --git a/backend/app/services/billing/stripe/webhook_handler.rb b/backend/app/services/billing/stripe/webhook_handler.rb new file mode 100644 index 0000000..35440f5 --- /dev/null +++ b/backend/app/services/billing/stripe/webhook_handler.rb @@ -0,0 +1,131 @@ +module Billing + module Stripe + class WebhookHandler + def self.call(event) + new(event).call + end + + def initialize(event) + @event = event + end + + def call + case @event.type + when "checkout.session.completed" + handle_checkout_completed(@event.data.object) + when "customer.subscription.updated", "customer.subscription.created" + sync_subscription(@event.data.object) + when "customer.subscription.deleted" + downgrade_to_free(@event.data.object) + when "invoice.payment_failed" + mark_past_due(@event.data.object) + end + end + + private + + def handle_checkout_completed(session) + team_id = metadata_team_id(session) + return if team_id.blank? + + team = Team.find_by(id: team_id) + return unless team + + sub = team.subscription || team.build_subscription + sub.update!(stripe_customer_id: session.customer) if session.customer.present? + + if session.subscription.present? + stripe_sub = ::Stripe::Subscription.retrieve(session.subscription) + plan_slug = metadata_plan_slug(session) || "premium_light" + plan_slug = "premium_full" if plan_slug == "premium" + sync_subscription(stripe_sub, team: team, plan_slug: plan_slug) + end + end + + def sync_subscription(stripe_sub, team: nil, plan_slug: nil) + team ||= Team.joins(:subscription) + .find_by(subscriptions: { stripe_subscription_id: stripe_sub.id }) + team ||= Team.find_by(id: metadata_team_id(stripe_sub)) + return unless team + + plan_slug ||= metadata_plan_slug(stripe_sub) || "premium_light" + plan_slug = "premium_full" if plan_slug == "premium" + plan = Plan[plan_slug] + status = map_status(stripe_sub.status) + period_start = unix_time(stripe_sub.current_period_start) + period_end = unix_time(stripe_sub.current_period_end) + + Billing::AssignPlan.call( + team: team, + plan_slug: plan.slug, + status: status, + stripe_attrs: { + stripe_customer_id: stripe_sub.customer, + stripe_subscription_id: stripe_sub.id, + current_period_start: period_start, + current_period_end: period_end, + cancel_at_period_end: stripe_sub.cancel_at_period_end + } + ) + end + + def downgrade_to_free(stripe_sub) + team = Team.joins(:subscription) + .find_by(subscriptions: { stripe_subscription_id: stripe_sub.id }) + return unless team + + Billing::AssignPlan.call( + team: team, + plan_slug: "free", + status: "active", + stripe_attrs: { + stripe_subscription_id: nil, + current_period_start: nil, + current_period_end: nil, + cancel_at_period_end: false + } + ) + end + + def mark_past_due(invoice) + return if invoice.subscription.blank? + + team = Team.joins(:subscription) + .find_by(subscriptions: { stripe_subscription_id: invoice.subscription }) + return unless team&.subscription + + team.subscription.update!(status: "past_due") + end + + def unix_time(value) + return nil if value.blank? + + Time.zone.at(value.to_i) + end + + def metadata_plan_slug(obj) + meta = obj.metadata + return nil if meta.blank? + + meta["plan_slug"] || meta[:plan_slug] || (meta.respond_to?(:plan_slug) ? meta.plan_slug : nil) + end + + def metadata_team_id(obj) + meta = obj.metadata + return nil if meta.blank? + + meta["team_id"] || meta[:team_id] || (meta.respond_to?(:team_id) ? meta.team_id : nil) + end + + def map_status(stripe_status) + case stripe_status + when "trialing" then "trialing" + when "active" then "active" + when "past_due", "unpaid" then "past_due" + when "canceled", "incomplete_expired" then "canceled" + else "incomplete" + end + end + end + end +end diff --git a/backend/app/services/mediamtx/client.rb b/backend/app/services/mediamtx/client.rb new file mode 100644 index 0000000..4d56a74 --- /dev/null +++ b/backend/app/services/mediamtx/client.rb @@ -0,0 +1,101 @@ +require "set" + +module Mediamtx + class Client + class Error < StandardError; end + + def initialize(base_url: MatchLiveTv.mediamtx_api_url) + @conn = Faraday.new(url: base_url) do |f| + f.request :json + f.response :json + f.adapter Faraday.default_adapter + end + end + + def create_path(session) + path = session.mediamtx_path_name + record = session.match.team.entitlements.recording_enabled_for_mediamtx? + body = { + source: "publisher", + overridePublisher: true, + record: record, + recordPath: "/recordings/%path/%Y-%m-%d_%H-%M-%S", + recordSegmentDuration: "60s" + } + unless session.matchlivetv_platform? + body[:runOnReady] = run_on_ready_script(session) + body[:runOnReadyRestart] = true + body[:runOnNotReady] = webhook_curl(session, "disconnect") + end + 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 path create failed: #{response.status} #{err}" + end + true + end + + def delete_path(session) + delete_path_name(session.mediamtx_path_name) + end + + def delete_path_name(path_name) + @conn.post("/v3/config/paths/delete/#{CGI.escape(path_name)}") + end + + def list_paths + response = @conn.get("/v3/paths/list") + return [] unless response.success? + + body = response.body + body.is_a?(Hash) ? (body["items"] || []) : [] + rescue Error, Faraday::Error + [] + end + + def online_path_names + Set.new(list_paths.filter_map { |item| item["name"] if item["online"] }) + end + + private + + def publish_webhook(session) + webhook_curl(session, "connect") + end + + def disconnect_webhook(session) + webhook_curl(session, "disconnect") + end + + def webhook_curl(session, event) + secret = MatchLiveTv.mediamtx_webhook_secret + rails = ENV.fetch("RAILS_WEBHOOK_URL", "http://rails:3000") + payload = %({"session_id":"#{session.id}"}) + # MediaMTX image non include curl; wget è disponibile nell'immagine ufficiale + <<~SCRIPT.squish + wget -q -O- --post-data='#{payload}' --header='Content-Type: application/json' + --header="X-MediaMTX-Signature: $(printf '%s' '#{payload}' | openssl dgst -sha256 -hmac '#{secret}' | cut -d' ' -f2)" + #{rails}/webhooks/mediamtx/#{event} + SCRIPT + end + + def run_on_ready_script(session) + connect = webhook_curl(session, "connect") + return connect if session.matchlivetv_platform? + + "#{connect} & #{relay_script(session)}" + end + + def relay_script(session) + return "echo 'no youtube key'" if session.stream_key.blank? + return "echo 'youtube relay skipped'" unless session.platform == "youtube" + + key = session.stream_key + path = session.mediamtx_path_name + <<~SCRIPT.squish + ffmpeg -re -i rtmp://127.0.0.1:1935/#{path} -c copy -f flv + rtmp://a.rtmp.youtube.com/live2/#{key} 2>/var/log/ffmpeg-#{path}.log + SCRIPT + end + end +end diff --git a/backend/app/services/recordings/index_session.rb b/backend/app/services/recordings/index_session.rb new file mode 100644 index 0000000..fe5c227 --- /dev/null +++ b/backend/app/services/recordings/index_session.rb @@ -0,0 +1,25 @@ +module Recordings + class IndexSession + def initialize(session) + @session = session + end + + def call + team = @session.match.team + return unless team.entitlements.can_access_recordings? + + retention_days = team.entitlements.recording_retention_days + expires_at = retention_days.positive? ? retention_days.days.from_now : nil + + recording = Recording.find_or_initialize_by(stream_session: @session) + recording.assign_attributes( + team: team, + status: "ready", + storage_path: @session.mediamtx_path_name, + expires_at: expires_at + ) + recording.save! + recording + end + end +end diff --git a/backend/app/services/sessions/create.rb b/backend/app/services/sessions/create.rb new file mode 100644 index 0000000..c50a14d --- /dev/null +++ b/backend/app/services/sessions/create.rb @@ -0,0 +1,61 @@ +module Sessions + class Create + def initialize(user:, match:, params:) + @user = user + @match = match + @params = params + end + + def call + platform = @params[:platform].presence || "matchlivetv" + ent = @match.team.entitlements + ent.assert_can_stream_on!(platform) + ent.assert_concurrent_stream! + + session = StreamSession.new( + match: @match, + user: @user, + platform: platform, + privacy_status: @params[:privacy_status] || "unlisted", + quality_preset: @params[:quality_preset] || "720p_30_2.5mbps", + target_bitrate: @params[:target_bitrate] || 2_500_000, + target_fps: @params[:target_fps] || 30, + status: "idle" + ) + + if session.platform == "youtube" + attach_youtube_broadcast!(session) + end + + StreamSession.transaction do + session.save! + session.create_score_state! + Mediamtx::Client.new.create_path(session) + log_event(session, "pairing", { created: true, platform: session.platform }) + end + + session + end + + private + + def attach_youtube_broadcast!(session) + yt = Youtube::BroadcastService.new(session.match.team) + title = "#{session.match.team.name} vs #{session.match.opponent_name}" + broadcast = yt.create_broadcast!( + title: title, + privacy_status: session.privacy_status, + scheduled_at: session.match.scheduled_at + ) + + session.youtube_broadcast_id = broadcast[:broadcast_id] + session.youtube_stream_id = broadcast[:stream_id] + session.stream_key = broadcast[:stream_key] + session.rtmp_url = broadcast[:rtmp_url] + end + + def log_event(session, type, metadata) + session.stream_events.create!(event_type: type, metadata: metadata, occurred_at: Time.current) + end + end +end diff --git a/backend/app/services/sessions/pause.rb b/backend/app/services/sessions/pause.rb new file mode 100644 index 0000000..3852008 --- /dev/null +++ b/backend/app/services/sessions/pause.rb @@ -0,0 +1,20 @@ +module Sessions + class Pause + def initialize(session) + @session = session + end + + def call + @session.pause! if @session.may_pause? + log_event("paused") + SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" }) + @session + end + + private + + def log_event(type) + @session.stream_events.create!(event_type: type, occurred_at: Time.current, metadata: {}) + end + end +end diff --git a/backend/app/services/sessions/start.rb b/backend/app/services/sessions/start.rb new file mode 100644 index 0000000..864f05f --- /dev/null +++ b/backend/app/services/sessions/start.rb @@ -0,0 +1,21 @@ +module Sessions + class Start + def initialize(session) + @session = session + end + + def call + @session.match.team.entitlements.assert_concurrent_stream!(excluding_session: @session) + @session.begin_connect! if @session.may_begin_connect? + @session.update!(status: "connecting") unless @session.connecting? + broadcast_status("connecting") + @session + end + + private + + def broadcast_status(status) + SessionChannel.broadcast_message(@session, { type: "stream_event", event: "starting", status: status }) + end + end +end diff --git a/backend/app/services/sessions/stop.rb b/backend/app/services/sessions/stop.rb new file mode 100644 index 0000000..6077fcf --- /dev/null +++ b/backend/app/services/sessions/stop.rb @@ -0,0 +1,30 @@ +module Sessions + class Stop + def initialize(session) + @session = session + end + + def call + cancel_timeout_job + @session.end_stream! + Youtube::BroadcastService.new(@session.match.team).complete_broadcast!(@session.youtube_broadcast_id) + Recordings::IndexSession.new(@session).call + Mediamtx::Client.new.delete_path(@session) + log_event("ended") + SessionChannel.broadcast_message(@session, { type: "stream_event", event: "ended" }) + @session + end + + private + + def cancel_timeout_job + return if @session.timeout_job_id.blank? + + Sidekiq::ScheduledSet.new.find_job(@session.timeout_job_id)&.delete + end + + def log_event(type) + @session.stream_events.create!(event_type: type, occurred_at: Time.current, metadata: {}) + end + end +end diff --git a/backend/app/services/teams/entitlement_error.rb b/backend/app/services/teams/entitlement_error.rb new file mode 100644 index 0000000..6ca01cd --- /dev/null +++ b/backend/app/services/teams/entitlement_error.rb @@ -0,0 +1,11 @@ +module Teams + class EntitlementError < StandardError + attr_reader :code, :billing_url + + def initialize(message, code: "premium_required", billing_url: nil) + super(message) + @code = code + @billing_url = billing_url + end + end +end diff --git a/backend/app/services/teams/entitlements.rb b/backend/app/services/teams/entitlements.rb new file mode 100644 index 0000000..8afbeef --- /dev/null +++ b/backend/app/services/teams/entitlements.rb @@ -0,0 +1,239 @@ +module Teams + class Entitlements + ACTIVE_STREAM_STATUSES = %w[idle connecting live reconnecting paused].freeze + + def initialize(team) + @team = team + end + + def subscription + @subscription ||= @team.subscription || ensure_free_subscription! + end + + def plan + subscription.plan + end + + def active? + subscription.active? + end + + def premium_active? + subscription.premium? + end + + def premium_full? + subscription.premium_full? + end + + def youtube_enabled? + active? && plan.youtube_enabled? + end + + def phone_download_enabled? + active? && plan.phone_download_enabled? + end + + def max_staff_transmission + plan.max_staff_transmission + end + + def max_staff_regia + plan.max_staff_regia + end + + def max_staff + plan.max_staff + end + + def staff_count + staff_count_for("transmission") + staff_count_for("regia") + end + + def staff_count_for(kind) + members_count_for(kind) + pending_invitations_count_for(kind) + end + + def members_count + @team.user_teams.where(role: "member").count + end + + def members_count_for(kind) + @team.user_teams.where(role: "member", staff_kind: kind).count + end + + def pending_invitations_count + @team.team_invitations.pending.count + end + + def pending_invitations_count_for(kind) + @team.team_invitations.pending.where(staff_kind: kind).count + end + + def concurrent_streams_limit + plan.concurrent_streams_limit + end + + def concurrent_streams_used + StreamSession.joins(:match) + .where(matches: { team_id: @team.id }) + .where(status: ACTIVE_STREAM_STATUSES) + .count + end + + def concurrent_streams_limit_reached? + limit = concurrent_streams_limit + return false if limit.nil? + + concurrent_streams_used >= limit + end + + def can_stream_on?(platform) + return false unless active? + + platform = platform.to_s + return false unless plan.allows_platform?(platform) + + true + end + + def assert_can_stream_on!(platform) + platform = platform.to_s + unless active? + raise EntitlementError.new( + "Abbonamento non attivo per questa squadra", + code: "subscription_inactive", + billing_url: billing_url + ) + end + + if platform == "youtube" && !youtube_enabled? + raise EntitlementError.new( + "YouTube richiede un piano Premium", + code: "premium_required", + billing_url: billing_url + ) + end + + if platform == "youtube" && premium_full? == false && plan.youtube_mode != "matchlivetv_light" + raise EntitlementError.new( + "Il canale YouTube della società richiede Premium Full", + code: "premium_full_required", + billing_url: billing_url + ) + end + + return if plan.allows_platform?(platform) + + raise EntitlementError.new("Piattaforma non consentita", code: "platform_denied", billing_url: billing_url) + end + + def assert_concurrent_stream!(excluding_session: nil) + return unless active? + + used = concurrent_streams_used + used -= 1 if excluding_session && ACTIVE_STREAM_STATUSES.include?(excluding_session.status) + + limit = concurrent_streams_limit + return if limit.nil? + return if used < limit + + raise EntitlementError.new( + "Limite dirette concorrenti raggiunto (#{limit}). Chiudi una diretta o passa a un piano superiore.", + code: "concurrent_limit_reached", + billing_url: billing_url + ) + end + + def assert_can_invite!(staff_kind: "transmission") + return unless active? + + kind = staff_kind.to_s + unless TeamInvitation::STAFF_KINDS.include?(kind) + raise EntitlementError.new("Ruolo staff non valido", code: "invalid_staff_kind") + end + + limit = staff_limit_for(kind) + return if limit.nil? + + used = staff_count_for(kind) + return if used < limit + + label = kind == "regia" ? "regia" : "trasmissione" + raise EntitlementError.new( + "Limite staff #{label} raggiunto (#{limit} all'anno). Passa a un piano superiore.", + code: "staff_limit_reached", + billing_url: billing_url + ) + end + + def staff_limit_for(kind) + kind == "regia" ? max_staff_regia : max_staff_transmission + end + + def assert_can_connect_youtube! + unless premium_full? + raise EntitlementError.new( + "Collegamento al canale YouTube della società richiede Premium Full", + code: "premium_full_required", + billing_url: billing_url + ) + end + + assert_can_stream_on!("youtube") + end + + def can_access_recordings? + active? && plan.recordings_enabled? + end + + def recording_enabled_for_mediamtx? + can_access_recordings? + end + + def recording_retention_days + plan.recording_days + end + + def billing_url + "#{MatchLiveTv.app_public_url.chomp('/')}/teams/#{@team.id}/billing" + end + + def staff_manage_url + "#{MatchLiveTv.app_public_url.chomp('/')}/teams/#{@team.id}/dashboard" + end + + def as_json + { + plan_slug: plan.slug, + plan_name: plan.name, + premium_active: premium_active?, + premium_full: premium_full?, + subscription_status: subscription.status, + features: plan.features, + billing_url: billing_url, + staff_manage_url: staff_manage_url, + max_staff: max_staff, + max_staff_transmission: max_staff_transmission, + max_staff_regia: max_staff_regia, + staff_used: staff_count, + staff_transmission_used: staff_count_for("transmission"), + staff_regia_used: staff_count_for("regia"), + concurrent_streams_used: concurrent_streams_used, + concurrent_streams_limit: concurrent_streams_limit, + recordings_enabled: can_access_recordings?, + recording_retention_days: recording_retention_days, + phone_download_enabled: phone_download_enabled?, + youtube_enabled: youtube_enabled?, + youtube_mode: plan.youtube_mode + } + end + + private + + def ensure_free_subscription! + Billing::AssignPlan.call(team: @team, plan_slug: "free") + @team.reload.subscription + end + end +end diff --git a/backend/app/services/webhooks/mediamtx_handler.rb b/backend/app/services/webhooks/mediamtx_handler.rb new file mode 100644 index 0000000..f40ed31 --- /dev/null +++ b/backend/app/services/webhooks/mediamtx_handler.rb @@ -0,0 +1,94 @@ +module Webhooks + class MediamtxHandler + def initialize(event:, session_id:, metadata: {}) + @event = event + @session_id = session_id + @metadata = metadata + end + + def call + session = StreamSession.find_by(id: @session_id) + return unless session + + case @event + when "connect" + handle_connect(session) + when "disconnect" + handle_disconnect(session) + when "ready" + handle_ready(session) + end + end + + private + + def handle_connect(session) + return if session.live? && session.stream_events.where(event_type: "connected").exists? + + if session.reconnecting? + session.reconnect! if session.may_reconnect? + cancel_timeout(session) + log(session, "reconnected") + broadcast(session, "reconnected") + elsif session.connecting? || session.idle? + session.go_live! if session.may_go_live? + log(session, "connected") + broadcast(session, "connected") + end + end + + def handle_disconnect(session) + return unless session.live? || session.connecting? + + if session.connecting? + session.update!(status: "reconnecting") + elsif session.may_lose_connection? + session.lose_connection! + end + log(session, "disconnected", @metadata) + broadcast(session, "disconnected") + schedule_timeout(session) + end + + def handle_ready(session) + log(session, "connected", { ready: true }) + end + + def schedule_timeout(session) + job = DisconnectionTimeoutJob.perform_in( + MatchLiveTv.reconnect_timeout_seconds.seconds, + session.id + ) + session.update!(timeout_job_id: job) + end + + def cancel_timeout(session) + return if session.timeout_job_id.blank? + + Sidekiq::ScheduledSet.new.find_job(session.timeout_job_id)&.delete + session.update!(timeout_job_id: nil) + end + + def log(session, type, meta = {}) + return if duplicate_event?(session, type, meta) + + session.stream_events.create!( + event_type: type, + metadata: meta.merge(source: "mediamtx"), + occurred_at: Time.current + ) + end + + def duplicate_event?(session, type, meta) + session.stream_events + .where(event_type: type) + .where("occurred_at > ?", 2.seconds.ago) + .where(metadata: meta.merge(source: "mediamtx")) + .exists? + end + + def broadcast(session, event) + SessionChannel.broadcast_message(session, { type: "stream_event", event: event }) + end + end +end diff --git a/backend/app/services/webhooks/signature.rb b/backend/app/services/webhooks/signature.rb new file mode 100644 index 0000000..fce95ba --- /dev/null +++ b/backend/app/services/webhooks/signature.rb @@ -0,0 +1,14 @@ +module Webhooks + class Signature + def self.valid?(payload, signature) + return false if signature.blank? + + expected = OpenSSL::HMAC.hexdigest( + "SHA256", + MatchLiveTv.mediamtx_webhook_secret, + payload + ) + ActiveSupport::SecurityUtils.secure_compare(expected, signature) + end + end +end diff --git a/backend/app/services/youtube/broadcast_service.rb b/backend/app/services/youtube/broadcast_service.rb new file mode 100644 index 0000000..c82a88d --- /dev/null +++ b/backend/app/services/youtube/broadcast_service.rb @@ -0,0 +1,89 @@ +module Youtube + class BroadcastService + class Error < StandardError; end + + def initialize(team) + @team = team + @credential = team.youtube_credential + end + + def create_broadcast!(title:, privacy_status: "unlisted", scheduled_at: nil) + return mock_broadcast if @credential.blank? || missing_oauth_config? + + client = authorized_client + broadcast = Google::Apis::YoutubeV3::LiveBroadcast.new( + snippet: Google::Apis::YoutubeV3::LiveBroadcastSnippet.new( + title: title, + scheduled_start_time: scheduled_at&.iso8601 + ), + status: Google::Apis::YoutubeV3::LiveBroadcastStatus.new( + privacy_status: privacy_status, + self_declared_made_for_kids: false + ), + content_details: Google::Apis::YoutubeV3::LiveBroadcastContentDetails.new( + enable_auto_start: true, + enable_auto_stop: true + ) + ) + result = client.insert_live_broadcast("snippet,status,contentDetails", broadcast) + + stream = Google::Apis::YoutubeV3::LiveStream.new( + snippet: Google::Apis::YoutubeV3::LiveStreamSnippet.new(title: "#{title} stream"), + cdn: Google::Apis::YoutubeV3::CdnSettings.new( + frame_rate: "30fps", + ingestion_type: "rtmp", + resolution: "720p" + ) + ) + stream_result = client.insert_live_stream("snippet,cdn", stream) + client.bind_live_broadcast(result.id, "id,contentDetails", stream_result.id) + + { + broadcast_id: result.id, + stream_id: stream_result.id, + stream_key: stream_result.cdn.ingestion_info.stream_name, + rtmp_url: stream_result.cdn.ingestion_info.ingestion_address + } + rescue Google::Apis::Error => e + raise Error, e.message + end + + def complete_broadcast!(broadcast_id) + return if @credential.blank? || missing_oauth_config? + + client = authorized_client + client.transition_live_broadcast(broadcast_id, "complete") + rescue Google::Apis::Error + nil + end + + def viewer_count(broadcast_id) + return 0 if @credential.blank? || missing_oauth_config? + + client = authorized_client + resp = client.list_live_broadcasts("statistics", id: broadcast_id) + resp.items&.first&.statistics&.concurrent_viewers.to_i + rescue Google::Apis::Error + 0 + end + + private + + def mock_broadcast + { + broadcast_id: "mock_#{SecureRandom.hex(8)}", + stream_id: "mock_stream", + stream_key: ENV.fetch("YOUTUBE_MOCK_STREAM_KEY", "mock-stream-key"), + rtmp_url: "rtmp://a.rtmp.youtube.com/live2" + } + end + + def missing_oauth_config? + ENV["YOUTUBE_CLIENT_ID"].blank? + end + + def authorized_client + OauthRefresh.new(@credential).apply!(Google::Apis::YoutubeV3::YouTubeService.new) + end + end +end diff --git a/backend/app/services/youtube/oauth_exchange.rb b/backend/app/services/youtube/oauth_exchange.rb new file mode 100644 index 0000000..7a47726 --- /dev/null +++ b/backend/app/services/youtube/oauth_exchange.rb @@ -0,0 +1,25 @@ +module Youtube + class OauthExchange + TOKEN_URL = "https://oauth2.googleapis.com/token".freeze + + def self.call(code) + response = Faraday.post(TOKEN_URL, { + code: code, + client_id: ENV["YOUTUBE_CLIENT_ID"], + client_secret: ENV["YOUTUBE_CLIENT_SECRET"], + redirect_uri: ENV["YOUTUBE_REDIRECT_URI"], + grant_type: "authorization_code" + }) + data = JSON.parse(response.body) + raise BroadcastService::Error, data["error_description"] if data["error"] + + { + access_token: data["access_token"], + refresh_token: data["refresh_token"], + expires_in: data["expires_in"].to_i, + channel_id: nil, + channel_title: nil + } + end + end +end diff --git a/backend/app/services/youtube/oauth_refresh.rb b/backend/app/services/youtube/oauth_refresh.rb new file mode 100644 index 0000000..15e89d8 --- /dev/null +++ b/backend/app/services/youtube/oauth_refresh.rb @@ -0,0 +1,31 @@ +module Youtube + class OauthRefresh + TOKEN_URL = "https://oauth2.googleapis.com/token".freeze + + def initialize(credential) + @credential = credential + end + + def apply!(service) + refresh! if @credential.expired? + service.authorization = @credential.access_token + service + end + + def refresh! + response = Faraday.post(TOKEN_URL, { + client_id: ENV["YOUTUBE_CLIENT_ID"], + client_secret: ENV["YOUTUBE_CLIENT_SECRET"], + refresh_token: @credential.refresh_token, + grant_type: "refresh_token" + }) + data = JSON.parse(response.body) + raise Youtube::BroadcastService::Error, data["error"] if data["error"] + + @credential.update!( + access_token: data["access_token"], + expires_at: Time.current + data["expires_in"].to_i.seconds + ) + end + end +end diff --git a/backend/app/services/youtube/oauth_url.rb b/backend/app/services/youtube/oauth_url.rb new file mode 100644 index 0000000..36b3738 --- /dev/null +++ b/backend/app/services/youtube/oauth_url.rb @@ -0,0 +1,22 @@ +module Youtube + class OauthUrl + AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth".freeze + SCOPES = [ + "https://www.googleapis.com/auth/youtube", + "https://www.googleapis.com/auth/youtube.force-ssl" + ].join(" ").freeze + + def self.build(team_id:, redirect_uri:) + params = { + client_id: ENV.fetch("YOUTUBE_CLIENT_ID", "not_configured"), + redirect_uri: redirect_uri, + response_type: "code", + scope: SCOPES, + access_type: "offline", + prompt: "consent", + state: team_id + } + "#{AUTH_URL}?#{URI.encode_www_form(params)}" + end + end +end diff --git a/backend/app/views/admin/dashboard/index.html.erb b/backend/app/views/admin/dashboard/index.html.erb new file mode 100644 index 0000000..50bffd6 --- /dev/null +++ b/backend/app/views/admin/dashboard/index.html.erb @@ -0,0 +1,25 @@ +

Sessioni attive

+<% if @active_sessions.any? %> + + + + <% @active_sessions.each do |s| %> + + + + + + + <% end %> + +
MatchStatusIniziata
<%= s.match.team.name %> vs <%= s.match.opponent_name %><%= s.status %><%= s.started_at %><%= link_to "Dettaglio", admin_session_path(s) %>
+<% else %> +

Nessuna sessione attiva.

+<% end %> + +

Squadre

+ diff --git a/backend/app/views/admin/sessions/index.html.erb b/backend/app/views/admin/sessions/index.html.erb new file mode 100644 index 0000000..2a7a157 --- /dev/null +++ b/backend/app/views/admin/sessions/index.html.erb @@ -0,0 +1,14 @@ +

Stream Sessions

+ + + + <% @sessions.each do |s| %> + + + + + + + <% end %> + +
MatchStatusDisconnects
<%= s.match.team.name %> vs <%= s.match.opponent_name %><%= s.status %><%= s.disconnection_count %><%= link_to "Dettaglio", admin_session_path(s) %>
diff --git a/backend/app/views/admin/sessions/show.html.erb b/backend/app/views/admin/sessions/show.html.erb new file mode 100644 index 0000000..6829e3e --- /dev/null +++ b/backend/app/views/admin/sessions/show.html.erb @@ -0,0 +1,21 @@ +

Session <%= @session.id %>

+

Status: <%= @session.status %>

+

Match: <%= @session.match.team.name %> vs <%= @session.match.opponent_name %>

+<% if @session.youtube_broadcast_id %> +

YouTube: Broadcast

+<% end %> +

RTMP ingest: <%= @session.rtmp_ingest_url %>

+ +

Eventi

+ + + + <% @events.each do |e| %> + + + + + + <% end %> + +
TipoQuandoMeta
<%= e.event_type %><%= e.occurred_at %><%= e.metadata.to_json %>
diff --git a/backend/app/views/admin/teams/index.html.erb b/backend/app/views/admin/teams/index.html.erb new file mode 100644 index 0000000..b57ef48 --- /dev/null +++ b/backend/app/views/admin/teams/index.html.erb @@ -0,0 +1,13 @@ +

Teams

+ + + + <% @teams.each do |t| %> + + + + + + <% end %> + +
NomeSportYouTube
<%= link_to t.name, admin_team_path(t) %><%= t.sport %><%= t.youtube_credential.present? ? "✓" : "—" %>
diff --git a/backend/app/views/admin/teams/show.html.erb b/backend/app/views/admin/teams/show.html.erb new file mode 100644 index 0000000..13b4a88 --- /dev/null +++ b/backend/app/views/admin/teams/show.html.erb @@ -0,0 +1,10 @@ +

<%= @team.name %>

+

Sport: <%= @team.sport %>

+

YouTube: <%= @team.youtube_credential ? "Connesso" : link_to("Connetti", "/api/v1/teams/#{@team.id}/youtube/authorize") %>

+ +

Partite

+ diff --git a/backend/app/views/layouts/admin.html.erb b/backend/app/views/layouts/admin.html.erb new file mode 100644 index 0000000..28ee4c9 --- /dev/null +++ b/backend/app/views/layouts/admin.html.erb @@ -0,0 +1,32 @@ + + + + Match Live TV Admin + + + + +
+

MATCH LIVE TV — Admin

+ +
+ <%= yield %> + + diff --git a/backend/app/views/layouts/mailer.html.erb b/backend/app/views/layouts/mailer.html.erb new file mode 100644 index 0000000..3aac900 --- /dev/null +++ b/backend/app/views/layouts/mailer.html.erb @@ -0,0 +1,13 @@ + + + + + + + + + <%= yield %> + + diff --git a/backend/app/views/layouts/mailer.text.erb b/backend/app/views/layouts/mailer.text.erb new file mode 100644 index 0000000..37f0bdd --- /dev/null +++ b/backend/app/views/layouts/mailer.text.erb @@ -0,0 +1 @@ +<%= yield %> diff --git a/backend/app/views/layouts/marketing.html.erb b/backend/app/views/layouts/marketing.html.erb new file mode 100644 index 0000000..ec2f0ee --- /dev/null +++ b/backend/app/views/layouts/marketing.html.erb @@ -0,0 +1,20 @@ + + + + + + <%= content_for?(:title) ? yield(:title) : "Match Live TV" %> + <%= render "shared/meta_tags" %> + + + + + <%= render "shared/marketing_nav" %> +
+ <% if flash[:notice] %>
<%= flash[:notice] %>
<% end %> + <% if flash[:alert] %>
<%= flash[:alert] %>
<% end %> + <%= yield %> +
+ <%= render "shared/marketing_footer" %> + + diff --git a/backend/app/views/layouts/marketing_live.html.erb b/backend/app/views/layouts/marketing_live.html.erb new file mode 100644 index 0000000..3dc6fc8 --- /dev/null +++ b/backend/app/views/layouts/marketing_live.html.erb @@ -0,0 +1,20 @@ + + + + + + <%= content_for?(:title) ? yield(:title) : "Match Live TV" %> + <%= render "shared/meta_tags" %> + + + + <%= yield :head %> + + + <%= render "shared/marketing_nav" %> +
+ <%= yield %> +
+ <%= render "shared/marketing_footer" %> + + diff --git a/backend/app/views/layouts/public.html.erb b/backend/app/views/layouts/public.html.erb new file mode 100644 index 0000000..5b94f43 --- /dev/null +++ b/backend/app/views/layouts/public.html.erb @@ -0,0 +1,56 @@ + + + + + + <%= content_for?(:title) ? yield(:title) : "Match Live TV" %> + + + +
+
+ <%= link_to public_pricing_path, class: "brand" do %>Match Live TV<% end %> + +
+
+
+ <% if flash[:notice] %>
<%= flash[:notice] %>
<% end %> + <% if flash[:alert] %>
<%= flash[:alert] %>
<% end %> + <%= yield %> +
+ + diff --git a/backend/app/views/public/invitations/show.html.erb b/backend/app/views/public/invitations/show.html.erb new file mode 100644 index 0000000..0f39838 --- /dev/null +++ b/backend/app/views/public/invitations/show.html.erb @@ -0,0 +1,12 @@ +<% content_for :title, "Invito squadra" %> + +
+

Accesso staff — <%= @invitation.team.name %>

+

Sei stato aggiunto allo staff come <%= @invitation.staff_kind == "regia" ? "Regia" : "Trasmissione" %> (<%= @invitation.email %>).

+ <% if logged_in? %> + <%= button_to "Accetta invito", public_invitation_path(token: @token), method: :post, class: "btn btn-primary" %> + <% else %> +

<%= link_to "Accedi", public_login_path %> o <%= link_to "registrati", public_signup_path %> con <%= @invitation.email %>.

+ <%= button_to "Accetta (se già loggato)", public_invitation_path(token: @token), method: :post, class: "btn btn-secondary" %> + <% end %> +
diff --git a/backend/app/views/public/live/index.html.erb b/backend/app/views/public/live/index.html.erb new file mode 100644 index 0000000..8e465b6 --- /dev/null +++ b/backend/app/views/public/live/index.html.erb @@ -0,0 +1,76 @@ +<% content_for :title do %>Dirette in corso — Match Live TV<% end %> + +
+

Dirette in corso

+

+ Cerca per nome squadra o avversario, poi apri la diretta per guardare lo stream. +

+ +<%= form_with url: public_live_index_path, method: :get, local: true, class: "search-form" do %> + + + <% if @query.present? %> + <%= link_to "Azzera", public_live_index_path, class: "btn btn-secondary" %> + <% end %> +<% end %> + +<% if @query.present? %> +

+ Risultati per «<%= h @query %>»: <%= @sessions.size %> diretta<%= @sessions.size == 1 ? "" : "e" %> +

+<% end %> + +<% if @sessions.any? %> +
+ <% @sessions.each do |session| %> + <% match = session.match %> + <% on_air = @online_paths.include?(session.mediamtx_path_name) %> +
+

<%= match.team.name %> vs <%= match.opponent_name %>

+

+ <% if match.location.present? %><%= match.location %> · <% end %> + <%= session.platform == "matchlivetv" ? "Match Live TV" : session.platform.capitalize %> +

+ <% if session.score_state %> +

+ <%= live_score_sets_label(session.score_state) %> + <% if live_score_partials_label(session.score_state).present? %> + Parziali: <%= live_score_partials_label(session.score_state) %> + <% end %> + <%= session.score_state.home_points %> - <%= session.score_state.away_points %> +

+ <% end %> +
+ <% if on_air %> + In onda + <% elsif session.status == "live" %> + Live + <% elsif session.status == "reconnecting" %> + Riconnessione + <% else %> + In avvio + <% end %> +
+ <%= link_to "Guarda diretta →", public_live_path(session), class: "btn-watch" %> +
+ <% end %> +
+<% else %> +
+ <% if @query.present? %> +

Nessuna diretta attiva per «<%= h @query %>».

+

Prova un altro nome squadra o <%= link_to "mostra tutte le dirette", public_live_index_path %>.

+ <% else %> +

Al momento non ci sono dirette attive.

+

Quando una squadra avvia lo streaming dall’app, la partita comparirà qui.

+ <% end %> +
+<% end %> +
diff --git a/backend/app/views/public/live/show.html.erb b/backend/app/views/public/live/show.html.erb new file mode 100644 index 0000000..1a3db06 --- /dev/null +++ b/backend/app/views/public/live/show.html.erb @@ -0,0 +1,227 @@ +<% content_for :title do %><%= @match.team.name %> vs <%= @match.opponent_name %> — Match Live TV<% end %> + +<% content_for :head do %> + <% unless @stream_closed %> + + <% end %> +<% end %> + +
+<%= link_to "← Tutte le dirette", public_live_index_path, class: "back-link" %> + +

<%= @match.team.name %> vs <%= @match.opponent_name %>

+

+ <% if @stream_closed %> + Diretta chiusa + <% elsif @on_air %> + In onda + <% else %> + In attesa + <% end %> +

+ +
+

<%= live_score_sets_label(@session.score_state) || "—" %>

+

> + Parziali: <%= live_score_partials_label(@session.score_state) %> +

+

<%= live_score_points_label(@match, @session.score_state) %>

+
+ +<% if @stream_closed %> +
+ +

Diretta terminata

+

Lo streaming di questa partita è stato chiuso.

+ <% if @session.ended_at %> +

Chiusa il <%= l(@session.ended_at.in_time_zone, format: :long) %>

+ <% end %> +

Il punteggio finale resta visibile sopra.

+ <%= link_to "Tutte le dirette", public_live_index_path, class: "btn btn-secondary stream-ended-btn" %> +
+<% else %> + + + + +<% end %> +
diff --git a/backend/app/views/public/pages/features.html.erb b/backend/app/views/public/pages/features.html.erb new file mode 100644 index 0000000..7cd3c5a --- /dev/null +++ b/backend/app/views/public/pages/features.html.erb @@ -0,0 +1,63 @@ +<% content_for :title, "Funzionalità — Match Live TV" %> +<% content_for :meta_description, "Camera, regia, punteggio live, gestione staff, archivio gare e YouTube con Premium Light e Full." %> + +
+

Funzionalità

+

Tutto ciò che serve per trasmettere partite giovanili in modo professionale, senza complessità da broadcaster TV.

+ +
+

Per la società e lo staff

+
+
+

Staff con email

+

Delega trasmissione e regia a coach e volontari: ognuno ha credenziali personali.

+
+
+

Revoca accessi

+

L'owner rimuove membri dello staff e annulla richieste pendenti dalla dashboard web.

+
+
+

Limiti per piano

+

Posti staff (trasmissione e regia) e partite concorrenti scalano con Free, Premium Light e Premium Full.

+
+
+
+ +
+

In campo e in diretta

+
+
+

Modalità camera

+

Streaming RTMP resiliente: la diretta continua anche con reti instabili.

+
+
+

Regia separata

+

Secondo dispositivo per aggiornare il punteggio via QR pairing.

+
+
+

Pagina pubblica /live

+

Genitori e tifosi guardano dal browser senza installare nulla.

+
+
+

Archivio replay

+

Premium Light/Full: salvataggio server (30 o 90 giorni) e download su telefono.

+
+
+

YouTube Match TV Light

+

Premium Light: le dirette sono visibili anche sul canale YouTube Match TV Light.

+
+
+

YouTube della società

+

Premium Full: relay verso il canale YouTube del club.

+
+
+

Sito della società

+

Integrazione embed sul sito del club — in arrivo con Premium Full.

+
+
+
+ +

+ <%= link_to "Registra la squadra", public_signup_path, class: "btn btn-primary" %> +

+
diff --git a/backend/app/views/public/pages/home.html.erb b/backend/app/views/public/pages/home.html.erb new file mode 100644 index 0000000..7e0e541 --- /dev/null +++ b/backend/app/views/public/pages/home.html.erb @@ -0,0 +1,88 @@ +<% content_for :title, "Match Live TV — Ogni partita, ogni evento, per chi non può esserci" %> +<% content_for :meta_description, "Trasmetti le partite dallo smartphone. Nonni, parenti e amici guardano dal browser, senza app. Se te la perdi, la ritrovi nell'archivio." %> + +
+
+
+

LIVE

+

Ogni partita, ogni evento, per chi non può esserci.

+

+ Trasmetti in diretta dallo smartphone. I nonni, i parenti lontani, gli amici — + guardano dal browser, senza installare nulla. + E se te la sei persa, la ritrovi nell'archivio. +

+
+ <%= link_to "Registra la tua squadra", public_signup_path, class: "btn btn-primary" %> + <%= link_to "Guarda le dirette", public_live_index_path, class: "btn btn-secondary" %> +
+
    +
  • + + Dirette stabili +
  • +
  • + + Archivio sicuro +
  • +
  • + + Condividi con chi vuoi +
  • +
  • + + Tutto dal tuo telefono +
  • +
+
+
+ <%= image_tag "/hero-devices.png", alt: "Smartphone su treppiede per la diretta e secondo telefono per il punteggio in palestra", class: "hero-devices-img", loading: "eager", fetchpriority: "high" %> +
+
+
+ +
+

Come funziona

+
+
+
+ <%= image_tag "/home-step-registra-squadra.png?v=1", alt: "Registrazione squadra: scudo, pallone e modulo con email, password e nome squadra", class: "step-visual-img", loading: "lazy" %> +
+
01
+

Registra la squadra

+

La società si iscrive sul sito e sceglie il piano più adatto.

+
+
+
+ <%= image_tag "/home-step-invita-trasmette.png?v=1", alt: "Invito a trasmettere: smartphone con invio email e busta con accetta invito", class: "step-visual-img", loading: "lazy" %> +
+
02
+

Invita chi trasmette

+

Coach e volontari ricevono un invito: ognuno accede con la propria email.

+
+
+
+ <%= image_tag "/home-step-vai-in-diretta.png?v=1", alt: "Vai in diretta: smartphone su treppiede, punteggio live e condivisione link", class: "step-visual-img", loading: "lazy" %> +
+
03
+

Vai in diretta

+

Dal telefono si avvia la partita: punteggio aggiornato e link da condividere con le famiglie.

+
+
+
+ <%= image_tag "/home-step-tutti-guardano.png?v=2", alt: "Tutti guardano da casa: archivio partite, partita salvata e link per genitori e parenti", class: "step-visual-img", loading: "lazy" %> +
+
04
+

Tutti guardano da casa

+

Genitori, nonni e parenti aprono il link nel browser. La partita resta anche in archivio.

+
+
+
+ +
+

Piani per ogni esigenza

+

Inizia gratis. Passa a Premium quando vuoi più partite in contemporanea, archivio più lungo e diretta anche su YouTube.

+
+ <%= image_tag "/home-piani-ecosistema.png?v=1", alt: "Ecosistema Match Live TV: smartphone in diretta, laptop con live e statistiche, tablet con archivio partite e cloud", class: "plans-teaser-img", loading: "lazy" %> +
+ <%= link_to "Confronta i piani", public_prezzi_path, class: "btn btn-primary" %> +
diff --git a/backend/app/views/public/pages/pricing.html.erb b/backend/app/views/public/pages/pricing.html.erb new file mode 100644 index 0000000..bb20003 --- /dev/null +++ b/backend/app/views/public/pages/pricing.html.erb @@ -0,0 +1,72 @@ +<% content_for :title, "Prezzi — Match Live TV" %> + +
+

Piani per la tua squadra

+

Abbonamento annuale per squadra: lo staff gestisce trasmissione e regia con credenziali dedicate. Pagamento sicuro su Stripe.

+ +
+
+

Free

+
€0
+
    +
  • 1 persona staff / anno per la trasmissione
  • +
  • 1 persona staff / anno per la regia
  • +
  • 1 live alla volta su matchlivetv.eminux.it
  • +
  • Punteggio live e regia da app
  • +
  • No salvataggio / replay
  • +
  • No YouTube
  • +
+ <%= link_to "Inizia gratis", public_signup_path, class: "btn btn-secondary" %> +
+ + + +
+

Premium Full

+
€200/anno
+

oppure €20/mese

+
    +
  • Staff illimitato / anno (trasmissione e regia)
  • +
  • Fino a 10 partite in contemporanea
  • +
  • Collegamento con il canale YouTube della società
  • +
  • Archivio server 90 giorni e download su telefono
  • +
  • Sito società (in arrivo)
  • +
+ <%= link_to "Registrati", public_signup_path, class: "btn btn-primary" %> +
+
+ + + + + + + + + + + + + + + +
FreePremium LightPremium Full
Staff trasmissione / anno15Illimitato
Staff regia / anno15Illimitato
Partite in contemporanea1310
Live su Match Live TV
YouTubeNoMatch TV LightCanale società
Replay serverNo30 gg90 gg
Download telefonoNo
Prezzo€0€40/anno o €5/mese€200/anno o €20/mese
+ +
+

Esigenze diverse?

+

Per società con più squadre, tornei o integrazioni custom, contattaci: troviamo insieme l'offerta migliore.

+ <%= mail_to "info@matchlivetv.eminux.it", "Contattaci", class: "btn btn-primary" %> +
+
diff --git a/backend/app/views/public/pages/privacy.html.erb b/backend/app/views/public/pages/privacy.html.erb new file mode 100644 index 0000000..da3c60d --- /dev/null +++ b/backend/app/views/public/pages/privacy.html.erb @@ -0,0 +1,9 @@ +<% content_for :title, "Privacy — Match Live TV" %> +
+

Informativa privacy

+
+

Match Live TV tratta i dati necessari per account, gestione staff e streaming (email, nome, dati di utilizzo delle sessioni).

+

Per richieste o cancellazione account contattare il gestore del servizio all'indirizzo indicato nella dashboard della squadra.

+

Documento provvisorio — da completare con i dati del titolare del trattamento.

+
+
diff --git a/backend/app/views/public/pages/terms.html.erb b/backend/app/views/public/pages/terms.html.erb new file mode 100644 index 0000000..6c81b3b --- /dev/null +++ b/backend/app/views/public/pages/terms.html.erb @@ -0,0 +1,9 @@ +<% content_for :title, "Termini di servizio — Match Live TV" %> +
+

Termini di servizio

+
+

L'uso di Match Live TV implica l'accettazione dei limiti del piano sottoscritto (staff, dirette concorrenti, archivio).

+

Le dirette sono responsabilità della squadra titolare dell'account. È vietato l'uso per contenuti non conformi alle norme sulla protezione dei minori.

+

Documento provvisorio — da rivedere con consulenza legale.

+
+
diff --git a/backend/app/views/public/registrations/new.html.erb b/backend/app/views/public/registrations/new.html.erb new file mode 100644 index 0000000..062a178 --- /dev/null +++ b/backend/app/views/public/registrations/new.html.erb @@ -0,0 +1,20 @@ +<% content_for :title, "Registrati — Match Live TV" %> + +
+

Registra la squadra

+

Crea l'account del referente (coach o dirigente), poi configurerai la squadra.

+
+ <%= form_with model: @user, url: public_signup_path do |f| %> + <%= f.label :name, "Nome" %> + <%= f.text_field :name, required: true %> + <%= f.label :email, "Email" %> + <%= f.email_field :email, required: true %> + <%= f.label :password, "Password" %> + <%= f.password_field :password, required: true, minlength: 8 %> + <%= f.label :password_confirmation, "Conferma password" %> + <%= f.password_field :password_confirmation, required: true %> + <%= f.submit "Continua", class: "btn btn-primary" %> + <% end %> + +
+
diff --git a/backend/app/views/public/replay/show.html.erb b/backend/app/views/public/replay/show.html.erb new file mode 100644 index 0000000..04cc59b --- /dev/null +++ b/backend/app/views/public/replay/show.html.erb @@ -0,0 +1,37 @@ +<% content_for :title, "Replay — #{@match.team.name} vs #{@match.opponent_name}" %> + +
+ <%= link_to "← Tutte le dirette", public_live_index_path, class: "back-link" %> + +

Replay

+

<%= @match.team.name %> vs <%= @match.opponent_name %>

+ + <% if @recording %> +
+ <% if @session.hls_playback_url.present? %> + + + + <% else %> +

Registrazione indicizzata. Il playback dipende dalla retention del piano Premium.

+ <% end %> + <% if @recording.expires_at %> +

Disponibile fino al <%= l @recording.expires_at, format: :long %>

+ <% end %> +
+ <% end %> +
diff --git a/backend/app/views/public/sessions/new.html.erb b/backend/app/views/public/sessions/new.html.erb new file mode 100644 index 0000000..c05c7b3 --- /dev/null +++ b/backend/app/views/public/sessions/new.html.erb @@ -0,0 +1,15 @@ +<% content_for :title, "Accedi — Match Live TV" %> + +
+

Accedi

+
+ <%= form_with url: public_login_path do |f| %> + <%= label_tag :email, "Email" %> + <%= email_field_tag :email, params[:email], required: true %> + <%= label_tag :password, "Password" %> + <%= password_field_tag :password, required: true %> + <%= submit_tag "Entra", class: "btn btn-primary" %> + <% end %> + +
+
diff --git a/backend/app/views/public/teams/billing.html.erb b/backend/app/views/public/teams/billing.html.erb new file mode 100644 index 0000000..256efa2 --- /dev/null +++ b/backend/app/views/public/teams/billing.html.erb @@ -0,0 +1,49 @@ +<% content_for :title, "Abbonamento — #{@team.name}" %> + +
+

Abbonamento

+

Squadra: <%= @team.name %> · Piano attuale: <%= @entitlements.plan.name %>

+ +
+ <% @plans.each do |plan| %> +
+

<%= plan.name %>

+ <% if plan.slug == "free" %> +
€0
+ <% elsif plan.slug == "premium_light" %> +
€40/anno
+

oppure €5/mese

+ <% else %> +
€200/anno
+

oppure €20/mese

+ <% end %> +
    +
  • Staff trasmissione: <%= plan.max_staff_transmission || "illimitato" %> / anno
  • +
  • Staff regia: <%= plan.max_staff_regia || "illimitato" %> / anno
  • +
  • Partite: <%= plan.concurrent_streams_limit || "illimitate" %> in contemporanea
  • +
  • Replay: <%= plan.recordings_enabled? ? "#{plan.recording_days} giorni" : "no" %>
  • +
  • YouTube: <% if plan.slug == "premium_light" %>Match TV Light<% elsif plan.youtube_enabled? %>canale società<% else %>no<% end %>
  • +
+ <% if plan.slug == @entitlements.plan.slug %> + Piano attuale + <% elsif plan.slug == "free" %> +

Contatta il supporto per downgrade.

+ <% elsif MatchLiveTv.stripe_enabled? %> + <%= link_to "Attiva #{plan.name}", public_team_checkout_path(@team, plan: plan.slug), class: "btn btn-primary" %> + <% else %> +

Stripe non configurato

+ <% end %> +
+ <% end %> +
+ +
+

Esigenze diverse? <%= mail_to "info@matchlivetv.eminux.it", "Contattaci" %> per un'offerta su misura.

+
+ + <% if @entitlements.premium_active? && MatchLiveTv.stripe_enabled? %> +

<%= button_to "Gestisci su Stripe", public_team_portal_path(@team), class: "btn btn-secondary" %>

+ <% end %> + +

<%= link_to "← Dashboard", public_team_dashboard_path(@team) %>

+
diff --git a/backend/app/views/public/teams/dashboard.html.erb b/backend/app/views/public/teams/dashboard.html.erb new file mode 100644 index 0000000..379bd4f --- /dev/null +++ b/backend/app/views/public/teams/dashboard.html.erb @@ -0,0 +1,82 @@ +<% content_for :title, "#{@team.name} — Dashboard" %> + +
+

<%= @team.name %>

+

+ Piano: <%= @entitlements.plan.name %> + · Staff trasmissione: <%= @entitlements.staff_count_for("transmission") %> / <%= @entitlements.max_staff_transmission || "∞" %> + · Staff regia: <%= @entitlements.staff_count_for("regia") %> / <%= @entitlements.max_staff_regia || "∞" %> + · Partite attive: <%= @entitlements.concurrent_streams_used %><% if @entitlements.concurrent_streams_limit %> / <%= @entitlements.concurrent_streams_limit %><% else %> (illimitate)<% end %> +

+ +
+ <%= link_to "Abbonamento", public_team_billing_path(@team), class: "btn btn-primary" %> + <%= link_to "Aggiungi staff", public_team_invite_path(@team), class: "btn btn-secondary" %> + <%= link_to "Dirette live", public_live_index_path, class: "btn btn-secondary" %> +
+ +

Staff attivo

+
+ <% if @members.any? %> + + + + <% @members.each do |ut| %> + + + + + + + <% end %> + +
NomeEmailRuolo
<%= ut.user.name %><%= ut.user.email %><%= ut.staff_kind == "regia" ? "Regia" : "Trasmissione" %> + <%= button_to "Revoca", public_team_remove_member_path(@team, ut.user), method: :delete, class: "btn btn-secondary", style: "padding:4px 10px;font-size:0.8rem", form: { data: { turbo_confirm: "Revocare l'accesso a #{ut.user.name}?" } } %> +
+ <% else %> +

Nessun membro staff. <%= link_to "Aggiungi qualcuno", public_team_invite_path(@team) %>.

+ <% end %> +
+ +

Staff in attesa

+
+ <% if @pending_invitations.any? %> + + + + <% @pending_invitations.each do |inv| %> + + + + + + + <% end %> + +
EmailRuoloScade
<%= inv.email %><%= inv.staff_kind == "regia" ? "Regia" : "Trasmissione" %><%= l inv.expires_at, format: :short %> + <%= button_to "Annulla", public_team_destroy_invitation_path(@team, inv), method: :delete, class: "btn btn-secondary", style: "padding:4px 10px;font-size:0.8rem" %> +
+ <% else %> +

Nessuna richiesta in sospeso.

+ <% end %> +
+ + <% if @entitlements.can_access_recordings? && @recordings.any? %> +

Archivio gare

+
+ + + + <% @recordings.each do |rec| %> + + + + + <% end %> + +
PartitaReplay
<%= rec.stream_session.match.team.name %> vs <%= rec.stream_session.match.opponent_name %><%= link_to "Guarda", rec.replay_url %>
+
+ <% end %> + +

Usa l'app mobile con le stesse credenziali per avviare partite e dirette.

+
diff --git a/backend/app/views/public/teams/invite.html.erb b/backend/app/views/public/teams/invite.html.erb new file mode 100644 index 0000000..2c86303 --- /dev/null +++ b/backend/app/views/public/teams/invite.html.erb @@ -0,0 +1,28 @@ +<% content_for :title, "Aggiungi staff — #{@team.name}" %> + +
+

Aggiungi staff

+

+ Trasmissione: <%= @entitlements.staff_count_for("transmission") %> / <%= @entitlements.max_staff_transmission || "∞" %> + · Regia: <%= @entitlements.staff_count_for("regia") %> / <%= @entitlements.max_staff_regia || "∞" %> +

+ +
+ <%= form_with url: public_team_invite_path(@team), method: :post do %> + <%= label_tag :staff_kind, "Ruolo" %> + <%= select_tag :staff_kind, options_for_select([ + ["Trasmissione (camera / streaming)", "transmission"], + ["Regia (punteggio)", "regia"] + ], params[:staff_kind] || "transmission") %> + <%= label_tag :email, "Email del membro staff" %> + <%= email_field_tag :email, params[:email], required: true %> + <%= submit_tag "Genera link di accesso", class: "btn btn-primary" %> + <% end %> + <% if defined?(@invite_url) && @invite_url.present? %> +

Condividi (valido 7 giorni):

+ <%= @invite_url %> +

Il membro dello staff si registra con la stessa email e accetta l'accesso.

+ <% end %> +
+

<%= link_to "← Dashboard", public_team_dashboard_path(@team) %>

+
diff --git a/backend/app/views/public/teams/new.html.erb b/backend/app/views/public/teams/new.html.erb new file mode 100644 index 0000000..6f9e65e --- /dev/null +++ b/backend/app/views/public/teams/new.html.erb @@ -0,0 +1,21 @@ +<% content_for :title, "Crea squadra — Match Live TV" %> + +
+

La tua squadra

+

Ultimo passo: nome, sport e piano iniziale.

+
+ <%= form_with url: public_teams_path do %> + <%= label_tag "team[name]", "Nome squadra" %> + <%= text_field_tag "team[name]", nil, required: true %> + <%= label_tag "team[sport]", "Sport" %> + <%= select_tag "team[sport]", options_for_select([["Pallavolo", "volleyball"], ["Calcio", "football"], ["Basket", "basketball"]], "volleyball") %> + <%= label_tag :plan, "Piano iniziale" %> + <%= select_tag :plan, options_for_select([ + ["Free — 1 staff trasmissione + 1 regia, 1 live", "free"], + ["Premium Light — €40/anno o €5/mese", "premium_light"], + ["Premium Full — €200/anno o €20/mese", "premium_full"] + ], "free") %> + <%= submit_tag "Crea squadra", class: "btn btn-primary" %> + <% end %> +
+
diff --git a/backend/app/views/shared/_marketing_footer.html.erb b/backend/app/views/shared/_marketing_footer.html.erb new file mode 100644 index 0000000..f362b95 --- /dev/null +++ b/backend/app/views/shared/_marketing_footer.html.erb @@ -0,0 +1,13 @@ +
+
+
+ Match Live TV — Ogni partita, ogni evento, per chi non può esserci +
+
+ <%= link_to "Prezzi", public_prezzi_path %> · + <%= link_to "Dirette", public_live_index_path %> · + <%= link_to "Privacy", public_privacy_path %> · + <%= link_to "Termini", public_termini_path %> +
+
+
diff --git a/backend/app/views/shared/_marketing_nav.html.erb b/backend/app/views/shared/_marketing_nav.html.erb new file mode 100644 index 0000000..7ecb4f6 --- /dev/null +++ b/backend/app/views/shared/_marketing_nav.html.erb @@ -0,0 +1,76 @@ +<% live_section = request.path.start_with?("/live") %> +
+
+
+ <%= link_to root_path, class: "brand" do %>Match Live TV<% end %> + + +
+
+ + +
+ + + + diff --git a/backend/app/views/shared/_meta_tags.html.erb b/backend/app/views/shared/_meta_tags.html.erb new file mode 100644 index 0000000..ed59ed4 --- /dev/null +++ b/backend/app/views/shared/_meta_tags.html.erb @@ -0,0 +1,7 @@ +<% description = content_for?(:meta_description) ? yield(:meta_description) : "Trasmetti le partite dal telefono. Parenti e nonni guardano dal browser. Se te la perdi, la ritrovi nell'archivio." %> + + + + + + diff --git a/backend/bin/brakeman b/backend/bin/brakeman new file mode 100755 index 0000000..ace1c9b --- /dev/null +++ b/backend/bin/brakeman @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +ARGV.unshift("--ensure-latest") + +load Gem.bin_path("brakeman", "brakeman") diff --git a/backend/bin/bundle b/backend/bin/bundle new file mode 100755 index 0000000..50da5fd --- /dev/null +++ b/backend/bin/bundle @@ -0,0 +1,109 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'bundle' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +require "rubygems" + +m = Module.new do + module_function + + def invoked_as_script? + File.expand_path($0) == File.expand_path(__FILE__) + end + + def env_var_version + ENV["BUNDLER_VERSION"] + end + + def cli_arg_version + return unless invoked_as_script? # don't want to hijack other binstubs + return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` + bundler_version = nil + update_index = nil + ARGV.each_with_index do |a, i| + if update_index && update_index.succ == i && a.match?(Gem::Version::ANCHORED_VERSION_PATTERN) + bundler_version = a + end + next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ + bundler_version = $1 + update_index = i + end + bundler_version + end + + def gemfile + gemfile = ENV["BUNDLE_GEMFILE"] + return gemfile if gemfile && !gemfile.empty? + + File.expand_path("../Gemfile", __dir__) + end + + def lockfile + lockfile = + case File.basename(gemfile) + when "gems.rb" then gemfile.sub(/\.rb$/, ".locked") + else "#{gemfile}.lock" + end + File.expand_path(lockfile) + end + + def lockfile_version + return unless File.file?(lockfile) + lockfile_contents = File.read(lockfile) + return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ + Regexp.last_match(1) + end + + def bundler_requirement + @bundler_requirement ||= + env_var_version || + cli_arg_version || + bundler_requirement_for(lockfile_version) + end + + def bundler_requirement_for(version) + return "#{Gem::Requirement.default}.a" unless version + + bundler_gem_version = Gem::Version.new(version) + + bundler_gem_version.approximate_recommendation + end + + def load_bundler! + ENV["BUNDLE_GEMFILE"] ||= gemfile + + activate_bundler + end + + def activate_bundler + gem_error = activation_error_handling do + gem "bundler", bundler_requirement + end + return if gem_error.nil? + require_error = activation_error_handling do + require "bundler/version" + end + return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION)) + warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`" + exit 42 + end + + def activation_error_handling + yield + nil + rescue StandardError, LoadError => e + e + end +end + +m.load_bundler! + +if m.invoked_as_script? + load Gem.bin_path("bundler", "bundle") +end diff --git a/backend/bin/docker-entrypoint b/backend/bin/docker-entrypoint new file mode 100755 index 0000000..840d093 --- /dev/null +++ b/backend/bin/docker-entrypoint @@ -0,0 +1,13 @@ +#!/bin/bash -e + +# Enable jemalloc for reduced memory usage and latency. +if [ -z "${LD_PRELOAD+x}" ] && [ -f /usr/lib/*/libjemalloc.so.2 ]; then + export LD_PRELOAD="$(echo /usr/lib/*/libjemalloc.so.2)" +fi + +# If running the rails server then create or migrate existing database +if [ "${1}" == "./bin/rails" ] && [ "${2}" == "server" ]; then + ./bin/rails db:prepare +fi + +exec "${@}" diff --git a/backend/bin/rails b/backend/bin/rails new file mode 100755 index 0000000..efc0377 --- /dev/null +++ b/backend/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/backend/bin/rake b/backend/bin/rake new file mode 100755 index 0000000..4fbf10b --- /dev/null +++ b/backend/bin/rake @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "rake" +Rake.application.run diff --git a/backend/bin/rubocop b/backend/bin/rubocop new file mode 100755 index 0000000..40330c0 --- /dev/null +++ b/backend/bin/rubocop @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +# explicit rubocop config increases performance slightly while avoiding config confusion. +ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) + +load Gem.bin_path("rubocop", "rubocop") diff --git a/backend/bin/setup b/backend/bin/setup new file mode 100755 index 0000000..eb1e55e --- /dev/null +++ b/backend/bin/setup @@ -0,0 +1,37 @@ +#!/usr/bin/env ruby +require "fileutils" + +APP_ROOT = File.expand_path("..", __dir__) +APP_NAME = "app" + +def system!(*args) + system(*args, exception: true) +end + +FileUtils.chdir APP_ROOT do + # This script is a way to set up or update your development environment automatically. + # This script is idempotent, so that you can run it at any time and get an expectable outcome. + # Add necessary setup steps to this file. + + puts "== Installing dependencies ==" + system! "gem install bundler --conservative" + system("bundle check") || system!("bundle install") + + # puts "\n== Copying sample files ==" + # unless File.exist?("config/database.yml") + # FileUtils.cp "config/database.yml.sample", "config/database.yml" + # end + + puts "\n== Preparing database ==" + system! "bin/rails db:prepare" + + puts "\n== Removing old logs and tempfiles ==" + system! "bin/rails log:clear tmp:clear" + + puts "\n== Restarting application server ==" + system! "bin/rails restart" + + # puts "\n== Configuring puma-dev ==" + # system "ln -nfs #{APP_ROOT} ~/.puma-dev/#{APP_NAME}" + # system "curl -Is https://#{APP_NAME}.test/up | head -n 1" +end diff --git a/backend/config.ru b/backend/config.ru new file mode 100644 index 0000000..4a3c09a --- /dev/null +++ b/backend/config.ru @@ -0,0 +1,6 @@ +# This file is used by Rack-based servers to start the application. + +require_relative "config/environment" + +run Rails.application +Rails.application.load_server diff --git a/backend/config/application.rb b/backend/config/application.rb new file mode 100644 index 0000000..7f71b3e --- /dev/null +++ b/backend/config/application.rb @@ -0,0 +1,30 @@ +require_relative "boot" + +require "rails" +# Pick the frameworks you want: +require "active_model/railtie" +require "active_job/railtie" +require "active_record/railtie" +require "active_storage/engine" +require "action_controller/railtie" +require "action_mailer/railtie" +require "action_mailbox/engine" +require "action_text/engine" +require "action_view/railtie" +require "action_cable/engine" +# require "rails/test_unit/railtie" + +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) + +module App + class Application < Rails::Application + config.load_defaults 7.2 + config.autoload_lib(ignore: %w[assets tasks]) + config.api_only = false + config.active_job.queue_adapter = :sidekiq + config.time_zone = "Europe/Rome" + config.generators { |g| g.orm :active_record, primary_key_type: :uuid } + end +end diff --git a/backend/config/boot.rb b/backend/config/boot.rb new file mode 100644 index 0000000..988a5dd --- /dev/null +++ b/backend/config/boot.rb @@ -0,0 +1,4 @@ +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "bundler/setup" # Set up gems listed in the Gemfile. +require "bootsnap/setup" # Speed up boot time by caching expensive operations. diff --git a/backend/config/cable.yml b/backend/config/cable.yml new file mode 100644 index 0000000..1aad390 --- /dev/null +++ b/backend/config/cable.yml @@ -0,0 +1,10 @@ +development: + adapter: redis + url: <%= ENV.fetch("REDIS_URL", "redis://localhost:6379/0") %> + +test: + adapter: test + +production: + adapter: redis + url: <%= ENV.fetch("REDIS_URL", "redis://localhost:6379/0") %> diff --git a/backend/config/credentials.yml.enc b/backend/config/credentials.yml.enc new file mode 100644 index 0000000..90b4726 --- /dev/null +++ b/backend/config/credentials.yml.enc @@ -0,0 +1 @@ +iUeJHiuhCvxm/D8G4wDoJUfOcttCPg3HCQwWGreLmNBwzZDpxcHwZvNezpVkotHCl9+uwHHXx+u4cncDr2iwD/K7sDjYdd34+wiqXKOIjbuy8yRArndjvcTOpPKoY6JrTCmoJ+NhYz9g4G3NZXxc604MrjuNvOEAr3bYJdH932699WaZJYJbBTODbZ4EFJByOn30qgdSwPHp3aG2R2zMsJn46v00QftdEdAr/Nyy1PergwKtxj0JfI7ChAev3Wy6JBruzONX1krBauSzqkeMK7NCqyboVP8HmJC6SRhvuhuHXGKtCRep1oOdPwyYf0OLMFnyARbTMnxN1W+MDTbs2n6rOg4vpzy3QIgYb/aXLXZd+B8znP44pvN9x963pmaI6Jkimez5UYT0HNV8Df+dhbtHgfE4--pHQJ1krK0oIVvx7F--cqzNinUJSxx+aUQJyyKBdA== \ No newline at end of file diff --git a/backend/config/database.yml b/backend/config/database.yml new file mode 100644 index 0000000..1cc3387 --- /dev/null +++ b/backend/config/database.yml @@ -0,0 +1,83 @@ +# PostgreSQL. Versions 9.3 and up are supported. +# +# Install the pg driver: +# gem install pg +# On macOS with Homebrew: +# gem install pg -- --with-pg-config=/usr/local/bin/pg_config +# On Windows: +# gem install pg +# Choose the win32 build. +# Install PostgreSQL and put its /bin directory on your path. +# +# Configure Using Gemfile +# gem "pg" +# +default: &default + adapter: postgresql + encoding: unicode + # For details on connection pooling, see Rails configuration guide + # https://guides.rubyonrails.org/configuring.html#database-pooling + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + + +development: + <<: *default + database: app_development + + # The specified database role being used to connect to PostgreSQL. + # To create additional roles in PostgreSQL see `$ createuser --help`. + # When left blank, PostgreSQL will use the default role. This is + # the same name as the operating system user running Rails. + #username: app + + # The password associated with the PostgreSQL role (username). + #password: + + # Connect on a TCP socket. Omitted by default since the client uses a + # domain socket that doesn't need configuration. Windows does not have + # domain sockets, so uncomment these lines. + #host: localhost + + # The TCP port the server listens on. Defaults to 5432. + # If your server runs on a different port number, change accordingly. + #port: 5432 + + # Schema search path. The server defaults to $user,public + #schema_search_path: myapp,sharedapp,public + + # Minimum log levels, in increasing order: + # debug5, debug4, debug3, debug2, debug1, + # log, notice, warning, error, fatal, and panic + # Defaults to warning. + #min_messages: notice + +# Warning: The database defined as "test" will be erased and +# re-generated from your development database when you run "rake". +# Do not set this db to the same as development or production. +test: + <<: *default + database: app_test + +# As with config/credentials.yml, you never want to store sensitive information, +# like your database password, in your source code. If your source code is +# ever seen by anyone, they now have access to your database. +# +# Instead, provide the password or a full connection URL as an environment +# variable when you boot the app. For example: +# +# DATABASE_URL="postgres://myuser:mypass@localhost/somedatabase" +# +# If the connection URL is provided in the special DATABASE_URL environment +# variable, Rails will automatically merge its configuration values on top of +# the values provided in this file. Alternatively, you can specify a connection +# URL environment variable explicitly: +# +# production: +# url: <%= ENV["MY_APP_DATABASE_URL"] %> +# +# Read https://guides.rubyonrails.org/configuring.html#configuring-a-database +# for a full overview on how database connection configuration can be specified. +# +production: + <<: *default + url: <%= ENV["DATABASE_URL"] %> diff --git a/backend/config/deploy.yml b/backend/config/deploy.yml new file mode 100644 index 0000000..164f8b8 --- /dev/null +++ b/backend/config/deploy.yml @@ -0,0 +1,29 @@ +# Kamal deploy configuration +service: match-live-tv +image: matchlivetv/backend + +servers: + web: + hosts: + - YOUR_HETZNER_IP + +proxy: + ssl: true + host: api.matchlivetv.example.com + +env: + clear: + RAILS_ENV: production + MEDIAMTX_API_URL: http://mediamtx:9997 + MEDIAMTX_RTMP_URL: rtmp://mediamtx:1935 + secret: + - SECRET_KEY_BASE + - DATABASE_URL + - REDIS_URL + - JWT_SECRET + - MEDIAMTX_WEBHOOK_SECRET + - YOUTUBE_CLIENT_ID + - YOUTUBE_CLIENT_SECRET + +builder: + arch: amd64 diff --git a/backend/config/environment.rb b/backend/config/environment.rb new file mode 100644 index 0000000..cac5315 --- /dev/null +++ b/backend/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/backend/config/environments/development.rb b/backend/config/environments/development.rb new file mode 100644 index 0000000..98128ff --- /dev/null +++ b/backend/config/environments/development.rb @@ -0,0 +1,75 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # In the development environment your application's code is reloaded any time + # it changes. This slows down response time but is perfect for development + # since you don't have to restart the web server when you make code changes. + config.enable_reloading = true + + # Do not eager load code on boot. + config.eager_load = false + + # Show full error reports. + config.consider_all_requests_local = true + + # Enable server timing. + config.server_timing = true + + # Enable/disable caching. By default caching is disabled. + # Run rails dev:cache to toggle caching. + if Rails.root.join("tmp/caching-dev.txt").exist? + config.cache_store = :memory_store + config.public_file_server.headers = { "Cache-Control" => "public, max-age=#{2.days.to_i}" } + else + config.action_controller.perform_caching = false + + config.cache_store = :null_store + end + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Don't care if the mailer can't send. + config.action_mailer.raise_delivery_errors = false + + # Disable caching for Action Mailer templates even if Action Controller + # caching is enabled. + config.action_mailer.perform_caching = false + + config.action_mailer.default_url_options = { host: "localhost", port: 3000 } + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # Raise exceptions for disallowed deprecations. + config.active_support.disallowed_deprecation = :raise + + # Tell Active Support which deprecation messages to disallow. + config.active_support.disallowed_deprecation_warnings = [] + + # Raise an error on page load if there are pending migrations. + config.active_record.migration_error = :page_load + + # Highlight code that triggered database queries in logs. + config.active_record.verbose_query_logs = true + + # Highlight code that enqueued background job in logs. + config.active_job.verbose_enqueue_logs = true + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + config.action_view.annotate_rendered_view_with_filenames = true + + # Uncomment if you wish to allow Action Cable access from any origin. + # config.action_cable.disable_request_forgery_protection = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true + + # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. + # config.generators.apply_rubocop_autocorrect_after_generate! +end diff --git a/backend/config/environments/production.rb b/backend/config/environments/production.rb new file mode 100644 index 0000000..9e16a40 --- /dev/null +++ b/backend/config/environments/production.rb @@ -0,0 +1,49 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + config.enable_reloading = false + config.eager_load = true + config.consider_all_requests_local = false + + config.active_storage.service = :local + + # Nginx Proxy Manager termina TLS + config.assume_ssl = true + config.force_ssl = true + config.ssl_options = { + redirect: { + exclude: ->(request) { request.path == "/up" || request.path == "/cable" } + } + } + + config.action_cable.disable_request_forgery_protection = true + + if (cable_url = ENV["CABLE_URL"]).present? + config.action_cable.url = cable_url + end + + cable_origins = ENV.fetch("CORS_ORIGINS", "*").split(",").map(&:strip) + config.action_cable.allowed_request_origins = cable_origins + [ + %r{https?://.*} + ] + + allowed_hosts = ENV.fetch("ALLOWED_HOSTS", "").split(",").map(&:strip).reject(&:empty?) + config.hosts = allowed_hosts if allowed_hosts.any? + + config.host_authorization = { + exclude: ->(request) { request.path == "/up" || request.path.start_with?("/cable") } + } + + config.logger = ActiveSupport::Logger.new(STDOUT) + .tap { |logger| logger.formatter = ::Logger::Formatter.new } + .then { |logger| ActiveSupport::TaggedLogging.new(logger) } + + config.log_tags = [ :request_id ] + config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") + + config.action_mailer.perform_caching = false + config.i18n.fallbacks = true + config.active_support.report_deprecations = false + config.active_record.dump_schema_after_migration = false + config.active_record.attributes_for_inspect = [ :id ] +end diff --git a/backend/config/environments/test.rb b/backend/config/environments/test.rb new file mode 100644 index 0000000..0c616a1 --- /dev/null +++ b/backend/config/environments/test.rb @@ -0,0 +1,67 @@ +require "active_support/core_ext/integer/time" + +# The test environment is used exclusively to run your application's +# test suite. You never need to work with it otherwise. Remember that +# your test database is "scratch space" for the test suite and is wiped +# and recreated between test runs. Don't rely on the data there! + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # While tests run files are not watched, reloading is not necessary. + config.enable_reloading = false + + # Eager loading loads your entire application. When running a single test locally, + # this is usually not necessary, and can slow down your test suite. However, it's + # recommended that you enable it in continuous integration systems to ensure eager + # loading is working properly before deploying your code. + config.eager_load = ENV["CI"].present? + + # Configure public file server for tests with Cache-Control for performance. + config.public_file_server.headers = { "Cache-Control" => "public, max-age=#{1.hour.to_i}" } + + # Show full error reports and disable caching. + config.consider_all_requests_local = true + config.action_controller.perform_caching = false + config.cache_store = :null_store + + # Render exception templates for rescuable exceptions and raise for other exceptions. + config.action_dispatch.show_exceptions = :rescuable + + # Disable request forgery protection in test environment. + config.action_controller.allow_forgery_protection = false + + # Store uploaded files on the local file system in a temporary directory. + config.active_storage.service = :test + + # Disable caching for Action Mailer templates even if Action Controller + # caching is enabled. + config.action_mailer.perform_caching = false + + # Tell Action Mailer not to deliver emails to the real world. + # The :test delivery method accumulates sent emails in the + # ActionMailer::Base.deliveries array. + config.action_mailer.delivery_method = :test + + # Unlike controllers, the mailer instance doesn't have any context about the + # incoming request so you'll need to provide the :host parameter yourself. + config.action_mailer.default_url_options = { host: "www.example.com" } + + # Print deprecation notices to the stderr. + config.active_support.deprecation = :stderr + + # Raise exceptions for disallowed deprecations. + config.active_support.disallowed_deprecation = :raise + + # Tell Active Support which deprecation messages to disallow. + config.active_support.disallowed_deprecation_warnings = [] + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true +end diff --git a/backend/config/initializers/attr_encrypted.rb b/backend/config/initializers/attr_encrypted.rb new file mode 100644 index 0000000..be7c5b5 --- /dev/null +++ b/backend/config/initializers/attr_encrypted.rb @@ -0,0 +1 @@ +# attr_encrypted uses per-model :key option (see YoutubeCredential, StreamSession) diff --git a/backend/config/initializers/cors.rb b/backend/config/initializers/cors.rb new file mode 100644 index 0000000..34672bf --- /dev/null +++ b/backend/config/initializers/cors.rb @@ -0,0 +1,9 @@ +Rails.application.config.middleware.insert_before 0, Rack::Cors do + allow do + origins ENV.fetch("CORS_ORIGINS", "*").split(",") + resource "*", + headers: :any, + methods: %i[get post put patch delete options head], + expose: %w[Authorization] + end +end diff --git a/backend/config/initializers/filter_parameter_logging.rb b/backend/config/initializers/filter_parameter_logging.rb new file mode 100644 index 0000000..6c73e85 --- /dev/null +++ b/backend/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,4 @@ +Rails.application.config.filter_parameters += [ + :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, + :stream_key, :password, :access_token, :refresh_token, :pairing_token +] diff --git a/backend/config/initializers/inflections.rb b/backend/config/initializers/inflections.rb new file mode 100644 index 0000000..3860f65 --- /dev/null +++ b/backend/config/initializers/inflections.rb @@ -0,0 +1,16 @@ +# Be sure to restart your server when you modify this file. + +# Add new inflection rules using the following format. Inflections +# are locale specific, and you may define rules for as many different +# locales as you wish. All of these examples are active by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.plural /^(ox)$/i, "\\1en" +# inflect.singular /^(ox)en/i, "\\1" +# inflect.irregular "person", "people" +# inflect.uncountable %w( fish sheep ) +# end + +# These inflection rules are supported but not enabled by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.acronym "RESTful" +# end diff --git a/backend/config/initializers/match_live_tv.rb b/backend/config/initializers/match_live_tv.rb new file mode 100644 index 0000000..08fb6f0 --- /dev/null +++ b/backend/config/initializers/match_live_tv.rb @@ -0,0 +1,55 @@ +module MatchLiveTv + class << self + def jwt_secret + ENV.fetch("JWT_SECRET", Rails.application.secret_key_base) + end + + def mediamtx_api_url + ENV.fetch("MEDIAMTX_API_URL", "http://localhost:9997") + end + + def mediamtx_rtmp_url + ENV.fetch("MEDIAMTX_RTMP_URL", "rtmp://localhost:1935") + end + + def mediamtx_webhook_secret + ENV.fetch("MEDIAMTX_WEBHOOK_SECRET", "change_me") + end + + def reconnect_timeout_seconds + ENV.fetch("RECONNECT_TIMEOUT_SECONDS", "300").to_i + end + + def hls_public_url + ENV.fetch("HLS_PUBLIC_URL", "http://localhost:8888") + end + + def mediamtx_hls_url + ENV.fetch("MEDIAMTX_HLS_URL", "http://mediamtx:8888") + end + + def app_public_url + ENV.fetch("APP_PUBLIC_URL", "http://localhost:3000") + end + + def stripe_secret_key + ENV["STRIPE_SECRET_KEY"].presence + end + + def stripe_webhook_secret + ENV["STRIPE_WEBHOOK_SECRET"].presence + end + + def stripe_premium_light_price_id + ENV.fetch("STRIPE_PREMIUM_LIGHT_PRICE_ID", ENV.fetch("STRIPE_PREMIUM_PRICE_ID", "")) + end + + def stripe_premium_full_price_id + ENV.fetch("STRIPE_PREMIUM_FULL_PRICE_ID", ENV.fetch("STRIPE_PREMIUM_PRICE_ID", "")) + end + + def stripe_enabled? + stripe_secret_key.present? + end + end +end diff --git a/backend/config/initializers/rack_attack.rb b/backend/config/initializers/rack_attack.rb new file mode 100644 index 0000000..ce8f295 --- /dev/null +++ b/backend/config/initializers/rack_attack.rb @@ -0,0 +1,11 @@ +class Rack::Attack + throttle("api/ip", limit: 300, period: 5.minutes) do |req| + req.ip if req.path.start_with?("/api/") + end + + throttle("auth/ip", limit: 20, period: 5.minutes) do |req| + req.ip if req.path.include?("/auth/login") + end +end + +Rails.application.config.middleware.use Rack::Attack if defined?(Rack::Attack) diff --git a/backend/config/initializers/sidekiq.rb b/backend/config/initializers/sidekiq.rb new file mode 100644 index 0000000..3caf607 --- /dev/null +++ b/backend/config/initializers/sidekiq.rb @@ -0,0 +1,9 @@ +require "sidekiq/api" + +Sidekiq.configure_server do |config| + config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0") } +end + +Sidekiq.configure_client do |config| + config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0") } +end diff --git a/backend/config/initializers/sidekiq_cron.rb b/backend/config/initializers/sidekiq_cron.rb new file mode 100644 index 0000000..57d20c9 --- /dev/null +++ b/backend/config/initializers/sidekiq_cron.rb @@ -0,0 +1,2 @@ +# Schedule CleanupExpiredSessionsJob hourly via host cron or Kamal: +# 0 * * * * cd /app && bundle exec rails runner "CleanupExpiredSessionsJob.perform_async" diff --git a/backend/config/initializers/stripe.rb b/backend/config/initializers/stripe.rb new file mode 100644 index 0000000..6ae98a7 --- /dev/null +++ b/backend/config/initializers/stripe.rb @@ -0,0 +1,3 @@ +if MatchLiveTv.stripe_secret_key.present? + Stripe.api_key = MatchLiveTv.stripe_secret_key +end diff --git a/backend/config/locales/en.yml b/backend/config/locales/en.yml new file mode 100644 index 0000000..6c349ae --- /dev/null +++ b/backend/config/locales/en.yml @@ -0,0 +1,31 @@ +# Files in the config/locales directory are used for internationalization and +# are automatically loaded by Rails. If you want to use locales other than +# English, add the necessary files in this directory. +# +# To use the locales, use `I18n.t`: +# +# I18n.t "hello" +# +# In views, this is aliased to just `t`: +# +# <%= t("hello") %> +# +# To use a different locale, set it with `I18n.locale`: +# +# I18n.locale = :es +# +# This would use the information in config/locales/es.yml. +# +# To learn more about the API, please read the Rails Internationalization guide +# at https://guides.rubyonrails.org/i18n.html. +# +# Be aware that YAML interprets the following case-insensitive strings as +# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings +# must be quoted to be interpreted as strings. For example: +# +# en: +# "yes": yup +# enabled: "ON" + +en: + hello: "Hello world" diff --git a/backend/config/puma.rb b/backend/config/puma.rb new file mode 100644 index 0000000..03c166f --- /dev/null +++ b/backend/config/puma.rb @@ -0,0 +1,34 @@ +# This configuration file will be evaluated by Puma. The top-level methods that +# are invoked here are part of Puma's configuration DSL. For more information +# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. + +# Puma starts a configurable number of processes (workers) and each process +# serves each request in a thread from an internal thread pool. +# +# The ideal number of threads per worker depends both on how much time the +# application spends waiting for IO operations and on how much you wish to +# to prioritize throughput over latency. +# +# As a rule of thumb, increasing the number of threads will increase how much +# traffic a given process can handle (throughput), but due to CRuby's +# Global VM Lock (GVL) it has diminishing returns and will degrade the +# response time (latency) of the application. +# +# The default is set to 3 threads as it's deemed a decent compromise between +# throughput and latency for the average Rails application. +# +# Any libraries that use a connection pool or another resource pool should +# be configured to provide at least as many connections as the number of +# threads. This includes Active Record's `pool` parameter in `database.yml`. +threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) +threads threads_count, threads_count + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +port ENV.fetch("PORT", 3000) + +# Allow puma to be restarted by `bin/rails restart` command. +plugin :tmp_restart + +# Specify the PID file. Defaults to tmp/pids/server.pid in development. +# In other environments, only set the PID file if requested. +pidfile ENV["PIDFILE"] if ENV["PIDFILE"] diff --git a/backend/config/routes.rb b/backend/config/routes.rb new file mode 100644 index 0000000..98fa609 --- /dev/null +++ b/backend/config/routes.rb @@ -0,0 +1,96 @@ +Rails.application.routes.draw do + mount ActionCable.server => "/cable" + + get "up" => "rails/health#show", as: :rails_health_check + + namespace :api do + namespace :v1 do + post "auth/login", to: "auth#login" + post "auth/register", to: "auth#register" + post "auth/logout", to: "auth#logout" + post "auth/refresh", to: "auth#refresh" + get "auth/me", to: "auth#me" + + resources :teams do + member do + post :members, action: :add_member + delete "members/:user_id", action: :remove_member + get :recordings + end + resources :matches, only: %i[index create] + get "youtube/authorize", to: "youtube#authorize" + end + + resources :matches, only: %i[show update destroy] do + resources :sessions, only: %i[create], controller: "stream_sessions" + end + + resources :sessions, only: %i[show], controller: "stream_sessions" do + member do + patch :start + patch :stop + patch :pause + get :events + post :telemetry + post :pairing_token + post :claim_pairing + post :network_test + get :youtube_stats + end + end + + get "youtube/callback", to: "youtube#callback" + end + end + + namespace :webhooks do + post "mediamtx/connect", to: "mediamtx#connect" + post "mediamtx/disconnect", to: "mediamtx#disconnect" + post "mediamtx/ready", to: "mediamtx#ready" + post "mediamtx/validate_publish", to: "mediamtx#validate_publish" + post "stripe", to: "stripe#create" + end + + post "internal/validate_publish", to: "webhooks/mediamtx#validate_publish" + + namespace :admin do + root to: "dashboard#index" + resources :teams, only: %i[index show] + resources :sessions, only: %i[index show] + end + + get "hls/*path", to: "hls_proxy#show", format: false, constraints: { path: /.+/ } + + get "live", to: "public/live#index", as: :public_live_index + get "live/:id", to: "public/live#show", as: :public_live + get "live/:id/status.json", to: "public/live#status", as: :public_live_status + get "replay/:id", to: "public/replay#show", as: :public_replay + + root to: "public/pages#home" + + scope module: :public, as: :public do + get "funzionalita", to: "pages#features", as: :features + get "prezzi", to: "pages#pricing", as: :prezzi + get "pricing", to: redirect("/prezzi") + get "privacy", to: "pages#privacy", as: :privacy + get "termini", to: "pages#terms", as: :termini + get "signup", to: "registrations#new" + post "signup", to: "registrations#create" + get "login", to: "sessions#new" + post "login", to: "sessions#create" + delete "logout", to: "sessions#destroy" + get "teams/new", to: "teams#new" + post "teams", to: "teams#create" + get "teams/:id/dashboard", to: "teams#dashboard", as: :team_dashboard + get "teams/:id/billing", to: "teams#billing", as: :team_billing + get "teams/:id/checkout", to: "teams#checkout", as: :team_checkout + post "teams/:id/portal", to: "teams#portal", as: :team_portal + get "teams/:id/invite", to: "teams#invite", as: :team_invite + post "teams/:id/invite", to: "teams#create_invitation" + delete "teams/:id/members/:user_id", to: "teams#remove_member", as: :team_remove_member + delete "teams/:id/invitations/:invitation_id", to: "teams#destroy_invitation", as: :team_destroy_invitation + get "join/:token", to: "invitations#show", as: :invitation + post "join/:token", to: "invitations#accept" + end + +end diff --git a/backend/config/sidekiq.yml b/backend/config/sidekiq.yml new file mode 100644 index 0000000..3d67b00 --- /dev/null +++ b/backend/config/sidekiq.yml @@ -0,0 +1,4 @@ +:concurrency: 5 +:queues: + - default + - critical diff --git a/backend/config/storage.yml b/backend/config/storage.yml new file mode 100644 index 0000000..4942ab6 --- /dev/null +++ b/backend/config/storage.yml @@ -0,0 +1,34 @@ +test: + service: Disk + root: <%= Rails.root.join("tmp/storage") %> + +local: + service: Disk + root: <%= Rails.root.join("storage") %> + +# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) +# amazon: +# service: S3 +# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> +# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> +# region: us-east-1 +# bucket: your_own_bucket-<%= Rails.env %> + +# Remember not to checkin your GCS keyfile to a repository +# google: +# service: GCS +# project: your_project +# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> +# bucket: your_own_bucket-<%= Rails.env %> + +# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) +# microsoft: +# service: AzureStorage +# storage_account_name: your_account_name +# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> +# container: your_container_name-<%= Rails.env %> + +# mirror: +# service: Mirror +# primary: local +# mirrors: [ amazon, google, microsoft ] diff --git a/backend/db/migrate/20260525000001_create_match_live_tv_schema.rb b/backend/db/migrate/20260525000001_create_match_live_tv_schema.rb new file mode 100644 index 0000000..d91377a --- /dev/null +++ b/backend/db/migrate/20260525000001_create_match_live_tv_schema.rb @@ -0,0 +1,113 @@ +class CreateMatchLiveTvSchema < ActiveRecord::Migration[7.2] + def change + enable_extension "pgcrypto" unless extension_enabled?("pgcrypto") + + create_table :users, id: :uuid do |t| + t.string :email, null: false + t.string :password_digest, null: false + t.string :name, null: false + t.string :role, null: false, default: "coach" + t.timestamps + end + add_index :users, :email, unique: true + + create_table :teams, id: :uuid do |t| + t.string :name, null: false + t.string :sport, default: "volleyball" + t.string :logo_url + t.timestamps + end + + create_table :user_teams, id: :uuid do |t| + t.references :user, type: :uuid, null: false, foreign_key: true + t.references :team, type: :uuid, null: false, foreign_key: true + t.string :role, null: false, default: "member" + t.timestamps + end + add_index :user_teams, %i[user_id team_id], unique: true + + create_table :youtube_credentials, id: :uuid do |t| + t.references :team, type: :uuid, null: false, foreign_key: true + t.text :access_token_encrypted + t.text :refresh_token_encrypted + t.datetime :expires_at + t.string :channel_id + t.string :channel_title + t.timestamps + end + + create_table :matches, id: :uuid do |t| + t.references :team, type: :uuid, null: false, foreign_key: true + t.string :opponent_name, null: false + t.string :location + t.datetime :scheduled_at + t.string :sport, default: "volleyball" + t.integer :sets_to_win, default: 3 + t.jsonb :roster_numbers, default: [] + t.string :category + t.string :phase + t.timestamps + end + + create_table :stream_sessions, id: :uuid do |t| + t.references :match, type: :uuid, null: false, foreign_key: true + t.references :user, type: :uuid, null: false, foreign_key: true + t.string :status, null: false, default: "idle" + t.string :platform, default: "youtube" + t.text :stream_key_encrypted + t.string :rtmp_url + t.string :youtube_broadcast_id + t.string :youtube_stream_id + t.string :privacy_status, default: "unlisted" + t.string :quality_preset, default: "720p_30_2.5mbps" + t.integer :target_bitrate, default: 2_500_000 + t.integer :target_fps, default: 30 + t.datetime :started_at + t.datetime :ended_at + t.integer :total_duration_secs, default: 0 + t.integer :disconnection_count, default: 0 + t.string :publish_token + t.string :pairing_token_digest + t.datetime :pairing_token_expires_at + t.string :timeout_job_id + t.timestamps + end + add_index :stream_sessions, :publish_token, unique: true + add_index :stream_sessions, :status + + create_table :stream_events, id: :uuid do |t| + t.references :stream_session, type: :uuid, null: false, foreign_key: true + t.string :event_type, null: false + t.jsonb :metadata, default: {} + t.datetime :occurred_at, null: false + end + add_index :stream_events, %i[stream_session_id occurred_at] + + create_table :score_states, id: :uuid do |t| + t.references :stream_session, type: :uuid, null: false, foreign_key: true, index: { unique: true } + t.integer :home_sets, default: 0 + t.integer :away_sets, default: 0 + t.integer :home_points, default: 0 + t.integer :away_points, default: 0 + t.integer :current_set, default: 1 + t.string :period + t.boolean :timeout_home, default: false + t.boolean :timeout_away, default: false + t.timestamps + end + + create_table :device_states, id: :uuid do |t| + t.references :stream_session, type: :uuid, null: false, foreign_key: true + t.string :device_role, null: false + t.integer :battery_level + t.string :network_type + t.integer :signal_strength + t.integer :current_bitrate + t.integer :target_bitrate + t.integer :fps + t.datetime :last_seen_at + t.timestamps + end + add_index :device_states, %i[stream_session_id device_role], unique: true + end +end diff --git a/backend/db/migrate/20260525120000_add_set_partials_to_score_states.rb b/backend/db/migrate/20260525120000_add_set_partials_to_score_states.rb new file mode 100644 index 0000000..dfa961d --- /dev/null +++ b/backend/db/migrate/20260525120000_add_set_partials_to_score_states.rb @@ -0,0 +1,5 @@ +class AddSetPartialsToScoreStates < ActiveRecord::Migration[7.2] + def change + add_column :score_states, :set_partials, :jsonb, null: false, default: [] + end +end diff --git a/backend/db/migrate/20260525140000_add_scoring_rules_to_matches.rb b/backend/db/migrate/20260525140000_add_scoring_rules_to_matches.rb new file mode 100644 index 0000000..d9cf751 --- /dev/null +++ b/backend/db/migrate/20260525140000_add_scoring_rules_to_matches.rb @@ -0,0 +1,5 @@ +class AddScoringRulesToMatches < ActiveRecord::Migration[7.2] + def change + add_column :matches, :scoring_rules, :jsonb, null: false, default: {} + end +end diff --git a/backend/db/migrate/20260525160000_add_billing_and_recordings.rb b/backend/db/migrate/20260525160000_add_billing_and_recordings.rb new file mode 100644 index 0000000..82bcb15 --- /dev/null +++ b/backend/db/migrate/20260525160000_add_billing_and_recordings.rb @@ -0,0 +1,47 @@ +class AddBillingAndRecordings < ActiveRecord::Migration[7.2] + def change + create_table :plans, id: :uuid do |t| + t.string :slug, null: false + t.string :name, null: false + t.string :stripe_price_id + t.jsonb :features, null: false, default: {} + t.timestamps + end + add_index :plans, :slug, unique: true + + create_table :subscriptions, id: :uuid do |t| + t.references :team, null: false, type: :uuid, foreign_key: true, index: { unique: true } + t.references :plan, null: false, type: :uuid, foreign_key: true + t.string :status, null: false, default: "active" + t.string :stripe_customer_id + t.string :stripe_subscription_id + t.datetime :current_period_start + t.datetime :current_period_end + t.boolean :cancel_at_period_end, default: false, null: false + t.timestamps + end + add_index :subscriptions, :stripe_subscription_id, unique: true, where: "stripe_subscription_id IS NOT NULL" + + create_table :recordings, id: :uuid do |t| + t.references :stream_session, null: false, type: :uuid, foreign_key: true, index: { unique: true } + t.references :team, null: false, type: :uuid, foreign_key: true + t.string :status, null: false, default: "processing" + t.string :storage_path + t.datetime :expires_at + t.timestamps + end + add_index :recordings, :expires_at + + create_table :team_invitations, id: :uuid do |t| + t.references :team, null: false, type: :uuid, foreign_key: true + t.string :email, null: false + t.string :token_digest, null: false + t.string :role, null: false, default: "member" + t.datetime :expires_at, null: false + t.datetime :accepted_at + t.timestamps + end + add_index :team_invitations, [:team_id, :email] + add_index :team_invitations, :token_digest, unique: true + end +end diff --git a/backend/db/migrate/20260526120000_update_plans_three_tier.rb b/backend/db/migrate/20260526120000_update_plans_three_tier.rb new file mode 100644 index 0000000..acbbf4f --- /dev/null +++ b/backend/db/migrate/20260526120000_update_plans_three_tier.rb @@ -0,0 +1,9 @@ +class UpdatePlansThreeTier < ActiveRecord::Migration[7.2] + def up + # Plan features and slugs are applied via db/seeds/plans.rb (loaded from db:seed) + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/backend/db/migrate/20260527120000_add_staff_kind.rb b/backend/db/migrate/20260527120000_add_staff_kind.rb new file mode 100644 index 0000000..fe06683 --- /dev/null +++ b/backend/db/migrate/20260527120000_add_staff_kind.rb @@ -0,0 +1,8 @@ +class AddStaffKind < ActiveRecord::Migration[7.2] + def change + add_column :team_invitations, :staff_kind, :string, null: false, default: "transmission" + add_column :user_teams, :staff_kind, :string + add_index :team_invitations, [:team_id, :staff_kind] + add_index :user_teams, [:team_id, :staff_kind] + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb new file mode 100644 index 0000000..eefdffb --- /dev/null +++ b/backend/db/schema.rb @@ -0,0 +1,212 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[7.2].define(version: 2026_05_26_120000) do + # These are extensions that must be enabled in order to support this database + enable_extension "pgcrypto" + enable_extension "plpgsql" + + create_table "device_states", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "stream_session_id", null: false + t.string "device_role", null: false + t.integer "battery_level" + t.string "network_type" + t.integer "signal_strength" + t.integer "current_bitrate" + t.integer "target_bitrate" + t.integer "fps" + t.datetime "last_seen_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["stream_session_id", "device_role"], name: "index_device_states_on_stream_session_id_and_device_role", unique: true + t.index ["stream_session_id"], name: "index_device_states_on_stream_session_id" + end + + create_table "matches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "team_id", null: false + t.string "opponent_name", null: false + t.string "location" + t.datetime "scheduled_at" + t.string "sport", default: "volleyball" + t.integer "sets_to_win", default: 3 + t.jsonb "roster_numbers", default: [] + t.string "category" + t.string "phase" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.jsonb "scoring_rules", default: {}, null: false + t.index ["team_id"], name: "index_matches_on_team_id" + end + + create_table "plans", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "slug", null: false + t.string "name", null: false + t.string "stripe_price_id" + t.jsonb "features", default: {}, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["slug"], name: "index_plans_on_slug", unique: true + end + + create_table "recordings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "stream_session_id", null: false + t.uuid "team_id", null: false + t.string "status", default: "processing", null: false + t.string "storage_path" + t.datetime "expires_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["expires_at"], name: "index_recordings_on_expires_at" + t.index ["stream_session_id"], name: "index_recordings_on_stream_session_id", unique: true + t.index ["team_id"], name: "index_recordings_on_team_id" + end + + create_table "score_states", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "stream_session_id", null: false + t.integer "home_sets", default: 0 + t.integer "away_sets", default: 0 + t.integer "home_points", default: 0 + t.integer "away_points", default: 0 + t.integer "current_set", default: 1 + t.string "period" + t.boolean "timeout_home", default: false + t.boolean "timeout_away", default: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.jsonb "set_partials", default: [], null: false + t.index ["stream_session_id"], name: "index_score_states_on_stream_session_id", unique: true + end + + create_table "stream_events", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "stream_session_id", null: false + t.string "event_type", null: false + t.jsonb "metadata", default: {} + t.datetime "occurred_at", null: false + t.index ["stream_session_id", "occurred_at"], name: "index_stream_events_on_stream_session_id_and_occurred_at" + t.index ["stream_session_id"], name: "index_stream_events_on_stream_session_id" + end + + create_table "stream_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "match_id", null: false + t.uuid "user_id", null: false + t.string "status", default: "idle", null: false + t.string "platform", default: "youtube" + t.text "stream_key_encrypted" + t.string "rtmp_url" + t.string "youtube_broadcast_id" + t.string "youtube_stream_id" + t.string "privacy_status", default: "unlisted" + t.string "quality_preset", default: "720p_30_2.5mbps" + t.integer "target_bitrate", default: 2500000 + t.integer "target_fps", default: 30 + t.datetime "started_at" + t.datetime "ended_at" + t.integer "total_duration_secs", default: 0 + t.integer "disconnection_count", default: 0 + t.string "publish_token" + t.string "pairing_token_digest" + t.datetime "pairing_token_expires_at" + t.string "timeout_job_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["match_id"], name: "index_stream_sessions_on_match_id" + t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true + t.index ["status"], name: "index_stream_sessions_on_status" + t.index ["user_id"], name: "index_stream_sessions_on_user_id" + end + + create_table "subscriptions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "team_id", null: false + t.uuid "plan_id", null: false + t.string "status", default: "active", null: false + t.string "stripe_customer_id" + t.string "stripe_subscription_id" + t.datetime "current_period_start" + t.datetime "current_period_end" + t.boolean "cancel_at_period_end", default: false, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["plan_id"], name: "index_subscriptions_on_plan_id" + t.index ["stripe_subscription_id"], name: "index_subscriptions_on_stripe_subscription_id", unique: true, where: "(stripe_subscription_id IS NOT NULL)" + t.index ["team_id"], name: "index_subscriptions_on_team_id", unique: true + end + + create_table "team_invitations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "team_id", null: false + t.string "email", null: false + t.string "token_digest", null: false + t.string "role", default: "member", null: false + t.datetime "expires_at", null: false + t.datetime "accepted_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["team_id", "email"], name: "index_team_invitations_on_team_id_and_email" + t.index ["team_id"], name: "index_team_invitations_on_team_id" + t.index ["token_digest"], name: "index_team_invitations_on_token_digest", unique: true + end + + create_table "teams", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "name", null: false + t.string "sport", default: "volleyball" + t.string "logo_url" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "user_teams", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "user_id", null: false + t.uuid "team_id", null: false + t.string "role", default: "member", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["team_id"], name: "index_user_teams_on_team_id" + t.index ["user_id", "team_id"], name: "index_user_teams_on_user_id_and_team_id", unique: true + t.index ["user_id"], name: "index_user_teams_on_user_id" + end + + create_table "users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "email", null: false + t.string "password_digest", null: false + t.string "name", null: false + t.string "role", default: "coach", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["email"], name: "index_users_on_email", unique: true + end + + create_table "youtube_credentials", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "team_id", null: false + t.text "access_token_encrypted" + t.text "refresh_token_encrypted" + t.datetime "expires_at" + t.string "channel_id" + t.string "channel_title" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["team_id"], name: "index_youtube_credentials_on_team_id" + end + + add_foreign_key "device_states", "stream_sessions" + add_foreign_key "matches", "teams" + add_foreign_key "recordings", "stream_sessions" + add_foreign_key "recordings", "teams" + add_foreign_key "score_states", "stream_sessions" + add_foreign_key "stream_events", "stream_sessions" + add_foreign_key "stream_sessions", "matches" + add_foreign_key "stream_sessions", "users" + add_foreign_key "subscriptions", "plans" + add_foreign_key "subscriptions", "teams" + add_foreign_key "team_invitations", "teams" + add_foreign_key "user_teams", "teams" + add_foreign_key "user_teams", "users" + add_foreign_key "youtube_credentials", "teams" +end diff --git a/backend/db/seeds.rb b/backend/db/seeds.rb new file mode 100644 index 0000000..6b145d2 --- /dev/null +++ b/backend/db/seeds.rb @@ -0,0 +1,34 @@ +load Rails.root.join("db/seeds/plans.rb") + +coach = User.find_or_create_by!(email: "coach@matchlivetv.test") do |u| + u.name = "Coach Demo" + u.password = "password123" + u.role = "coach" +end + +admin = User.find_or_create_by!(email: "admin@matchlivetv.test") do |u| + u.name = "Admin" + u.password = "password123" + u.role = "admin" +end + +team = Team.find_or_create_by!(name: "Tigers Volley") do |t| + t.sport = "volleyball" +end + +UserTeam.find_or_create_by!(user: coach, team: team) { |ut| ut.role = "owner" } +UserTeam.find_or_create_by!(user: admin, team: team) { |ut| ut.role = "member" } + +Billing::AssignPlan.call(team: team, plan_slug: "free") unless team.subscription + +match = team.matches.find_or_create_by!(opponent_name: "ASD Eagles Pavia") do |m| + m.location = "PalaTigers - Milano" + m.scheduled_at = 2.hours.from_now + m.sets_to_win = 3 + m.roster_numbers = [4, 6, 8, 9, 10, 11, 12, 14, 16, 17, 21] + m.category = "U16" + m.phase = "Semifinale" +end + +puts "Seed OK: coach@matchlivetv.test / password123" +puts "Team: #{team.name}, Match: #{match.opponent_name}" diff --git a/backend/db/seeds/plans.rb b/backend/db/seeds/plans.rb new file mode 100644 index 0000000..8429dec --- /dev/null +++ b/backend/db/seeds/plans.rb @@ -0,0 +1,56 @@ +def seed_plan!(slug, name, features, stripe_price_id: nil) + plan = Plan.find_or_initialize_by(slug: slug) + plan.name = name + plan.features = features + plan.stripe_price_id = stripe_price_id if stripe_price_id.present? + plan.save! +end + +legacy = Plan.find_by(slug: "premium") +if legacy && !Plan.exists?(slug: "premium_full") + legacy.update!(slug: "premium_full", name: "Premium Full") +end + +seed_plan!("free", "Free", { + "max_staff_transmission" => 1, + "max_staff_regia" => 1, + "concurrent_streams_limit" => 1, + "platforms" => %w[matchlivetv], + "recordings_enabled" => false, + "recording_days" => 0, + "phone_download_enabled" => false, + "youtube_enabled" => false, + "youtube_mode" => nil +}) + +seed_plan!("premium_light", "Premium Light", { + "max_staff_transmission" => 5, + "max_staff_regia" => 5, + "concurrent_streams_limit" => 3, + "platforms" => %w[matchlivetv], + "recordings_enabled" => true, + "recording_days" => 30, + "phone_download_enabled" => true, + "youtube_enabled" => true, + "youtube_mode" => "matchlivetv_light" +}, stripe_price_id: ENV["STRIPE_PREMIUM_LIGHT_PRICE_ID"].presence) + +seed_plan!("premium_full", "Premium Full", { + "max_staff_transmission" => nil, + "max_staff_regia" => nil, + "concurrent_streams_limit" => 10, + "platforms" => %w[matchlivetv youtube facebook twitch], + "recordings_enabled" => true, + "recording_days" => 90, + "phone_download_enabled" => true, + "youtube_enabled" => true, + "youtube_mode" => "team" +}, stripe_price_id: ENV["STRIPE_PREMIUM_FULL_PRICE_ID"].presence || ENV["STRIPE_PREMIUM_PRICE_ID"].presence) + +Subscription.joins(:plan).where(plans: { slug: "premium_full" }).find_each do |sub| + next if sub.plan.slug == "premium_full" + + sub.update!(plan: Plan["premium_full"]) +end + +puts "Plans: #{Plan.pluck(:slug).join(', ')}" diff --git a/backend/lib/json_web_token.rb b/backend/lib/json_web_token.rb new file mode 100644 index 0000000..4e62d4c --- /dev/null +++ b/backend/lib/json_web_token.rb @@ -0,0 +1,15 @@ +class JsonWebToken + class << self + def encode(payload, exp: 24.hours.from_now) + payload[:exp] = exp.to_i + JWT.encode(payload, MatchLiveTv.jwt_secret, "HS256") + end + + def decode(token) + body = JWT.decode(token, MatchLiveTv.jwt_secret, true, algorithm: "HS256").first + HashWithIndifferentAccess.new(body) + rescue JWT::DecodeError + nil + end + end +end diff --git a/backend/lib/tasks/.keep b/backend/lib/tasks/.keep new file mode 100644 index 0000000..e69de29 diff --git a/backend/lib/tasks/stream_sessions.rake b/backend/lib/tasks/stream_sessions.rake new file mode 100644 index 0000000..81eb644 --- /dev/null +++ b/backend/lib/tasks/stream_sessions.rake @@ -0,0 +1,50 @@ +namespace :streams do + desc "Termina sessioni attive non in onda su MediaMTX e rimuove path RTMP orfani" + task cleanup_stale: :environment do + client = Mediamtx::Client.new + online = client.online_path_names + stale = StreamSession.broadcasting.includes(match: :team).reject do |session| + online.include?(session.mediamtx_path_name) + end + + puts "MediaMTX online: #{online.size}" + puts "Sessioni da chiudere: #{stale.size}" + + stale.each do |session| + Sessions::Stop.new(session).call + puts " ended #{session.id} (#{session.match.team.name} vs #{session.match.opponent_name})" + rescue StandardError => e + session.finish! if session.may_finish? + client.delete_path(session) if session.mediamtx_path_name.present? + puts " ended #{session.id} (fallback: #{e.message})" + end + + removed = cleanup_orphan_mediamtx_paths(client, online) + puts "Path MediaMTX rimossi: #{removed}" + puts "Fatto." + end +end + +def cleanup_orphan_mediamtx_paths(client, online_paths) + removed = 0 + + client.list_paths.each do |item| + name = item["name"] + next unless name&.start_with?("match_") + + session_id = name.delete_prefix("match_") + session = StreamSession.find_by(id: session_id) + should_remove = !online_paths.include?(name) && + (session.nil? || %w[ended error].include?(session.status)) + + next unless should_remove + + client.delete_path_name(name) + removed += 1 + puts " deleted path #{name}" + rescue Mediamtx::Client::Error, Faraday::Error => e + puts " skip #{name}: #{e.message}" + end + + removed +end diff --git a/backend/public/favicon.svg b/backend/public/favicon.svg new file mode 100644 index 0000000..61eeea8 --- /dev/null +++ b/backend/public/favicon.svg @@ -0,0 +1 @@ +ML diff --git a/backend/public/hero-devices.png b/backend/public/hero-devices.png new file mode 100644 index 0000000..e109db9 Binary files /dev/null and b/backend/public/hero-devices.png differ diff --git a/backend/public/home-piani-ecosistema.png b/backend/public/home-piani-ecosistema.png new file mode 100644 index 0000000..6226157 Binary files /dev/null and b/backend/public/home-piani-ecosistema.png differ diff --git a/backend/public/home-step-invita-trasmette.png b/backend/public/home-step-invita-trasmette.png new file mode 100644 index 0000000..4bfe9de Binary files /dev/null and b/backend/public/home-step-invita-trasmette.png differ diff --git a/backend/public/home-step-registra-squadra.png b/backend/public/home-step-registra-squadra.png new file mode 100644 index 0000000..fc1fc77 Binary files /dev/null and b/backend/public/home-step-registra-squadra.png differ diff --git a/backend/public/home-step-tutti-guardano.png b/backend/public/home-step-tutti-guardano.png new file mode 100644 index 0000000..cfe8a96 Binary files /dev/null and b/backend/public/home-step-tutti-guardano.png differ diff --git a/backend/public/home-step-vai-in-diretta.png b/backend/public/home-step-vai-in-diretta.png new file mode 100644 index 0000000..e60637b Binary files /dev/null and b/backend/public/home-step-vai-in-diretta.png differ diff --git a/backend/public/live.css b/backend/public/live.css new file mode 100644 index 0000000..a13d657 --- /dev/null +++ b/backend/public/live.css @@ -0,0 +1,213 @@ +/* Stili pagine dirette — usa layout marketing + marketing.css */ +.live-main .wrap { + max-width: 1100px; + margin: 0 auto; + padding: 24px 20px 48px; +} + +.live-main video { + width: 100%; + max-height: 70vh; + background: #000; + border-radius: 12px; +} + +.live-main .badge { + display: inline-block; + padding: 4px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.live-main .badge-live { background: #e53935; color: #fff; } +.live-main .badge-on-air { background: #2e7d32; color: #fff; } +.live-main .badge-wait { background: #444; color: #ddd; } +.live-main .badge-connecting { background: #f57c00; color: #fff; } +.live-main .badge-ended { background: #374151; color: #ddd; } + +.live-main .stream-ended { + text-align: center; + padding: 48px 24px; + margin: 16px 0; + background: linear-gradient(160deg, #14141c 0%, #0a0a0e 100%); + border: 1px dashed #2a2a36; + border-radius: 16px; +} + +.live-main .stream-ended-icon { + font-size: 3.5rem; + line-height: 1; + margin-bottom: 16px; + opacity: 0.85; +} + +.live-main .stream-ended h2 { + margin: 0 0 8px; + font-size: 1.35rem; + color: #fff; +} + +.live-main .stream-ended p { + margin: 0 0 8px; + color: #aaa; + font-size: 0.95rem; +} + +.live-main .stream-ended-meta { color: #888; font-size: 0.85rem; } +.live-main .stream-ended-hint { margin-top: 12px !important; } +.live-main .stream-ended-btn { margin-top: 20px; display: inline-block; } + +.live-main .scoreboard { margin: 12px 0 16px; } + +.live-main .sets-line { + font-size: 0.95rem; + color: #f5c542; + font-weight: 600; + margin: 0 0 4px; + letter-spacing: 0.02em; +} + +.live-main .partials-line { + font-size: 0.88rem; + color: #9ecbff; + margin: 0 0 4px; +} + +.live-main .partials-line[hidden] { display: none; } + +.live-main .score { font-size: 1.5rem; font-weight: 700; margin: 0; } + +.live-main h1 { font-size: 1.35rem; margin: 0 0 8px; } + +.live-main h2.page-subtitle { + font-size: 1.1rem; + margin: 0 0 16px; + font-weight: 600; + color: #ccc; +} + +.live-main .search-form { + display: flex; + gap: 10px; + margin-bottom: 24px; + flex-wrap: wrap; +} + +.live-main .search-form input[type="search"] { + flex: 1; + min-width: 200px; + padding: 12px 16px; + border: 1px solid #333; + border-radius: 10px; + background: #14141c; + color: #fff; + font-size: 1rem; +} + +.live-main .search-form input[type="search"]:focus { + outline: none; + border-color: #e53935; +} + +.live-main .live-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; +} + +.live-main .live-card { + background: #14141c; + border: 1px solid #2a2a36; + border-radius: 14px; + padding: 18px; + transition: border-color 0.15s, transform 0.15s; +} + +.live-main .live-card:hover { + border-color: #e53935; + transform: translateY(-2px); +} + +.live-main .live-card h3 { + margin: 0 0 6px; + font-size: 1.05rem; +} + +.live-main .live-card .meta { + color: #999; + font-size: 0.85rem; + margin-bottom: 12px; +} + +.live-main .live-card .card-score { margin: 0 0 10px; font-size: 0.9rem; } + +.live-main .live-card .card-sets { + display: block; + color: #f5c542; + font-weight: 600; + font-size: 0.8rem; + margin-bottom: 2px; +} + +.live-main .live-card .card-partials { + display: block; + color: #9ecbff; + font-size: 0.75rem; + margin-bottom: 2px; +} + +.live-main .live-card .card-points { + font-weight: 700; + font-size: 1.1rem; +} + +.live-main .live-card .badges { margin-bottom: 14px; } + +.live-main .live-card .btn-watch { + width: 100%; + background: #e53935; + color: #fff; + text-align: center; + padding: 10px; + border-radius: 8px; + font-weight: 700; + display: block; + text-decoration: none; +} + +.live-main .live-card .btn-watch:hover { + background: #c62828; + text-decoration: none; + color: #fff; +} + +.live-main .empty-state { + text-align: center; + padding: 48px 24px; + background: #14141c; + border-radius: 14px; + border: 1px dashed #2a2a36; + color: #aaa; +} + +.live-main .back-link { + display: inline-block; + margin-bottom: 16px; + color: #aaa; + font-size: 0.9rem; +} + +.live-main .results-hint { + color: #888; + font-size: 0.9rem; + margin: -12px 0 20px; +} + +.live-main .replay-video { + width: 100%; + border-radius: 12px; + background: #000; +} diff --git a/backend/public/marketing.css b/backend/public/marketing.css new file mode 100644 index 0000000..5879049 --- /dev/null +++ b/backend/public/marketing.css @@ -0,0 +1,510 @@ +* { box-sizing: border-box; } +body { + margin: 0; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + background: #0a0a0e; + color: #f0f0f5; + line-height: 1.55; +} +a { color: #ff6b6b; text-decoration: none; } +a:hover { text-decoration: underline; } +.wrap { max-width: 1100px; margin: 0 auto; padding: 0 20px 48px; } +.site-chrome { + position: sticky; + top: 0; + z-index: 1100; +} +.site-masthead { + border-bottom: 1px solid #252530; + background: rgba(10, 10, 14, 0.92); + backdrop-filter: blur(8px); +} +.mast-inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding-top: 14px; + padding-bottom: 14px; +} +.brand { font-weight: 800; font-size: 1.15rem; letter-spacing: 0.03em; color: #fff; text-decoration: none; flex-shrink: 0; } +.brand span { color: #e53935; } +.nav-toggle { + display: none; + flex-direction: column; + justify-content: center; + gap: 5px; + width: 44px; + height: 44px; + padding: 10px; + border: 1px solid #333; + border-radius: 8px; + background: #14141c; + cursor: pointer; + flex-shrink: 0; +} +.nav-toggle-bar { + display: block; + width: 100%; + height: 2px; + background: #fff; + border-radius: 1px; + transition: transform 0.2s ease, opacity 0.2s ease; +} +.site-chrome.nav-open .nav-toggle-bar:nth-child(1) { transform: translateY(7px) rotate(45deg); } +.site-chrome.nav-open .nav-toggle-bar:nth-child(2) { opacity: 0; } +.site-chrome.nav-open .nav-toggle-bar:nth-child(3) { transform: translateY(-7px) rotate(-45deg); } +.nav-backdrop { display: none; } +.nav { font-size: 0.92rem; } +.nav-panel { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 18px; +} +.nav a { color: #ccc; } +.nav a:hover { color: #fff; text-decoration: none; } +.nav a.nav-active { color: #fff; font-weight: 700; } +.nav a.btn-primary:hover { color: #fff; } +.nav-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; +} +.nav-actions form { margin: 0; display: block; } +.nav-btn { padding: 8px 14px !important; font-size: 0.85rem !important; } +body.nav-menu-open { overflow: hidden; } + +@media (max-width: 899px) { + .nav-toggle { + display: flex; + position: relative; + z-index: 1202; + } + .site-masthead { + position: relative; + z-index: 1202; + } + body.nav-menu-open .site-chrome { + z-index: 1300; + } + .nav-backdrop { + display: block; + position: fixed; + inset: 0; + background: #0a0a0e; + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: opacity 0.25s ease, visibility 0.25s ease; + z-index: 1290; + } + body.nav-menu-open .nav-backdrop { + opacity: 1; + visibility: visible; + pointer-events: auto; + } + .site-chrome .nav { + position: fixed; + inset: 0; + z-index: 1310; + display: flex; + flex-direction: column; + background: #0a0a0e; + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: opacity 0.25s ease, visibility 0.25s ease; + overflow: hidden; + } + body.nav-menu-open .site-chrome .nav { + opacity: 1; + visibility: visible; + pointer-events: auto; + } + .nav-panel { + flex: 1; + flex-direction: column; + align-items: stretch; + gap: 0; + width: 100%; + max-width: none; + margin: 0; + padding: 72px 24px 32px; + padding-top: calc(72px + env(safe-area-inset-top, 0px)); + padding-bottom: calc(32px + env(safe-area-inset-bottom, 0px)); + overflow-y: auto; + } + .nav-panel > a, + .nav-panel .nav-link-item { + display: block; + width: 100%; + padding: 20px 0; + font-size: 1.35rem; + font-weight: 600; + color: #e8e8ee; + border-bottom: 1px solid #252530; + line-height: 1.3; + } + .nav-panel > a.nav-active { + color: #fff; + border-bottom-color: #e53935; + } + .nav-actions { + flex-direction: column; + align-items: stretch; + gap: 14px; + margin-top: 24px; + padding: 24px 0 0; + border-top: 1px solid #252530; + } + .nav-actions .nav-link-item { + display: block; + width: 100%; + padding: 20px 0; + font-size: 1.35rem; + font-weight: 600; + color: #e8e8ee; + text-align: left; + border-bottom: 1px solid #252530; + } + .nav-actions a.nav-link-item:hover { color: #fff; text-decoration: none; } + .nav-actions .btn { + display: block; + width: 100%; + text-align: center; + padding: 16px 20px !important; + font-size: 1.05rem !important; + border-radius: 10px; + } +} + +@media (min-width: 900px) { + .site-chrome { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + max-width: 1100px; + margin: 0 auto; + padding: 0 20px; + background: rgba(10, 10, 14, 0.92); + backdrop-filter: blur(8px); + border-bottom: 1px solid #252530; + } + .site-masthead { + flex: 0 0 auto; + background: transparent; + backdrop-filter: none; + border: none; + } + .mast-inner { + padding: 14px 0; + } + .mast-inner.wrap { + max-width: none; + margin: 0; + padding-left: 0; + padding-right: 0; + } + .nav { + display: flex; + justify-content: flex-end; + align-items: center; + position: static; + flex: 1 1 auto; + min-width: 0; + opacity: 1; + visibility: visible; + background: transparent; + pointer-events: auto; + overflow: visible; + } + .nav-panel { + justify-content: flex-end; + flex-wrap: nowrap; + gap: 8px 20px; + } + .nav-actions { + flex-wrap: nowrap; + gap: 8px 12px; + } + .nav-backdrop { display: none !important; } +} +.btn { display: inline-block; padding: 10px 18px; border-radius: 8px; font-weight: 700; font-size: 0.9rem; border: none; cursor: pointer; text-decoration: none; } +.btn-primary { background: #e53935; color: #fff; } +.btn-primary:hover { background: #c62828; text-decoration: none; color: #fff; } +.btn-secondary { background: #252530; color: #fff; } +.btn-secondary:hover { background: #333; text-decoration: none; color: #fff; } +.flash { padding: 12px 16px; border-radius: 8px; margin-bottom: 16px; } +.flash.notice { background: #1b3d2a; color: #a5f0b8; } +.flash.alert { background: #3d1b1b; color: #ffb4b4; } +.hero-split { + padding: 40px 0 48px; + text-align: left; + overflow: hidden; +} +.hero-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 32px 40px; + align-items: center; +} +.hero-content { max-width: 560px; } +.hero-live-badge { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 0 0 20px; + padding: 6px 14px; + border: 1px solid #3a3a48; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.12em; + color: #fff; +} +.hero-live-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #e53935; + box-shadow: 0 0 10px rgba(229, 57, 53, 0.8); +} +.hero-split h1 { + font-size: clamp(1.85rem, 4.2vw, 3rem); + margin: 0 0 18px; + line-height: 1.12; + font-weight: 800; + letter-spacing: -0.02em; +} +.hero-accent { color: #e53935; } +.hero-split .tagline { + font-size: clamp(0.98rem, 1.6vw, 1.12rem); + color: #b8b8c4; + max-width: none; + margin: 0 0 28px; + line-height: 1.65; +} +.hero-split .hero-cta { + display: flex; + flex-wrap: wrap; + gap: 12px; + justify-content: flex-start; + margin-bottom: 32px; +} +.hero-features { + list-style: none; + margin: 0; + padding: 8px 0 0; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + align-items: stretch; + gap: 0; + width: 100%; + max-width: 560px; +} +.hero-feature-item { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + gap: 12px; + padding: 12px 20px; + box-sizing: border-box; +} +.hero-feature-item:not(.hero-feature-item--last)::after { + content: ""; + position: absolute; + top: 8%; + bottom: 8%; + right: 0; + width: 1px; + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 0.08) 12%, + rgba(255, 255, 255, 0.32) 50%, + rgba(255, 255, 255, 0.08) 88%, + rgba(255, 255, 255, 0) 100% + ); + pointer-events: none; +} +.hero-feature-icon { + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + flex-shrink: 0; + color: #e53935; + font-size: 1.4rem; + line-height: 1; +} +.hero-feature-icon i { + display: block; +} +.hero-feature-label { + display: block; + width: 100%; + max-width: 9.5rem; + margin: 0 auto; + text-align: center; + font-size: 0.82rem; + color: #ccc; + line-height: 1.4; +} +.hero-visual { + position: relative; + display: flex; + justify-content: center; + align-items: center; + min-height: 280px; +} +.hero-visual::before { + content: ""; + position: absolute; + width: min(420px, 90%); + aspect-ratio: 1; + border-radius: 50%; + background: radial-gradient(circle, rgba(229, 57, 53, 0.22) 0%, transparent 68%); + pointer-events: none; +} +.hero-devices-img { + position: relative; + z-index: 1; + width: 100%; + max-width: 520px; + height: auto; + object-fit: contain; + filter: drop-shadow(0 24px 48px rgba(0, 0, 0, 0.55)); +} + +@media (max-width: 899px) { + .hero-split { padding: 28px 0 36px; } + .hero-grid { + grid-template-columns: 1fr; + gap: 28px; + } + .hero-content { max-width: none; } + .hero-features { + max-width: none; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .hero-feature-item { + padding: 16px 18px; + } + .hero-feature-item:not(.hero-feature-item--last)::after { + display: none; + } + .hero-feature-item:nth-child(odd):not(:nth-child(3))::after { + display: block; + } + .hero-feature-item:nth-child(1)::before, + .hero-feature-item:nth-child(2)::before { + content: ""; + position: absolute; + left: 10%; + right: 10%; + bottom: 0; + height: 1px; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 0.08) 12%, + rgba(255, 255, 255, 0.32) 50%, + rgba(255, 255, 255, 0.08) 88%, + rgba(255, 255, 255, 0) 100% + ); + pointer-events: none; + } + .hero-visual { min-height: 200px; } + .hero-devices-img { max-width: 100%; } +} + +@media (max-width: 480px) { + .hero-split .hero-cta { flex-direction: column; } + .hero-split .hero-cta .btn { width: 100%; text-align: center; } +} +.section { padding: 40px 0; } +.section h2 { font-size: 1.6rem; margin: 0 0 20px; text-align: center; } +.steps { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px; } +.step { background: #14141c; border: 1px solid #2a2a36; border-radius: 12px; padding: 22px; } +.step--visual { padding: 0; overflow: hidden; display: flex; flex-direction: column; } +.step--visual .step-num, +.step--visual h3, +.step--visual p { padding-left: 22px; padding-right: 22px; } +.step--visual .step-num { padding-top: 18px; margin: 0; } +.step--visual h3 { margin: 8px 0 0; padding-bottom: 0; } +.step--visual p { margin: 8px 0 0; padding-bottom: 22px; color: #aaa; font-size: 0.92rem; } +.step-visual { + width: 100%; + background: #0a0a0e; + border-bottom: 1px solid #2a2a36; + line-height: 0; +} +.step-visual-img { + display: block; + width: 100%; + height: auto; + object-fit: cover; +} +.step-num { color: #e53935; font-weight: 800; font-size: 0.85rem; } +.plans-teaser { text-align: center; } +.plans-teaser-lead { + color: #aaa; + max-width: 520px; + margin: 0 auto 28px; +} +.plans-teaser-visual { + max-width: 920px; + margin: 0 auto 32px; + line-height: 0; +} +.plans-teaser-img { + display: block; + width: 100%; + height: auto; + object-fit: contain; + filter: drop-shadow(0 20px 40px rgba(0, 0, 0, 0.5)); +} +.plans { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 20px; margin-top: 24px; } +.plan-card { background: #14141c; border: 1px solid #2a2a36; border-radius: 14px; padding: 24px; } +.plan-card.featured { border-color: #e53935; } +.plan-card h3 { margin: 0 0 8px; } +.plan-price { font-size: 1.8rem; font-weight: 800; margin: 12px 0; } +.plan-card ul { padding-left: 18px; color: #bbb; margin: 0 0 20px; } +.features-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 18px; } +.feature-card { background: #14141c; border-radius: 12px; padding: 20px; border: 1px solid #2a2a36; } +.feature-card h3 { margin: 0 0 8px; font-size: 1.05rem; } +.site-footer { border-top: 1px solid #252530; padding: 32px 0; margin-top: 40px; color: #888; font-size: 0.88rem; } +.site-footer .wrap { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px; } +.compare-table { width: 100%; border-collapse: collapse; margin-top: 20px; font-size: 0.9rem; } +.compare-table th, .compare-table td { padding: 10px 12px; border-bottom: 1px solid #2a2a36; text-align: left; } +.compare-table th { color: #aaa; font-weight: 600; } +.card { background: #14141c; border: 1px solid #2a2a36; border-radius: 12px; padding: 20px; margin-bottom: 16px; } +table.data { width: 100%; border-collapse: collapse; } +table.data th, table.data td { padding: 8px; border-bottom: 1px solid #2a2a36; text-align: left; } +input, select { width: 100%; padding: 10px 12px; margin-bottom: 12px; border-radius: 8px; border: 1px solid #333; background: #0a0a0e; color: #fff; } +label { display: block; margin-bottom: 4px; font-size: 0.88rem; color: #999; } +.auth-page { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: calc(100vh - 200px); + padding: 48px 20px 64px; + text-align: center; +} +.auth-page h1 { margin: 0 0 8px; } +.auth-page .auth-lead { color: #aaa; margin: 0 0 24px; max-width: 420px; } +.auth-page .card { + width: 100%; + max-width: 420px; + margin: 0; + text-align: left; +} +.auth-page .card-wide { max-width: 480px; } +.auth-page .auth-footer { margin-top: 16px; font-size: 0.92rem; color: #aaa; } diff --git a/backend/public/robots.txt b/backend/public/robots.txt new file mode 100644 index 0000000..c19f78a --- /dev/null +++ b/backend/public/robots.txt @@ -0,0 +1 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file diff --git a/backend/spec/rails_helper.rb b/backend/spec/rails_helper.rb new file mode 100644 index 0000000..e90fdb0 --- /dev/null +++ b/backend/spec/rails_helper.rb @@ -0,0 +1,9 @@ +ENV["RAILS_ENV"] ||= "test" +require_relative "../config/environment" +require "rspec/rails" + +RSpec.configure do |config| + config.use_transactional_fixtures = true + config.infer_spec_type_from_file_location! + config.filter_rails_from_backtrace! +end diff --git a/backend/spec/requests/auth_spec.rb b/backend/spec/requests/auth_spec.rb new file mode 100644 index 0000000..ea29b2e --- /dev/null +++ b/backend/spec/requests/auth_spec.rb @@ -0,0 +1,16 @@ +require "rails_helper" + +RSpec.describe "Auth API", type: :request do + let!(:user) { User.create!(email: "test@example.com", name: "Test", password: "password123", role: "coach") } + + it "logs in with valid credentials" do + post "/api/v1/auth/login", params: { email: user.email, password: "password123" } + expect(response).to have_http_status(:ok) + expect(JSON.parse(response.body)).to have_key("access_token") + end + + it "rejects invalid credentials" do + post "/api/v1/auth/login", params: { email: user.email, password: "wrong" } + expect(response).to have_http_status(:unauthorized) + end +end diff --git a/backend/spec/services/teams/entitlements_spec.rb b/backend/spec/services/teams/entitlements_spec.rb new file mode 100644 index 0000000..011ac13 --- /dev/null +++ b/backend/spec/services/teams/entitlements_spec.rb @@ -0,0 +1,49 @@ +require "rails_helper" + +RSpec.describe Teams::Entitlements do + let(:team) { Team.create!(name: "Test Team", sport: "volleyball") } + + before do + load Rails.root.join("db/seeds/plans.rb") + Billing::AssignPlan.call(team: team, plan_slug: "free") + end + + subject(:ent) { described_class.new(team) } + + it "allows matchlivetv on free" do + expect(ent.can_stream_on?("matchlivetv")).to be true + end + + it "denies youtube on free" do + expect { ent.assert_can_stream_on!("youtube") }.to raise_error(Teams::EntitlementError) + end + + it "allows youtube on premium full" do + Billing::AssignPlan.call(team: team, plan_slug: "premium_full") + expect(ent.can_stream_on?("youtube")).to be true + end + + it "enforces transmission staff limit on free" do + team.team_invitations.create!( + email: "camera@test.com", + token_digest: SecureRandom.hex(32), + role: "member", + staff_kind: "transmission", + expires_at: 7.days.from_now + ) + expect { ent.assert_can_invite!(staff_kind: "transmission") }.to raise_error(Teams::EntitlementError) do |e| + expect(e.code).to eq("staff_limit_reached") + end + end + + it "allows regia invite when only transmission slot is used on free" do + team.team_invitations.create!( + email: "camera@test.com", + token_digest: SecureRandom.hex(32), + role: "member", + staff_kind: "transmission", + expires_at: 7.days.from_now + ) + expect { ent.assert_can_invite!(staff_kind: "regia") }.not_to raise_error + end +end diff --git a/backend/spec/services/webhooks/mediamtx_handler_spec.rb b/backend/spec/services/webhooks/mediamtx_handler_spec.rb new file mode 100644 index 0000000..b16125f --- /dev/null +++ b/backend/spec/services/webhooks/mediamtx_handler_spec.rb @@ -0,0 +1,24 @@ +require "rails_helper" + +RSpec.describe Webhooks::MediamtxHandler do + let(:user) { User.create!(email: "c@test.com", name: "C", password: "password123", role: "coach") } + let(:team) { Team.create!(name: "T") } + let!(:match) { team.matches.create!(opponent_name: "Away") } + let!(:session) do + StreamSession.create!( + match: match, user: user, status: "live", platform: "youtube", + publish_token: "tok", started_at: Time.current + ) + end + + it "transitions to reconnecting on disconnect" do + described_class.new(event: "disconnect", session_id: session.id).call + expect(session.reload.status).to eq("reconnecting") + end + + it "returns to live on reconnect" do + session.update!(status: "reconnecting") + described_class.new(event: "connect", session_id: session.id).call + expect(session.reload.status).to eq("live") + end +end diff --git a/backend/spec/spec_helper.rb b/backend/spec/spec_helper.rb new file mode 100644 index 0000000..1f6769b --- /dev/null +++ b/backend/spec/spec_helper.rb @@ -0,0 +1,5 @@ +RSpec.configure do |config| + config.expect_with :rspec do |c| + c.syntax = :expect + end +end diff --git a/backend/test_write b/backend/test_write new file mode 100644 index 0000000..e69de29 diff --git a/backend/vendor/.keep b/backend/vendor/.keep new file mode 100644 index 0000000..e69de29 diff --git a/docs/E2E.md b/docs/E2E.md new file mode 100644 index 0000000..d12ea59 --- /dev/null +++ b/docs/E2E.md @@ -0,0 +1,34 @@ +# Test end-to-end Match Live TV + +## Prerequisiti + +```bash +cd infra && docker compose up -d +``` + +## 1. API smoke test + +```bash +./scripts/e2e_api.sh +``` + +## 2. Flusso sessione + +1. Login → crea sessione su partita seed +2. MediaMTX path `match_{uuid}` con alwaysAvailable +3. Simula webhook disconnect → status `reconnecting` +4. Simula webhook connect → status `live` + +## 3. Stream RTMP (device) + +1. Avvia app Flutter in Camera Mode +2. `startStream` con `rtmp_ingest_url` dalla API +3. Verifica path online: `curl http://localhost:9997/v3/paths/list` +4. Airplane mode 30s → slate su YouTube +5. Ripristina rete → reconnect automatico + +## 4. Regia + +1. Secondo device in Controller Mode +2. Scan QR pairing +3. +1 punto → overlay aggiornato su camera via Action Cable diff --git a/docs/PRODUCT_PREMIUM.md b/docs/PRODUCT_PREMIUM.md new file mode 100644 index 0000000..c424991 --- /dev/null +++ b/docs/PRODUCT_PREMIUM.md @@ -0,0 +1,35 @@ +# Match Live TV — Piani commerciali + +## Piani + +| | Free | Premium Light | Premium Full | +|---|------|---------------|----------------| +| Staff trasmissione / anno | 1 | 5 | Illimitato | +| Staff regia / anno | 1 | 5 | Illimitato | +| Partite in contemporanea | 1 | 3 | 10 | +| Live Match Live TV | Sì | Sì | Sì | +| YouTube | No | Match TV Light | Canale società | +| Replay server | No | 30 giorni | 90 giorni | +| Download telefono | No | Sì | Sì | +| Prezzo | €0 | €40/anno o €5/mese | €200/anno o €20/mese | +| Embed sito società | No | No | In arrivo | + +Esigenze custom: contatto `info@matchlivetv.eminux.it`. + +## Sito + +- `/` — Landing +- `/funzionalita` — Feature +- `/prezzi` — Prezzi (statici in pagina) +- `/live` — Dirette pubbliche +- `/signup` — Registrazione → squadra → dashboard + +## Stripe + +```bash +STRIPE_PREMIUM_LIGHT_PRICE_ID=price_... +STRIPE_PREMIUM_FULL_PRICE_ID=price_... +STRIPE_WEBHOOK_SECRET=whsec_... +``` + +Checkout: `GET /teams/:id/checkout?plan=premium_light|premium_full` diff --git a/docs/infrastructure/SERVER_DEPLOYMENT.md b/docs/infrastructure/SERVER_DEPLOYMENT.md new file mode 100644 index 0000000..6020dc5 --- /dev/null +++ b/docs/infrastructure/SERVER_DEPLOYMENT.md @@ -0,0 +1,253 @@ +# Deploy server — Match Live TV Backend + +**Server:** `eminux@192.168.1.146` (Debian 13) +**Path deploy:** `~/matchlivetv` (dopo bootstrap opzionale: `/opt/matchlivetv`) +**Ultimo aggiornamento:** 2026-05-25 + +--- + +## Architettura rete + +``` +Internet + │ + ├──► Router WAN :443 ──► Nginx Proxy Manager (LAN) ──► http://192.168.1.146:3000 (Rails API + Action Cable) + │ + └──► Router WAN :1935 ──► MediaMTX RTMP (telefoni, ingest video diretto) +``` + +Il flusso video **non passa da Rails**: telefono → RTMP :1935 → MediaMTX → relay ffmpeg → YouTube. + +--- + +## Scelte tecniche + +| Scelta | Motivazione | +|--------|-------------| +| Docker + Compose | Riproducibilità, stesso stack in dev e prod | +| `docker-compose.prod.yml` separato | Dev monta volumi sorgente; prod usa immagine buildata | +| Rails esposto su `:3000` solo LAN | NPM termina HTTPS; non esporre 3000 su WAN | +| MediaMTX `:1935` su WAN | RTMP non supporta facilmente reverse proxy TLS su NPM | +| API MediaMTX `:9997` non pubblicata | Solo rete Docker interna | +| Postgres/Redis senza porte host | Sicurezza | +| Kamal non usato in questa fase | Compose sufficiente per singolo nodo; Kamal in roadmap Gitea | + +--- + +## Porte router (port forwarding verso 192.168.1.146) + +### Aprire su Internet + +| Porta | Protocollo | Servizio | +|-------|------------|----------| +| **1935** | TCP | RTMP ingest (obbligatorio per streaming da 4G) | + +### Opzionale + +| Porta | Protocollo | Servizio | +|-------|------------|----------| +| **8888** | TCP | HLS playback registrazioni | + +### Non aprire + +| Porta | Motivo | +|-------|--------| +| 443 | Gestito da NPM (altro host LAN), non da questo server | +| 3000 | API raggiungibile via NPM sulla LAN | +| 5432, 6379 | Database interni | +| 9997 | API MediaMTX | +| 8554 | RTSP non usato MVP | + +--- + +## Nginx Proxy Manager (HTTPS) + +1. **Proxy Host** → Forward Hostname: `http://192.168.1.146:3000` (dominio es. `matchlivetv.eminux.it`) +2. **Websockets:** ON (obbligatorio per `/cable`) +3. **Custom location HLS** (spettatori): + - Location: `/hls` + - Forward: `http://192.168.1.146:8888` + - Scheme: http + - Così l'HLS è `https://matchlivetv.eminux.it/hls/match_{uuid}/index.m3u8` +4. SSL Let's Encrypt su NPM +5. Aggiorna `infra/.env` sul server: + - `APP_PUBLIC_URL=https://matchlivetv.eminux.it` + - `HLS_PUBLIC_URL=https://matchlivetv.eminux.it/hls` + - `CORS_ORIGINS=https://matchlivetv.eminux.it` + - `ALLOWED_HOSTS=matchlivetv.eminux.it,192.168.1.146` + - `MEDIAMTX_RTMP_URL=rtmp://matchlivetv.eminux.it:1935` + - `YOUTUBE_REDIRECT_URI=https://matchlivetv.eminux.it/api/v1/youtube/callback` (solo tier premium) + +--- + +## Installazione iniziale (una tantum) + +### Prerequisito: sudo senza password + +L'utente `eminux` deve poter eseguire sudo senza password da SSH, oppure eseguire il bootstrap da console Proxmox: + +```bash +echo 'eminux ALL=(ALL) NOPASSWD: ALL' | sudo tee /etc/sudoers.d/eminux-nopasswd +sudo chmod 440 /etc/sudoers.d/eminux-nopasswd +``` + +### 1. Bootstrap server (Docker, UFW, /opt) + +Sul server: + +```bash +sudo bash ~/matchlivetv/scripts/deploy/server_bootstrap.sh +``` + +Installa: Docker Engine, Compose plugin, UFW (22, 1935, 8888, 3000 da LAN), crea `/opt/matchlivetv`. + +**Riavviare la sessione SSH** dopo bootstrap per il gruppo `docker`. + +### 2. Spostare deploy in /opt (opzionale) + +```bash +sudo mv ~/matchlivetv /opt/matchlivetv +sudo chown -R eminux:eminux /opt/matchlivetv +``` + +### 3. Configurare secret + +```bash +cd /opt/matchlivetv/infra # o ~/matchlivetv/infra +nano .env # verificare POSTGRES_PASSWORD, SECRET_KEY_BASE, MEDIAMTX_RTMP_URL, ecc. +``` + +### 4. Avviare stack + +```bash +bash /opt/matchlivetv/scripts/deploy/post_bootstrap_deploy.sh +``` + +Oppure manualmente: + +```bash +cd /opt/matchlivetv/infra +docker compose -f docker-compose.prod.yml --env-file .env up -d --build +``` + +### 5. Seed dati demo (una tantum) + +```bash +docker compose -f docker-compose.prod.yml exec rails bundle exec rails db:seed +``` + +Credenziali demo: `coach@matchlivetv.test` / `password123` + +### 6. Verifica + +```bash +curl http://192.168.1.146:3000/up +curl -X POST http://192.168.1.146:3000/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"coach@matchlivetv.test","password":"password123"}' +``` + +--- + +## Aggiornamento codice (pre-Gitea) + +Dalla macchina di sviluppo: + +```bash +# Tar + scp (rsync non installato sul server) +tar -C /path/to/MatchLiveTV/src -czf /tmp/matchlivetv-deploy.tar.gz \ + --exclude='./mobile/build' --exclude='./backend/log' --exclude='./.git' . +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' + +# Sul server +cd ~/matchlivetv/infra && docker compose -f docker-compose.prod.yml up -d --build +``` + +Script: [`scripts/deploy/sync_to_server.sh`](../../scripts/deploy/sync_to_server.sh) (richiede rsync sul server). + +--- + +## Operazioni quotidiane + +```bash +cd /opt/matchlivetv/infra + +# Stato +docker compose -f docker-compose.prod.yml ps + +# Log +docker compose -f docker-compose.prod.yml logs -f rails +docker compose -f docker-compose.prod.yml logs -f mediamtx + +# Restart +docker compose -f docker-compose.prod.yml restart rails sidekiq + +# Stop +docker compose -f docker-compose.prod.yml down +``` + +### Backup volumi + +- `postgres_data` — database +- `recordings` — HLS MediaMTX + +```bash +docker run --rm -v infra_postgres_data:/data -v $(pwd):/backup alpine \ + tar czf /backup/postgres_backup_$(date +%F).tar.gz -C /data . +``` + +--- + +## Variabili ambiente (`infra/.env`) + +| Variabile | Descrizione | +|-----------|-------------| +| `POSTGRES_PASSWORD` | Password DB | +| `SECRET_KEY_BASE` | Rails secret | +| `JWT_SECRET` | Token API mobile | +| `MEDIAMTX_WEBHOOK_SECRET` | HMAC webhook | +| `MEDIAMTX_RTMP_URL` | URL pubblico RTMP per telefoni | +| `APP_PUBLIC_URL` | Base HTTPS per link watch (`/live/:id`) | +| `HLS_PUBLIC_URL` | Base URL HLS (es. `https://dominio/hls`) | +| `CORS_ORIGINS` | Origini app (dominio HTTPS) | +| `ALLOWED_HOSTS` | Host header Rails | +| `YOUTUBE_*` | OAuth YouTube (tier premium) | + +Template: [`infra/.env.production.example`](../../infra/.env.production.example) + +--- + +## Roadmap Gitea / CI (fase 2) + +1. Repository Gitea con mirror monorepo +2. Pipeline su push `main`: + - `bundle exec rspec` (backend) + - `docker build` immagine `matchlivetv/backend:$CI_COMMIT_SHA` + - Push registry (Gitea packages o Docker Hub privato) +3. Deploy job SSH: + ```bash + docker compose -f docker-compose.prod.yml pull + docker compose -f docker-compose.prod.yml up -d + ``` +4. Secret in Gitea: tutte le variabili `.env` + +--- + +## Stato deploy 2026-05-25 + +| Step | Stato | +|------|-------| +| Codice sincronizzato in `~/matchlivetv` | Completato | +| `infra/.env` con secret generati | Completato (sul server) | +| Docker installato | **In attesa** — richiede `sudo bash server_bootstrap.sh` | +| Stack avviato | **In attesa** — dopo Docker | + +--- + +## Informazioni ancora da definire + +- IP pubblico WAN o DDNS per `MEDIAMTX_RTMP_URL` da internet +- Dominio per NPM e certificato SSL +- Credenziali YouTube OAuth produzione +- URL repository Gitea diff --git a/infra/.env.example b/infra/.env.example new file mode 100644 index 0000000..254a499 --- /dev/null +++ b/infra/.env.example @@ -0,0 +1,7 @@ +POSTGRES_PASSWORD=matchlivetv_dev +SECRET_KEY_BASE=change_me_to_a_long_random_string_in_production +MEDIAMTX_WEBHOOK_SECRET=mediamtx_webhook_dev_secret +JWT_SECRET=matchlivetv_jwt_dev_secret_change_in_prod +YOUTUBE_CLIENT_ID= +YOUTUBE_CLIENT_SECRET= +YOUTUBE_REDIRECT_URI=http://localhost:3000/api/v1/youtube/callback diff --git a/infra/.env.production.example b/infra/.env.production.example new file mode 100644 index 0000000..3beda68 --- /dev/null +++ b/infra/.env.production.example @@ -0,0 +1,30 @@ +# Copia come .env sul server: cp .env.production.example .env +# Genera secret: openssl rand -hex 32 + +POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD +SECRET_KEY_BASE=CHANGE_ME_openssl_rand_hex_64 +JWT_SECRET=CHANGE_ME_openssl_rand_hex_32 +MEDIAMTX_WEBHOOK_SECRET=CHANGE_ME_openssl_rand_hex_32 + +# RTMP pubblico per telefoni (IP WAN o DDNS + porta 1935) +MEDIAMTX_RTMP_URL=rtmp://YOUR_PUBLIC_IP_OR_DOMAIN:1935 + +# Dominio pubblico (Nginx Proxy Manager → Rails :3000) +APP_PUBLIC_URL=https://matchlivetv.yourdomain.com +HLS_PUBLIC_URL=https://matchlivetv.yourdomain.com/hls + +CORS_ORIGINS=https://matchlivetv.yourdomain.com +ALLOWED_HOSTS=matchlivetv.yourdomain.com,192.168.1.146 +YOUTUBE_REDIRECT_URI=https://matchlivetv.yourdomain.com/api/v1/youtube/callback + +# YouTube OAuth (opzionale) +YOUTUBE_CLIENT_ID= +YOUTUBE_CLIENT_SECRET= + +# Stripe (abbonamento Premium squadra) +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +STRIPE_PREMIUM_LIGHT_PRICE_ID= +STRIPE_PREMIUM_FULL_PRICE_ID= + +RAILS_LOG_LEVEL=info diff --git a/infra/docker-compose.prod.yml b/infra/docker-compose.prod.yml new file mode 100644 index 0000000..3b24fb1 --- /dev/null +++ b/infra/docker-compose.prod.yml @@ -0,0 +1,117 @@ +# Produzione — Match Live TV +# Uso: docker compose -f docker-compose.prod.yml --env-file .env up -d --build + +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: matchlivetv + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + restart: unless-stopped + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + + mediamtx: + image: bluenviron/mediamtx:latest + restart: unless-stopped + ports: + - "1935:1935" # RTMP ingest (telefoni) — esporre su router + - "8888:8888" # HLS playback (opzionale) + volumes: + - ./mediamtx.yml:/mediamtx.yml:ro + - ./slates:/slates:ro + - recordings:/recordings + command: /mediamtx.yml + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:9997/v3/config/global/get"] + interval: 15s + timeout: 5s + retries: 3 + + rails: + build: + context: ../backend + dockerfile: Dockerfile + restart: unless-stopped + command: > + bash -c "bundle exec rails db:prepare && + bundle exec rails server -b 0.0.0.0 -p 3000 -e production" + environment: + RAILS_ENV: production + DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/matchlivetv + REDIS_URL: redis://redis:6379/0 + MEDIAMTX_API_URL: http://mediamtx:9997 + MEDIAMTX_RTMP_URL: ${MEDIAMTX_RTMP_URL:-rtmp://mediamtx:1935} + MEDIAMTX_WEBHOOK_SECRET: ${MEDIAMTX_WEBHOOK_SECRET} + RAILS_WEBHOOK_URL: http://rails:3000 + SECRET_KEY_BASE: ${SECRET_KEY_BASE} + JWT_SECRET: ${JWT_SECRET} + CORS_ORIGINS: ${CORS_ORIGINS:-*} + ALLOWED_HOSTS: ${ALLOWED_HOSTS:-*} + YOUTUBE_CLIENT_ID: ${YOUTUBE_CLIENT_ID:-} + YOUTUBE_CLIENT_SECRET: ${YOUTUBE_CLIENT_SECRET:-} + YOUTUBE_REDIRECT_URI: ${YOUTUBE_REDIRECT_URI:-} + HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888} + APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000} + RAILS_LOG_TO_STDOUT: "true" + RAILS_LOG_LEVEL: ${RAILS_LOG_LEVEL:-info} + ports: + - "3000:3000" # Solo LAN — NPM proxy HTTPS + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + mediamtx: + condition: service_started + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:3000/up"] + interval: 15s + timeout: 5s + retries: 10 + start_period: 90s + + sidekiq: + build: + context: ../backend + dockerfile: Dockerfile + restart: unless-stopped + command: bundle exec sidekiq -C config/sidekiq.yml -e production + environment: + RAILS_ENV: production + DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/matchlivetv + REDIS_URL: redis://redis:6379/0 + MEDIAMTX_API_URL: http://mediamtx:9997 + MEDIAMTX_WEBHOOK_SECRET: ${MEDIAMTX_WEBHOOK_SECRET} + RAILS_WEBHOOK_URL: http://rails:3000 + SECRET_KEY_BASE: ${SECRET_KEY_BASE} + JWT_SECRET: ${JWT_SECRET} + YOUTUBE_CLIENT_ID: ${YOUTUBE_CLIENT_ID:-} + YOUTUBE_CLIENT_SECRET: ${YOUTUBE_CLIENT_SECRET:-} + HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888} + APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000} + depends_on: + rails: + condition: service_healthy + +volumes: + postgres_data: + redis_data: + recordings: diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml new file mode 100644 index 0000000..62066ae --- /dev/null +++ b/infra/docker-compose.yml @@ -0,0 +1,119 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: matchlivetv + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-matchlivetv_dev} + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + volumes: + - redis_data:/data + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + + mediamtx: + image: bluenviron/mediamtx:latest + ports: + - "1935:1935" + - "8554:8554" + - "8888:8888" + - "9997:9997" + volumes: + - ./mediamtx.yml:/mediamtx.yml:ro + - ./slates:/slates:ro + - recordings:/recordings + command: /mediamtx.yml + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:9997/v3/config/global/get"] + interval: 10s + timeout: 5s + retries: 3 + + rails: + build: + context: ../backend + dockerfile: Dockerfile + command: bash -c "bundle exec rails db:prepare db:seed && bundle exec rails server -b 0.0.0.0 -p 3000" + environment: + RAILS_ENV: development + DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-matchlivetv_dev}@postgres:5432/matchlivetv + REDIS_URL: redis://redis:6379/0 + MEDIAMTX_API_URL: http://mediamtx:9997 + MEDIAMTX_RTMP_URL: rtmp://mediamtx:1935 + MEDIAMTX_WEBHOOK_SECRET: ${MEDIAMTX_WEBHOOK_SECRET:-mediamtx_webhook_dev_secret} + RAILS_WEBHOOK_URL: http://rails:3000 + SECRET_KEY_BASE: ${SECRET_KEY_BASE:-dev_secret_key_base_change_me_32chars_min} + JWT_SECRET: ${JWT_SECRET:-matchlivetv_jwt_dev_secret} + YOUTUBE_CLIENT_ID: ${YOUTUBE_CLIENT_ID:-} + YOUTUBE_CLIENT_SECRET: ${YOUTUBE_CLIENT_SECRET:-} + YOUTUBE_REDIRECT_URI: ${YOUTUBE_REDIRECT_URI:-http://localhost:3000/api/v1/youtube/callback} + RAILS_LOG_TO_STDOUT: "true" + ports: + - "3000:3000" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + mediamtx: + condition: service_started + volumes: + - ../backend:/app + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/up"] + interval: 15s + timeout: 5s + retries: 10 + start_period: 60s + + sidekiq: + build: + context: ../backend + dockerfile: Dockerfile + command: bundle exec sidekiq -C config/sidekiq.yml + environment: + RAILS_ENV: development + DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-matchlivetv_dev}@postgres:5432/matchlivetv + REDIS_URL: redis://redis:6379/0 + MEDIAMTX_API_URL: http://mediamtx:9997 + MEDIAMTX_WEBHOOK_SECRET: ${MEDIAMTX_WEBHOOK_SECRET:-mediamtx_webhook_dev_secret} + RAILS_WEBHOOK_URL: http://rails:3000 + SECRET_KEY_BASE: ${SECRET_KEY_BASE:-dev_secret_key_base_change_me_32chars_min} + JWT_SECRET: ${JWT_SECRET:-matchlivetv_jwt_dev_secret} + YOUTUBE_CLIENT_ID: ${YOUTUBE_CLIENT_ID:-} + YOUTUBE_CLIENT_SECRET: ${YOUTUBE_CLIENT_SECRET:-} + depends_on: + rails: + condition: service_healthy + volumes: + - ../backend:/app + + nginx: + image: nginx:alpine + ports: + - "8080:80" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + depends_on: + - rails + +volumes: + postgres_data: + redis_data: + recordings: diff --git a/infra/mediamtx.yml b/infra/mediamtx.yml new file mode 100644 index 0000000..2959ce8 --- /dev/null +++ b/infra/mediamtx.yml @@ -0,0 +1,45 @@ +# MediaMTX configuration for Match Live TV +# Docs: https://mediamtx.org/docs/references/configuration-file + +logLevel: info + +# Allow API from Docker network (Rails container) +authInternalUsers: + - user: any + pass: + ips: [] + permissions: + - action: api + - action: metrics + - action: pprof + - action: publish + - action: read + +api: yes +apiAddress: :9997 +rtmp: yes +rtmpAddress: :1935 +hls: yes +hlsAddress: :8888 +hlsVariant: mpegts + +pathDefaults: + source: publisher + overridePublisher: yes + record: yes + recordPath: /recordings/%path/%Y-%m-%d_%H-%M-%S + recordSegmentDuration: 60s + recordDeleteAfter: 48h + +paths: + # Dynamic session paths are created via MediaMTX HTTP API from Rails. + # Template for reference (Rails sets alwaysAvailable per path): + # match_{uuid}: + # alwaysAvailable: true + # alwaysAvailableFile: /slates/offline.mp4 + # alwaysAvailableTracks: + # - codec: H264 + # - codec: MPEG4Audio + # sampleRate: 48000 + # channelCount: 2 + all_others: diff --git a/infra/nginx.conf b/infra/nginx.conf new file mode 100644 index 0000000..ec70999 --- /dev/null +++ b/infra/nginx.conf @@ -0,0 +1,34 @@ +events { + worker_connections 1024; +} + +http { + upstream rails { + server rails:3000; + } + + server { + listen 80; + server_name localhost; + + client_max_body_size 10M; + + location /cable { + proxy_pass http://rails; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 86400; + } + + location / { + proxy_pass http://rails; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } +} diff --git a/infra/scripts/generate_slate.sh b/infra/scripts/generate_slate.sh new file mode 100755 index 0000000..ec873b9 --- /dev/null +++ b/infra/scripts/generate_slate.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# Generate offline slate MP4 for MediaMTX alwaysAvailable +set -e +OUT_DIR="$(dirname "$0")/../slates" +OUT="$OUT_DIR/offline.mp4" +mkdir -p "$OUT_DIR" +docker run --rm -v "$OUT_DIR:/out" linuxserver/ffmpeg:latest \ + -f lavfi -i color=c=0x1a1a1a:s=1280x720:r=30 \ + -f lavfi -i sine=frequency=440:sample_rate=48000 \ + -t 30 -c:v libx264 -pix_fmt yuv420p -g 30 -preset fast \ + -c:a aac -b:a 128k -y /out/offline.mp4 +echo "Created $OUT" diff --git a/infra/slates/README.md b/infra/slates/README.md new file mode 100644 index 0000000..69a6ec3 --- /dev/null +++ b/infra/slates/README.md @@ -0,0 +1,11 @@ +# Slate video (offline) + +Place `offline.mp4` here — H.264 + AAC, 1280x720, 30fps, 48kHz stereo. +Must match phone encoder settings for seamless `alwaysAvailable` merge. + +Generate a test slate with ffmpeg: + +```bash +ffmpeg -f lavfi -i color=c=0x1a1a1a:s=1280x720:r=30 -f lavfi -i sine=frequency=440:sample_rate=48000 \ + -t 10 -c:v libx264 -pix_fmt yuv420p -g 30 -c:a aac -b:a 128k offline.mp4 +``` diff --git a/infra/slates/offline.mp4 b/infra/slates/offline.mp4 new file mode 100644 index 0000000..c870254 Binary files /dev/null and b/infra/slates/offline.mp4 differ diff --git a/mobile/.gitignore b/mobile/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/mobile/.gitignore @@ -0,0 +1,45 @@ +# 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 diff --git a/mobile/.metadata b/mobile/.metadata new file mode 100644 index 0000000..a906de7 --- /dev/null +++ b/mobile/.metadata @@ -0,0 +1,45 @@ +# 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' diff --git a/mobile/README.md b/mobile/README.md new file mode 100644 index 0000000..15db89a --- /dev/null +++ b/mobile/README.md @@ -0,0 +1,17 @@ +# 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. diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/mobile/analysis_options.yaml @@ -0,0 +1,28 @@ +# 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 diff --git a/mobile/android/.gitignore b/mobile/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/mobile/android/.gitignore @@ -0,0 +1,14 @@ +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 diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts new file mode 100644 index 0000000..46895e5 --- /dev/null +++ b/mobile/android/app/build.gradle.kts @@ -0,0 +1,59 @@ +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") +} diff --git a/mobile/android/app/proguard-rules.pro b/mobile/android/app/proguard-rules.pro new file mode 100644 index 0000000..57c43b6 --- /dev/null +++ b/mobile/android/app/proguard-rules.pro @@ -0,0 +1,2 @@ +# RootEncoder / SLF4J (R8 release) +-dontwarn org.slf4j.impl.StaticLoggerBinder diff --git a/mobile/android/app/src/debug/AndroidManifest.xml b/mobile/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/mobile/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ee2d458 --- /dev/null +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/MainActivity.kt b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/MainActivity.kt new file mode 100644 index 0000000..16c544b --- /dev/null +++ b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/MainActivity.kt @@ -0,0 +1,21 @@ +package com.matchlivetv.match_live_tv + +import android.os.Bundle +import io.flutter.embedding.android.FlutterFragmentActivity +import io.flutter.embedding.engine.FlutterEngine + +class MainActivity : FlutterFragmentActivity() { + + 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()) + } +} diff --git a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamPreviewHolder.kt b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamPreviewHolder.kt new file mode 100644 index 0000000..c93c24a --- /dev/null +++ b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamPreviewHolder.kt @@ -0,0 +1,9 @@ +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 +} diff --git a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingEngine.kt b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingEngine.kt new file mode 100644 index 0000000..33c9d48 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingEngine.kt @@ -0,0 +1,476 @@ +package com.matchlivetv.match_live_tv + +import android.content.Context +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import androidx.annotation.RequiresApi +import com.pedro.common.ConnectChecker +import com.pedro.library.generic.GenericStream +import com.pedro.library.util.BitrateAdapter +import com.pedro.library.view.OpenGlView +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +data class StreamingConfig( + val rtmpUrl: String, + val width: Int = 1280, + val height: Int = 720, + val videoBitrate: Int = 2_500_000, + val audioBitrate: Int = 128_000, + val fps: Int = 30, + val rotation: Int = 0, + val sampleRate: Int = 44_100, + val stereo: Boolean = true, + val maxReconnectAttempts: Int = 10, + val reconnectDelayMs: Long = 5_000L, +) + +data class StreamingMetrics( + val isStreaming: Boolean, + val isPreviewActive: Boolean, + val isConnected: Boolean, + val state: String, + val bitrateKbps: Long, + val fps: Int, + val durationMs: Long, + val reconnectAttempts: Int, + val lastError: String?, +) { + fun toMap(): Map = mapOf( + "isStreaming" to isStreaming, + "isPreviewActive" to isPreviewActive, + "isConnected" to isConnected, + "state" to state, + "bitrateKbps" to bitrateKbps, + "fps" to fps, + "durationMs" to durationMs, + "reconnectAttempts" to reconnectAttempts, + "lastError" to lastError, + ) +} + +enum class StreamingState { + IDLE, + PREVIEWING, + CONNECTING, + STREAMING, + RECONNECTING, + STOPPING, + ERROR, +} + +interface StreamingEngineListener { + fun onStateChanged(state: StreamingState, message: String? = null) + fun onMetricsUpdated(metrics: StreamingMetrics) + fun onError(message: String) +} + +@RequiresApi(Build.VERSION_CODES.LOLLIPOP) +class StreamingEngine( + private val appContext: Context, +) : ConnectChecker { + + private val mainHandler = Handler(Looper.getMainLooper()) + private val listeners = mutableSetOf() + + private var genericStream: GenericStream? = null + private var previewSurface: OpenGlView? = null + + private var config: StreamingConfig? = null + private var state: StreamingState = StreamingState.IDLE + private var streamStartedAtMs: Long = 0L + private var currentBitrateKbps: Long = 0L + private var currentFps: Int = 0 + private var lastError: String? = null + private var lastVideoFrameCount: Long = 0L + private var lastFpsSampleAtMs: Long = 0L + private val reconnectAttempts = AtomicInteger(0) + private val isReleased = AtomicBoolean(false) + + private val bitrateAdapter = BitrateAdapter { adaptedBitrate -> + genericStream?.setVideoBitrateOnFly(adaptedBitrate) + } + + private val metricsRunnable = object : Runnable { + override fun run() { + emitMetrics() + if (state == StreamingState.STREAMING || + state == StreamingState.CONNECTING || + state == StreamingState.RECONNECTING || + state == StreamingState.PREVIEWING + ) { + mainHandler.postDelayed(this, METRICS_INTERVAL_MS) + } + } + } + + fun addListener(listener: StreamingEngineListener) { + listeners.add(listener) + } + + fun removeListener(listener: StreamingEngineListener) { + listeners.remove(listener) + } + + fun bindStreamPreview(glView: OpenGlView) { + ensureMainThread() + val surfaceChanged = previewSurface != null && previewSurface !== glView + previewSurface = glView + + if (surfaceChanged) { + genericStream?.let { stream -> + if (stream.isOnPreview) { + stream.stopPreview() + } + } + } + + if (state == StreamingState.STREAMING || + state == StreamingState.CONNECTING || + state == StreamingState.RECONNECTING + ) { + attachPreviewToStream(forceRebind = surfaceChanged) + return + } + + val cfg = config ?: previewConfig() + val stream = genericStream ?: obtainGenericStream(cfg) + genericStream = stream + + if (!stream.isOnPreview) { + stopPreviewAndStreamForPrepare(stream) + val prepared = try { + stream.prepareVideo( + cfg.width, + cfg.height, + cfg.videoBitrate, + cfg.fps, + rotation = cfg.rotation, + ) && stream.prepareAudio( + cfg.sampleRate, + cfg.stereo, + cfg.audioBitrate, + ) + } catch (_: Exception) { + false + } + if (!prepared) { + notifyError("Impossibile preparare la preview camera") + return + } + } + + attachPreviewToStream() + updateState(StreamingState.PREVIEWING) + startMetricsLoop() + } + + fun unbindStreamPreview() { + ensureMainThread() + previewSurface = null + genericStream?.let { stream -> + if (stream.isOnPreview) { + stream.stopPreview() + } + } + if (state == StreamingState.PREVIEWING) { + updateState(StreamingState.IDLE) + } + } + + fun startStream(streamConfig: StreamingConfig) { + ensureMainThread() + if (isReleased.get()) { + notifyError("StreamingEngine rilasciato") + return + } + if (state == StreamingState.STREAMING || + state == StreamingState.CONNECTING || + state == StreamingState.RECONNECTING + ) { + return + } + + config = streamConfig + lastError = null + reconnectAttempts.set(0) + + val stream = genericStream ?: obtainGenericStream(streamConfig) + genericStream = stream + stopPreviewAndStreamForPrepare(stream) + + val prepared = try { + stream.prepareVideo( + streamConfig.width, + streamConfig.height, + streamConfig.videoBitrate, + streamConfig.fps, + rotation = streamConfig.rotation, + ) && stream.prepareAudio( + streamConfig.sampleRate, + streamConfig.stereo, + streamConfig.audioBitrate, + ) + } catch (exception: IllegalArgumentException) { + false + } catch (exception: IllegalStateException) { + false + } + + if (!prepared) { + notifyError("Configurazione audio/video non valida") + updateState(StreamingState.ERROR, "prepare failed") + return + } + + attachPreviewToStream() + + stream.getStreamClient().setReTries(streamConfig.maxReconnectAttempts) + bitrateAdapter.setMaxBitrate(streamConfig.videoBitrate + streamConfig.audioBitrate) + streamStartedAtMs = SystemClock.elapsedRealtime() + updateState(StreamingState.CONNECTING) + stream.startStream(streamConfig.rtmpUrl) + startMetricsLoop() + } + + fun stopStream() { + ensureMainThread() + if (state == StreamingState.IDLE || state == StreamingState.STOPPING) { + return + } + + updateState(StreamingState.STOPPING) + stopMetricsLoop() + + genericStream?.let { stream -> + if (stream.isStreaming) { + stream.stopStream() + } + if (stream.isOnPreview) { + stream.stopPreview() + } + stream.release() + } + genericStream = null + + streamStartedAtMs = 0L + currentBitrateKbps = 0L + currentFps = 0 + reconnectAttempts.set(0) + updateState(StreamingState.IDLE) + emitMetrics() + } + + fun release() { + ensureMainThread() + if (!isReleased.compareAndSet(false, true)) { + return + } + stopStream() + unbindStreamPreview() + listeners.clear() + } + + fun getMetrics(): StreamingMetrics = buildMetrics() + + override fun onConnectionStarted(url: String) { + postMain { + updateState(StreamingState.CONNECTING, url) + } + } + + override fun onConnectionSuccess() { + postMain { + reconnectAttempts.set(0) + lastError = null + updateState(StreamingState.STREAMING) + } + } + + override fun onConnectionFailed(reason: String) { + postMain { + lastError = reason + val stream = genericStream ?: return@postMain + val currentConfig = config ?: return@postMain + + updateState(StreamingState.RECONNECTING, reason) + val attempt = reconnectAttempts.incrementAndGet() + val retryScheduled = stream.getStreamClient().reTry( + currentConfig.reconnectDelayMs, + reason, + null, + ) + + if (retryScheduled) { + emitMetrics() + } else { + updateState(StreamingState.ERROR, reason) + notifyError("Connessione RTMP fallita: $reason") + stream.stopStream() + } + } + } + + override fun onNewBitrate(bitrate: Long) { + postMain { + currentBitrateKbps = bitrate / 1000 + genericStream?.let { stream -> + bitrateAdapter.adaptBitrate( + bitrate, + stream.getStreamClient().hasCongestion(), + ) + } + emitMetrics() + } + } + + override fun onDisconnect() { + postMain { + if (state == StreamingState.STREAMING || state == StreamingState.RECONNECTING) { + updateState(StreamingState.RECONNECTING, "disconnected") + } + } + } + + override fun onAuthError() { + postMain { + lastError = "auth_error" + updateState(StreamingState.ERROR, "auth_error") + genericStream?.stopStream() + notifyError("Autenticazione RTMP fallita") + } + } + + override fun onAuthSuccess() { + postMain { + updateState(StreamingState.STREAMING) + } + } + + private fun stopPreviewAndStreamForPrepare(stream: GenericStream) { + if (stream.isStreaming) { + stream.stopStream() + } + if (stream.isOnPreview) { + stream.stopPreview() + } + } + + private fun attachPreviewToStream(forceRebind: Boolean = false) { + val glView = previewSurface ?: StreamPreviewHolder.openGlView ?: return + previewSurface = glView + val stream = genericStream ?: return + if (forceRebind && stream.isOnPreview) { + stream.stopPreview() + } + if (!stream.isOnPreview) { + stream.startPreview(glView, autoHandle = true) + } + } + + private fun previewConfig(): StreamingConfig { + return config ?: StreamingConfig( + rtmpUrl = "rtmp://127.0.0.1/preview", + width = 1280, + height = 720, + ) + } + + private fun obtainGenericStream(streamConfig: StreamingConfig): GenericStream { + return GenericStream( + appContext, + this, + ).apply { + getGlInterface().autoHandleOrientation = true + getStreamClient().setBitrateExponentialFactor(0.5f) + getStreamClient().setReTries(streamConfig.maxReconnectAttempts) + } + } + + private fun buildMetrics(): StreamingMetrics { + val durationMs = if (streamStartedAtMs > 0L) { + SystemClock.elapsedRealtime() - streamStartedAtMs + } else { + 0L + } + + return StreamingMetrics( + isStreaming = state == StreamingState.STREAMING || + state == StreamingState.RECONNECTING || + state == StreamingState.CONNECTING, + isPreviewActive = state == StreamingState.PREVIEWING || + (genericStream?.isOnPreview == true), + isConnected = state == StreamingState.STREAMING, + state = state.name.lowercase(), + bitrateKbps = currentBitrateKbps, + fps = currentFps, + durationMs = durationMs, + reconnectAttempts = reconnectAttempts.get(), + lastError = lastError, + ) + } + + private fun updateState(newState: StreamingState, message: String? = null) { + state = newState + listeners.forEach { it.onStateChanged(newState, message) } + emitMetrics() + } + + private fun emitMetrics() { + updateFpsEstimate() + val metrics = buildMetrics() + listeners.forEach { it.onMetricsUpdated(metrics) } + } + + private fun updateFpsEstimate() { + val stream = genericStream ?: return + val now = SystemClock.elapsedRealtime() + val sentFrames = stream.getStreamClient().getSentVideoFrames() + if (lastFpsSampleAtMs == 0L) { + lastFpsSampleAtMs = now + lastVideoFrameCount = sentFrames + return + } + + val elapsedMs = now - lastFpsSampleAtMs + if (elapsedMs >= 1_000L) { + val frameDelta = sentFrames - lastVideoFrameCount + currentFps = ((frameDelta * 1000L) / elapsedMs).toInt() + lastVideoFrameCount = sentFrames + lastFpsSampleAtMs = now + } + } + + private fun notifyError(message: String) { + listeners.forEach { it.onError(message) } + } + + private fun startMetricsLoop() { + mainHandler.removeCallbacks(metricsRunnable) + mainHandler.post(metricsRunnable) + } + + private fun stopMetricsLoop() { + mainHandler.removeCallbacks(metricsRunnable) + } + + private fun postMain(block: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) { + block() + } else { + mainHandler.post(block) + } + } + + private fun ensureMainThread() { + check(Looper.myLooper() == Looper.getMainLooper()) { + "StreamingEngine must be used on the main thread" + } + } + + companion object { + private const val METRICS_INTERVAL_MS = 1_000L + } +} diff --git a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingEngineHolder.kt b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingEngineHolder.kt new file mode 100644 index 0000000..ea2bcf1 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingEngineHolder.kt @@ -0,0 +1,22 @@ +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 + } +} diff --git a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingForegroundService.kt b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingForegroundService.kt new file mode 100644 index 0000000..b3e9ef3 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingForegroundService.kt @@ -0,0 +1,243 @@ +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 = + 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) -> Unit>() + + fun addListener(listener: (Map) -> Unit) { + synchronized(listeners) { + listeners.add(listener) + } + } + + fun removeListener(listener: (Map) -> Unit) { + synchronized(listeners) { + listeners.remove(listener) + } + } + + fun emit(event: Map) { + val snapshot = synchronized(listeners) { listeners.toList() } + snapshot.forEach { it(event) } + } +} diff --git a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingPlugin.kt b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingPlugin.kt new file mode 100644 index 0000000..fba0655 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingPlugin.kt @@ -0,0 +1,286 @@ +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 com.pedro.library.view.OpenGlView +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) -> 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 { mainHandlerRebindPreview() }, + ) + + 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 + mainHandlerRebindPreview() + } + + override fun onDetachedFromActivityForConfigChanges() { + activityBinding = null + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + activityBinding = binding + mainHandlerRebindPreview() + } + + private fun mainHandlerRebindPreview() { + android.os.Handler(android.os.Looper.getMainLooper()).post { + attachPreviewIfPossible() + } + } + + override fun onDetachedFromActivity() { + activityBinding = null + unbindServiceIfNeeded() + } + + override fun onMethodCall(call: MethodCall, result: Result) { + when (call.method) { + "startStream" -> startStream(call, result) + "stopStream" -> stopStream(result) + "getMetrics" -> getMetrics(result) + "startPreview" -> startPreview(result) + "stopPreview" -> stopPreview(result) + "bindPreview" -> bindPreview(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 config = StreamingConfig( + rtmpUrl = rtmpUrl, + width = (args["width"] as? Number)?.toInt() ?: 1280, + height = (args["height"] as? Number)?.toInt() ?: 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, + 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 stopStream(result: Result) { + appContext.startService(StreamingForegroundService.stopIntent(appContext)) + getEngine()?.let { engine -> + engine.removeListener(engineListener) + engine.stopStream() + } + unbindServiceIfNeeded() + 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(StreamPreviewHolder.openGlView != null) + } + + 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 attachPreviewIfPossible() { + val glView = StreamPreviewHolder.openGlView ?: return + val engine = getOrCreateEngine() + engine.bindStreamPreview(glView) + } + + 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" + } +} diff --git a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingPreviewPlatformView.kt b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingPreviewPlatformView.kt new file mode 100644 index 0000000..2c3417a --- /dev/null +++ b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingPreviewPlatformView.kt @@ -0,0 +1,42 @@ +package com.matchlivetv.match_live_tv + +import android.content.Context +import android.view.View +import android.widget.FrameLayout +import com.pedro.library.view.OpenGlView +import io.flutter.plugin.common.StandardMessageCodec +import io.flutter.plugin.platform.PlatformView +import io.flutter.plugin.platform.PlatformViewFactory + +class StreamingPreviewPlatformView( + context: Context, + private val onPreviewReady: (OpenGlView) -> Unit, +) : PlatformView { + + private val openGlView = OpenGlView(context).apply { + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ) + } + + init { + StreamPreviewHolder.openGlView = openGlView + onPreviewReady(openGlView) + } + + override fun getView(): View = openGlView + + override fun dispose() { + // Non azzerare il holder: la rotazione ricrea la PlatformView e il plugin riaggancia. + } +} + +class StreamingPreviewFactory( + private val onPreviewReady: (OpenGlView) -> Unit, +) : PlatformViewFactory(StandardMessageCodec.INSTANCE) { + + override fun create(context: Context, viewId: Int, args: Any?): PlatformView { + return StreamingPreviewPlatformView(context, onPreviewReady) + } +} diff --git a/mobile/android/app/src/main/res/drawable-v21/launch_background.xml b/mobile/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/mobile/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/mobile/android/app/src/main/res/drawable/launch_background.xml b/mobile/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/mobile/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/values-night/styles.xml b/mobile/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/mobile/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/mobile/android/app/src/main/res/values/strings.xml b/mobile/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..425b3bb --- /dev/null +++ b/mobile/android/app/src/main/res/values/strings.xml @@ -0,0 +1,12 @@ + + + Streaming live + Notifiche durante lo streaming Match Live TV + Match Live TV + Streaming attivo + Connessione al server RTMP… + Trasmissione in corso + Riconnessione in corso… + Errore di streaming + Stop + diff --git a/mobile/android/app/src/main/res/values/styles.xml b/mobile/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/mobile/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/mobile/android/app/src/main/res/xml/network_security_config.xml b/mobile/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..82f15b7 --- /dev/null +++ b/mobile/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,9 @@ + + + + + 10.0.2.2 + localhost + 127.0.0.1 + + diff --git a/mobile/android/app/src/profile/AndroidManifest.xml b/mobile/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/mobile/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/mobile/android/build.gradle.kts b/mobile/android/build.gradle.kts new file mode 100644 index 0000000..d08ad63 --- /dev/null +++ b/mobile/android/build.gradle.kts @@ -0,0 +1,25 @@ +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("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/mobile/android/gradle.properties b/mobile/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/mobile/android/gradle.properties @@ -0,0 +1,6 @@ +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 diff --git a/mobile/android/gradle/wrapper/gradle-wrapper.properties b/mobile/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2d428bf --- /dev/null +++ b/mobile/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/mobile/android/settings.gradle.kts b/mobile/android/settings.gradle.kts new file mode 100644 index 0000000..2786c72 --- /dev/null +++ b/mobile/android/settings.gradle.kts @@ -0,0 +1,35 @@ +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") diff --git a/mobile/ios/.gitignore b/mobile/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/mobile/ios/.gitignore @@ -0,0 +1,34 @@ +**/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 diff --git a/mobile/ios/Flutter/AppFrameworkInfo.plist b/mobile/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/mobile/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/mobile/ios/Flutter/Debug.xcconfig b/mobile/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/mobile/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/mobile/ios/Flutter/Release.xcconfig b/mobile/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/mobile/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..abfdfb5 --- /dev/null +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,644 @@ +// !$*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 = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 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 = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 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 = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* 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 = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 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 = ""; + }; +/* 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 = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata b/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/mobile/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +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) + } +} diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "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" + } +} diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "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" + } +} diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# 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. \ No newline at end of file diff --git a/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard b/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/ios/Runner/Base.lproj/Main.storyboard b/mobile/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/mobile/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist new file mode 100644 index 0000000..58e4999 --- /dev/null +++ b/mobile/ios/Runner/Info.plist @@ -0,0 +1,70 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Match Live Tv + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + match_live_tv + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/mobile/ios/Runner/Runner-Bridging-Header.h b/mobile/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/mobile/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/mobile/ios/Runner/SceneDelegate.swift b/mobile/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/mobile/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +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. + } + +} diff --git a/mobile/lib/app/router.dart b/mobile/lib/app/router.dart new file mode 100644 index 0000000..566306b --- /dev/null +++ b/mobile/lib/app/router.dart @@ -0,0 +1,90 @@ +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/controller_mode/controller_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(authProvider, (prev, next) { + final authChanged = prev?.isAuthenticated != next.isAuthenticated; + if (authChanged) notifyListeners(); + }); + } + + final Ref _ref; +} + +final routerProvider = Provider((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) => const LoginScreen(), + ), + 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); + }, + ), + GoRoute( + path: '/session/:id/controller', + builder: (context, state) { + final sessionId = state.pathParameters['id']!; + return ControllerScreen(sessionId: sessionId); + }, + ), + ], + ); +}); diff --git a/mobile/lib/app/theme.dart b/mobile/lib/app/theme.dart new file mode 100644 index 0000000..64bd13c --- /dev/null +++ b/mobile/lib/app/theme.dart @@ -0,0 +1,93 @@ +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, + ); + } +} diff --git a/mobile/lib/core/auth_storage.dart b/mobile/lib/core/auth_storage.dart new file mode 100644 index 0000000..ed379dc --- /dev/null +++ b/mobile/lib/core/auth_storage.dart @@ -0,0 +1,64 @@ +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 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 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); + } +} diff --git a/mobile/lib/core/config.dart b/mobile/lib/core/config.dart new file mode 100644 index 0000000..035886f --- /dev/null +++ b/mobile/lib/core/config.dart @@ -0,0 +1,24 @@ +/// 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'; + } +} diff --git a/mobile/lib/features/archive/archive_screen.dart b/mobile/lib/features/archive/archive_screen.dart new file mode 100644 index 0000000..6a38c52 --- /dev/null +++ b/mobile/lib/features/archive/archive_screen.dart @@ -0,0 +1,113 @@ +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/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(primaryTeamProvider); + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text('Archivio gare'), + 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( + 'Archivio replay con Premium Light o Full', + style: theme.textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () => showPremiumRequiredDialog( + context, + message: 'Salva e rivedi le gare per 90 giorni 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: Text('Nessuna gara in archivio')); + } + 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]; + return Card( + color: AppTheme.surface, + child: ListTile( + title: Text('${r.teamName} vs ${r.opponentName}'), + subtitle: Text( + r.endedAt != null ? df.format(r.endedAt!.toLocal()) : '—', + ), + trailing: const Icon(Icons.play_circle_outline), + onTap: () async { + final url = r.replayUrl; + if (url == null) return; + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl( + uri, + mode: team.canDownloadOnPhone + ? LaunchMode.externalApplication + : LaunchMode.inAppBrowserView, + ); + } + }, + ), + ); + }, + ); + }, + ); + }, + ), + ); + } +} diff --git a/mobile/lib/features/auth/login_screen.dart b/mobile/lib/features/auth/login_screen.dart new file mode 100644 index 0000000..291404e --- /dev/null +++ b/mobile/lib/features/auth/login_screen.dart @@ -0,0 +1,157 @@ +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 '../../features/matches/matches_screen.dart'; +import '../../features/matches/team_providers.dart'; +import '../../providers/auth_provider.dart'; +import '../../shared/api_client.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}); + + @override + ConsumerState createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(text: 'coach@matchlivetv.test'); + final _passwordController = TextEditingController(text: 'password123'); + bool _obscure = true; + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _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 Wi‑Fi.', + ); + 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, + ); + ref.invalidate(teamsProvider); + ref.invalidate(matchesProvider); + ref.read(authProvider.notifier).setLoading(false); + if (mounted) context.go('/matches'); + return; + } on DioException catch (e) { + 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), + Text( + 'ACCEDI', + style: theme.textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Gestisci le dirette della tua squadra', + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + 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: 'ACCEDI', + loading: auth.loading, + onPressed: _login, + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/auth/splash_screen.dart b/mobile/lib/features/auth/splash_screen.dart new file mode 100644 index 0000000..a591a31 --- /dev/null +++ b/mobile/lib/features/auth/splash_screen.dart @@ -0,0 +1,55 @@ +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 createState() => _SplashScreenState(); +} + +class _SplashScreenState extends ConsumerState { + @override + void initState() { + super.initState(); + _bootstrap(); + } + + Future _bootstrap() async { + await ref.read(authProvider.notifier).restoreSession(); + await Future.delayed(const Duration(milliseconds: 800)); + if (!mounted) return; + final auth = ref.read(authProvider); + 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), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/camera_mode/camera_screen.dart b/mobile/lib/features/camera_mode/camera_screen.dart new file mode 100644 index 0000000..0c5e8e9 --- /dev/null +++ b/mobile/lib/features/camera_mode/camera_screen.dart @@ -0,0 +1,652 @@ +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 '../shared/live_score_actions.dart'; +import '../../shared/api_client.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/camera_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 createState() => _CameraScreenState(); +} + +class _CameraScreenState extends ConsumerState { + static const _previewKey = ValueKey('camera_streaming_preview'); + + Timer? _elapsedTimer; + Timer? _telemetryTimer; + Timer? _statsTimer; + int _batteryLevel = 100; + int? _lastDelta; + int _prevHomePoints = 0; + int _prevAwayPoints = 0; + + double _bitrateMbps = 0; + int _fps = 0; + String _networkType = '4G'; + int _viewerCount = 0; + + StreamSubscription>? _metricsSub; + Orientation? _lastOrientation; + + @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!), + ); + } + }); + _telemetryTimer = Timer.periodic(const Duration(seconds: 10), (_) => _sendTelemetry()); + _statsTimer = Timer.periodic(const Duration(seconds: 30), (_) => _fetchStats()); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final orientation = MediaQuery.orientationOf(context); + if (_lastOrientation != null && _lastOrientation != orientation) { + WidgetsBinding.instance.addPostFrameCallback((_) async { + await StreamingChannel.bindPreview(); + }); + } + _lastOrientation = orientation; + } + + Future _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 _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); + ref.read(sessionProvider.notifier).setSession(session, match: match); + ref.read(sessionProvider.notifier).setDeviceRole(DeviceRole.camera); + + if (session.score != null) { + ref.read(scoreProvider.notifier).reset(session.score!); + } + + // Ripresa dopo crash: la sessione resta connecting/live, riallineiamo lo stato. + if (session.status == 'idle' || session.status == 'paused') { + session = await client.startSession(widget.sessionId); + ref.read(sessionProvider.notifier).setSession(session, match: match); + } + + final ws = ref.read(websocketServiceProvider); + await ws.connect(sessionId: widget.sessionId, deviceRole: 'camera'); + + await Future.delayed(const Duration(milliseconds: 300)); + await StreamingChannel.bindPreview(); + + final fresh = await client.fetchSession(widget.sessionId); + ref.read(sessionProvider.notifier).setSession(fresh, match: match); + + if (fresh.rtmpIngestUrl != null) { + await Future.delayed(const Duration(milliseconds: 500)); + await StreamingChannel.startStream( + rtmpUrl: fresh.rtmpIngestUrl!, + targetBitrate: fresh.targetBitrate, + targetFps: 30, + ); + if (mounted && fresh.canResumeBroadcast) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Diretta ripresa — stai di nuovo in onda'), + duration: Duration(seconds: 3), + ), + ); + } + } + + _metricsSub = StreamingChannel.metricsStream + .receiveBroadcastStream() + .map((e) => Map.from(e as Map)) + .listen((event) { + if (event['type'] != 'metrics') return; + final metrics = Map.from( + (event['metrics'] as Map?)?.map((k, v) => MapEntry(k.toString(), v)) ?? {}, + ); + setState(() { + _bitrateMbps = (metrics['bitrateKbps'] as num? ?? 0) / 1000; + _fps = (metrics['fps'] as num?)?.toInt() ?? 0; + }); + }); + } 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 _readBattery() async { + final level = await Battery().batteryLevel; + if (mounted) setState(() => _batteryLevel = level); + } + + Future _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 _fetchStats() async { + try { + final stats = await ref.read(apiClientProvider).youtubeStats(widget.sessionId); + if (mounted) setState(() => _viewerCount = stats.concurrentViewers); + } catch (_) {} + } + + Future _lockOrientations() async { + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + DeviceOrientation.portraitUp, + ]); + } + + Future _unlockOrientation() async { + await SystemChrome.setPreferredOrientations(DeviceOrientation.values); + } + + void _syncScore() { + ref.read(websocketServiceProvider).sendScoreUpdate(ref.read(scoreProvider)); + } + + Future _homePoint() async { + ref.read(scoreProvider.notifier).incrementHome(); + _syncScore(); + final match = ref.read(sessionProvider).match; + await LiveScoreActions(ref).afterPointChange( + context, + homeName: match?.teamName ?? 'Casa', + awayName: match?.opponentName ?? 'Ospiti', + onCloseSetConfirmed: () => _closeSetConfirmed(), + ); + } + + Future _awayPoint() async { + ref.read(scoreProvider.notifier).incrementAway(); + _syncScore(); + final match = ref.read(sessionProvider).match; + await LiveScoreActions(ref).afterPointChange( + context, + homeName: match?.teamName ?? 'Casa', + awayName: match?.opponentName ?? 'Ospiti', + onCloseSetConfirmed: () => _closeSetConfirmed(), + ); + } + + void _homeMinus() { + ref.read(scoreProvider.notifier).decrementHome(); + _syncScore(); + } + + void _awayMinus() { + ref.read(scoreProvider.notifier).decrementAway(); + _syncScore(); + } + + Future _closeSet() async { + await LiveScoreActions(ref).requestCloseSet( + context, + onCloseSetConfirmed: () => _closeSetConfirmed(), + ); + } + + Future _closeSetConfirmed() async { + ref.read(scoreProvider.notifier).closeSet(); + _syncScore(); + final match = ref.read(sessionProvider).match; + await LiveScoreActions(ref).afterCloseSet( + context, + homeName: match?.teamName ?? 'Casa', + awayName: match?.opponentName ?? 'Ospiti', + onStopStream: _closeStreamPermanently, + ); + } + + Future _leaveSession() async { + try { + await StreamingChannel.stopStream(); + } catch (_) {} + try { + await ref.read(websocketServiceProvider).disconnect(); + } catch (_) {} + if (mounted) context.go('/matches'); + } + + /// Pausa temporanea: si può riprendere la stessa sessione. + Future _interruptStream() async { + try { + await StreamingChannel.stopStream(); + } catch (_) {} + try { + await ref.read(apiClientProvider).pauseSession(widget.sessionId); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Pausa sessione: $e')), + ); + } + } + await _leaveSession(); + } + + /// Chiusura definitiva: sessione ended, pagina web senza player. + Future _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(); + _telemetryTimer?.cancel(); + _statsTimer?.cancel(); + _metricsSub?.cancel(); + WakelockPlus.disable(); + _unlockOrientation(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final sessionState = ref.watch(sessionProvider); + final score = ref.watch(scoreProvider); + _trackScoreDelta(score); + + final match = sessionState.match; + 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 unica: non viene mai smontata al cambio orientamento. + return Scaffold( + backgroundColor: isLandscape ? Colors.black : AppTheme.background, + body: Stack( + fit: StackFit.expand, + children: [ + const Positioned.fill( + child: NativeStreamingPreview( + key: _previewKey, + showGrid: true, + platformLabel: 'LIVE', + ), + ), + if (isLandscape) + _LandscapeOverlay( + elapsed: sessionState.elapsed, + batteryLevel: _batteryLevel, + homeName: homeName, + awayName: awayName, + score: score, + lastDelta: _lastDelta, + networkType: _networkType, + bitrateMbps: _bitrateMbps, + fps: _fps, + viewerCount: _viewerCount, + onHomePoint: () => _homePoint(), + onAwayPoint: () => _awayPoint(), + onHomeMinus: _homeMinus, + onAwayMinus: _awayMinus, + onCloseSet: () => _closeSet(), + onInterrupt: _interruptStream, + 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, + onHomePoint: () => _homePoint(), + onAwayPoint: () => _awayPoint(), + onHomeMinus: _homeMinus, + onAwayMinus: _awayMinus, + onCloseSet: () => _closeSet(), + onInterrupt: _interruptStream, + 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.onHomePoint, + required this.onAwayPoint, + required this.onHomeMinus, + required this.onAwayMinus, + required this.onCloseSet, + required this.onInterrupt, + 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 onHomePoint; + final VoidCallback onAwayPoint; + final VoidCallback onHomeMinus; + final VoidCallback onAwayMinus; + final VoidCallback onCloseSet; + final VoidCallback onInterrupt; + final Future 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), + const Spacer(), + CameraCompactMetrics( + networkType: networkType, + bitrateMbps: bitrateMbps, + fps: fps, + batteryLevel: batteryLevel, + viewerCount: viewerCount > 0 ? viewerCount : null, + ), + ], + ), + ), + const Expanded(child: SizedBox.expand()), + Container( + color: AppTheme.background, + padding: EdgeInsets.fromLTRB( + inset.left, + 12, + inset.right, + math.max(inset.bottom, 16), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CameraScoreControls( + homeName: homeName, + awayName: awayName, + homePoints: score.homePoints, + awayPoints: score.awayPoints, + currentSet: score.currentSet, + homeSets: score.homeSets, + awaySets: score.awaySets, + pointsTarget: pointsTarget, + onHomePoint: onHomePoint, + onAwayPoint: onAwayPoint, + onHomeMinus: onHomeMinus, + onAwayMinus: onAwayMinus, + onCloseSet: onCloseSet, + ), + const SizedBox(height: 10), + OutlinedButton( + onPressed: onInterrupt, + child: const Text('Interrompi (riprendibile)'), + ), + const SizedBox(height: 8), + DestructiveCta( + label: 'Chiudi diretta', + onPressed: () => onCloseStream(), + ), + ], + ), + ), + ], + ); + } +} + +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.onHomePoint, + required this.onAwayPoint, + required this.onHomeMinus, + required this.onAwayMinus, + required this.onCloseSet, + required this.onInterrupt, + 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 onHomePoint; + final VoidCallback onAwayPoint; + final VoidCallback onHomeMinus; + final VoidCallback onAwayMinus; + final VoidCallback onCloseSet; + final VoidCallback onInterrupt; + final Future 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), + const Spacer(), + CameraCompactMetrics( + networkType: networkType, + bitrateMbps: bitrateMbps, + fps: fps, + batteryLevel: batteryLevel, + viewerCount: viewerCount > 0 ? viewerCount : null, + light: true, + ), + ], + ), + ), + const Expanded(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: [ + CameraScoreControls( + compact: true, + homeName: homeName, + awayName: awayName, + homePoints: score.homePoints, + awayPoints: score.awayPoints, + currentSet: score.currentSet, + homeSets: score.homeSets, + awaySets: score.awaySets, + lastDelta: lastDelta, + pointsTarget: pointsTarget, + onHomePoint: onHomePoint, + onAwayPoint: onAwayPoint, + onHomeMinus: onHomeMinus, + onAwayMinus: onAwayMinus, + onCloseSet: onCloseSet, + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: onInterrupt, + child: const Text('Interrompi', style: TextStyle(fontSize: 11)), + ), + ), + const SizedBox(width: 8), + Expanded( + child: DestructiveCta( + label: 'Chiudi', + compact: true, + onPressed: () => onCloseStream(), + ), + ), + ], + ), + ], + ), + ), + ], + ); + } +} diff --git a/mobile/lib/features/camera_mode/camera_screen_landscape.dart b/mobile/lib/features/camera_mode/camera_screen_landscape.dart new file mode 100644 index 0000000..7cd3320 --- /dev/null +++ b/mobile/lib/features/camera_mode/camera_screen_landscape.dart @@ -0,0 +1,134 @@ +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, + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/camera_mode/camera_screen_portrait.dart b/mobile/lib/features/camera_mode/camera_screen_portrait.dart new file mode 100644 index 0000000..a76c41a --- /dev/null +++ b/mobile/lib/features/camera_mode/camera_screen_portrait.dart @@ -0,0 +1,158 @@ +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), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/controller_mode/controller_landscape.dart b/mobile/lib/features/controller_mode/controller_landscape.dart new file mode 100644 index 0000000..4791039 --- /dev/null +++ b/mobile/lib/features/controller_mode/controller_landscape.dart @@ -0,0 +1,188 @@ +import 'package:flutter/material.dart'; + +import '../../app/theme.dart'; +import '../../providers/session_provider.dart'; +import '../../shared/models/score_state.dart'; +import '../../shared/widgets/connected_badge.dart'; +import '../../shared/widgets/destructive_cta.dart'; +import 'controller_screen.dart'; + +/// Layout gamepad a 3 colonne per uso landscape. +class ControllerLandscape extends StatelessWidget { + const ControllerLandscape({ + super.key, + required this.homeName, + required this.awayName, + required this.score, + required this.sessionState, + required this.onHomePoint, + required this.onAwayPoint, + required this.onHomeMinus, + required this.onAwayMinus, + required this.onCloseSet, + required this.onInterrupt, + required this.onCloseStream, + required this.pointsTarget, + }); + + final String homeName; + final String awayName; + final ScoreState score; + final SessionState sessionState; + final VoidCallback onHomePoint; + final VoidCallback onAwayPoint; + final VoidCallback onHomeMinus; + final VoidCallback onAwayMinus; + final VoidCallback onCloseSet; + final VoidCallback onInterrupt; + final Future Function() onCloseStream; + final int pointsTarget; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return SingleChildScrollView( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _TeamColumn( + teamLabel: 'CASA', + teamName: homeName, + sets: score.homeSets, + points: score.homePoints, + onPoint: onHomePoint, + onMinus: onHomeMinus, + ), + ), + Expanded( + flex: 2, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('REGIA', style: theme.textTheme.titleMedium), + const SizedBox(width: 12), + ConnectedBadge(connected: sessionState.connected), + ], + ), + const SizedBox(height: 8), + Text( + 'SET ${score.currentSet} → $pointsTarget pt', + style: theme.textTheme.headlineMedium?.copyWith( + color: AppTheme.accentYellow, + ), + ), + const SizedBox(height: 16), + YellowActionButton(label: 'CHIUDI SET', onPressed: onCloseSet), + const SizedBox(height: 16), + Text('ULTIME AZIONI', style: theme.textTheme.labelLarge), + const SizedBox(height: 8), + StreamMetricsFooter( + elapsed: sessionState.elapsed, + connected: sessionState.connected, + cameraDevice: sessionState.cameraDevice, + viewerCount: sessionState.viewerCount, + compact: true, + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: onInterrupt, + child: const Text('Interrompi'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: DestructiveCta( + label: 'Chiudi', + compact: true, + onPressed: () => onCloseStream(), + ), + ), + ], + ), + ], + ), + ), + ), + Expanded( + child: _TeamColumn( + teamLabel: 'OSPITI', + teamName: awayName, + sets: score.awaySets, + points: score.awayPoints, + onPoint: onAwayPoint, + onMinus: onAwayMinus, + alignRight: true, + ), + ), + ], + ), + ); + } +} + +class _TeamColumn extends StatelessWidget { + const _TeamColumn({ + required this.teamLabel, + required this.teamName, + required this.sets, + required this.points, + required this.onPoint, + required this.onMinus, + this.alignRight = false, + }); + + final String teamLabel; + final String teamName; + final int sets; + final int points; + final VoidCallback onPoint; + final VoidCallback onMinus; + final bool alignRight; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final crossAlign = + alignRight ? CrossAxisAlignment.end : CrossAxisAlignment.start; + + return Column( + crossAxisAlignment: crossAlign, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(teamLabel, style: theme.textTheme.labelLarge), + Text( + teamName.toUpperCase(), + style: theme.textTheme.titleLarge, + textAlign: alignRight ? TextAlign.right : TextAlign.left, + ), + const SizedBox(height: 8), + Text('SETS $sets', style: theme.textTheme.bodyMedium), + Text( + '$points', + style: theme.textTheme.displayLarge?.copyWith( + color: AppTheme.primaryRed, + fontSize: 56, + ), + ), + const SizedBox(height: 16), + ScoreTapButton(label: '+1', onTap: onPoint, large: true), + const SizedBox(height: 8), + OutlinedButton( + onPressed: points > 0 ? onMinus : null, + child: const Text('-1'), + ), + ], + ); + } +} diff --git a/mobile/lib/features/controller_mode/controller_portrait.dart b/mobile/lib/features/controller_mode/controller_portrait.dart new file mode 100644 index 0000000..72c611a --- /dev/null +++ b/mobile/lib/features/controller_mode/controller_portrait.dart @@ -0,0 +1,187 @@ +import 'package:flutter/material.dart'; + +import '../../app/theme.dart'; +import '../../providers/session_provider.dart'; +import '../../shared/models/score_state.dart'; +import '../../shared/widgets/destructive_cta.dart'; +import '../../shared/widgets/screen_insets.dart'; +import 'controller_screen.dart'; + +class ControllerPortrait extends StatelessWidget { + const ControllerPortrait({ + super.key, + required this.homeName, + required this.awayName, + required this.score, + required this.sessionState, + required this.onHomePoint, + required this.onAwayPoint, + required this.onHomeMinus, + required this.onAwayMinus, + required this.onCloseSet, + required this.onInterrupt, + required this.onCloseStream, + required this.pointsTarget, + }); + + final String homeName; + final String awayName; + final ScoreState score; + final SessionState sessionState; + final VoidCallback onHomePoint; + final VoidCallback onAwayPoint; + final VoidCallback onHomeMinus; + final VoidCallback onAwayMinus; + final VoidCallback onCloseSet; + final VoidCallback onInterrupt; + final Future Function() onCloseStream; + final int pointsTarget; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final match = sessionState.match; + + return SingleChildScrollView( + padding: ScreenInsets.contentPadding(context, horizontal: 16, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (match != null) + Text( + '${match.sport.toUpperCase()} · ${match.category ?? ''} · ${match.phase ?? ''} · SET ${score.currentSet}', + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + _Scoreboard( + homeName: homeName, + awayName: awayName, + score: score, + pointsTarget: pointsTarget, + onHomePoint: onHomePoint, + onAwayPoint: onAwayPoint, + onHomeMinus: onHomeMinus, + onAwayMinus: onAwayMinus, + ), + const SizedBox(height: 12), + YellowActionButton(label: 'CHIUDI SET', onPressed: onCloseSet), + const SizedBox(height: 16), + StreamMetricsFooter( + elapsed: sessionState.elapsed, + connected: sessionState.connected, + cameraDevice: sessionState.cameraDevice, + viewerCount: sessionState.viewerCount, + ), + const SizedBox(height: 16), + OutlinedButton( + onPressed: onInterrupt, + child: const Text('Interrompi (riprendibile)'), + ), + const SizedBox(height: 8), + DestructiveCta( + label: 'Chiudi diretta', + onPressed: () => onCloseStream(), + ), + ], + ), + ); + } +} + +class _Scoreboard extends StatelessWidget { + const _Scoreboard({ + required this.homeName, + required this.awayName, + required this.score, + required this.pointsTarget, + required this.onHomePoint, + required this.onAwayPoint, + required this.onHomeMinus, + required this.onAwayMinus, + }); + + final String homeName; + final String awayName; + final ScoreState score; + final int pointsTarget; + final VoidCallback onHomePoint; + final VoidCallback onAwayPoint; + final VoidCallback onHomeMinus; + final VoidCallback onAwayMinus; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: Column( + children: [ + Text(homeName.toUpperCase(), style: theme.textTheme.labelLarge), + Text( + 'SETS ${score.homeSets}', + style: theme.textTheme.bodyMedium, + ), + Text( + '${score.homePoints}', + style: theme.textTheme.displayMedium?.copyWith( + color: AppTheme.primaryRed, + ), + ), + Text('→ $pointsTarget pt', style: theme.textTheme.bodySmall), + const SizedBox(height: 8), + ScoreTapButton(label: '+1', onTap: onHomePoint), + const SizedBox(height: 6), + OutlinedButton( + onPressed: score.homePoints > 0 ? onHomeMinus : null, + child: const Text('-1'), + ), + ], + ), + ), + Text( + '-', + style: theme.textTheme.headlineLarge?.copyWith( + color: AppTheme.textSecondary, + ), + ), + Expanded( + child: Column( + children: [ + Text(awayName.toUpperCase(), style: theme.textTheme.labelLarge), + Text( + 'SETS ${score.awaySets}', + style: theme.textTheme.bodyMedium, + ), + Text( + '${score.awayPoints}', + style: theme.textTheme.displayMedium?.copyWith( + color: AppTheme.primaryRed, + ), + ), + const SizedBox(height: 8), + ScoreTapButton(label: '+1', onTap: onAwayPoint), + const SizedBox(height: 6), + OutlinedButton( + onPressed: score.awayPoints > 0 ? onAwayMinus : null, + child: const Text('-1'), + ), + ], + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/controller_mode/controller_screen.dart b/mobile/lib/features/controller_mode/controller_screen.dart new file mode 100644 index 0000000..ffb86bb --- /dev/null +++ b/mobile/lib/features/controller_mode/controller_screen.dart @@ -0,0 +1,424 @@ +import 'dart:async'; + +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 '../../app/theme.dart'; +import '../../providers/match_rules_provider.dart'; +import '../../providers/score_provider.dart'; +import '../../providers/session_provider.dart'; +import '../shared/live_score_actions.dart'; +import '../../shared/widgets/end_stream_dialog.dart'; +import '../../shared/api_client.dart'; +import '../../shared/websocket_service.dart'; +import '../../shared/widgets/connected_badge.dart'; +import '../../shared/widgets/match_live_wordmark.dart'; +import '../../shared/widgets/screen_insets.dart'; +import 'controller_landscape.dart'; +import 'controller_portrait.dart'; + +class ControllerScreen extends ConsumerStatefulWidget { + const ControllerScreen({super.key, required this.sessionId}); + + final String sessionId; + + @override + ConsumerState createState() => _ControllerScreenState(); +} + +class _ControllerScreenState extends ConsumerState { + Timer? _elapsedTimer; + Timer? _statsTimer; + + @override + void initState() { + super.initState(); + _lockLandscape(); + _bootstrap(); + _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!), + ); + } + }); + _statsTimer = Timer.periodic(const Duration(seconds: 30), (_) => _fetchStats()); + } + + Future _bootstrap() async { + try { + final client = ref.read(apiClientProvider); + final session = await client.fetchSession(widget.sessionId); + ref.read(sessionProvider.notifier).setSession(session); + if (session.score != null) { + ref.read(scoreProvider.notifier).reset(session.score!); + } + final ws = ref.read(websocketServiceProvider); + await ws.connect(sessionId: widget.sessionId, deviceRole: 'controller'); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Errore connessione sessione')), + ); + } + } + } + + Future _fetchStats() async { + try { + final client = ref.read(apiClientProvider); + final stats = await client.youtubeStats(widget.sessionId); + ref.read(sessionProvider.notifier).setViewerCount(stats.concurrentViewers); + } catch (_) {} + } + + Future _lockLandscape() async { + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + DeviceOrientation.portraitUp, + ]); + } + + Future _unlockOrientation() async { + await SystemChrome.setPreferredOrientations(DeviceOrientation.values); + } + + Future _leaveSession() async { + try { + await ref.read(websocketServiceProvider).disconnect(); + } catch (_) {} + if (mounted) context.go('/matches'); + } + + Future _interruptStream() async { + try { + ref.read(websocketServiceProvider).sendCommand('pause_stream'); + await ref.read(apiClientProvider).pauseSession(widget.sessionId); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Pausa: $e')), + ); + } + } + await _leaveSession(); + } + + Future _closeStreamPermanently() async { + if (!mounted) return; + final ok = await confirmEndStream(context); + if (!ok || !mounted) return; + + 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: $e')), + ); + } + } + await _leaveSession(); + } + + @override + void dispose() { + _elapsedTimer?.cancel(); + _statsTimer?.cancel(); + _unlockOrientation(); + super.dispose(); + } + + void _syncScore() { + ref.read(websocketServiceProvider).sendScoreUpdate(ref.read(scoreProvider)); + } + + Future _homePoint() async { + ref.read(scoreProvider.notifier).incrementHome(); + _syncScore(); + final match = ref.read(sessionProvider).match; + await LiveScoreActions(ref).afterPointChange( + context, + homeName: match?.teamName ?? 'Casa', + awayName: match?.opponentName ?? 'Ospiti', + onCloseSetConfirmed: () => _closeSetConfirmed(), + ); + } + + Future _awayPoint() async { + ref.read(scoreProvider.notifier).incrementAway(); + _syncScore(); + final match = ref.read(sessionProvider).match; + await LiveScoreActions(ref).afterPointChange( + context, + homeName: match?.teamName ?? 'Casa', + awayName: match?.opponentName ?? 'Ospiti', + onCloseSetConfirmed: () => _closeSetConfirmed(), + ); + } + + void _homeMinus() { + ref.read(scoreProvider.notifier).decrementHome(); + _syncScore(); + } + + void _awayMinus() { + ref.read(scoreProvider.notifier).decrementAway(); + _syncScore(); + } + + Future _closeSet() async { + await LiveScoreActions(ref).requestCloseSet( + context, + onCloseSetConfirmed: () => _closeSetConfirmed(), + ); + } + + Future _closeSetConfirmed() async { + ref.read(scoreProvider.notifier).closeSet(); + _syncScore(); + final match = ref.read(sessionProvider).match; + await LiveScoreActions(ref).afterCloseSet( + context, + homeName: match?.teamName ?? 'Casa', + awayName: match?.opponentName ?? 'Ospiti', + onStopStream: _closeStreamPermanently, + ); + } + + @override + Widget build(BuildContext context) { + final sessionState = ref.watch(sessionProvider); + final score = ref.watch(scoreProvider); + final match = sessionState.match; + final isLandscape = + MediaQuery.orientationOf(context) == Orientation.landscape; + + final homeName = match?.teamName ?? 'CASA'; + final awayName = match?.opponentName ?? 'OSPITI'; + final rules = ref.watch(matchScoringRulesProvider); + final pointsTarget = rules.pointsTarget(score.currentSet); + + final body = isLandscape + ? ControllerLandscape( + homeName: homeName, + awayName: awayName, + score: score, + sessionState: sessionState, + pointsTarget: pointsTarget, + onHomePoint: () => _homePoint(), + onAwayPoint: () => _awayPoint(), + onHomeMinus: _homeMinus, + onAwayMinus: _awayMinus, + onCloseSet: () => _closeSet(), + onInterrupt: _interruptStream, + onCloseStream: _closeStreamPermanently, + ) + : ControllerPortrait( + homeName: homeName, + awayName: awayName, + score: score, + sessionState: sessionState, + pointsTarget: pointsTarget, + onHomePoint: () => _homePoint(), + onAwayPoint: () => _awayPoint(), + onHomeMinus: _homeMinus, + onAwayMinus: _awayMinus, + onCloseSet: () => _closeSet(), + onInterrupt: _interruptStream, + onCloseStream: _closeStreamPermanently, + ); + + final inset = ScreenInsets.of(context); + + return Scaffold( + backgroundColor: AppTheme.background, + body: Column( + children: [ + if (!isLandscape) + Padding( + padding: EdgeInsets.fromLTRB( + inset.left, + inset.top, + inset.right, + 8, + ), + child: Row( + children: [ + const MatchLiveWordmark(compact: true), + const Spacer(), + ConnectedBadge(connected: sessionState.connected), + ], + ), + ), + Expanded( + child: Padding( + padding: EdgeInsets.only( + left: inset.left, + right: inset.right, + top: isLandscape ? inset.top : 0, + bottom: inset.bottom, + ), + child: body, + ), + ), + ], + ), + ); + } +} + +/// Pulsante score +1 con stile gamepad. +class ScoreTapButton extends StatelessWidget { + const ScoreTapButton({ + super.key, + required this.label, + required this.onTap, + this.large = false, + }); + + final String label; + final VoidCallback onTap; + final bool large; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Material( + color: AppTheme.surfaceElevated, + borderRadius: BorderRadius.circular(large ? 16 : 12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(large ? 16 : 12), + child: Container( + width: large ? double.infinity : null, + padding: EdgeInsets.symmetric( + vertical: large ? 20 : 12, + horizontal: large ? 24 : 16, + ), + child: Text( + label, + textAlign: TextAlign.center, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w900, + fontSize: large ? 28 : 18, + ), + ), + ), + ), + ); + } +} + +/// CTA gialla per azioni secondarie (PAUSA, CHIUDI SET). +class YellowActionButton extends StatelessWidget { + const YellowActionButton({ + super.key, + required this.label, + required this.onPressed, + }); + + final String label; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.accentYellow, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: Text( + label, + style: theme.textTheme.labelLarge?.copyWith(color: Colors.black), + ), + ), + ); + } +} + +/// Footer metriche stream (timer, segnale, Mbps, batteria). +class StreamMetricsFooter extends StatelessWidget { + const StreamMetricsFooter({ + super.key, + required this.elapsed, + required this.connected, + this.cameraDevice, + this.viewerCount, + this.compact = false, + }); + + final Duration elapsed; + final bool connected; + final dynamic cameraDevice; + final int? viewerCount; + final bool compact; + + String _format(Duration d) { + final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); + final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$m:$s'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final network = cameraDevice?.networkType ?? '—'; + final mbps = cameraDevice?.bitrateMbps != null + ? cameraDevice.bitrateMbps.toStringAsFixed(1) + : '—'; + final battery = cameraDevice?.batteryLevel?.toString() ?? '—'; + + return Container( + padding: EdgeInsets.all(compact ? 8 : 12), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(compact ? 8 : 10), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _MetricChip(icon: Icons.timer, label: _format(elapsed)), + _MetricChip(icon: Icons.signal_cellular_alt, label: network), + _MetricChip(icon: Icons.speed, label: '$mbps Mbps'), + _MetricChip(icon: Icons.battery_std, label: '$battery%'), + if (viewerCount != null) + _MetricChip(icon: Icons.visibility, label: '$viewerCount'), + ConnectedBadge(connected: connected, label: compact ? null : 'COLLEGATO'), + ], + ), + ); + } +} + +class _MetricChip extends StatelessWidget { + const _MetricChip({required this.icon, required this.label}); + + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: AppTheme.textSecondary), + const SizedBox(width: 4), + Text(label, style: Theme.of(context).textTheme.labelLarge?.copyWith(fontSize: 11)), + ], + ); + } +} diff --git a/mobile/lib/features/matches/matches_screen.dart b/mobile/lib/features/matches/matches_screen.dart new file mode 100644 index 0000000..b4f2214 --- /dev/null +++ b/mobile/lib/features/matches/matches_screen.dart @@ -0,0 +1,401 @@ +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 '../../providers/auth_provider.dart'; +import '../../shared/api_client.dart'; +import '../../shared/models/match.dart'; +import '../../shared/widgets/match_live_wordmark.dart'; +import '../../shared/widgets/primary_cta.dart'; +import '../../shared/widgets/screen_insets.dart'; +import 'resume_session_sheet.dart'; +import 'no_team_onboarding.dart'; +import 'team_providers.dart'; + +final matchesProvider = FutureProvider>((ref) async { + final auth = ref.watch(authProvider); + if (!auth.isAuthenticated) return []; + final teams = await ref.read(teamsProvider.future); + if (teams.isEmpty) return []; + final client = ref.read(apiClientProvider); + return client.fetchMatches(teams.first.id); +}); + +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(primaryTeamProvider.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, _) => Center(child: Text('Errore squadre: $e')), + 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(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( + 'Le tue partite', + style: theme.textTheme.bodyMedium, + ), + 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, + ), + ], + ), + ), + ), + ), + ], + ], + ), + ), + ), + if (matches.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Text( + 'Nessuna partita programmata', + style: theme.textTheme.bodyMedium, + ), + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final match = matches[index]; + return _MatchCard( + match: match, + dateFormat: dateFormat, + onTap: () { + if (match.hasActiveSession) { + showResumeSessionSheet(context, ref, match: match); + } else { + context.push('/setup/${match.id}/1'); + } + }, + onDelete: match.canResumeCamera + ? null + : () => _deleteMatch(context, ref, match), + ); + }, + childCount: matches.length, + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + bottomInset), + child: PrimaryCta( + label: 'NUOVA PARTITA', + icon: Icons.add, + onPressed: () => _createMatch(context, ref), + ), + ), + ), + ], + ), + ); + }, + ); + }, + ), + ); + } + + Future _deleteMatch( + BuildContext context, + WidgetRef ref, + MatchModel match, + ) async { + final confirmed = await showDialog( + 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')), + ); + } + } + } + + Future _createMatch(BuildContext context, WidgetRef ref) async { + final teams = await ref.read(teamsProvider.future); + if (teams.isEmpty) { + 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(teams.first.id, { + 'opponent_name': 'Avversario', + 'sport': 'volleyball', + 'sets_to_win': 3, + }); + if (context.mounted) context.push('/setup/${match.id}/1'); + } +} + +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( + canResume ? 'RIPRENDI' : (hasSession ? 'IN CORSO' : 'CONFIGURA'), + 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), + ), + ], + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/matches/no_team_onboarding.dart b/mobile/lib/features/matches/no_team_onboarding.dart new file mode 100644 index 0000000..ad91afd --- /dev/null +++ b/mobile/lib/features/matches/no_team_onboarding.dart @@ -0,0 +1,56 @@ +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); + } + }, + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/matches/resume_session_sheet.dart b/mobile/lib/features/matches/resume_session_sheet.dart new file mode 100644 index 0000000..62469eb --- /dev/null +++ b/mobile/lib/features/matches/resume_session_sheet.dart @@ -0,0 +1,97 @@ +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/session_provider.dart'; +import '../../shared/models/match.dart'; + +/// Azioni per riprendere una diretta interrotta (crash, chiusura app, ecc.). +void showResumeSessionSheet( + BuildContext context, + WidgetRef ref, { + required MatchModel match, +}) { + final sessionId = match.activeSessionId; + if (sessionId == null) return; + + showModalBottomSheet( + context: context, + backgroundColor: AppTheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (ctx) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Diretta in corso', + style: Theme.of(ctx).textTheme.titleLarge, + ), + const SizedBox(height: 4), + Text( + '${match.teamName} vs ${match.opponentName}', + style: Theme.of(ctx).textTheme.bodyMedium, + ), + if (match.activeSessionStatus != null) ...[ + const SizedBox(height: 8), + Text( + 'Stato: ${match.activeSessionStatus}', + style: Theme.of(ctx).textTheme.labelLarge?.copyWith( + color: AppTheme.primaryRed, + ), + ), + ], + const SizedBox(height: 20), + if (match.canResumeCamera) + FilledButton.icon( + onPressed: () { + ref.read(sessionProvider.notifier).setDeviceRole(DeviceRole.camera); + Navigator.pop(ctx); + context.push('/session/$sessionId/camera'); + }, + icon: const Icon(Icons.videocam), + label: const Text('Riprendi camera e trasmetti'), + style: FilledButton.styleFrom( + backgroundColor: AppTheme.primaryRed, + padding: const EdgeInsets.symmetric(vertical: 14), + ), + ), + if (match.activeSessionStatus == 'idle') ...[ + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: () { + Navigator.pop(ctx); + context.push('/setup/${match.id}/1'); + }, + icon: const Icon(Icons.settings), + label: const Text('Continua configurazione'), + ), + ], + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: () { + ref.read(sessionProvider.notifier).setDeviceRole(DeviceRole.controller); + Navigator.pop(ctx); + context.push('/session/$sessionId/controller'); + }, + icon: const Icon(Icons.sports_esports), + label: const Text('Apri regia (punteggio)'), + ), + const SizedBox(height: 8), + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Annulla'), + ), + ], + ), + ), + ); + }, + ); +} diff --git a/mobile/lib/features/matches/team_providers.dart b/mobile/lib/features/matches/team_providers.dart new file mode 100644 index 0000000..64ee8dc --- /dev/null +++ b/mobile/lib/features/matches/team_providers.dart @@ -0,0 +1,34 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../providers/auth_provider.dart'; +import '../../shared/api_client.dart'; +import '../../shared/models/recording_item.dart'; +import '../../shared/models/team.dart'; + +final teamsProvider = FutureProvider>((ref) async { + final auth = ref.watch(authProvider); + if (!auth.isAuthenticated) return []; + final client = ref.read(apiClientProvider); + return client.fetchTeams(); +}); + +final primaryTeamProvider = FutureProvider((ref) async { + final teams = await ref.watch(teamsProvider.future); + if (teams.isEmpty) return null; + return teams.first; +}); + +final teamByIdProvider = FutureProvider.family((ref, teamId) async { + final teams = await ref.watch(teamsProvider.future); + for (final t in teams) { + if (t.id == teamId) return t; + } + return null; +}); + +final recordingsProvider = FutureProvider.family, String>((ref, teamId) async { + final team = await ref.watch(teamByIdProvider(teamId).future); + if (team == null || !team.recordingsEnabled) return []; + final client = ref.read(apiClientProvider); + return client.fetchRecordings(teamId); +}); diff --git a/mobile/lib/features/setup_wizard/step_match.dart b/mobile/lib/features/setup_wizard/step_match.dart new file mode 100644 index 0000000..ef8e65b --- /dev/null +++ b/mobile/lib/features/setup_wizard/step_match.dart @@ -0,0 +1,284 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import '../../app/theme.dart'; +import '../../shared/api_client.dart'; +import '../../shared/models/match.dart'; +import '../../shared/widgets/primary_cta.dart'; +import '../../shared/widgets/screen_insets.dart'; + +class StepMatch extends ConsumerStatefulWidget { + const StepMatch({ + super.key, + required this.match, + required this.onNext, + }); + + final MatchModel match; + final VoidCallback onNext; + + @override + ConsumerState createState() => _StepMatchState(); +} + +class _StepMatchState extends ConsumerState { + late final TextEditingController _opponentController; + late final TextEditingController _locationController; + late final TextEditingController _categoryController; + late final TextEditingController _phaseController; + late final TextEditingController _rosterController; + int _setsToWin = 3; + DateTime? _scheduledAt; + bool _saving = false; + bool _customRules = false; + int _pointsPerSet = 25; + int _pointsDecidingSet = 15; + int _minPointLead = 2; + + @override + void initState() { + super.initState(); + _opponentController = TextEditingController(text: widget.match.opponentName); + _locationController = TextEditingController(text: widget.match.location ?? ''); + _categoryController = TextEditingController(text: widget.match.category ?? ''); + _phaseController = TextEditingController(text: widget.match.phase ?? ''); + _rosterController = TextEditingController( + text: widget.match.rosterNumbers.join(', '), + ); + _setsToWin = widget.match.setsToWin; + _scheduledAt = widget.match.scheduledAt; + final rules = widget.match.scoringRules; + if (rules != null && rules.isNotEmpty) { + _customRules = true; + _pointsPerSet = rules['points_per_set'] as int? ?? 25; + _pointsDecidingSet = rules['points_deciding_set'] as int? ?? 15; + _minPointLead = rules['min_point_lead'] as int? ?? 2; + } + } + + @override + void dispose() { + _opponentController.dispose(); + _locationController.dispose(); + _categoryController.dispose(); + _phaseController.dispose(); + _rosterController.dispose(); + super.dispose(); + } + + List _parseRoster(String raw) { + return raw + .split(RegExp(r'[,\s]+')) + .where((s) => s.isNotEmpty) + .map(int.tryParse) + .whereType() + .toList(); + } + + Future _pickDateTime() async { + final date = await showDatePicker( + context: context, + initialDate: _scheduledAt ?? DateTime.now(), + firstDate: DateTime.now().subtract(const Duration(days: 1)), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (date == null || !mounted) return; + + final time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(_scheduledAt ?? DateTime.now()), + ); + if (time == null) return; + + setState(() { + _scheduledAt = DateTime(date.year, date.month, date.day, time.hour, time.minute); + }); + } + + Future _save() async { + if (_opponentController.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Inserisci il nome avversario')), + ); + return; + } + + setState(() => _saving = true); + try { + final client = ref.read(apiClientProvider); + await client.updateMatch(widget.match.id, { + 'opponent_name': _opponentController.text.trim(), + 'location': _locationController.text.trim(), + 'scheduled_at': _scheduledAt?.toIso8601String(), + 'sets_to_win': _setsToWin, + if (_customRules) + 'scoring_rules': { + 'points_per_set': _pointsPerSet, + 'points_deciding_set': _pointsDecidingSet, + 'min_point_lead': _minPointLead, + }, + 'category': _categoryController.text.trim(), + 'phase': _phaseController.text.trim(), + 'roster_numbers': _parseRoster(_rosterController.text), + }); + widget.onNext(); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Errore nel salvataggio')), + ); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final dateLabel = _scheduledAt != null + ? DateFormat('EEE d MMM yyyy · HH:mm', 'it_IT').format(_scheduledAt!.toLocal()) + : 'Seleziona data e ora'; + + return SingleChildScrollView( + padding: ScreenInsets.contentPadding(context, horizontal: 20, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Dettagli partita', style: theme.textTheme.titleLarge), + const SizedBox(height: 20), + _ReadOnlyField(label: 'Squadra casa', value: widget.match.teamName), + const SizedBox(height: 12), + TextField( + controller: _opponentController, + decoration: const InputDecoration(labelText: 'Avversario'), + ), + const SizedBox(height: 12), + TextField( + controller: _locationController, + decoration: const InputDecoration(labelText: 'Luogo'), + ), + const SizedBox(height: 12), + ListTile( + contentPadding: EdgeInsets.zero, + title: Text('Orario', style: theme.textTheme.bodyMedium), + subtitle: Text(dateLabel, style: theme.textTheme.titleMedium), + trailing: const Icon(Icons.calendar_today), + onTap: _pickDateTime, + ), + const SizedBox(height: 12), + Text('Set da vincere', style: theme.textTheme.bodyMedium), + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment(value: 2, label: Text('2')), + ButtonSegment(value: 3, label: Text('3')), + ], + selected: {_setsToWin}, + onSelectionChanged: (s) => setState(() => _setsToWin = s.first), + ), + const SizedBox(height: 12), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Regole punteggio personalizzate'), + subtitle: const Text( + 'Per tornei non standard (punti set, tie-break, vantaggio minimo)', + ), + value: _customRules, + onChanged: (v) => setState(() => _customRules = v), + ), + if (_customRules) ...[ + const SizedBox(height: 8), + Text('Punti per vincere un set', style: theme.textTheme.bodyMedium), + const SizedBox(height: 6), + SegmentedButton( + segments: const [ + ButtonSegment(value: 21, label: Text('21')), + ButtonSegment(value: 25, label: Text('25')), + ButtonSegment(value: 30, label: Text('30')), + ], + selected: {_pointsPerSet}, + onSelectionChanged: (s) => setState(() => _pointsPerSet = s.first), + ), + const SizedBox(height: 10), + Text('Punti set decisivo', style: theme.textTheme.bodyMedium), + const SizedBox(height: 6), + SegmentedButton( + segments: const [ + ButtonSegment(value: 15, label: Text('15')), + ButtonSegment(value: 21, label: Text('21')), + ], + selected: {_pointsDecidingSet}, + onSelectionChanged: (s) => setState(() => _pointsDecidingSet = s.first), + ), + const SizedBox(height: 10), + Text('Vantaggio minimo', style: theme.textTheme.bodyMedium), + const SizedBox(height: 6), + SegmentedButton( + segments: const [ + ButtonSegment(value: 1, label: Text('1')), + ButtonSegment(value: 2, label: Text('2')), + ], + selected: {_minPointLead}, + onSelectionChanged: (s) => setState(() => _minPointLead = s.first), + ), + ], + const SizedBox(height: 12), + TextField( + controller: _categoryController, + decoration: const InputDecoration(labelText: 'Categoria'), + ), + const SizedBox(height: 12), + TextField( + controller: _phaseController, + decoration: const InputDecoration(labelText: 'Fase (es. semifinale)'), + ), + const SizedBox(height: 12), + TextField( + controller: _rosterController, + decoration: const InputDecoration( + labelText: 'Convocate (numeri maglia)', + hintText: '1, 5, 7, 12', + ), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 32), + PrimaryCta( + label: 'AVANTI >', + loading: _saving, + onPressed: _save, + icon: Icons.arrow_forward, + ), + ], + ), + ); + } +} + +class _ReadOnlyField extends StatelessWidget { + const _ReadOnlyField({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppTheme.surfaceElevated, + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.bodyMedium), + const SizedBox(height: 4), + Text(value, style: theme.textTheme.titleMedium), + ], + ), + ); + } +} diff --git a/mobile/lib/features/setup_wizard/step_network_test.dart b/mobile/lib/features/setup_wizard/step_network_test.dart new file mode 100644 index 0000000..736ccda --- /dev/null +++ b/mobile/lib/features/setup_wizard/step_network_test.dart @@ -0,0 +1,295 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:connectivity_plus/connectivity_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 '../../app/theme.dart'; +import '../../providers/score_provider.dart'; +import '../../providers/session_provider.dart'; +import '../../shared/api_client.dart'; +import '../../shared/models/match.dart'; +import '../../shared/models/score_state.dart'; +import '../../shared/models/stream_session.dart'; +import '../../shared/websocket_service.dart'; +import '../../shared/widgets/metric_card.dart'; +import '../../shared/widgets/primary_cta.dart'; +import '../../shared/widgets/screen_insets.dart'; + +class StepNetworkTest extends ConsumerStatefulWidget { + const StepNetworkTest({ + super.key, + required this.match, + required this.onBack, + }); + + final MatchModel match; + final VoidCallback onBack; + + @override + ConsumerState createState() => _StepNetworkTestState(); +} + +class _StepNetworkTestState extends ConsumerState { + bool _testing = false; + bool _ready = false; + double _downloadMbps = 0; + double _uploadMbps = 0; + int _latencyMs = 0; + String _networkType = '—'; + bool _starting = false; + + Future _runTest() async { + setState(() { + _testing = true; + _ready = false; + }); + + final connectivity = await Connectivity().checkConnectivity(); + final networkLabel = _connectivityLabel(connectivity); + + // Simulazione test rete locale (sostituibile con speed test reale). + await Future.delayed(const Duration(seconds: 2)); + final rng = Random(); + final download = 8 + rng.nextDouble() * 12; + final upload = 2 + rng.nextDouble() * 4; + final latency = 20 + rng.nextInt(80); + + final session = ref.read(sessionProvider).session; + NetworkTestResult? testResult; + if (session != null) { + try { + final client = ref.read(apiClientProvider); + testResult = await client.networkTest( + sessionId: session.id, + downloadMbps: download, + uploadMbps: upload, + latencyMs: latency, + networkType: networkLabel, + ); + } catch (_) { + testResult = null; + } + } + + if (mounted) { + setState(() { + _testing = false; + _downloadMbps = download; + _uploadMbps = upload; + _latencyMs = latency; + _networkType = networkLabel; + _ready = testResult?.ready ?? upload >= 2.0; + }); + } + } + + String _connectivityLabel(List results) { + if (results.contains(ConnectivityResult.wifi)) return 'WiFi'; + if (results.contains(ConnectivityResult.mobile)) return '4G'; + if (results.contains(ConnectivityResult.ethernet)) return 'Ethernet'; + return 'Sconosciuto'; + } + + Future _startLive() async { + final sessionState = ref.read(sessionProvider); + final session = sessionState.session; + if (session == null) return; + + setState(() => _starting = true); + try { + final client = ref.read(apiClientProvider); + final started = await client.startSession(session.id); + ref.read(sessionProvider.notifier).setSession(started, match: widget.match); + + if (started.score != null) { + ref.read(scoreProvider.notifier).reset(started.score!); + } else { + ref.read(scoreProvider.notifier).reset(const ScoreState()); + } + + final role = sessionState.deviceRole; + final path = role == DeviceRole.camera + ? '/session/${started.id}/camera' + : '/session/${started.id}/controller'; + + if (!mounted) return; + context.go(path); + + // WebSocket in background: non bloccare apertura camera/regia + try { + final ws = ref.read(websocketServiceProvider); + await ws.connect( + sessionId: started.id, + deviceRole: role == DeviceRole.camera ? 'camera' : 'controller', + ); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Diretta avviata ma sync regia limitata: $e', + ), + ), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Errore avvio diretta: $e')), + ); + } + } finally { + if (mounted) setState(() => _starting = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return SingleChildScrollView( + padding: ScreenInsets.contentPadding(context, horizontal: 20, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Test rete', style: theme.textTheme.titleLarge), + const SizedBox(height: 8), + Text( + 'Verifica che la connessione regga l\'upload della diretta.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 24), + Row( + children: [ + MetricCard( + label: 'Download', + value: _testing ? '...' : '${_downloadMbps.toStringAsFixed(1)} Mbps', + icon: Icons.download, + ), + const SizedBox(width: 8), + MetricCard( + label: 'Upload', + value: _testing ? '...' : '${_uploadMbps.toStringAsFixed(1)} Mbps', + icon: Icons.upload, + highlight: _ready, + ), + const SizedBox(width: 8), + MetricCard( + label: 'Latenza', + value: _testing ? '...' : '${_latencyMs} ms', + icon: Icons.speed, + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: MetricCard( + label: 'Tipo rete', + value: _networkType, + icon: Icons.signal_cellular_alt, + expand: false, + ), + ), + const SizedBox(height: 20), + if (_ready) ...[ + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppTheme.successGreen.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppTheme.successGreen.withValues(alpha: 0.4)), + ), + child: Text( + 'PRONTO PER ANDARE IN DIRETTA', + style: theme.textTheme.titleMedium?.copyWith( + color: AppTheme.successGreen, + ), + textAlign: TextAlign.center, + ), + ), + if (ref.read(sessionProvider).session?.watchPageUrl != null) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppTheme.surfaceElevated, + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Link per gli spettatori', + style: theme.textTheme.titleSmall, + ), + const SizedBox(height: 8), + SelectableText( + ref.read(sessionProvider).session!.watchPageUrl!, + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: () { + final url = ref.read(sessionProvider).session!.watchPageUrl!; + Clipboard.setData(ClipboardData(text: url)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Link copiato')), + ); + }, + icon: const Icon(Icons.link, size: 18), + label: const Text('COPIA LINK DIRETTA'), + ), + ], + ), + ), + ], + ], + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppTheme.surfaceElevated, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + 'In caso di disconnessione, lo stream resta attivo con slate per massimo 5 minuti mentre il telefono riconnette.', + style: theme.textTheme.bodyMedium, + ), + ), + const SizedBox(height: 24), + OutlinedButton( + onPressed: _testing ? null : _runTest, + child: Text(_testing ? 'TEST IN CORSO...' : 'AVVIA TEST RETE'), + ), + const SizedBox(height: 32), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _starting ? null : widget.onBack, + child: const Text('Indietro'), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: PrimaryCta( + label: 'INIZIA >', + loading: _starting, + onPressed: _ready ? _startLive : null, + icon: Icons.play_arrow, + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/setup_wizard/step_roles.dart b/mobile/lib/features/setup_wizard/step_roles.dart new file mode 100644 index 0000000..88c9ec2 --- /dev/null +++ b/mobile/lib/features/setup_wizard/step_roles.dart @@ -0,0 +1,238 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +import '../../app/theme.dart'; +import '../../providers/session_provider.dart'; +import '../../shared/api_client.dart'; +import '../../shared/models/match.dart'; +import '../../shared/widgets/primary_cta.dart'; +import '../../shared/widgets/screen_insets.dart'; + +class StepRoles extends ConsumerStatefulWidget { + const StepRoles({ + super.key, + required this.match, + required this.onBack, + required this.onNext, + }); + + final MatchModel match; + final VoidCallback onBack; + final VoidCallback onNext; + + @override + ConsumerState createState() => _StepRolesState(); +} + +class _StepRolesState extends ConsumerState { + DeviceRole _role = DeviceRole.controller; + String? _qrData; + bool _loadingQr = false; + + Future _generateQr() async { + final session = ref.read(sessionProvider).session; + if (session == null) return; + + setState(() => _loadingQr = true); + try { + final client = ref.read(apiClientProvider); + final response = await client.createPairingToken(session.id); + setState(() { + _qrData = jsonEncode(response.qrPayload); + }); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Errore generazione QR')), + ); + } + } finally { + if (mounted) setState(() => _loadingQr = false); + } + } + + void _confirm() { + ref.read(sessionProvider.notifier).setDeviceRole(_role); + widget.onNext(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return SingleChildScrollView( + padding: ScreenInsets.contentPadding(context, horizontal: 20, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Scegli il tuo ruolo', style: theme.textTheme.titleLarge), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _RoleCard( + title: 'CAMERA', + subtitle: 'Sul cavalletto', + icon: Icons.videocam, + selected: _role == DeviceRole.camera, + onTap: () => setState(() => _role = DeviceRole.camera), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _RoleCard( + title: 'REGIA', + subtitle: 'In mano', + icon: Icons.sports_esports, + selected: _role == DeviceRole.controller, + onTap: () => setState(() => _role = DeviceRole.controller), + ), + ), + ], + ), + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppTheme.accentYellow.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppTheme.accentYellow.withValues(alpha: 0.4)), + ), + child: Row( + children: [ + const Icon(Icons.battery_charging_full, color: AppTheme.accentYellow), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Mantieni in carica. Saranno 90 minuti a pieno carico.', + style: theme.textTheme.bodyMedium?.copyWith(color: Colors.white), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + Text('Collega il secondo telefono', style: theme.textTheme.titleMedium), + const SizedBox(height: 12), + Center( + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: _qrData != null + ? QrImageView( + data: _qrData!, + version: QrVersions.auto, + size: 180, + backgroundColor: Colors.white, + ) + : SizedBox( + width: 180, + height: 180, + child: _loadingQr + ? const Center( + child: CircularProgressIndicator( + color: AppTheme.primaryRed, + ), + ) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.qr_code, size: 48, color: Colors.black54), + const SizedBox(height: 8), + TextButton( + onPressed: _generateQr, + child: const Text( + 'GENERA QR', + style: TextStyle(color: Colors.black), + ), + ), + ], + ), + ), + ), + ), + const SizedBox(height: 8), + Text( + 'Scansiona con l\'altro dispositivo per collegare camera e regia.', + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: widget.onBack, + child: const Text('Indietro'), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: PrimaryCta( + label: 'AVANTI >', + onPressed: _confirm, + icon: Icons.arrow_forward, + ), + ), + ], + ), + ], + ), + ); + } +} + +class _RoleCard extends StatelessWidget { + const _RoleCard({ + required this.title, + required this.subtitle, + required this.icon, + required this.selected, + required this.onTap, + }); + + final String title; + final String subtitle; + final IconData icon; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Material( + color: selected + ? AppTheme.primaryRed.withValues(alpha: 0.15) + : AppTheme.surfaceElevated, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: selected ? AppTheme.primaryRed : Colors.transparent, + ), + ), + child: Column( + children: [ + Icon(icon, size: 32, color: selected ? AppTheme.primaryRed : Colors.white), + const SizedBox(height: 8), + Text(title, style: theme.textTheme.titleMedium), + Text(subtitle, style: theme.textTheme.bodyMedium, textAlign: TextAlign.center), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/setup_wizard/step_transmission.dart b/mobile/lib/features/setup_wizard/step_transmission.dart new file mode 100644 index 0000000..4f12e96 --- /dev/null +++ b/mobile/lib/features/setup_wizard/step_transmission.dart @@ -0,0 +1,331 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app/theme.dart'; +import '../../providers/session_provider.dart'; +import '../../shared/api_client.dart'; +import '../../shared/api_error.dart'; +import '../../shared/models/match.dart'; +import '../../shared/premium_dialog.dart'; +import '../../shared/widgets/primary_cta.dart'; +import '../../shared/widgets/screen_insets.dart'; +import '../../shared/models/team.dart'; +import '../matches/team_providers.dart'; + +class StepTransmission extends ConsumerStatefulWidget { + const StepTransmission({ + super.key, + required this.match, + required this.onBack, + required this.onNext, + }); + + final MatchModel match; + final VoidCallback onBack; + final VoidCallback onNext; + + @override + ConsumerState createState() => _StepTransmissionState(); +} + +class _StepTransmissionState extends ConsumerState { + String _platform = 'matchlivetv'; + String _privacy = 'unlisted'; + bool _creating = false; + + Future _createSession() async { + setState(() => _creating = true); + try { + final client = ref.read(apiClientProvider); + final session = await client.createSession( + widget.match.id, + platform: _platform, + privacyStatus: _privacy, + qualityPreset: '720p_30_2.5mbps', + targetBitrate: 2500000, + targetFps: 30, + ); + ref.read(sessionProvider.notifier).setSession(session, match: widget.match); + widget.onNext(); + } on DioException catch (e) { + final apiErr = ApiException.fromDio(e); + if (mounted) { + if (apiErr?.isPremiumRequired == true) { + await showPremiumRequiredDialog( + context, + message: apiErr!.message, + billingUrl: apiErr.billingUrl, + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(apiErr?.message ?? 'Errore nella creazione sessione')), + ); + } + } + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Errore nella creazione sessione')), + ); + } + } finally { + if (mounted) setState(() => _creating = false); + } + } + + void _onYoutubeTap(Team? team) { + if (team == null) return; + if (team.canUseYoutube && team.youtubeConnected) { + setState(() => _platform = 'youtube'); + return; + } + if (team.canUseYoutube && !team.youtubeConnected) { + showPremiumRequiredDialog( + context, + message: 'Collega il canale YouTube dalla dashboard sul sito, poi seleziona YouTube qui.', + billingUrl: team.billingUrl, + ); + return; + } + showPremiumRequiredDialog( + context, + message: 'YouTube Live è incluso nel piano Premium Full.', + billingUrl: team.billingUrl, + ); + } + + void _onExternalPremiumTap(String platform, Team? team) { + showPremiumRequiredDialog( + context, + message: '$platform è disponibile con Premium (in arrivo).', + billingUrl: team?.billingUrl, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final teamAsync = ref.watch(teamByIdProvider(widget.match.teamId)); + + return teamAsync.when( + loading: () => const Center(child: CircularProgressIndicator(color: AppTheme.primaryRed)), + error: (_, __) => const Center(child: Text('Errore caricamento squadra')), + data: (team) { + final youtubeSelectable = team != null && team.canUseYoutube && team.youtubeConnected; + + return SingleChildScrollView( + padding: ScreenInsets.contentPadding(context, horizontal: 20, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (team != null) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.surfaceElevated, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppTheme.primaryRed.withValues(alpha: 0.3)), + ), + child: Text( + 'Piano ${team.planName} · Staff ${team.staffLimitLabel}' + '${team.concurrentStreamsLimit != null ? ' · Dirette ${team.concurrentStreamsUsed}/${team.concurrentStreamsLimit}' : ''}', + style: theme.textTheme.bodySmall, + ), + ), + const SizedBox(height: 12), + ], + Text('Piattaforma', style: theme.textTheme.titleLarge), + const SizedBox(height: 12), + _PlatformCard( + title: 'Match Live TV', + subtitle: 'Diretta sul nostro sito (incluso)', + selected: _platform == 'matchlivetv', + enabled: true, + onTap: () => setState(() => _platform = 'matchlivetv'), + ), + const SizedBox(height: 8), + _PlatformCard( + title: 'YouTube Live', + subtitle: team?.canUseYoutube == true + ? (team!.youtubeConnected + ? 'Canale collegato' + : 'Collega canale sul sito') + : 'Premium Full — collegamento canale', + selected: _platform == 'youtube', + enabled: youtubeSelectable, + badge: team?.canUseYoutube == true ? null : 'Full', + onTap: () => _onYoutubeTap(team), + ), + const SizedBox(height: 8), + _PlatformCard( + title: 'Facebook Live', + subtitle: 'Premium — presto disponibile', + selected: false, + enabled: false, + badge: 'Premium', + onTap: () => _onExternalPremiumTap('Facebook Live', team), + ), + const SizedBox(height: 8), + _PlatformCard( + title: 'Twitch', + subtitle: 'Premium — presto disponibile', + selected: false, + enabled: false, + badge: 'Premium', + onTap: () => _onExternalPremiumTap('Twitch', team), + ), + const SizedBox(height: 24), + Text('Privacy', style: theme.textTheme.titleLarge), + const SizedBox(height: 12), + SegmentedButton( + segments: const [ + ButtonSegment(value: 'public', label: Text('Pubblico')), + ButtonSegment(value: 'unlisted', label: Text('Non in elenco')), + ], + selected: {_privacy}, + onSelectionChanged: (s) => setState(() => _privacy = s.first), + ), + const SizedBox(height: 24), + Text('Qualità', style: theme.textTheme.titleLarge), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.surfaceElevated, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppTheme.primaryRed.withValues(alpha: 0.4)), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('720p · 30fps · 2.5 Mbps', style: theme.textTheme.titleMedium), + const SizedBox(height: 4), + Text( + 'Bitrate fisso consigliato per reti instabili', + style: theme.textTheme.bodyMedium, + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppTheme.primaryRed.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + 'AUTO', + style: theme.textTheme.labelLarge?.copyWith( + color: AppTheme.primaryRed, + fontSize: 11, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 32), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _creating ? null : widget.onBack, + child: const Text('Indietro'), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: PrimaryCta( + label: 'AVANTI >', + loading: _creating, + onPressed: _createSession, + icon: Icons.arrow_forward, + ), + ), + ], + ), + ], + ), + ); + }, + ); + } +} + +class _PlatformCard extends StatelessWidget { + const _PlatformCard({ + required this.title, + this.subtitle, + required this.selected, + required this.enabled, + this.badge, + this.onTap, + }); + + final String title; + final String? subtitle; + final bool selected; + final bool enabled; + final String? badge; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Opacity( + opacity: enabled ? 1 : 0.55, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: selected + ? AppTheme.primaryRed.withValues(alpha: 0.15) + : AppTheme.surfaceElevated, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: selected ? AppTheme.primaryRed : Colors.transparent, + ), + ), + child: Row( + children: [ + Icon( + selected ? Icons.radio_button_checked : Icons.radio_button_off, + color: selected ? AppTheme.primaryRed : AppTheme.textSecondary, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.titleMedium), + if (subtitle != null) + Text(subtitle!, style: theme.textTheme.bodyMedium), + ], + ), + ), + if (badge != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppTheme.textSecondary.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(6), + ), + child: Text(badge!, style: theme.textTheme.labelSmall), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/setup_wizard/wizard_shell.dart b/mobile/lib/features/setup_wizard/wizard_shell.dart new file mode 100644 index 0000000..7707b3b --- /dev/null +++ b/mobile/lib/features/setup_wizard/wizard_shell.dart @@ -0,0 +1,173 @@ +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/session_provider.dart'; +import '../../shared/models/match.dart'; +import '../../shared/api_client.dart'; +import '../../shared/widgets/screen_insets.dart'; +import 'step_match.dart'; +import 'step_network_test.dart'; +import 'step_roles.dart'; +import 'step_transmission.dart'; + +class WizardShell extends ConsumerStatefulWidget { + const WizardShell({ + super.key, + required this.matchId, + required this.step, + }); + + final String matchId; + final int step; + + @override + ConsumerState createState() => _WizardShellState(); +} + +class _WizardShellState extends ConsumerState { + MatchModel? _match; + bool _loading = true; + + static const _stepTitles = [ + '01 · Partita', + '02 · Trasmissione', + '03 · Ruoli', + '04 · Test', + ]; + + @override + void initState() { + super.initState(); + _loadMatch(); + } + + @override + void didUpdateWidget(WizardShell oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.matchId != widget.matchId) { + _loadMatch(); + } + } + + Future _loadMatch() async { + setState(() => _loading = true); + try { + final client = ref.read(apiClientProvider); + final match = await client.fetchMatch(widget.matchId); + ref.read(sessionProvider.notifier).setMatch(match); + if (mounted) { + setState(() { + _match = match; + _loading = false; + }); + } + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + void _goToStep(int step) { + context.go('/setup/${widget.matchId}/$step'); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final stepIndex = widget.step.clamp(1, 4) - 1; + + return Scaffold( + appBar: AppBar( + title: Text(_stepTitles[stepIndex]), + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => context.go('/matches'), + ), + ), + body: _loading + ? const Center( + child: CircularProgressIndicator(color: AppTheme.primaryRed), + ) + : Column( + children: [ + _StepIndicator(current: widget.step), + Expanded( + child: AppSafeArea( + top: false, + child: _buildStep(stepIndex), + ), + ), + ], + ), + ); + } + + Widget _buildStep(int index) { + final match = _match; + if (match == null) { + return Center( + child: Text( + 'Partita non trovata', + style: Theme.of(context).textTheme.bodyMedium, + ), + ); + } + + switch (index) { + case 0: + return StepMatch( + match: match, + onNext: () => _goToStep(2), + ); + case 1: + return StepTransmission( + match: match, + onBack: () => _goToStep(1), + onNext: () => _goToStep(3), + ); + case 2: + return StepRoles( + match: match, + onBack: () => _goToStep(2), + onNext: () => _goToStep(4), + ); + case 3: + return StepNetworkTest( + match: match, + onBack: () => _goToStep(3), + ); + default: + return const SizedBox.shrink(); + } + } +} + +class _StepIndicator extends StatelessWidget { + const _StepIndicator({required this.current}); + + final int current; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: List.generate(4, (i) { + final step = i + 1; + final active = step <= current; + return Expanded( + child: Container( + height: 4, + margin: EdgeInsets.only(right: i < 3 ? 6 : 0), + decoration: BoxDecoration( + color: active ? AppTheme.primaryRed : AppTheme.surfaceElevated, + borderRadius: BorderRadius.circular(2), + ), + ), + ); + }), + ), + ); + } +} diff --git a/mobile/lib/features/shared/live_score_actions.dart b/mobile/lib/features/shared/live_score_actions.dart new file mode 100644 index 0000000..3fdf161 --- /dev/null +++ b/mobile/lib/features/shared/live_score_actions.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../providers/match_rules_provider.dart'; +import '../../providers/score_provider.dart'; +import '../../shared/scoring/match_scoring_rules.dart'; +import '../../shared/widgets/score_outcome_dialogs.dart'; + +/// Gestione regole punteggio, dialoghi set/partita e sync. +class LiveScoreActions { + LiveScoreActions(this.ref); + + final WidgetRef ref; + bool _dialogOpen = false; + + MatchScoringRules get _rules => ref.read(matchScoringRulesProvider); + + Future afterPointChange( + BuildContext context, { + required String homeName, + required String awayName, + required Future Function() onCloseSetConfirmed, + }) async { + if (_dialogOpen || !context.mounted) return; + final score = ref.read(scoreProvider); + final winner = _rules.setWinnerFromPoints( + homePoints: score.homePoints, + awayPoints: score.awayPoints, + currentSet: score.currentSet, + ); + if (winner == null) return; + + _dialogOpen = true; + final close = await showSetWonDialog( + context, + winner: winner, + homeName: homeName, + awayName: awayName, + homePoints: score.homePoints, + awayPoints: score.awayPoints, + ); + _dialogOpen = false; + if (close && context.mounted) { + await onCloseSetConfirmed(); + } + } + + Future requestCloseSet( + BuildContext context, { + required Future Function() onCloseSetConfirmed, + }) async { + if (_dialogOpen || !context.mounted) return; + final score = ref.read(scoreProvider); + final winner = _rules.setWinnerFromPoints( + homePoints: score.homePoints, + awayPoints: score.awayPoints, + currentSet: score.currentSet, + ); + if (winner == null) { + final ok = await showCloseSetAnywayDialog(context); + if (!ok) return; + } + await onCloseSetConfirmed(); + } + + Future afterCloseSet( + BuildContext context, { + required String homeName, + required String awayName, + required Future Function() onStopStream, + }) async { + if (_dialogOpen || !context.mounted) return; + final score = ref.read(scoreProvider); + final winner = _rules.matchWinner( + homeSets: score.homeSets, + awaySets: score.awaySets, + ); + if (winner == null) return; + + _dialogOpen = true; + final stop = await showMatchWonDialog( + context, + winner: winner, + homeName: homeName, + awayName: awayName, + homeSets: score.homeSets, + awaySets: score.awaySets, + ); + _dialogOpen = false; + if (stop && context.mounted) { + await onStopStream(); + } + } +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart new file mode 100644 index 0000000..531d6a3 --- /dev/null +++ b/mobile/lib/main.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/date_symbol_data_local.dart'; + +import 'app/router.dart'; +import 'app/theme.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await initializeDateFormatting('it_IT', null); + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + systemNavigationBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + systemNavigationBarIconBrightness: Brightness.light, + ), + ); + runApp(const ProviderScope(child: MatchLiveTvApp())); +} + +class MatchLiveTvApp extends ConsumerWidget { + const MatchLiveTvApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final router = ref.watch(routerProvider); + + return MaterialApp.router( + title: 'Match Live TV', + debugShowCheckedModeBanner: false, + theme: AppTheme.dark, + routerConfig: router, + ); + } +} diff --git a/mobile/lib/platform/streaming_channel.dart b/mobile/lib/platform/streaming_channel.dart new file mode 100644 index 0000000..1c3c343 --- /dev/null +++ b/mobile/lib/platform/streaming_channel.dart @@ -0,0 +1,53 @@ +import 'package:flutter/services.dart'; + +/// Bridge verso engine nativo Android (CameraX + RootEncoder). +class StreamingChannel { + StreamingChannel._(); + + static const MethodChannel _channel = + MethodChannel('com.matchlivetv.match_live_tv/streaming'); + + static const EventChannel _metricsChannel = + EventChannel('com.matchlivetv.match_live_tv/streaming_events'); + + static Future startPreview() async { + await _channel.invokeMethod('startPreview'); + } + + static Future bindPreview() async { + final ok = await _channel.invokeMethod('bindPreview'); + return ok ?? false; + } + + static Future stopPreview() async { + await _channel.invokeMethod('stopPreview'); + } + + static Future startStream({ + required String rtmpUrl, + int targetBitrate = 2500000, + int targetFps = 30, + }) async { + await _channel.invokeMethod('startStream', { + 'rtmpUrl': rtmpUrl, + 'videoBitrate': targetBitrate, + 'fps': targetFps, + }); + } + + static Future stopStream() async { + await _channel.invokeMethod('stopStream'); + } + + static Future pauseStream() async { + await _channel.invokeMethod('pauseStream'); + } + + static Future?> getMetrics() async { + final result = + await _channel.invokeMethod>('getMetrics'); + return result?.map((key, value) => MapEntry(key.toString(), value)); + } + + static EventChannel get metricsStream => _metricsChannel; +} diff --git a/mobile/lib/platform/streaming_preview.dart b/mobile/lib/platform/streaming_preview.dart new file mode 100644 index 0000000..5aaa2ed --- /dev/null +++ b/mobile/lib/platform/streaming_preview.dart @@ -0,0 +1,103 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Preview camera nativa (OpenGL) o placeholder su altre piattaforme. +class NativeStreamingPreview extends StatelessWidget { + const NativeStreamingPreview({ + super.key, + this.showGrid = true, + this.platformLabel = 'LIVE', + }); + + final bool showGrid; + final String platformLabel; + + @override + Widget build(BuildContext context) { + if (defaultTargetPlatform == TargetPlatform.android) { + return Stack( + fit: StackFit.expand, + children: [ + AndroidView( + key: key, + viewType: 'match_live_tv/streaming_preview', + layoutDirection: TextDirection.ltr, + creationParamsCodec: StandardMessageCodec(), + ), + if (showGrid) CameraPreviewOverlay(platformLabel: platformLabel), + ], + ); + } + return CameraPreviewPlaceholder(showGrid: showGrid); + } +} + +class CameraPreviewPlaceholder extends StatelessWidget { + const CameraPreviewPlaceholder({super.key, this.showGrid = true}); + + final bool showGrid; + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.black, + child: const Center( + child: Icon(Icons.videocam, size: 64, color: Colors.white24), + ), + ); + } +} + +/// Overlay REC / badge sopra la preview nativa. +class CameraPreviewOverlay extends StatelessWidget { + const CameraPreviewOverlay({super.key, this.platformLabel = 'LIVE'}); + + final String platformLabel; + + @override + Widget build(BuildContext context) { + final inset = MediaQuery.viewPaddingOf(context); + const gap = 8.0; + + return Stack( + fit: StackFit.expand, + children: [ + Positioned( + top: inset.top + gap, + left: inset.left + gap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'REC', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 11, + ), + ), + ), + ), + Positioned( + top: inset.top + gap, + right: inset.right + gap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + platformLabel, + style: const TextStyle(color: Colors.white, fontSize: 11), + ), + ), + ), + ], + ); + } +} diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart new file mode 100644 index 0000000..1eb9479 --- /dev/null +++ b/mobile/lib/providers/auth_provider.dart @@ -0,0 +1,90 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/auth_storage.dart'; +import '../shared/models/user.dart'; + +class AuthState { + const AuthState({ + this.user, + this.accessToken, + this.refreshToken, + this.loading = false, + this.error, + }); + + final User? user; + final String? accessToken; + final String? refreshToken; + final bool loading; + final String? error; + + bool get isAuthenticated => + accessToken != null && accessToken!.isNotEmpty && user != null; + + AuthState copyWith({ + User? user, + String? accessToken, + String? refreshToken, + bool? loading, + String? error, + bool clearError = false, + }) { + return AuthState( + user: user ?? this.user, + accessToken: accessToken ?? this.accessToken, + refreshToken: refreshToken ?? this.refreshToken, + loading: loading ?? this.loading, + error: clearError ? null : (error ?? this.error), + ); + } +} + +class AuthNotifier extends StateNotifier { + AuthNotifier() : super(const AuthState()); + + void setSession({ + required User user, + required String accessToken, + required String refreshToken, + }) { + state = AuthState( + user: user, + accessToken: accessToken, + refreshToken: refreshToken, + loading: false, + ); + AuthStorage.save( + user: user, + accessToken: accessToken, + refreshToken: refreshToken, + ); + } + + Future restoreSession() async { + final stored = await AuthStorage.load(); + if (stored == null) return; + state = AuthState( + user: stored.user, + accessToken: stored.accessToken, + refreshToken: stored.refreshToken, + loading: false, + ); + } + + void setLoading(bool loading) { + state = state.copyWith(loading: loading, clearError: true); + } + + void setError(String message) { + state = state.copyWith(loading: false, error: message); + } + + void logout() { + state = const AuthState(); + AuthStorage.clear(); + } +} + +final authProvider = StateNotifierProvider( + (ref) => AuthNotifier(), +); diff --git a/mobile/lib/providers/match_rules_provider.dart b/mobile/lib/providers/match_rules_provider.dart new file mode 100644 index 0000000..7ab3c8b --- /dev/null +++ b/mobile/lib/providers/match_rules_provider.dart @@ -0,0 +1,12 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../shared/scoring/match_scoring_rules.dart'; +import 'session_provider.dart'; + +final matchScoringRulesProvider = Provider((ref) { + final match = ref.watch(sessionProvider).match; + if (match == null) { + return const MatchScoringRules(setsToWin: 3); + } + return MatchScoringRules.fromMatch(match); +}); diff --git a/mobile/lib/providers/score_provider.dart b/mobile/lib/providers/score_provider.dart new file mode 100644 index 0000000..ccb28fd --- /dev/null +++ b/mobile/lib/providers/score_provider.dart @@ -0,0 +1,77 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../shared/models/score_state.dart'; + +class ScoreNotifier extends StateNotifier { + ScoreNotifier() : super(const ScoreState()); + + void reset(ScoreState score) { + state = score; + } + + void applyRemote(ScoreState score) { + state = score; + } + + void incrementHome() { + state = state.copyWith(homePoints: state.homePoints + 1); + } + + void incrementAway() { + state = state.copyWith(awayPoints: state.awayPoints + 1); + } + + void decrementHome() { + if (state.homePoints > 0) { + state = state.copyWith(homePoints: state.homePoints - 1); + } + } + + void decrementAway() { + if (state.awayPoints > 0) { + state = state.copyWith(awayPoints: state.awayPoints - 1); + } + } + + void closeSet() { + final homeWon = state.homePoints > state.awayPoints; + final partials = List.from(state.setPartials); + if (state.homePoints > 0 || state.awayPoints > 0) { + partials.add(SetPartial( + set: state.currentSet, + home: state.homePoints, + away: state.awayPoints, + )); + } + state = state.copyWith( + homeSets: homeWon ? state.homeSets + 1 : state.homeSets, + awaySets: homeWon ? state.awaySets : state.awaySets + 1, + homePoints: 0, + awayPoints: 0, + currentSet: state.currentSet + 1, + setPartials: partials, + timeoutHome: false, + timeoutAway: false, + ); + } + + void timeoutHome() { + state = state.copyWith(timeoutHome: true); + } + + void timeoutAway() { + state = state.copyWith(timeoutAway: true); + } + + void setTimeoutHome(bool value) { + state = state.copyWith(timeoutHome: value); + } + + void setTimeoutAway(bool value) { + state = state.copyWith(timeoutAway: value); + } +} + +final scoreProvider = StateNotifierProvider( + (ref) => ScoreNotifier(), +); diff --git a/mobile/lib/providers/session_provider.dart b/mobile/lib/providers/session_provider.dart new file mode 100644 index 0000000..61373ce --- /dev/null +++ b/mobile/lib/providers/session_provider.dart @@ -0,0 +1,165 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../shared/models/device_state.dart'; +import '../shared/models/match.dart'; +import '../shared/models/stream_session.dart'; + +enum DeviceRole { camera, controller } + +class SessionState { + const SessionState({ + this.session, + this.match, + this.connected = false, + this.deviceRole = DeviceRole.controller, + this.cameraDevice, + this.elapsed = Duration.zero, + this.viewerCount = 0, + this.loading = false, + this.error, + }); + + final StreamSession? session; + final MatchModel? match; + final bool connected; + final DeviceRole deviceRole; + final DeviceStateModel? cameraDevice; + final Duration elapsed; + final int viewerCount; + final bool loading; + final String? error; + + SessionState copyWith({ + StreamSession? session, + MatchModel? match, + bool? connected, + DeviceRole? deviceRole, + DeviceStateModel? cameraDevice, + Duration? elapsed, + int? viewerCount, + bool? loading, + String? error, + bool clearError = false, + }) { + return SessionState( + session: session ?? this.session, + match: match ?? this.match, + connected: connected ?? this.connected, + deviceRole: deviceRole ?? this.deviceRole, + cameraDevice: cameraDevice ?? this.cameraDevice, + elapsed: elapsed ?? this.elapsed, + viewerCount: viewerCount ?? this.viewerCount, + loading: loading ?? this.loading, + error: clearError ? null : (error ?? this.error), + ); + } +} + +class SessionNotifier extends StateNotifier { + SessionNotifier() : super(const SessionState()); + + void setSession(StreamSession session, {MatchModel? match}) { + state = state.copyWith( + session: session, + match: match, + clearError: true, + ); + } + + void setMatch(MatchModel match) { + state = state.copyWith(match: match); + } + + void setConnected(bool connected) { + state = state.copyWith(connected: connected); + } + + void setDeviceRole(DeviceRole role) { + state = state.copyWith(deviceRole: role); + } + + void updateDeviceState(DeviceStateModel device) { + if (device.deviceRole == 'camera') { + state = state.copyWith(cameraDevice: device); + } + if (device.status != null && state.session != null) { + state = state.copyWith( + session: StreamSession( + id: state.session!.id, + matchId: state.session!.matchId, + status: device.status!, + platform: state.session!.platform, + rtmpIngestUrl: state.session!.rtmpIngestUrl, + youtubeBroadcastId: state.session!.youtubeBroadcastId, + privacyStatus: state.session!.privacyStatus, + qualityPreset: state.session!.qualityPreset, + targetBitrate: state.session!.targetBitrate, + startedAt: state.session!.startedAt, + disconnectionCount: state.session!.disconnectionCount, + score: state.session!.score, + devices: state.session!.devices, + ), + ); + } + } + + void setElapsed(Duration elapsed) { + state = state.copyWith(elapsed: elapsed); + } + + void setViewerCount(int count) { + state = state.copyWith(viewerCount: count); + } + + void applyCommand(String action) { + final session = state.session; + if (session == null) return; + + String newStatus = session.status; + switch (action) { + case 'start_stream': + newStatus = 'live'; + break; + case 'pause_stream': + newStatus = 'paused'; + break; + case 'stop_stream': + newStatus = 'ended'; + break; + } + + state = state.copyWith( + session: StreamSession( + id: session.id, + matchId: session.matchId, + status: newStatus, + platform: session.platform, + rtmpIngestUrl: session.rtmpIngestUrl, + youtubeBroadcastId: session.youtubeBroadcastId, + privacyStatus: session.privacyStatus, + qualityPreset: session.qualityPreset, + targetBitrate: session.targetBitrate, + startedAt: session.startedAt, + disconnectionCount: session.disconnectionCount, + score: session.score, + devices: session.devices, + ), + ); + } + + void setLoading(bool loading) { + state = state.copyWith(loading: loading); + } + + void setError(String message) { + state = state.copyWith(loading: false, error: message); + } + + void reset() { + state = const SessionState(); + } +} + +final sessionProvider = StateNotifierProvider( + (ref) => SessionNotifier(), +); diff --git a/mobile/lib/shared/api_client.dart b/mobile/lib/shared/api_client.dart new file mode 100644 index 0000000..c086bad --- /dev/null +++ b/mobile/lib/shared/api_client.dart @@ -0,0 +1,249 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/config.dart'; +import '../providers/auth_provider.dart'; +import 'models/match.dart'; +import 'models/recording_item.dart'; +import 'models/stream_session.dart'; +import 'models/team.dart'; +import 'models/user.dart'; + +final apiClientProvider = Provider((ref) { + final auth = ref.watch(authProvider); + return ApiClient(accessToken: auth.accessToken); +}); + +class ApiClient { + ApiClient({this.accessToken}) { + _dio = Dio( + BaseOptions( + baseUrl: AppConfig.apiV1, + connectTimeout: const Duration(seconds: 15), + receiveTimeout: const Duration(seconds: 15), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ), + ); + + _dio.interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) { + if (accessToken != null && accessToken!.isNotEmpty) { + options.headers['Authorization'] = 'Bearer $accessToken'; + } + handler.next(options); + }, + ), + ); + } + + late final Dio _dio; + final String? accessToken; + + // --- Auth --- + + Future<({User user, AuthTokens tokens})> login({ + required String email, + required String password, + }) async { + final response = await _dio.post>( + '/auth/login', + data: {'email': email, 'password': password}, + ); + final data = response.data!; + return ( + user: User.fromJson(data['user'] as Map), + tokens: AuthTokens.fromJson(data), + ); + } + + Future refresh(String refreshToken) async { + final response = await _dio.post>( + '/auth/refresh', + data: {'refresh_token': refreshToken}, + ); + return AuthTokens.fromJson(response.data!); + } + + Future me() async { + final response = await _dio.get>('/auth/me'); + return User.fromJson(response.data!); + } + + Future logout() async { + await _dio.post('/auth/logout'); + } + + // --- Teams --- + + Future> fetchTeams() async { + final response = await _dio.get>('/teams'); + return response.data! + .map((e) => Team.fromJson(e as Map)) + .toList(); + } + + Future> fetchRecordings(String teamId) async { + final response = await _dio.get>('/teams/$teamId/recordings'); + return response.data! + .map((e) => RecordingItem.fromJson(e as Map)) + .toList(); + } + + // --- Matches --- + + Future> fetchMatches(String teamId) async { + final response = + await _dio.get>('/teams/$teamId/matches'); + return response.data! + .map((e) => MatchModel.fromJson(e as Map)) + .toList(); + } + + Future fetchMatch(String matchId) async { + final response = + await _dio.get>('/matches/$matchId'); + return MatchModel.fromJson(response.data!); + } + + Future updateMatch(String matchId, Map body) async { + final response = await _dio.patch>( + '/matches/$matchId', + data: {'match': body}, + ); + return MatchModel.fromJson(response.data!); + } + + Future deleteMatch(String matchId) async { + await _dio.delete('/matches/$matchId'); + } + + Future createMatch(String teamId, Map body) async { + final response = await _dio.post>( + '/teams/$teamId/matches', + data: {'match': body}, + ); + return MatchModel.fromJson(response.data!); + } + + // --- Sessions --- + + Future createSession( + String matchId, { + String platform = 'matchlivetv', + String privacyStatus = 'unlisted', + String qualityPreset = '720p_30_2.5mbps', + int? targetBitrate, + int? targetFps, + }) async { + final response = await _dio.post>( + '/matches/$matchId/sessions', + data: { + 'platform': platform, + 'privacy_status': privacyStatus, + 'quality_preset': qualityPreset, + if (targetBitrate != null) 'target_bitrate': targetBitrate, + if (targetFps != null) 'target_fps': targetFps, + }, + ); + return StreamSession.fromJson(response.data!); + } + + Future fetchSession(String sessionId) async { + final response = + await _dio.get>('/sessions/$sessionId'); + return StreamSession.fromJson(response.data!); + } + + Future startSession(String sessionId) async { + final response = + await _dio.patch>('/sessions/$sessionId/start'); + return StreamSession.fromJson(response.data!); + } + + Future stopSession(String sessionId) async { + final response = + await _dio.patch>('/sessions/$sessionId/stop'); + return StreamSession.fromJson(response.data!); + } + + Future pauseSession(String sessionId) async { + final response = + await _dio.patch>('/sessions/$sessionId/pause'); + return StreamSession.fromJson(response.data!); + } + + Future createPairingToken(String sessionId) async { + final response = await _dio.post>( + '/sessions/$sessionId/pairing_token', + ); + return PairingTokenResponse.fromJson(response.data!); + } + + Future claimPairing({ + required String sessionId, + required String pairingToken, + required String deviceRole, + }) async { + final response = await _dio.post>( + '/sessions/$sessionId/claim_pairing', + data: { + 'pairing_token': pairingToken, + 'device_role': deviceRole, + }, + ); + return StreamSession.fromJson(response.data!); + } + + Future networkTest({ + required String sessionId, + required double downloadMbps, + required double uploadMbps, + required int latencyMs, + required String networkType, + }) async { + final response = await _dio.post>( + '/sessions/$sessionId/network_test', + data: { + 'download_mbps': downloadMbps, + 'upload_mbps': uploadMbps, + 'latency_ms': latencyMs, + 'network_type': networkType, + }, + ); + return NetworkTestResult.fromJson(response.data!); + } + + Future youtubeStats(String sessionId) async { + final response = + await _dio.get>('/sessions/$sessionId/youtube_stats'); + return YoutubeStats.fromJson(response.data!); + } + + Future postTelemetry({ + required String sessionId, + required String deviceRole, + int? batteryLevel, + String? networkType, + int? signalStrength, + int? currentBitrate, + int? targetBitrate, + int? fps, + }) async { + await _dio.post( + '/sessions/$sessionId/telemetry', + data: { + 'device_role': deviceRole, + if (batteryLevel != null) 'battery_level': batteryLevel, + if (networkType != null) 'network_type': networkType, + if (signalStrength != null) 'signal_strength': signalStrength, + if (currentBitrate != null) 'current_bitrate': currentBitrate, + if (targetBitrate != null) 'target_bitrate': targetBitrate, + if (fps != null) 'fps': fps, + }, + ); + } +} diff --git a/mobile/lib/shared/api_error.dart b/mobile/lib/shared/api_error.dart new file mode 100644 index 0000000..63ce291 --- /dev/null +++ b/mobile/lib/shared/api_error.dart @@ -0,0 +1,32 @@ +class ApiException implements Exception { + ApiException({ + required this.message, + this.statusCode, + this.errorCode, + this.billingUrl, + }); + + final String message; + final int? statusCode; + final String? errorCode; + final String? billingUrl; + + bool get isPremiumRequired => errorCode == 'premium_required'; + + @override + String toString() => message; + + static ApiException? fromDio(dynamic error) { + // ignore: avoid_dynamic_calls + final response = error.response; + if (response == null) return null; + final data = response.data; + if (data is! Map) return null; + return ApiException( + message: data['error'] as String? ?? 'Errore di rete', + statusCode: response.statusCode as int?, + errorCode: data['error_code'] as String?, + billingUrl: data['billing_url'] as String?, + ); + } +} diff --git a/mobile/lib/shared/models/device_state.dart b/mobile/lib/shared/models/device_state.dart new file mode 100644 index 0000000..4642bba --- /dev/null +++ b/mobile/lib/shared/models/device_state.dart @@ -0,0 +1,37 @@ +class DeviceStateModel { + const DeviceStateModel({ + this.deviceRole = 'camera', + this.batteryLevel, + this.networkType, + this.signalStrength, + this.currentBitrate, + this.targetBitrate, + this.fps, + this.status, + }); + + final String deviceRole; + final int? batteryLevel; + final String? networkType; + final int? signalStrength; + final int? currentBitrate; + final int? targetBitrate; + final int? fps; + final String? status; + + factory DeviceStateModel.fromJson(Map json) { + return DeviceStateModel( + deviceRole: json['device_role'] as String? ?? 'camera', + batteryLevel: json['battery'] as int? ?? json['battery_level'] as int?, + networkType: json['network'] as String? ?? json['network_type'] as String?, + signalStrength: json['signal_strength'] as int?, + currentBitrate: json['bitrate'] as int? ?? json['current_bitrate'] as int?, + targetBitrate: json['target_bitrate'] as int?, + fps: json['fps'] as int?, + status: json['status'] as String?, + ); + } + + double get bitrateMbps => + currentBitrate != null ? currentBitrate! / 1_000_000 : 0; +} diff --git a/mobile/lib/shared/models/match.dart b/mobile/lib/shared/models/match.dart new file mode 100644 index 0000000..181c890 --- /dev/null +++ b/mobile/lib/shared/models/match.dart @@ -0,0 +1,81 @@ +class MatchModel { + const MatchModel({ + required this.id, + required this.teamId, + required this.teamName, + required this.opponentName, + this.location, + this.scheduledAt, + this.sport = 'volleyball', + this.setsToWin = 3, + this.scoringRules, + this.rosterNumbers = const [], + this.category, + this.phase, + this.activeSessionId, + this.activeSessionStatus, + }); + + final String id; + final String teamId; + final String teamName; + final String opponentName; + final String? location; + final DateTime? scheduledAt; + final String sport; + final int setsToWin; + final Map? scoringRules; + final List rosterNumbers; + final String? category; + final String? phase; + final String? activeSessionId; + final String? activeSessionStatus; + + /// Sessione avviata (o in ripresa) — si può rientrare in camera senza rifare il wizard. + bool get canResumeCamera { + const resumable = {'connecting', 'live', 'reconnecting', 'paused'}; + return activeSessionId != null && + activeSessionStatus != null && + resumable.contains(activeSessionStatus); + } + + bool get hasActiveSession => activeSessionId != null; + + factory MatchModel.fromJson(Map json) { + return MatchModel( + id: json['id'] as String, + teamId: json['team_id'] as String, + teamName: json['team_name'] as String? ?? '', + opponentName: json['opponent_name'] as String, + location: json['location'] as String?, + scheduledAt: json['scheduled_at'] != null + ? DateTime.tryParse(json['scheduled_at'] as String) + : null, + sport: json['sport'] as String? ?? 'volleyball', + setsToWin: json['sets_to_win'] as int? ?? 3, + scoringRules: json['scoring_rules'] is Map + ? Map.from(json['scoring_rules'] as Map) + : null, + rosterNumbers: (json['roster_numbers'] as List?) + ?.map((e) => (e as num).toInt()) + .toList() ?? + const [], + category: json['category'] as String?, + phase: json['phase'] as String?, + activeSessionId: json['active_session_id'] as String?, + activeSessionStatus: json['active_session_status'] as String?, + ); + } + + Map toJson() => { + 'opponent_name': opponentName, + 'location': location, + 'scheduled_at': scheduledAt?.toIso8601String(), + 'sport': sport, + 'sets_to_win': setsToWin, + if (scoringRules != null) 'scoring_rules': scoringRules, + 'roster_numbers': rosterNumbers, + 'category': category, + 'phase': phase, + }; +} diff --git a/mobile/lib/shared/models/recording_item.dart b/mobile/lib/shared/models/recording_item.dart new file mode 100644 index 0000000..d87d381 --- /dev/null +++ b/mobile/lib/shared/models/recording_item.dart @@ -0,0 +1,35 @@ +class RecordingItem { + const RecordingItem({ + required this.id, + required this.sessionId, + required this.opponentName, + required this.teamName, + this.endedAt, + this.replayUrl, + this.expiresAt, + }); + + final String id; + final String sessionId; + final String opponentName; + final String teamName; + final DateTime? endedAt; + final String? replayUrl; + final DateTime? expiresAt; + + factory RecordingItem.fromJson(Map json) { + return RecordingItem( + id: json['id'] as String, + sessionId: json['session_id'] as String, + opponentName: json['opponent_name'] as String, + teamName: json['team_name'] as String, + endedAt: json['ended_at'] != null + ? DateTime.parse(json['ended_at'] as String) + : null, + replayUrl: json['replay_url'] as String?, + expiresAt: json['expires_at'] != null + ? DateTime.parse(json['expires_at'] as String) + : null, + ); + } +} diff --git a/mobile/lib/shared/models/score_state.dart b/mobile/lib/shared/models/score_state.dart new file mode 100644 index 0000000..55cf4cf --- /dev/null +++ b/mobile/lib/shared/models/score_state.dart @@ -0,0 +1,100 @@ +class SetPartial { + const SetPartial({ + required this.set, + required this.home, + required this.away, + }); + + final int set; + final int home; + final int away; + + factory SetPartial.fromJson(Map json) { + return SetPartial( + set: json['set'] as int? ?? 1, + home: json['home'] as int? ?? 0, + away: json['away'] as int? ?? 0, + ); + } + + Map toJson() => { + 'set': set, + 'home': home, + 'away': away, + }; +} + +class ScoreState { + const ScoreState({ + this.homeSets = 0, + this.awaySets = 0, + this.homePoints = 0, + this.awayPoints = 0, + this.currentSet = 1, + this.setPartials = const [], + this.timeoutHome = false, + this.timeoutAway = false, + }); + + final int homeSets; + final int awaySets; + final int homePoints; + final int awayPoints; + final int currentSet; + final List setPartials; + final bool timeoutHome; + final bool timeoutAway; + + factory ScoreState.fromJson(Map json) { + final rawPartials = json['set_partials']; + final partials = rawPartials is List + ? rawPartials + .whereType() + .map((e) => SetPartial.fromJson(Map.from(e))) + .toList() + : []; + + return ScoreState( + homeSets: json['home_sets'] as int? ?? 0, + awaySets: json['away_sets'] as int? ?? 0, + homePoints: json['home_points'] as int? ?? 0, + awayPoints: json['away_points'] as int? ?? 0, + currentSet: json['current_set'] as int? ?? 1, + setPartials: partials, + timeoutHome: json['timeout_home'] as bool? ?? false, + timeoutAway: json['timeout_away'] as bool? ?? false, + ); + } + + Map toCablePayload() => { + 'type': 'score_update', + 'home_sets': homeSets, + 'away_sets': awaySets, + 'home_points': homePoints, + 'away_points': awayPoints, + 'current_set': currentSet, + 'set_partials': setPartials.map((p) => p.toJson()).toList(), + }; + + ScoreState copyWith({ + int? homeSets, + int? awaySets, + int? homePoints, + int? awayPoints, + int? currentSet, + List? setPartials, + bool? timeoutHome, + bool? timeoutAway, + }) { + return ScoreState( + homeSets: homeSets ?? this.homeSets, + awaySets: awaySets ?? this.awaySets, + homePoints: homePoints ?? this.homePoints, + awayPoints: awayPoints ?? this.awayPoints, + currentSet: currentSet ?? this.currentSet, + setPartials: setPartials ?? this.setPartials, + timeoutHome: timeoutHome ?? this.timeoutHome, + timeoutAway: timeoutAway ?? this.timeoutAway, + ); + } +} diff --git a/mobile/lib/shared/models/stream_session.dart b/mobile/lib/shared/models/stream_session.dart new file mode 100644 index 0000000..a0c8743 --- /dev/null +++ b/mobile/lib/shared/models/stream_session.dart @@ -0,0 +1,129 @@ +import 'device_state.dart'; +import 'score_state.dart'; + +class StreamSession { + const StreamSession({ + required this.id, + required this.matchId, + required this.status, + this.platform = 'matchlivetv', + this.rtmpIngestUrl, + this.hlsPlaybackUrl, + this.watchPageUrl, + this.youtubeBroadcastId, + this.privacyStatus = 'unlisted', + this.qualityPreset = '720p_30_2.5mbps', + this.targetBitrate = 2500000, + this.startedAt, + this.disconnectionCount = 0, + this.score, + this.devices = const [], + }); + + final String id; + final String matchId; + final String status; + final String platform; + final String? rtmpIngestUrl; + final String? hlsPlaybackUrl; + final String? watchPageUrl; + final String? youtubeBroadcastId; + final String privacyStatus; + final String qualityPreset; + final int targetBitrate; + final DateTime? startedAt; + final int disconnectionCount; + final ScoreState? score; + final List devices; + + bool get isLive => status == 'live' || status == 'reconnecting'; + + bool get isTerminal => status == 'ended' || status == 'error'; + + bool get canResumeBroadcast => + status == 'connecting' || + status == 'live' || + status == 'reconnecting' || + status == 'paused'; + + factory StreamSession.fromJson(Map json) { + return StreamSession( + id: json['id'] as String, + matchId: json['match_id'] as String, + status: json['status'] as String? ?? 'idle', + platform: json['platform'] as String? ?? 'matchlivetv', + rtmpIngestUrl: json['rtmp_ingest_url'] as String?, + hlsPlaybackUrl: json['hls_playback_url'] as String?, + watchPageUrl: json['watch_page_url'] as String?, + youtubeBroadcastId: json['youtube_broadcast_id'] as String?, + privacyStatus: json['privacy_status'] as String? ?? 'unlisted', + qualityPreset: json['quality_preset'] as String? ?? '720p_30_2.5mbps', + targetBitrate: json['target_bitrate'] as int? ?? 2500000, + startedAt: json['started_at'] != null + ? DateTime.tryParse(json['started_at'] as String) + : null, + disconnectionCount: json['disconnection_count'] as int? ?? 0, + score: json['score'] != null + ? ScoreState.fromJson(json['score'] as Map) + : null, + devices: (json['devices'] as List?) + ?.map((e) => DeviceStateModel.fromJson(e as Map)) + .toList() ?? + const [], + ); + } +} + +class PairingTokenResponse { + const PairingTokenResponse({ + required this.pairingToken, + required this.expiresAt, + required this.qrPayload, + }); + + final String pairingToken; + final DateTime expiresAt; + final Map qrPayload; + + factory PairingTokenResponse.fromJson(Map json) { + return PairingTokenResponse( + pairingToken: json['pairing_token'] as String, + expiresAt: DateTime.parse(json['expires_at'] as String), + qrPayload: json['qr_payload'] as Map, + ); + } +} + +class NetworkTestResult { + const NetworkTestResult({ + required this.ready, + required this.targetUploadMbps, + }); + + final bool ready; + final double targetUploadMbps; + + factory NetworkTestResult.fromJson(Map json) { + return NetworkTestResult( + ready: json['ready'] as bool? ?? false, + targetUploadMbps: (json['target_upload_mbps'] as num?)?.toDouble() ?? 2.5, + ); + } +} + +class YoutubeStats { + const YoutubeStats({ + required this.concurrentViewers, + required this.live, + }); + + final int concurrentViewers; + final bool live; + + factory YoutubeStats.fromJson(Map json) { + return YoutubeStats( + concurrentViewers: json['concurrent_viewers'] as int? ?? 0, + live: json['live'] as bool? ?? false, + ); + } +} diff --git a/mobile/lib/shared/models/team.dart b/mobile/lib/shared/models/team.dart new file mode 100644 index 0000000..147f25d --- /dev/null +++ b/mobile/lib/shared/models/team.dart @@ -0,0 +1,96 @@ +class Team { + const Team({ + required this.id, + required this.name, + required this.sport, + this.logoUrl, + this.youtubeConnected = false, + this.planSlug = 'free', + this.planName = 'Free', + this.premiumActive = false, + this.premiumFull = false, + this.billingUrl, + this.staffManageUrl, + this.maxStaff = 2, + this.staffUsed = 0, + this.maxStaffTransmission, + this.maxStaffRegia, + this.staffTransmissionUsed = 0, + this.staffRegiaUsed = 0, + this.concurrentStreamsUsed = 0, + this.concurrentStreamsLimit, + this.recordingsEnabled = false, + this.phoneDownloadEnabled = false, + this.youtubeEnabled = false, + this.youtubeMode, + }); + + final String id; + final String name; + final String sport; + final String? logoUrl; + final bool youtubeConnected; + final String planSlug; + final String planName; + final bool premiumActive; + final bool premiumFull; + final String? billingUrl; + final String? staffManageUrl; + final int maxStaff; + final int staffUsed; + final int? maxStaffTransmission; + final int? maxStaffRegia; + final int staffTransmissionUsed; + final int staffRegiaUsed; + final int concurrentStreamsUsed; + final int? concurrentStreamsLimit; + final bool recordingsEnabled; + final bool phoneDownloadEnabled; + final bool youtubeEnabled; + final String? youtubeMode; + + bool get canUseYoutube => premiumFull && youtubeEnabled; + bool get canUseRecordings => recordingsEnabled; + bool get canDownloadOnPhone => phoneDownloadEnabled; + + String get staffLimitLabel { + if (maxStaffTransmission == null && maxStaffRegia == null) { + return 'illimitato'; + } + final tx = maxStaffTransmission == null + ? 'tx ∞' + : 'tx $staffTransmissionUsed/$maxStaffTransmission'; + final rg = maxStaffRegia == null + ? 'rg ∞' + : 'rg $staffRegiaUsed/$maxStaffRegia'; + return '$tx · $rg'; + } + + factory Team.fromJson(Map json) { + return Team( + id: json['id'] as String, + name: json['name'] as String, + sport: json['sport'] as String? ?? 'volleyball', + logoUrl: json['logo_url'] as String?, + youtubeConnected: json['youtube_connected'] as bool? ?? false, + planSlug: json['plan_slug'] as String? ?? 'free', + planName: json['plan_name'] as String? ?? 'Free', + premiumActive: json['premium_active'] as bool? ?? false, + premiumFull: json['premium_full'] as bool? ?? false, + billingUrl: json['billing_url'] as String?, + staffManageUrl: json['staff_manage_url'] as String?, + maxStaff: json['max_staff'] as int? ?? 2, + staffUsed: json['staff_used'] as int? ?? 0, + maxStaffTransmission: json['max_staff_transmission'] as int?, + maxStaffRegia: json['max_staff_regia'] as int?, + staffTransmissionUsed: json['staff_transmission_used'] as int? ?? 0, + staffRegiaUsed: json['staff_regia_used'] as int? ?? 0, + concurrentStreamsUsed: json['concurrent_streams_used'] as int? ?? 0, + concurrentStreamsLimit: json['concurrent_streams_limit'] as int?, + recordingsEnabled: json['recordings_enabled'] as bool? ?? false, + phoneDownloadEnabled: json['phone_download_enabled'] as bool? ?? false, + youtubeEnabled: json['youtube_enabled'] as bool? ?? false, + youtubeMode: json['youtube_mode'] as String?, + ); + } +} diff --git a/mobile/lib/shared/models/user.dart b/mobile/lib/shared/models/user.dart new file mode 100644 index 0000000..7f35f05 --- /dev/null +++ b/mobile/lib/shared/models/user.dart @@ -0,0 +1,46 @@ +class User { + const User({ + required this.id, + required this.email, + required this.name, + required this.role, + }); + + final String id; + final String email; + final String name; + final String role; + + factory User.fromJson(Map json) { + return User( + id: json['id'] as String, + email: json['email'] as String, + name: json['name'] as String, + role: json['role'] as String? ?? 'coach', + ); + } + + Map toJson() => { + 'id': id, + 'email': email, + 'name': name, + 'role': role, + }; +} + +class AuthTokens { + const AuthTokens({ + required this.accessToken, + required this.refreshToken, + }); + + final String accessToken; + final String refreshToken; + + factory AuthTokens.fromJson(Map json) { + return AuthTokens( + accessToken: json['access_token'] as String, + refreshToken: json['refresh_token'] as String, + ); + } +} diff --git a/mobile/lib/shared/premium_dialog.dart b/mobile/lib/shared/premium_dialog.dart new file mode 100644 index 0000000..169f946 --- /dev/null +++ b/mobile/lib/shared/premium_dialog.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../app/theme.dart'; + +Future showPremiumRequiredDialog( + BuildContext context, { + required String message, + String? billingUrl, +}) async { + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppTheme.surface, + title: const Text('Premium richiesto'), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Chiudi'), + ), + if (billingUrl != null && billingUrl.isNotEmpty) + FilledButton( + onPressed: () async { + final uri = Uri.parse(billingUrl); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + if (ctx.mounted) Navigator.pop(ctx); + }, + style: FilledButton.styleFrom(backgroundColor: AppTheme.primaryRed), + child: const Text('Attiva sul sito'), + ), + ], + ), + ); +} diff --git a/mobile/lib/shared/scoring/match_scoring_rules.dart b/mobile/lib/shared/scoring/match_scoring_rules.dart new file mode 100644 index 0000000..b01f5d6 --- /dev/null +++ b/mobile/lib/shared/scoring/match_scoring_rules.dart @@ -0,0 +1,70 @@ +import '../models/match.dart'; + +/// Lato che ha vinto set o partita. +enum ScoringSide { home, away } + +/// Regole punteggio (pallavolo di default, campi sovrascrivibili per tornei). +class MatchScoringRules { + const MatchScoringRules({ + required this.setsToWin, + this.pointsPerSet = 25, + this.pointsDecidingSet = 15, + this.minPointLead = 2, + }); + + final int setsToWin; + final int pointsPerSet; + final int pointsDecidingSet; + final int minPointLead; + + int get maxSets => (setsToWin * 2) - 1; + + factory MatchScoringRules.fromMatch(MatchModel match) { + final overrides = match.scoringRules; + return MatchScoringRules( + setsToWin: match.setsToWin, + pointsPerSet: overrides?['points_per_set'] as int? ?? 25, + pointsDecidingSet: overrides?['points_deciding_set'] as int? ?? 15, + minPointLead: overrides?['min_point_lead'] as int? ?? 2, + ); + } + + Map toJson() => { + 'points_per_set': pointsPerSet, + 'points_deciding_set': pointsDecidingSet, + 'min_point_lead': minPointLead, + }; + + /// Punti necessari per chiudere il set corrente. + int pointsTarget(int currentSet) { + if (currentSet >= maxSets) return pointsDecidingSet; + return pointsPerSet; + } + + /// Chi ha vinto il set in corso in base ai punti (null se ancora aperto). + ScoringSide? setWinnerFromPoints({ + required int homePoints, + required int awayPoints, + required int currentSet, + }) { + final target = pointsTarget(currentSet); + final lead = minPointLead; + if (homePoints >= target && homePoints - awayPoints >= lead) { + return ScoringSide.home; + } + if (awayPoints >= target && awayPoints - homePoints >= lead) { + return ScoringSide.away; + } + return null; + } + + bool isMatchOver({required int homeSets, required int awaySets}) { + return homeSets >= setsToWin || awaySets >= setsToWin; + } + + ScoringSide? matchWinner({required int homeSets, required int awaySets}) { + if (homeSets >= setsToWin) return ScoringSide.home; + if (awaySets >= setsToWin) return ScoringSide.away; + return null; + } +} diff --git a/mobile/lib/shared/websocket_service.dart b/mobile/lib/shared/websocket_service.dart new file mode 100644 index 0000000..c7b8354 --- /dev/null +++ b/mobile/lib/shared/websocket_service.dart @@ -0,0 +1,158 @@ +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:x_action_cable_v2/x_action_cable.dart'; + +import '../core/config.dart'; +import '../providers/auth_provider.dart'; +import '../providers/score_provider.dart'; +import '../providers/session_provider.dart'; +import 'models/device_state.dart'; +import 'models/score_state.dart'; + +final websocketServiceProvider = Provider((ref) { + final service = WebsocketService(ref); + ref.onDispose(service.dispose); + return service; +}); + +typedef CableMessageHandler = void Function(Map data); + +class WebsocketService { + WebsocketService(this._ref); + + final Ref _ref; + ActionCable? _cable; + ActionChannel? _channel; + String? _sessionId; + + bool get isConnected => _cable != null && _channel != null; + + Future connect({ + required String sessionId, + required String deviceRole, + }) async { + if (_sessionId == sessionId && isConnected) return; + + await disconnect(); + + final token = _ref.read(authProvider).accessToken; + if (token == null || token.isEmpty) { + throw StateError('Token di accesso mancante'); + } + + _sessionId = sessionId; + + _cable = ActionCable.connect( + AppConfig.cableUrl, + headers: {'Authorization': 'Bearer $token'}, + onConnected: () { + _ref.read(sessionProvider.notifier).setConnected(true); + }, + onConnectionLost: () { + _ref.read(sessionProvider.notifier).setConnected(false); + }, + onCannotConnect: (_) { + _ref.read(sessionProvider.notifier).setConnected(false); + }, + ); + + _channel = _cable!.subscribe( + 'SessionChannel', + channelParams: { + 'session_id': sessionId, + 'device_role': deviceRole, + }, + onSubscribed: () { + _ref.read(sessionProvider.notifier).setConnected(true); + }, + onDisconnected: () { + _ref.read(sessionProvider.notifier).setConnected(false); + }, + callbacks: [ + ActionCallback( + name: 'receive_message', + callback: _handleMessage, + ), + ], + ); + } + + void _handleMessage(ActionResponse response) { + if (response.hasError || response.data == null) return; + _dispatch(Map.from(response.data!)); + } + + void _dispatch(Map data) { + final type = data['type'] as String?; + + switch (type) { + case 'score_update': + _ref.read(scoreProvider.notifier).applyRemote(ScoreState.fromJson(data)); + break; + case 'device_state': + _ref + .read(sessionProvider.notifier) + .updateDeviceState(DeviceStateModel.fromJson(data)); + break; + case 'timeout': + final team = data['team'] as String?; + if (team == 'home') { + _ref.read(scoreProvider.notifier).setTimeoutHome(true); + } else if (team == 'away') { + _ref.read(scoreProvider.notifier).setTimeoutAway(true); + } + break; + case 'command': + final action = data['action'] as String?; + if (action != null) { + _ref.read(sessionProvider.notifier).applyCommand(action); + } + break; + default: + break; + } + } + + void sendScoreUpdate(ScoreState score) { + _perform({'type': 'score_update', ...score.toCablePayload()}); + } + + void sendTimeout(String team) { + _perform({'type': 'timeout', 'team': team}); + } + + void sendCommand(String action) { + _perform({'type': 'command', 'action': action}); + } + + void sendDeviceState(DeviceStateModel state) { + _perform({ + 'type': 'device_state', + 'device_role': state.deviceRole, + 'battery': state.batteryLevel, + 'network': state.networkType, + 'bitrate': state.currentBitrate, + 'fps': state.fps, + }); + } + + void _perform(Map payload) { + _channel?.performAction('receive', params: payload); + } + + Future disconnect() async { + _channel?.unsubscribe(); + _channel = null; + _cable?.disconnect(); + _cable = null; + _sessionId = null; + _ref.read(sessionProvider.notifier).setConnected(false); + } + + void dispose() { + disconnect(); + } + + String encodeQrPayload(Map payload) => jsonEncode(payload); +} diff --git a/mobile/lib/shared/widgets/camera_compact_metrics.dart b/mobile/lib/shared/widgets/camera_compact_metrics.dart new file mode 100644 index 0000000..e36d0c2 --- /dev/null +++ b/mobile/lib/shared/widgets/camera_compact_metrics.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +/// Riga compatta metriche stream (sostituisce le MetricCard ingombranti in camera). +class CameraCompactMetrics extends StatelessWidget { + const CameraCompactMetrics({ + super.key, + required this.networkType, + required this.bitrateMbps, + required this.fps, + this.batteryLevel, + this.viewerCount, + this.light = false, + }); + + final String networkType; + final double bitrateMbps; + final int fps; + final int? batteryLevel; + final int? viewerCount; + final bool light; + + @override + Widget build(BuildContext context) { + final color = light ? Colors.white70 : Theme.of(context).textTheme.bodySmall?.color; + final parts = [ + networkType, + '${bitrateMbps.toStringAsFixed(1)} Mbps', + '$fps fps', + if (batteryLevel != null) '$batteryLevel%', + if (viewerCount != null && viewerCount! > 0) '$viewerCount spett.', + ]; + return Text( + parts.join(' · '), + style: TextStyle(fontSize: 11, color: color, fontWeight: FontWeight.w500), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } +} diff --git a/mobile/lib/shared/widgets/camera_score_controls.dart b/mobile/lib/shared/widgets/camera_score_controls.dart new file mode 100644 index 0000000..3cbce1e --- /dev/null +++ b/mobile/lib/shared/widgets/camera_score_controls.dart @@ -0,0 +1,303 @@ +import 'package:flutter/material.dart'; + +import '../../app/theme.dart'; +import '../../features/controller_mode/controller_screen.dart'; +import 'score_overlay_bar.dart'; + +/// Controlli punteggio per overlay camera (portrait e landscape). +class CameraScoreControls extends StatelessWidget { + const CameraScoreControls({ + super.key, + required this.homeName, + required this.awayName, + required this.homePoints, + required this.awayPoints, + required this.currentSet, + required this.homeSets, + required this.awaySets, + required this.onHomePoint, + required this.onAwayPoint, + required this.onHomeMinus, + required this.onAwayMinus, + required this.onCloseSet, + this.lastDelta, + this.compact = false, + this.pointsTarget, + }); + + final String homeName; + final String awayName; + final int homePoints; + final int awayPoints; + final int currentSet; + final int homeSets; + final int awaySets; + final VoidCallback onHomePoint; + final VoidCallback onAwayPoint; + final VoidCallback onHomeMinus; + final VoidCallback onAwayMinus; + final VoidCallback onCloseSet; + final int? lastDelta; + final bool compact; + final int? pointsTarget; + + @override + Widget build(BuildContext context) { + if (compact) { + return _LandscapeControls( + homeName: homeName, + awayName: awayName, + homePoints: homePoints, + awayPoints: awayPoints, + currentSet: currentSet, + homeSets: homeSets, + awaySets: awaySets, + lastDelta: lastDelta, + pointsTarget: pointsTarget, + onHomePoint: onHomePoint, + onAwayPoint: onAwayPoint, + onHomeMinus: onHomeMinus, + onAwayMinus: onAwayMinus, + onCloseSet: onCloseSet, + ); + } + + return _PortraitControls( + homeName: homeName, + awayName: awayName, + homePoints: homePoints, + awayPoints: awayPoints, + currentSet: currentSet, + homeSets: homeSets, + awaySets: awaySets, + pointsTarget: pointsTarget, + onHomePoint: onHomePoint, + onAwayPoint: onAwayPoint, + onHomeMinus: onHomeMinus, + onAwayMinus: onAwayMinus, + onCloseSet: onCloseSet, + ); + } +} + +class _PortraitControls extends StatelessWidget { + const _PortraitControls({ + required this.homeName, + required this.awayName, + required this.homePoints, + required this.awayPoints, + required this.currentSet, + required this.homeSets, + required this.awaySets, + required this.onHomePoint, + required this.onAwayPoint, + required this.onHomeMinus, + required this.onAwayMinus, + required this.onCloseSet, + this.pointsTarget, + }); + + final String homeName; + final String awayName; + final int homePoints; + final int awayPoints; + final int currentSet; + final int homeSets; + final int awaySets; + final VoidCallback onHomePoint; + final VoidCallback onAwayPoint; + final VoidCallback onHomeMinus; + final VoidCallback onAwayMinus; + final VoidCallback onCloseSet; + final int? pointsTarget; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + ScoreOverlayBar( + homeName: homeName, + awayName: awayName, + homePoints: homePoints, + awayPoints: awayPoints, + currentSet: currentSet, + homeSets: homeSets, + awaySets: awaySets, + pointsTarget: pointsTarget, + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: ScoreTapButton(label: '+1 $homeName', onTap: onHomePoint, large: true), + ), + const SizedBox(width: 8), + Expanded( + child: ScoreTapButton(label: '+1 $awayName', onTap: onAwayPoint, large: true), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: homePoints > 0 ? onHomeMinus : null, + child: Text('-1 $homeName', style: const TextStyle(fontSize: 12)), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton( + onPressed: awayPoints > 0 ? onAwayMinus : null, + child: Text('-1 $awayName', style: const TextStyle(fontSize: 12)), + ), + ), + ], + ), + const SizedBox(height: 8), + YellowActionButton(label: 'Chiudi set', onPressed: onCloseSet), + ], + ); + } +} + +class _LandscapeControls extends StatelessWidget { + const _LandscapeControls({ + required this.homeName, + required this.awayName, + required this.homePoints, + required this.awayPoints, + required this.currentSet, + required this.homeSets, + required this.awaySets, + required this.onHomePoint, + required this.onAwayPoint, + required this.onHomeMinus, + required this.onAwayMinus, + required this.onCloseSet, + this.lastDelta, + this.pointsTarget, + }); + + final String homeName; + final String awayName; + final int homePoints; + final int awayPoints; + final int currentSet; + final int homeSets; + final int awaySets; + final VoidCallback onHomePoint; + final VoidCallback onAwayPoint; + final VoidCallback onHomeMinus; + final VoidCallback onAwayMinus; + final VoidCallback onCloseSet; + final int? lastDelta; + final int? pointsTarget; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + ScoreOverlayBar( + homeName: homeName, + awayName: awayName, + homePoints: homePoints, + awayPoints: awayPoints, + currentSet: currentSet, + homeSets: homeSets, + awaySets: awaySets, + compact: true, + lastDelta: lastDelta, + pointsTarget: pointsTarget, + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded(child: _CompactScoreBtn(label: '+1', sub: homeName, onTap: onHomePoint)), + const SizedBox(width: 6), + Expanded(child: _CompactScoreBtn(label: '+1', sub: awayName, onTap: onAwayPoint)), + const SizedBox(width: 6), + Expanded( + child: _CompactScoreBtn( + label: '-1', + sub: homeName, + onTap: homePoints > 0 ? onHomeMinus : null, + muted: true, + ), + ), + const SizedBox(width: 6), + Expanded( + child: _CompactScoreBtn( + label: '-1', + sub: awayName, + onTap: awayPoints > 0 ? onAwayMinus : null, + muted: true, + ), + ), + ], + ), + const SizedBox(height: 6), + YellowActionButton(label: 'Chiudi set', onPressed: onCloseSet), + ], + ); + } +} + +class _CompactScoreBtn extends StatelessWidget { + const _CompactScoreBtn({ + required this.label, + required this.sub, + required this.onTap, + this.muted = false, + }); + + final String label; + final String sub; + final VoidCallback? onTap; + final bool muted; + + @override + Widget build(BuildContext context) { + final enabled = onTap != null; + return Material( + color: muted + ? Colors.black.withValues(alpha: enabled ? 0.65 : 0.35) + : AppTheme.primaryRed.withValues(alpha: enabled ? 0.92 : 0.45), + borderRadius: BorderRadius.circular(10), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: TextStyle( + color: enabled ? Colors.white : Colors.white38, + fontWeight: FontWeight.w900, + fontSize: 18, + ), + ), + Text( + sub, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: enabled ? Colors.white70 : Colors.white30, + fontSize: 9, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/shared/widgets/connected_badge.dart b/mobile/lib/shared/widgets/connected_badge.dart new file mode 100644 index 0000000..931332c --- /dev/null +++ b/mobile/lib/shared/widgets/connected_badge.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +import '../../app/theme.dart'; + +/// Dot verde + etichetta COLLEGATO. +class ConnectedBadge extends StatelessWidget { + const ConnectedBadge({ + super.key, + this.connected = true, + this.label, + }); + + final bool connected; + final String? label; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final color = connected ? AppTheme.successGreen : AppTheme.textSecondary; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + label ?? (connected ? 'COLLEGATO' : 'DISCONNESSO'), + style: theme.textTheme.labelLarge?.copyWith( + color: color, + fontSize: 12, + ), + ), + ], + ); + } +} diff --git a/mobile/lib/shared/widgets/destructive_cta.dart b/mobile/lib/shared/widgets/destructive_cta.dart new file mode 100644 index 0000000..24f5f66 --- /dev/null +++ b/mobile/lib/shared/widgets/destructive_cta.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; + +import '../../app/theme.dart'; + +/// CTA distruttiva nera con icona rossa (INTERROMPI, FERMA TRASMISSIONE). +class DestructiveCta extends StatelessWidget { + const DestructiveCta({ + super.key, + required this.label, + required this.onPressed, + this.loading = false, + this.compact = false, + }); + + final String label; + final VoidCallback? onPressed; + final bool loading; + final bool compact; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SizedBox( + width: compact ? null : double.infinity, + height: compact ? 44 : 52, + child: OutlinedButton( + onPressed: loading ? null : onPressed, + style: OutlinedButton.styleFrom( + backgroundColor: Colors.black, + foregroundColor: AppTheme.primaryRed, + side: BorderSide( + color: AppTheme.primaryRed.withValues(alpha: 0.6), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(compact ? 10 : 12), + ), + ), + child: loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppTheme.primaryRed, + ), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: compact ? MainAxisSize.min : MainAxisSize.max, + children: [ + const Icon(Icons.stop_circle_outlined, size: 20), + const SizedBox(width: 8), + Text( + label, + style: theme.textTheme.labelLarge?.copyWith( + color: AppTheme.primaryRed, + fontSize: compact ? 12 : 14, + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/shared/widgets/end_stream_dialog.dart b/mobile/lib/shared/widgets/end_stream_dialog.dart new file mode 100644 index 0000000..1c7b18b --- /dev/null +++ b/mobile/lib/shared/widgets/end_stream_dialog.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import '../../app/theme.dart'; + +/// Conferma chiusura definitiva della diretta (non riprendibile). +Future confirmEndStream(BuildContext context) async { + final result = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppTheme.surface, + title: const Text('Chiudi diretta'), + content: const Text( + 'La diretta verrà chiusa definitivamente. ' + 'Gli spettatori vedranno che lo stream è terminato e non potrai riprendere questa sessione.\n\n' + 'Per una pausa temporanea usa «Interrompi».', + ), + 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('Chiudi definitivamente'), + ), + ], + ), + ); + return result == true; +} diff --git a/mobile/lib/shared/widgets/live_badge.dart b/mobile/lib/shared/widgets/live_badge.dart new file mode 100644 index 0000000..b13f278 --- /dev/null +++ b/mobile/lib/shared/widgets/live_badge.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import '../../app/theme.dart'; + +/// Pill rossa con dot pulsante e timer diretta. +class LiveBadge extends StatelessWidget { + const LiveBadge({ + super.key, + required this.elapsed, + this.compact = false, + }); + + final Duration elapsed; + final bool compact; + + String _format(Duration d) { + final h = d.inHours; + final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); + final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + if (h > 0) return '$h:$m:$s'; + return '$m:$s'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: EdgeInsets.symmetric( + horizontal: compact ? 10 : 14, + vertical: compact ? 4 : 6, + ), + decoration: BoxDecoration( + color: AppTheme.primaryRed, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: compact ? 6 : 8, + height: compact ? 6 : 8, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + ), + SizedBox(width: compact ? 6 : 8), + Text( + compact ? 'LIVE ${_format(elapsed)}' : 'IN DIRETTA ${_format(elapsed)}', + style: theme.textTheme.labelLarge?.copyWith( + color: Colors.white, + fontSize: compact ? 11 : 13, + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/shared/widgets/match_live_wordmark.dart b/mobile/lib/shared/widgets/match_live_wordmark.dart new file mode 100644 index 0000000..ce76a26 --- /dev/null +++ b/mobile/lib/shared/widgets/match_live_wordmark.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; + +import '../../app/theme.dart'; + +/// Wordmark orizzontale: MATCH + pill LIVE + TV. +class MatchLiveWordmark extends StatelessWidget { + const MatchLiveWordmark({ + super.key, + this.compact = false, + this.showSlogan = false, + }); + + final bool compact; + final bool showSlogan; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final matchStyle = theme.textTheme.displaySmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w900, + letterSpacing: 1, + ); + final tvStyle = theme.textTheme.displaySmall?.copyWith( + color: AppTheme.textSecondary, + fontWeight: FontWeight.w900, + letterSpacing: 1, + ); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text('MATCH', style: matchStyle), + const SizedBox(width: 8), + Container( + padding: EdgeInsets.symmetric( + horizontal: compact ? 8 : 12, + vertical: compact ? 2 : 4, + ), + decoration: BoxDecoration( + color: AppTheme.primaryRed, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + 'LIVE', + style: theme.textTheme.labelLarge?.copyWith( + color: Colors.white, + fontSize: compact ? 14 : 18, + ), + ), + ), + const SizedBox(width: 8), + Text('TV', style: tvStyle), + ], + ), + if (showSlogan) ...[ + const SizedBox(height: 12), + Text( + 'LO STREAMING CHE NON MUORE', + style: theme.textTheme.labelLarge?.copyWith( + color: AppTheme.textSecondary, + fontSize: 12, + letterSpacing: 2, + ), + ), + ], + ], + ); + } +} diff --git a/mobile/lib/shared/widgets/metric_card.dart b/mobile/lib/shared/widgets/metric_card.dart new file mode 100644 index 0000000..613bd0c --- /dev/null +++ b/mobile/lib/shared/widgets/metric_card.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; + +import '../../app/theme.dart'; + +/// Card metrica singola (segnale, Mbps, fps). +class MetricCard extends StatelessWidget { + const MetricCard({ + super.key, + required this.label, + required this.value, + this.icon, + this.highlight = false, + this.expand = true, + }); + + final String label; + final String value; + final IconData? icon; + final bool highlight; + final bool expand; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final card = Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), + decoration: BoxDecoration( + color: AppTheme.surfaceElevated, + borderRadius: BorderRadius.circular(10), + border: highlight + ? Border.all(color: AppTheme.successGreen.withValues(alpha: 0.5)) + : null, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 18, color: AppTheme.textSecondary), + const SizedBox(height: 4), + ], + Text( + value, + style: theme.textTheme.titleLarge?.copyWith( + color: highlight ? AppTheme.successGreen : Colors.white, + fontWeight: FontWeight.w900, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 2), + Text( + label, + style: theme.textTheme.bodyMedium?.copyWith(fontSize: 11), + textAlign: TextAlign.center, + ), + ], + ), + ); + + if (!expand) { + return SizedBox(width: 72, child: card); + } + + return Expanded(child: card); + } +} diff --git a/mobile/lib/shared/widgets/primary_cta.dart b/mobile/lib/shared/widgets/primary_cta.dart new file mode 100644 index 0000000..ad916fd --- /dev/null +++ b/mobile/lib/shared/widgets/primary_cta.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +import '../../app/theme.dart'; + +/// CTA primaria rossa full-width (AVANTI, INIZIA). +class PrimaryCta extends StatelessWidget { + const PrimaryCta({ + super.key, + required this.label, + required this.onPressed, + this.loading = false, + this.icon, + }); + + final String label; + final VoidCallback? onPressed; + final bool loading; + final IconData? icon; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: loading ? null : onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryRed, + foregroundColor: Colors.white, + disabledBackgroundColor: AppTheme.primaryRed.withValues(alpha: 0.5), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 0, + ), + child: loading + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + label, + style: theme.textTheme.titleMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w800, + letterSpacing: 1, + ), + ), + if (icon != null) ...[ + const SizedBox(width: 8), + Icon(icon, size: 20), + ], + ], + ), + ), + ); + } +} diff --git a/mobile/lib/shared/widgets/score_outcome_dialogs.dart b/mobile/lib/shared/widgets/score_outcome_dialogs.dart new file mode 100644 index 0000000..e1de669 --- /dev/null +++ b/mobile/lib/shared/widgets/score_outcome_dialogs.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; + +import '../../app/theme.dart'; +import '../scoring/match_scoring_rules.dart'; + +Future showSetWonDialog( + BuildContext context, { + required ScoringSide winner, + required String homeName, + required String awayName, + required int homePoints, + required int awayPoints, +}) async { + final winnerName = winner == ScoringSide.home ? homeName : awayName; + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + backgroundColor: AppTheme.surface, + title: const Text('Set concluso'), + content: Text( + '$winnerName vince il set $homePoints-$awayPoints.\n\nChiudere il set e passare al successivo?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Continua a segnare'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom(backgroundColor: AppTheme.accentYellow), + child: const Text('Chiudi set', style: TextStyle(color: Colors.black)), + ), + ], + ), + ); + return result == true; +} + +Future showCloseSetAnywayDialog(BuildContext context) async { + final result = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppTheme.surface, + title: const Text('Chiudi set'), + content: const Text( + 'Il punteggio non soddisfa ancora le regole del torneo. Chiudere il set comunque?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annulla'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Chiudi comunque'), + ), + ], + ), + ); + return result == true; +} + +Future showMatchWonDialog( + BuildContext context, { + required ScoringSide winner, + required String homeName, + required String awayName, + required int homeSets, + required int awaySets, +}) async { + final winnerName = winner == ScoringSide.home ? homeName : awayName; + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + backgroundColor: AppTheme.surface, + title: const Text('Partita terminata'), + content: Text( + '$winnerName vince la partita ($homeSets-$awaySets set).\n\nChiudere definitivamente la diretta?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Continua in onda'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom(backgroundColor: AppTheme.primaryRed), + child: const Text('Chiudi diretta'), + ), + ], + ), + ); + return result == true; +} diff --git a/mobile/lib/shared/widgets/score_overlay_bar.dart b/mobile/lib/shared/widgets/score_overlay_bar.dart new file mode 100644 index 0000000..ac9a5c6 --- /dev/null +++ b/mobile/lib/shared/widgets/score_overlay_bar.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; + +import '../../app/theme.dart'; + +/// Barra overlay punteggio: SET n | HOME x - AWAY y. +class ScoreOverlayBar extends StatelessWidget { + const ScoreOverlayBar({ + super.key, + required this.homeName, + required this.awayName, + required this.homePoints, + required this.awayPoints, + required this.currentSet, + this.homeSets, + this.awaySets, + this.compact = false, + this.lastDelta, + this.pointsTarget, + }); + + final String homeName; + final String awayName; + final int homePoints; + final int awayPoints; + final int currentSet; + final int? homeSets; + final int? awaySets; + final bool compact; + final int? lastDelta; + final int? pointsTarget; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final bg = Colors.black.withValues(alpha: 0.72); + + final bar = Container( + padding: EdgeInsets.symmetric( + horizontal: compact ? 10 : 14, + vertical: compact ? 6 : 8, + ), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(compact ? 8 : 10), + border: Border.all(color: Colors.white12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + pointsTarget != null ? 'SET $currentSet → $pointsTarget' : 'SET $currentSet', + style: theme.textTheme.labelLarge?.copyWith( + color: AppTheme.accentYellow, + fontSize: compact ? 11 : 13, + ), + ), + Container( + width: 1, + height: compact ? 14 : 18, + margin: const EdgeInsets.symmetric(horizontal: 10), + color: Colors.white24, + ), + Text( + homeName.toUpperCase(), + style: theme.textTheme.labelLarge?.copyWith( + fontSize: compact ? 11 : 13, + ), + ), + const SizedBox(width: 6), + Text( + '$homePoints', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w900, + fontSize: compact ? 16 : 20, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + '-', + style: theme.textTheme.titleMedium?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ), + Text( + '$awayPoints', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w900, + fontSize: compact ? 16 : 20, + ), + ), + const SizedBox(width: 6), + Text( + awayName.toUpperCase(), + style: theme.textTheme.labelLarge?.copyWith( + fontSize: compact ? 11 : 13, + ), + ), + if (homeSets != null && awaySets != null) ...[ + const SizedBox(width: 10), + Text( + '($homeSets-$awaySets)', + style: theme.textTheme.bodyMedium?.copyWith(fontSize: 11), + ), + ], + if (lastDelta != null && lastDelta != 0) ...[ + const SizedBox(width: 8), + Text( + lastDelta! > 0 ? '+$lastDelta' : '$lastDelta', + style: theme.textTheme.labelLarge?.copyWith( + color: lastDelta! > 0 ? AppTheme.successGreen : AppTheme.primaryRed, + fontSize: compact ? 11 : 13, + ), + ), + ], + ], + ), + ); + + return LayoutBuilder( + builder: (context, constraints) { + if (!constraints.hasBoundedWidth) { + return bar; + } + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: bar, + ); + }, + ); + } +} diff --git a/mobile/lib/shared/widgets/screen_insets.dart b/mobile/lib/shared/widgets/screen_insets.dart new file mode 100644 index 0000000..2d5e283 --- /dev/null +++ b/mobile/lib/shared/widgets/screen_insets.dart @@ -0,0 +1,84 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +/// Padding di sistema (status bar, notch, barra navigazione) con minimo configurabile. +class ScreenInsets { + ScreenInsets._(); + + static EdgeInsets of(BuildContext context, {double minimum = 8}) { + final mq = MediaQuery.of(context); + final padding = mq.padding; + final view = mq.viewPadding; + return EdgeInsets.only( + left: math.max(math.max(padding.left, view.left), minimum), + top: math.max(math.max(padding.top, view.top), minimum), + right: math.max(math.max(padding.right, view.right), minimum), + bottom: math.max(math.max(padding.bottom, view.bottom), minimum), + ); + } + + /// Padding per contenuti scrollabili (login, wizard, elenco partite). + /// Overlay camera: in landscape su Android la barra nav può stare a sinistra. + static EdgeInsets cameraOverlay(BuildContext context, {double minimum = 12}) { + final base = of(context, minimum: minimum); + final landscape = + MediaQuery.orientationOf(context) == Orientation.landscape; + if (landscape && base.left < 48) { + return base.copyWith(left: 48); + } + return base; + } + + static EdgeInsets contentPadding( + BuildContext context, { + double horizontal = 20, + double vertical = 16, + double minimum = 8, + }) { + final inset = of(context, minimum: minimum); + return EdgeInsets.fromLTRB( + math.max(inset.left, horizontal), + math.max(inset.top, vertical), + math.max(inset.right, horizontal), + math.max(inset.bottom, vertical), + ); + } +} + +/// SafeArea con padding minimo su tutti i lati (gestisce edge-to-edge Android). +class AppSafeArea extends StatelessWidget { + const AppSafeArea({ + super.key, + required this.child, + this.minimum = 8, + this.top = true, + this.bottom = true, + this.left = true, + this.right = true, + }); + + final Widget child; + final double minimum; + final bool top; + final bool bottom; + final bool left; + final bool right; + + @override + Widget build(BuildContext context) { + return SafeArea( + minimum: EdgeInsets.only( + left: left ? minimum : 0, + top: top ? minimum : 0, + right: right ? minimum : 0, + bottom: bottom ? minimum : 0, + ), + top: top, + bottom: bottom, + left: left, + right: right, + child: child, + ); + } +} diff --git a/mobile/linux/.gitignore b/mobile/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/mobile/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/mobile/linux/CMakeLists.txt b/mobile/linux/CMakeLists.txt new file mode 100644 index 0000000..3cb0f5d --- /dev/null +++ b/mobile/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "match_live_tv") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.matchlivetv.match_live_tv") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/mobile/linux/flutter/CMakeLists.txt b/mobile/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/mobile/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/mobile/linux/flutter/generated_plugin_registrant.cc b/mobile/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..f6f23bf --- /dev/null +++ b/mobile/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); +} diff --git a/mobile/linux/flutter/generated_plugin_registrant.h b/mobile/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/mobile/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/mobile/linux/flutter/generated_plugins.cmake b/mobile/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..df8d2f7 --- /dev/null +++ b/mobile/linux/flutter/generated_plugins.cmake @@ -0,0 +1,25 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/mobile/linux/runner/CMakeLists.txt b/mobile/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/mobile/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/mobile/linux/runner/main.cc b/mobile/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/mobile/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/mobile/linux/runner/my_application.cc b/mobile/linux/runner/my_application.cc new file mode 100644 index 0000000..cc32559 --- /dev/null +++ b/mobile/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "match_live_tv"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "match_live_tv"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/mobile/linux/runner/my_application.h b/mobile/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/mobile/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/mobile/macos/.gitignore b/mobile/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/mobile/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/mobile/macos/Flutter/Flutter-Debug.xcconfig b/mobile/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/mobile/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/mobile/macos/Flutter/Flutter-Release.xcconfig b/mobile/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/mobile/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/mobile/macos/Flutter/GeneratedPluginRegistrant.swift b/mobile/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..6d56bd2 --- /dev/null +++ b/mobile/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,24 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import battery_plus +import connectivity_plus +import mobile_scanner +import package_info_plus +import shared_preferences_foundation +import url_launcher_macos +import wakelock_plus + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + BatteryPlusMacosPlugin.register(with: registry.registrar(forPlugin: "BatteryPlusMacosPlugin")) + ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) + MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) +} diff --git a/mobile/macos/Runner.xcodeproj/project.pbxproj b/mobile/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..b82ced3 --- /dev/null +++ b/mobile/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,729 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* match_live_tv.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "match_live_tv.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* match_live_tv.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* match_live_tv.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + 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)/match_live_tv.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/match_live_tv"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + 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)/match_live_tv.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/match_live_tv"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + 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)/match_live_tv.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/match_live_tv"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + 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_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + 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_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + 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_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + 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_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* 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 = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/mobile/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/mobile/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/mobile/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/mobile/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/mobile/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..34d04f3 --- /dev/null +++ b/mobile/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/macos/Runner.xcworkspace/contents.xcworkspacedata b/mobile/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/mobile/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/mobile/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/mobile/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/mobile/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/mobile/macos/Runner/AppDelegate.swift b/mobile/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/mobile/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/mobile/macos/Runner/Base.lproj/MainMenu.xib b/mobile/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/mobile/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/macos/Runner/Configs/AppInfo.xcconfig b/mobile/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..e97a26c --- /dev/null +++ b/mobile/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = match_live_tv + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.matchLiveTv + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.matchlivetv. All rights reserved. diff --git a/mobile/macos/Runner/Configs/Debug.xcconfig b/mobile/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/mobile/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/mobile/macos/Runner/Configs/Release.xcconfig b/mobile/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/mobile/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/mobile/macos/Runner/Configs/Warnings.xcconfig b/mobile/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/mobile/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/mobile/macos/Runner/DebugProfile.entitlements b/mobile/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/mobile/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/mobile/macos/Runner/Info.plist b/mobile/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/mobile/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/mobile/macos/Runner/MainFlutterWindow.swift b/mobile/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/mobile/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/mobile/macos/Runner/Release.entitlements b/mobile/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/mobile/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/mobile/macos/RunnerTests/RunnerTests.swift b/mobile/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/mobile/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +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. + } + +} diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml new file mode 100644 index 0000000..81bc94c --- /dev/null +++ b/mobile/pubspec.yaml @@ -0,0 +1,34 @@ +name: match_live_tv +description: Match Live TV - Lo streaming che non muore +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: ^3.12.0 + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.8 + flutter_riverpod: ^2.6.1 + go_router: ^14.8.1 + dio: ^5.8.0+1 + x_action_cable_v2: ^1.0.0 + connectivity_plus: ^6.1.4 + battery_plus: ^6.2.1 + wakelock_plus: ^1.3.2 + permission_handler: ^11.4.0 + google_fonts: ^6.2.1 + intl: ^0.20.2 + qr_flutter: ^4.1.0 + mobile_scanner: ^6.0.10 + shared_preferences: ^2.5.3 + url_launcher: ^6.3.1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true diff --git a/mobile/test/widget_test.dart b/mobile/test/widget_test.dart new file mode 100644 index 0000000..6e46190 --- /dev/null +++ b/mobile/test/widget_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:match_live_tv/shared/widgets/live_badge.dart'; +import 'package:match_live_tv/shared/widgets/match_live_wordmark.dart'; + +void main() { + testWidgets('MatchLiveWordmark renders', (tester) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: MatchLiveWordmark())), + ); + expect(find.text('MATCH'), findsOneWidget); + expect(find.text('LIVE'), findsOneWidget); + }); + + testWidgets('LiveBadge shows timer', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: LiveBadge(elapsed: Duration(minutes: 45, seconds: 35))), + ), + ); + expect(find.textContaining('IN DIRETTA'), findsOneWidget); + }); +} diff --git a/mobile/web/favicon.png b/mobile/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/mobile/web/favicon.png differ diff --git a/mobile/web/icons/Icon-192.png b/mobile/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/mobile/web/icons/Icon-192.png differ diff --git a/mobile/web/icons/Icon-512.png b/mobile/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/mobile/web/icons/Icon-512.png differ diff --git a/mobile/web/icons/Icon-maskable-192.png b/mobile/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/mobile/web/icons/Icon-maskable-192.png differ diff --git a/mobile/web/icons/Icon-maskable-512.png b/mobile/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/mobile/web/icons/Icon-maskable-512.png differ diff --git a/mobile/web/index.html b/mobile/web/index.html new file mode 100644 index 0000000..be80a12 --- /dev/null +++ b/mobile/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + match_live_tv + + + + + + + diff --git a/mobile/web/manifest.json b/mobile/web/manifest.json new file mode 100644 index 0000000..49cc9de --- /dev/null +++ b/mobile/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "match_live_tv", + "short_name": "match_live_tv", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/mobile/windows/.gitignore b/mobile/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/mobile/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/mobile/windows/CMakeLists.txt b/mobile/windows/CMakeLists.txt new file mode 100644 index 0000000..b0419b6 --- /dev/null +++ b/mobile/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(match_live_tv LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "match_live_tv") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/mobile/windows/flutter/CMakeLists.txt b/mobile/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/mobile/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/mobile/windows/flutter/generated_plugin_registrant.cc b/mobile/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..bfc9598 --- /dev/null +++ b/mobile/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,23 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + BatteryPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("BatteryPlusWindowsPlugin")); + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); +} diff --git a/mobile/windows/flutter/generated_plugin_registrant.h b/mobile/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/mobile/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/mobile/windows/flutter/generated_plugins.cmake b/mobile/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..d60dff0 --- /dev/null +++ b/mobile/windows/flutter/generated_plugins.cmake @@ -0,0 +1,28 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + battery_plus + connectivity_plus + permission_handler_windows + url_launcher_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/mobile/windows/runner/CMakeLists.txt b/mobile/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/mobile/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/mobile/windows/runner/Runner.rc b/mobile/windows/runner/Runner.rc new file mode 100644 index 0000000..960b12c --- /dev/null +++ b/mobile/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.matchlivetv" "\0" + VALUE "FileDescription", "match_live_tv" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "match_live_tv" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.matchlivetv. All rights reserved." "\0" + VALUE "OriginalFilename", "match_live_tv.exe" "\0" + VALUE "ProductName", "match_live_tv" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/mobile/windows/runner/flutter_window.cpp b/mobile/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/mobile/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/mobile/windows/runner/flutter_window.h b/mobile/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/mobile/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/mobile/windows/runner/main.cpp b/mobile/windows/runner/main.cpp new file mode 100644 index 0000000..6373396 --- /dev/null +++ b/mobile/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"match_live_tv", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/mobile/windows/runner/resource.h b/mobile/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/mobile/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/mobile/windows/runner/resources/app_icon.ico b/mobile/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/mobile/windows/runner/resources/app_icon.ico differ diff --git a/mobile/windows/runner/runner.exe.manifest b/mobile/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/mobile/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/mobile/windows/runner/utils.cpp b/mobile/windows/runner/utils.cpp new file mode 100644 index 0000000..3cb7146 --- /dev/null +++ b/mobile/windows/runner/utils.cpp @@ -0,0 +1,69 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + // First, find the length of the string with a safe upper bound (CWE-126). + // UNICODE_STRING_MAX_CHARS (32767) is the maximum length of a UNICODE_STRING. + int input_length = static_cast(wcsnlen(utf16_string, UNICODE_STRING_MAX_CHARS)); + // Now use that bounded length to determine the required buffer size. + // When an explicit length is passed, WideCharToMultiByte does not include + // the null terminator in its returned size. + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, nullptr, 0, nullptr, nullptr); + std::string utf8_string; + if (target_length == 0 || static_cast(target_length) > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/mobile/windows/runner/utils.h b/mobile/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/mobile/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/mobile/windows/runner/win32_window.cpp b/mobile/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/mobile/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/mobile/windows/runner/win32_window.h b/mobile/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/mobile/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/scripts/deploy/post_bootstrap_deploy.sh b/scripts/deploy/post_bootstrap_deploy.sh new file mode 100644 index 0000000..013642e --- /dev/null +++ b/scripts/deploy/post_bootstrap_deploy.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Dopo server_bootstrap.sh: avvia lo stack produzione +set -euo pipefail + +DEPLOY_ROOT="${DEPLOY_ROOT:-$HOME/matchlivetv}" +if [[ -d /opt/matchlivetv ]]; then + DEPLOY_ROOT="/opt/matchlivetv" +fi + +cd "$DEPLOY_ROOT/infra" + +if [[ ! -f .env ]]; then + echo "Manca infra/.env — copia da .env.production.example e compila i secret" + exit 1 +fi + +docker compose -f docker-compose.prod.yml --env-file .env up -d --build + +echo "Attendo healthcheck Rails..." +sleep 30 +curl -sf "http://127.0.0.1:3000/up" && echo " OK" || echo " FAIL — vedi: docker compose -f docker-compose.prod.yml logs rails" + +echo "Seed demo (opzionale, una tantum):" +echo " docker compose -f docker-compose.prod.yml exec rails bundle exec rails db:seed" diff --git a/scripts/deploy/server_bootstrap.sh b/scripts/deploy/server_bootstrap.sh new file mode 100755 index 0000000..d8bbce2 --- /dev/null +++ b/scripts/deploy/server_bootstrap.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Eseguire UNA VOLTA sul server con sudo (da console SSH o: sudo bash server_bootstrap.sh) +set -euo pipefail + +if [[ "${EUID}" -ne 0 ]]; then + if sudo -n true 2>/dev/null; then + exec sudo -n bash "$0" "$@" + else + echo "ERRORE: eseguire come root o con sudo senza password:" + echo " sudo bash $0" + echo "" + echo "Per abilitare NOPASSWD per eminux (opzionale):" + echo " echo 'eminux ALL=(ALL) NOPASSWD: ALL' | sudo tee /etc/sudoers.d/eminux-nopasswd" + exit 1 + fi +fi + +export DEBIAN_FRONTEND=noninteractive + +apt-get update -qq +apt-get install -y -qq ca-certificates curl git ufw rsync + +if ! command -v docker >/dev/null 2>&1; then + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc + chmod a+r /etc/apt/keyrings/docker.asc + + . /etc/os-release + CODENAME="${VERSION_CODENAME:-bookworm}" + if [[ "$CODENAME" == "trixie" ]]; then + CODENAME="bookworm" + fi + + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian ${CODENAME} stable" \ + > /etc/apt/sources.list.d/docker.list + + apt-get update -qq + apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +fi + +usermod -aG docker eminux 2>/dev/null || true +systemctl enable --now docker + +mkdir -p /opt/matchlivetv +chown -R eminux:eminux /opt/matchlivetv + +# UFW base +ufw --force reset || true +ufw default deny incoming +ufw default allow outgoing +ufw allow 22/tcp comment 'SSH' +ufw allow 1935/tcp comment 'RTMP MediaMTX' +ufw allow 8888/tcp comment 'HLS MediaMTX optional' +ufw allow from 192.168.0.0/16 to any port 3000 proto tcp comment 'Rails LAN NPM' +ufw --force enable + +docker --version +docker compose version +echo "Bootstrap completato. Riavvia sessione SSH di eminux per gruppo docker." diff --git a/scripts/deploy/sync_to_server.sh b/scripts/deploy/sync_to_server.sh new file mode 100755 index 0000000..5b72f83 --- /dev/null +++ b/scripts/deploy/sync_to_server.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Sincronizza il monorepo sul server di produzione (pre-Gitea) +set -euo pipefail + +SERVER="${DEPLOY_SERVER:-eminux@192.168.1.146}" +DEST="${DEPLOY_PATH:-/opt/matchlivetv}" +SRC="$(cd "$(dirname "$0")/../.." && pwd)" + +echo "Sync $SRC -> $SERVER:$DEST" + +if ssh "$SERVER" 'command -v rsync >/dev/null 2>&1'; then + rsync -avz --delete \ + --exclude '.git' \ + --exclude 'mobile/build' \ + --exclude 'mobile/.dart_tool' \ + --exclude 'backend/log' \ + --exclude 'backend/tmp' \ + --exclude 'backend/.bundle' \ + --exclude 'infra/.env' \ + --exclude 'node_modules' \ + "$SRC/" "$SERVER:$DEST/" +else + echo "rsync non disponibile sul server, uso tar+scp..." + tar -C "$SRC" -czf /tmp/matchlivetv-deploy.tar.gz \ + --exclude='./mobile/build' --exclude='./mobile/.dart_tool' \ + --exclude='./backend/log' --exclude='./backend/tmp' --exclude='./infra/.env' --exclude='./.git' . + scp /tmp/matchlivetv-deploy.tar.gz "$SERVER:~/matchlivetv-deploy.tar.gz" + ssh "$SERVER" "mkdir -p $DEST && tar -xzf ~/matchlivetv-deploy.tar.gz -C $DEST" +fi + +echo "Done. On server run:" +echo " cd $DEST/infra && cp .env.production.example .env # edit secrets" +echo " docker compose -f docker-compose.prod.yml up -d --build" diff --git a/scripts/e2e_api.sh b/scripts/e2e_api.sh new file mode 100755 index 0000000..eb3cb43 --- /dev/null +++ b/scripts/e2e_api.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail +API="${API:-http://localhost:3000}" +PLATFORM="${PLATFORM:-matchlivetv}" + +echo "== Login ==" +LOGIN=$(curl -sf -X POST "$API/api/v1/auth/login" \ + -H "Content-Type: application/json" \ + -d '{"email":"coach@matchlivetv.test","password":"password123"}') +TOKEN=$(echo "$LOGIN" | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") +echo "OK token received" + +AUTH="Authorization: Bearer $TOKEN" + +echo "== Teams ==" +TEAMS=$(curl -sf -H "$AUTH" "$API/api/v1/teams") +TEAM_ID=$(echo "$TEAMS" | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['id'])") +echo "Team: $TEAM_ID" + +echo "== Matches ==" +MATCHES=$(curl -sf -H "$AUTH" "$API/api/v1/teams/$TEAM_ID/matches") +MATCH_ID=$(echo "$MATCHES" | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['id'])") +echo "Match: $MATCH_ID" + +echo "== Create session (platform=$PLATFORM) ==" +SESSION=$(curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \ + "$API/api/v1/matches/$MATCH_ID/sessions" \ + -d "{\"platform\":\"$PLATFORM\",\"privacy_status\":\"unlisted\",\"quality_preset\":\"720p_30_2.5mbps\"}") +SESSION_ID=$(echo "$SESSION" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") +echo "Session: $SESSION_ID" + +python3 -c " +import sys, json +s = json.loads('''$SESSION''') +assert s.get('platform') == '$PLATFORM', s +if '$PLATFORM' == 'matchlivetv': + assert s.get('hls_playback_url'), 'missing hls_playback_url' + assert s.get('watch_page_url'), 'missing watch_page_url' + assert not s.get('youtube_broadcast_id'), s +print('Session fields OK') +" + +echo "== Start session ==" +curl -sf -X PATCH -H "$AUTH" "$API/api/v1/sessions/$SESSION_ID/start" > /dev/null +echo "Started" + +echo "== Webhook disconnect ==" +SECRET="${MEDIAMTX_WEBHOOK_SECRET:-mediamtx_webhook_dev_secret}" +BODY="{\"session_id\":\"$SESSION_ID\"}" +SIG=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}') +curl -sf -X POST "$API/webhooks/mediamtx/disconnect" \ + -H "Content-Type: application/json" \ + -H "X-MediaMTX-Signature: $SIG" \ + -d "$BODY" +echo "Disconnect OK" + +STATUS=$(curl -sf -H "$AUTH" "$API/api/v1/sessions/$SESSION_ID" | python3 -c "import sys,json; print(json.load(sys.stdin)['status'])") +echo "Status after disconnect: $STATUS (expected reconnecting)" + +echo "== Webhook connect ==" +curl -sf -X POST "$API/webhooks/mediamtx/connect" \ + -H "Content-Type: application/json" \ + -H "X-MediaMTX-Signature: $SIG" \ + -d "$BODY" +STATUS=$(curl -sf -H "$AUTH" "$API/api/v1/sessions/$SESSION_ID" | python3 -c "import sys,json; print(json.load(sys.stdin)['status'])") +echo "Status after reconnect: $STATUS (expected live)" + +echo "== Validate publish token ==" +curl -sf -X POST "$API/internal/validate_publish" \ + -d "session_id=$SESSION_ID&token=fake" | grep -q '"valid":false' && echo "Invalid token rejected OK" + +echo "== All E2E API checks passed ==" diff --git a/scripts/run_mobile_android.sh b/scripts/run_mobile_android.sh new file mode 100755 index 0000000..0b7c19a --- /dev/null +++ b/scripts/run_mobile_android.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Avvio su emulatore Android con port forwarding +set -euo pipefail +export PATH="$HOME/flutter/bin:$HOME/Android/Sdk/platform-tools:$PATH" +export ANDROID_HOME="$HOME/Android/Sdk" +export ANDROID_AVD_HOME="${ANDROID_AVD_HOME:-$HOME/.var/app/com.google.AndroidStudio/config/.android/avd}" + +adb reverse tcp:3000 tcp:3000 2>/dev/null || true + +cd "$(dirname "$0")/../mobile" +flutter pub get +flutter run -d emulator-5554 \ + --dart-define=API_BASE_URL=http://127.0.0.1:3000 \ + --dart-define=CABLE_URL=ws://127.0.0.1:3000/cable diff --git a/scripts/run_mobile_android_prod.sh b/scripts/run_mobile_android_prod.sh new file mode 100755 index 0000000..4892113 --- /dev/null +++ b/scripts/run_mobile_android_prod.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Telefono Android fisico → API produzione +set -euo pipefail +export PATH="$HOME/flutter/bin:$HOME/Android/Sdk/platform-tools:$PATH" +export ANDROID_HOME="$HOME/Android/Sdk" + +DEVICE="${ANDROID_DEVICE:-}" +if [[ -z "$DEVICE" ]]; then + DEVICE=$(adb devices | awk '/device$/{print $1; exit}' | grep -v emulator || true) +fi +if [[ -z "$DEVICE" ]]; then + echo "Nessun telefono USB collegato. Collega il device e abilita debug USB." + exit 1 +fi + +API_BASE_URL="${API_BASE_URL:-https://matchlivetv.eminux.it}" +MODE="${BUILD_MODE:-debug}" + +cd "$(dirname "$0")/../mobile" +flutter pub get + +ARGS=(run -d "$DEVICE" "--dart-define=API_BASE_URL=$API_BASE_URL") +if [[ "$MODE" == "release" ]]; then + ARGS=(run --release -d "$DEVICE" "--dart-define=API_BASE_URL=$API_BASE_URL") +fi + +echo "Device: $DEVICE | API: $API_BASE_URL | Mode: $MODE" +flutter "${ARGS[@]}" diff --git a/scripts/run_mobile_linux.sh b/scripts/run_mobile_linux.sh new file mode 100755 index 0000000..83796b3 --- /dev/null +++ b/scripts/run_mobile_linux.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Avvio leggero su Linux desktop (evita emulatore Android che consuma ~80% CPU) +set -euo pipefail +export PATH="$HOME/flutter/bin:$PATH" +cd "$(dirname "$0")/../mobile" +flutter pub get +flutter run -d linux --dart-define=API_BASE_URL=http://127.0.0.1:3000