diff --git a/backend/app/controllers/api/v1/matches_controller.rb b/backend/app/controllers/api/v1/matches_controller.rb index 250ab2c..bd70f27 100644 --- a/backend/app/controllers/api/v1/matches_controller.rb +++ b/backend/app/controllers/api/v1/matches_controller.rb @@ -2,6 +2,7 @@ module Api module V1 class MatchesController < BaseController include Api::UrlHelper + include Api::CoverJson include BrandingAttachments before_action :set_team, only: %i[index create] @@ -21,6 +22,7 @@ module Api normalize_scoring_rules!(attrs) match = @team.matches.create!(attrs) attach_opponent_logo(match) + attach_match_cover(match) render json: match_json(match), status: :created end @@ -34,6 +36,7 @@ module Api normalize_opponent_color!(attrs) @match.update!(attrs) attach_opponent_logo(@match) + attach_match_cover(@match) render json: match_json(@match) end @@ -98,6 +101,11 @@ module Api match.opponent_logo_file.attach(file) if file.present? end + def attach_match_cover(match) + attach_cover_image(match, entitlements: match.team.entitlements) + enqueue_cover_slate_generation(match) + end + def match_json(match, detail: false) active = active_session_for(match) team = match.team @@ -123,6 +131,7 @@ module Api home_logo_url: api_absolute_url(team.effective_logo_url), opponent_primary_color: match.effective_opponent_primary_color, opponent_logo_url: api_absolute_url(match.opponent_logo_url), + **match_cover_json(match), active_session_id: active&.id, active_session_status: active&.status, stream_completed: match.stream_completed?, diff --git a/backend/app/controllers/api/v1/teams_controller.rb b/backend/app/controllers/api/v1/teams_controller.rb index 28ea697..7f91cab 100644 --- a/backend/app/controllers/api/v1/teams_controller.rb +++ b/backend/app/controllers/api/v1/teams_controller.rb @@ -137,7 +137,8 @@ module Api youtube_mode: ent.plan.youtube_mode, staff_role: current_user.staff_role_for(team), can_stream: current_user.can_stream_for?(team), - can_connect_youtube: team.club.owned_by?(current_user) && ent.premium_full? && ent.youtube_enabled? + can_connect_youtube: team.club.owned_by?(current_user) && ent.premium_full? && ent.youtube_enabled?, + custom_cover_enabled: ent.can_use_custom_cover? } if detail data[:members] = team.user_teams.includes(:user).where.not(role: "owner").map do |ut| diff --git a/backend/app/controllers/concerns/api/cover_json.rb b/backend/app/controllers/concerns/api/cover_json.rb new file mode 100644 index 0000000..2a779f3 --- /dev/null +++ b/backend/app/controllers/concerns/api/cover_json.rb @@ -0,0 +1,16 @@ +module Api + module CoverJson + extend ActiveSupport::Concern + + private + + def match_cover_json(match) + resolver = Streams::CoverResolver.new(match) + { + effective_cover_url: api_absolute_url(resolver.effective_cover_url), + cover_source: resolver.cover_source, + custom_cover_enabled: match.team.entitlements.can_use_custom_cover? + } + end + end +end diff --git a/backend/app/controllers/concerns/branding_attachments.rb b/backend/app/controllers/concerns/branding_attachments.rb index 54ef516..21d12e8 100644 --- a/backend/app/controllers/concerns/branding_attachments.rb +++ b/backend/app/controllers/concerns/branding_attachments.rb @@ -8,6 +8,26 @@ module BrandingAttachments record.logo_file.attach(file) if file.present? end + def attach_cover_image(record, entitlements:) + remove_flag = params[:remove_cover].to_s == "1" || params.dig(:match, :remove_cover).to_s == "1" + if remove_flag + record.cover_image.purge if record.cover_image.attached? + record.cover_slate.purge if record.cover_slate.attached? + return + end + + file = params.dig(:branding, :cover_image) || params[:cover_image] + return if file.blank? + + entitlements.assert_can_upload_cover! + record.cover_slate.purge if record.cover_slate.attached? + record.cover_image.attach(file) + end + + def enqueue_cover_slate_generation(record) + GenerateCoverSlateJob.enqueue_for(record) if record.cover_image.attached? + end + def normalize_hex_color(value, fallback) v = value.to_s.strip v = "##{v}" if v.match?(/\A[0-9A-Fa-f]{6}\z/) diff --git a/backend/app/controllers/public/clubs_controller.rb b/backend/app/controllers/public/clubs_controller.rb index 895d579..fe99952 100644 --- a/backend/app/controllers/public/clubs_controller.rb +++ b/backend/app/controllers/public/clubs_controller.rb @@ -64,17 +64,26 @@ module Public def edit require_club_owner!(@club) + @entitlements = @club.teams.first&.entitlements end def update require_club_owner!(@club) + @entitlements = @club.teams.first!.entitlements @club.assign_attributes(club_params) attach_branding_logo(@club) + attach_cover_image(@club, entitlements: @entitlements) @club.save! + enqueue_cover_slate_generation(@club) redirect_to public_club_path(@club), notice: t("flash.clubs.updated") rescue ActiveRecord::RecordInvalid => e + @entitlements = @club.teams.first&.entitlements flash.now[:alert] = e.record.errors.full_messages.join(", ") render :edit, status: :unprocessable_entity + rescue Teams::EntitlementError => e + @entitlements = @club.teams.first&.entitlements + flash.now[:alert] = e.message + render :edit, status: :unprocessable_entity end def billing diff --git a/backend/app/controllers/public/live_controller.rb b/backend/app/controllers/public/live_controller.rb index ed8ca86..a56575b 100644 --- a/backend/app/controllers/public/live_controller.rb +++ b/backend/app/controllers/public/live_controller.rb @@ -51,6 +51,7 @@ module Public @session.status.in?(%w[connecting live reconnecting paused]) ) @publisher_online = !@stream_closed && mediamtx_publisher_online?(@session) + @cover_poster_url = Streams::CoverResolver.new(@match).effective_cover_url end def status diff --git a/backend/app/controllers/public/teams_controller.rb b/backend/app/controllers/public/teams_controller.rb index 22ef10b..1f41ac8 100644 --- a/backend/app/controllers/public/teams_controller.rb +++ b/backend/app/controllers/public/teams_controller.rb @@ -42,19 +42,28 @@ module Public def edit require_club_owner_for_team!(@team) @club = @team.club + @entitlements = @team.entitlements end def update require_club_owner_for_team!(@team) @team.assign_attributes(team_params) attach_branding_logo(@team) + attach_cover_image(@team, entitlements: @team.entitlements) attach_team_photo(@team) @team.save! + enqueue_cover_slate_generation(@team) redirect_to public_team_details_path(@team), notice: t("flash.teams.updated") rescue ActiveRecord::RecordInvalid => e @club = @team.club + @entitlements = @team.entitlements flash.now[:alert] = e.record.errors.full_messages.join(", ") render :edit, status: :unprocessable_entity + rescue Teams::EntitlementError => e + @club = @team.club + @entitlements = @team.entitlements + flash.now[:alert] = e.message + render :edit, status: :unprocessable_entity end def invite diff --git a/backend/app/jobs/generate_cover_slate_job.rb b/backend/app/jobs/generate_cover_slate_job.rb new file mode 100644 index 0000000..b2bc0f2 --- /dev/null +++ b/backend/app/jobs/generate_cover_slate_job.rb @@ -0,0 +1,19 @@ +class GenerateCoverSlateJob < ApplicationJob + queue_as :default + + def self.enqueue_for(record) + return unless record&.cover_image&.attached? + + queue = record.is_a?(Match) ? :critical : :default + set(queue: queue).perform_later(record.class.name, record.id) + end + + def perform(class_name, record_id) + record = class_name.constantize.find_by(id: record_id) + return unless record&.cover_image&.attached? + + Streams::GenerateCoverSlate.call(record) + rescue Streams::GenerateCoverSlate::Error => e + Rails.logger.warn("[GenerateCoverSlateJob] #{class_name}##{record_id} #{e.message}") + end +end diff --git a/backend/app/models/club.rb b/backend/app/models/club.rb index f57aef7..4926162 100644 --- a/backend/app/models/club.rb +++ b/backend/app/models/club.rb @@ -1,5 +1,6 @@ class Club < ApplicationRecord include Brandable + include Coverable include ClubBillingProfile has_many :club_memberships, dependent: :destroy diff --git a/backend/app/models/concerns/coverable.rb b/backend/app/models/concerns/coverable.rb new file mode 100644 index 0000000..3d1f300 --- /dev/null +++ b/backend/app/models/concerns/coverable.rb @@ -0,0 +1,50 @@ +module Coverable + extend ActiveSupport::Concern + + COVER_IMAGE_TYPES = %w[image/png image/jpeg image/webp].freeze + MAX_COVER_BYTES = 5.megabytes + + included do + has_one_attached :cover_image + has_one_attached :cover_slate + + validate :cover_image_file_type, if: -> { cover_image.attached? && cover_image.blob&.new_record? } + validate :cover_image_file_size, if: -> { cover_image.attached? && cover_image.blob&.new_record? } + end + + def cover_image_attached? + cover_image.attached? + end + + def cover_url + return unless cover_image.attached? + + Rails.application.routes.url_helpers.rails_blob_path(cover_image, only_path: true) + end + + def effective_cover_url + if cover_image.attached? + cover_url + else + cover_parent&.effective_cover_url + end + end + + private + + def cover_parent + nil + end + + def cover_image_file_type + return if cover_image.content_type.in?(COVER_IMAGE_TYPES) + + errors.add(:cover_image, I18n.t("coverable.errors.invalid_type")) + end + + def cover_image_file_size + return if cover_image.byte_size <= MAX_COVER_BYTES + + errors.add(:cover_image, I18n.t("coverable.errors.too_large")) + end +end diff --git a/backend/app/models/match.rb b/backend/app/models/match.rb index 3a0f3e8..9580ec7 100644 --- a/backend/app/models/match.rb +++ b/backend/app/models/match.rb @@ -1,4 +1,6 @@ class Match < ApplicationRecord + include Coverable + belongs_to :team has_many :stream_sessions, dependent: :destroy @@ -174,4 +176,8 @@ class Match < ApplicationRecord rescue ArgumentError => e errors.add(:scoring_rules, e.message) end + + def cover_parent + nil + end end diff --git a/backend/app/models/plan.rb b/backend/app/models/plan.rb index ebd4f00..166e3d1 100644 --- a/backend/app/models/plan.rb +++ b/backend/app/models/plan.rb @@ -84,6 +84,10 @@ class Plan < ApplicationRecord feature("youtube_enabled") == true end + def custom_cover_enabled? + feature("custom_cover") == true + end + def premium? slug.in?(PREMIUM_SLUGS) end diff --git a/backend/app/models/team.rb b/backend/app/models/team.rb index 072d749..3cb2d62 100644 --- a/backend/app/models/team.rb +++ b/backend/app/models/team.rb @@ -1,5 +1,6 @@ class Team < ApplicationRecord include Brandable + include Coverable belongs_to :club has_many :user_teams, dependent: :destroy @@ -99,6 +100,10 @@ class Team < ApplicationRecord club end + def cover_parent + club + end + def assign_slug self.slug = Teams::GenerateSlug.call(self) if slug.blank? end diff --git a/backend/app/services/mediamtx/client.rb b/backend/app/services/mediamtx/client.rb index 4388b55..533647b 100644 --- a/backend/app/services/mediamtx/client.rb +++ b/backend/app/services/mediamtx/client.rb @@ -28,7 +28,7 @@ module Mediamtx overridePublisher: true ) body[:alwaysAvailable] = true - body[:alwaysAvailableFile] = slate_file_path + body[:alwaysAvailableFile] = slate_file_path(session) # YouTube: telefono → MediaMTX; relay copy verso RTMPS in sidekiq. response = @conn.post("/v3/config/paths/add/#{CGI.escape(path)}", body) unless response.success? @@ -81,7 +81,7 @@ module Mediamtx return true if always_available_remembered?(path, enabled: enabled) body = if enabled - { alwaysAvailable: true, alwaysAvailableFile: slate_file_path } + { alwaysAvailable: true, alwaysAvailableFile: slate_file_path(session) } else { alwaysAvailable: false } end @@ -173,7 +173,13 @@ module Mediamtx SCRIPT end - def slate_file_path + def slate_file_path(session) + return default_slate_file_path if session.blank? + + Streams::SlateDistributor.slate_path_for_session(session) + end + + def default_slate_file_path ENV.fetch("MEDIAMTX_SLATE_FILE", "/slates/offline.mp4") end diff --git a/backend/app/services/sessions/create.rb b/backend/app/services/sessions/create.rb index 421dae4..0e3c743 100644 --- a/backend/app/services/sessions/create.rb +++ b/backend/app/services/sessions/create.rb @@ -37,6 +37,8 @@ module Sessions session.stream_node = Streams::NodeRegistry.allocate! session.save! Scoring::Engine.ensure_score_for(session) + Streams::CoverSlateEnsurer.ensure_for!(@match) + Streams::SlateDistributor.ensure_for!(session) Mediamtx::Client.for_session(session).create_path(session) log_event( session, diff --git a/backend/app/services/streams/cover_resolver.rb b/backend/app/services/streams/cover_resolver.rb new file mode 100644 index 0000000..58b6c8a --- /dev/null +++ b/backend/app/services/streams/cover_resolver.rb @@ -0,0 +1,66 @@ +module Streams + # Catena effective cover: partita → squadra → società → default Match Live TV. + class CoverResolver + Result = Struct.new(:record, :source, :cover_url, :slate_local_path, keyword_init: true) + + DEFAULT_COVER_URL = "/images/copertina-canale.png".freeze + + def initialize(match) + @match = match + @team = match.team + @club = @team.club + @entitlements = @team.entitlements + end + + def effective_cover_url + resolve&.cover_url || default_cover_url + end + + def cover_source + resolve&.source || "default" + end + + def resolve + return nil unless @entitlements.can_use_custom_cover? + + candidates.each do |record, source| + next unless record.cover_image.attached? + next unless record.cover_slate.attached? + + path = CoverSlatePaths.local_path_for(record) + next if path.blank? + + return Result.new( + record: record, + source: source, + cover_url: blob_path(record.cover_image), + slate_local_path: path + ) + end + + nil + end + + def default_cover_url + DEFAULT_COVER_URL + end + + def default_slate_path + ENV.fetch("MEDIAMTX_SLATE_FILE", "/slates/offline.mp4") + end + + private + + def candidates + [ + [@match, "match"], + [@team, "team"], + [@club, "club"] + ] + end + + def blob_path(attachment) + Rails.application.routes.url_helpers.rails_blob_path(attachment, only_path: true) + end + end +end diff --git a/backend/app/services/streams/cover_slate_ensurer.rb b/backend/app/services/streams/cover_slate_ensurer.rb new file mode 100644 index 0000000..00db1c9 --- /dev/null +++ b/backend/app/services/streams/cover_slate_ensurer.rb @@ -0,0 +1,35 @@ +module Streams + # Garantisce che la slate MP4 esista per il record effettivo della partita (sync al go-live). + class CoverSlateEnsurer + def self.ensure_for!(match) + new(match).ensure! + end + + def initialize(match) + @match = match + @resolver = CoverResolver.new(match) + end + + def ensure! + result = @resolver.resolve + return unless result + + record = result.record + return if slate_ready?(record) + + GenerateCoverSlate.call(record) + rescue GenerateCoverSlate::Error => e + Rails.logger.warn("[CoverSlateEnsurer] match=#{@match.id} #{e.message}") + nil + end + + private + + def slate_ready?(record) + return false unless record.cover_slate.attached? + + path = CoverSlatePaths.local_path_for(record) + path.present? && File.exist?(path) + end + end +end diff --git a/backend/app/services/streams/cover_slate_paths.rb b/backend/app/services/streams/cover_slate_paths.rb new file mode 100644 index 0000000..dc84398 --- /dev/null +++ b/backend/app/services/streams/cover_slate_paths.rb @@ -0,0 +1,30 @@ +module Streams + class CoverSlatePaths + def self.slates_custom_root + ENV.fetch("MEDIAMTX_SLATES_CUSTOM_DIR", "/slates/custom") + end + + def self.filename_for(record) + return nil unless record.cover_slate.attached? + + digest = record.cover_slate.blob.checksum + prefix = record.class.name.underscore + "#{prefix}-#{record.id}-#{digest}.mp4" + end + + def self.expected_path(record) + name = filename_for(record) + return nil if name.blank? + + File.join(slates_custom_root, name) + end + + def self.local_path_for(record) + path = expected_path(record) + return nil if path.blank? + return path if File.exist?(path) + + nil + end + end +end diff --git a/backend/app/services/streams/generate_cover_slate.rb b/backend/app/services/streams/generate_cover_slate.rb new file mode 100644 index 0000000..dd7d754 --- /dev/null +++ b/backend/app/services/streams/generate_cover_slate.rb @@ -0,0 +1,121 @@ +require "open3" + +module Streams + # Converte cover_image in MP4 slate (H.264 720p + AAC mono), allineato a infra/scripts/generate_slate.sh. + class GenerateCoverSlate + class Error < StandardError; end + + SLATE_DURATION_SECS = 30 + VIDEO_FILTER = "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=0x0a0a0e".freeze + + def self.call(record) + new(record).call + end + + def initialize(record) + @record = record + end + + def call + return @record unless @record.cover_image.attached? + + @image_temp = download_cover_image + @mp4_temp = encode_slate(@image_temp) + attach_slate(@mp4_temp) + write_to_slates_dir(@mp4_temp) + @record + rescue Error + raise + rescue StandardError => e + raise Error, e.message + ensure + cleanup_tempfiles + end + + private + + def download_cover_image + ext = extension_for(@record.cover_image.content_type) + path = temp_path("cover-in", ext) + @record.cover_image.blob.open(tmpdir: Dir.tmpdir) do |file| + FileUtils.cp(file.path, path) + end + path + end + + def encode_slate(image_path) + out = temp_path("cover-slate", ".mp4") + cmd = ffmpeg_args(image_path, out) + stdout, stderr, status = Open3.capture3(*cmd) + unless status.success? && File.exist?(out) && File.size(out).positive? + detail = stderr.to_s.strip.presence || stdout.to_s.strip + raise Error, "ffmpeg slate failed: #{detail}" + end + + out + end + + def ffmpeg_args(input, output) + [ + "ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", + "-loop", "1", "-framerate", "30", "-i", input, + "-f", "lavfi", "-i", "anullsrc=r=48000:cl=mono", + "-filter:v", VIDEO_FILTER, + "-map", "0:v", "-map", "1:a", + "-t", SLATE_DURATION_SECS.to_s, + "-c:v", "libx264", "-pix_fmt", "yuv420p", "-profile:v", "baseline", "-level", "3.1", + "-x264-params", "keyint=30:min-keyint=30:scenecut=0:bframes=0", + "-g", "30", "-keyint_min", "30", "-force_key_frames", "expr:gte(t,n_forced*1)", + "-preset", "fast", + "-c:a", "aac", "-b:a", "128k", "-ac", "1", "-shortest", "-y", output + ] + end + + def attach_slate(mp4_path) + @record.cover_slate.purge if @record.cover_slate.attached? + File.open(mp4_path, "rb") do |io| + @record.cover_slate.attach( + io: io, + filename: slate_filename, + content_type: "video/mp4" + ) + end + @record.reload + end + + def write_to_slates_dir(mp4_path) + dest = CoverSlatePaths.expected_path(@record) + return unless dest + + FileUtils.mkdir_p(File.dirname(dest)) + FileUtils.cp(mp4_path, dest) + rescue Errno::EACCES, Errno::EROFS => e + Rails.logger.info("[GenerateCoverSlate] skip disk copy #{dest}: #{e.class}") + end + + def slate_filename + "#{@record.class.name.underscore}-#{@record.id}.mp4" + end + + def extension_for(content_type) + case content_type + when "image/png" then ".png" + when "image/webp" then ".webp" + else ".jpg" + end + end + + def temp_path(prefix, ext) + path = File.join(Dir.tmpdir, "#{prefix}-#{@record.class.name}-#{@record.id}-#{SecureRandom.hex(4)}#{ext}") + @tempfiles ||= [] + @tempfiles << path + path + end + + def cleanup_tempfiles + Array(@tempfiles).each do |path| + FileUtils.rm_f(path) + end + end + end +end diff --git a/backend/app/services/streams/slate_distributor.rb b/backend/app/services/streams/slate_distributor.rb new file mode 100644 index 0000000..92911bd --- /dev/null +++ b/backend/app/services/streams/slate_distributor.rb @@ -0,0 +1,101 @@ +module Streams + # Assicura che la slate MP4 custom sia sul nodo assegnato (home/lab locale, CPX via agent). + class SlateDistributor + class Error < StandardError; end + + def self.slate_path_for_session(session) + new(session).mediamtx_slate_path + end + + def self.ensure_for!(session) + new(session).ensure! + end + + def initialize(session) + @session = session + @match = session.match + @node = session.stream_node + @resolver = CoverResolver.new(@match) + end + + def mediamtx_slate_path + ensure! unless defined?(@mediamtx_path) + @mediamtx_path + end + + def ensure! + result = @resolver.resolve + unless result + @mediamtx_path = default_slate_path + return @mediamtx_path + end + + host_path = result.slate_local_path + filename = File.basename(host_path) + mediamtx_path = "#{CoverSlatePaths.slates_custom_root}/#{filename}" + + if cloud_node? + push_to_agent!(filename, result.record, host_path) + else + verify_local_slate!(host_path) + end + + @mediamtx_path = mediamtx_path + rescue Error => e + Rails.logger.warn("[SlateDistributor] session=#{@session.id} #{e.message}") + @mediamtx_path = default_slate_path + end + + private + + def cloud_node? + @node&.role == "cloud" && agent_url.present? + end + + def agent_url + ENV["STREAM_NODE_RELAY_AGENT_URL"].presence || @node&.relay_agent_url + end + + def agent_secret + ENV["STREAM_NODE_AGENT_SECRET"].presence || "mediamtx_webhook_dev_secret" + end + + def push_to_agent!(filename, record, host_path) + bytes = read_slate_bytes(record, host_path) + raise Error, "slate bytes missing for #{filename}" if bytes.blank? + + uri = URI.parse("#{agent_url.chomp('/')}/slates/#{CGI.escape(filename)}") + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 5 + http.read_timeout = 120 + req = Net::HTTP::Put.new(uri) + req["Authorization"] = "Bearer #{agent_secret}" if agent_secret.present? + req["Content-Type"] = "video/mp4" + req.body = bytes + res = http.request(req) + return if res.is_a?(Net::HTTPSuccess) + + raise Error, "agent PUT /slates/#{filename} HTTP #{res.code} #{res.body.to_s.truncate(200)}" + end + + def read_slate_bytes(record, host_path) + return File.binread(host_path) if host_path.present? && File.exist?(host_path) + + return unless record.cover_slate.attached? + + record.cover_slate.blob.open(tmpdir: Dir.tmpdir) do |file| + return File.binread(file.path) + end + end + + def verify_local_slate!(host_path) + return if host_path.present? && File.exist?(host_path) + + raise Error, "slate missing on disk: #{host_path}" + end + + def default_slate_path + ENV.fetch("MEDIAMTX_SLATE_FILE", "/slates/offline.mp4") + end + end +end diff --git a/backend/app/services/teams/entitlements.rb b/backend/app/services/teams/entitlements.rb index b656672..43823dc 100644 --- a/backend/app/services/teams/entitlements.rb +++ b/backend/app/services/teams/entitlements.rb @@ -35,6 +35,20 @@ module Teams active? && plan.phone_download_enabled? end + def can_use_custom_cover? + premium_full? && plan.custom_cover_enabled? + end + + def assert_can_upload_cover! + unless can_use_custom_cover? + raise EntitlementError.new( + "La copertina sponsor personalizzata richiede Premium Full", + code: "premium_full_required", + billing_url: billing_url + ) + end + end + def max_staff_transmission plan.max_staff_transmission end @@ -225,7 +239,8 @@ module Teams recording_retention_days: recording_retention_days, phone_download_enabled: phone_download_enabled?, youtube_enabled: youtube_enabled?, - youtube_mode: plan.youtube_mode + youtube_mode: plan.youtube_mode, + custom_cover_enabled: can_use_custom_cover? } end diff --git a/backend/app/views/public/clubs/edit.html.erb b/backend/app/views/public/clubs/edit.html.erb index 5ba7695..90a2249 100644 --- a/backend/app/views/public/clubs/edit.html.erb +++ b/backend/app/views/public/clubs/edit.html.erb @@ -10,6 +10,9 @@ <%= label_tag "club[sport]", t("club.sport_label") %> <%= select_tag "club[sport]", sport_catalog_options(@club.sport) %> <%= render "shared/branding_fields", record: @club, legend: t("club.branding_legend") %> + <%= render "shared/cover_fields", + record: @club, + can_upload: @entitlements&.can_use_custom_cover? %> <%= render "shared/billing_profile_fields", record: @club %> <%= submit_tag t("club.edit.save"), class: "btn btn-primary" %> <% end %> diff --git a/backend/app/views/public/live/show.html.erb b/backend/app/views/public/live/show.html.erb index a2d3134..b26c6f4 100644 --- a/backend/app/views/public/live/show.html.erb +++ b/backend/app/views/public/live/show.html.erb @@ -46,7 +46,7 @@ <% else %>
- + <%= render "public/live/player_overlays", match: @match, session: @session %>
<%= render "shared/branding_fields", record: @team, show_inherit_hint: true, legend: t("team.edit.branding_legend") %> + <%= render "shared/cover_fields", + record: @team, + show_inherit_hint: true, + can_upload: @entitlements&.can_use_custom_cover? %> <%= label_tag "team[slug]", t("team.edit.slug_label") %>

diff --git a/backend/app/views/shared/_cover_fields.html.erb b/backend/app/views/shared/_cover_fields.html.erb new file mode 100644 index 0000000..da4f357 --- /dev/null +++ b/backend/app/views/shared/_cover_fields.html.erb @@ -0,0 +1,55 @@ +<% record = local_assigns[:record] %> +<% show_inherit_hint = local_assigns[:show_inherit_hint] %> +<% can_upload = local_assigns.fetch(:can_upload, false) %> +<% cover_src = record.effective_cover_url.presence || Streams::CoverResolver::DEFAULT_COVER_URL %> + +

+ <%= t("club.branding.cover_legend") %> + + <% if show_inherit_hint %> +

<%= t("club.branding.cover_inherit_hint") %>

+ <% end %> + +

<%= t("club.branding.cover_hint") %>

+ +
+ <%= label_tag "branding[cover_image]", t("club.branding.cover_file_label") %> +
+ <%= image_tag cover_src, + alt: t("club.branding.cover_preview_alt"), + class: "branding-cover-preview__img", + data: { branding_cover_img: true } %> +
+ + <% if can_upload %> + <%= file_field_tag "branding[cover_image]", + accept: "image/png,image/jpeg,image/webp", + data: { branding_cover_file: true }, + class: "branding-file-input" %> + <% if record.cover_image_attached? %> + + <% end %> + <% else %> +

<%= t("club.branding.cover_requires_full") %>

+ <%= link_to t("club.branding.cover_see_plans"), public_prezzi_path, class: "btn btn-secondary btn-sm" %> + <% end %> +
+
+ + diff --git a/backend/config/locales/app.en.yml b/backend/config/locales/app.en.yml index 38d9a00..d64eab1 100644 --- a/backend/config/locales/app.en.yml +++ b/backend/config/locales/app.en.yml @@ -33,6 +33,14 @@ en: quick_colors: Quick colors header_preview_label: Header preview name_placeholder: Club / team name + cover_legend: Live cover (sponsors) + cover_inherit_hint: Leave blank to use the club cover, otherwise the default Match Live TV cover. + cover_hint: "16:9 image recommended (PNG, JPEG or WebP, max 5 MB). Shown to viewers and YouTube when paused or offline." + cover_file_label: Cover image + cover_preview_alt: Live cover preview + cover_remove_label: Remove custom cover + cover_requires_full: Custom sponsor covers are available on the Premium Full plan. + cover_see_plans: See plans dashboard: title: "%{name} — Club" plan_label: "Plan:" diff --git a/backend/config/locales/app.it.yml b/backend/config/locales/app.it.yml index b995de5..cc6d458 100644 --- a/backend/config/locales/app.it.yml +++ b/backend/config/locales/app.it.yml @@ -33,6 +33,14 @@ it: quick_colors: Colori rapidi header_preview_label: Anteprima intestazione name_placeholder: Nome società / squadra + cover_legend: Copertina diretta (sponsor) + cover_inherit_hint: Lascia vuoto per usare la copertina della società, altrimenti quella standard Match Live TV. + cover_hint: "Immagine 16:9 consigliata (PNG, JPEG o WebP, max 5 MB). In pausa o senza segnale la vedranno spettatori e YouTube." + cover_file_label: Immagine copertina + cover_preview_alt: Anteprima copertina diretta + cover_remove_label: Rimuovi copertina personalizzata + cover_requires_full: La copertina sponsor personalizzata è disponibile con il piano Premium Full. + cover_see_plans: Vedi piani dashboard: title: "%{name} — Società" plan_label: "Piano:" diff --git a/backend/config/locales/coverable.en.yml b/backend/config/locales/coverable.en.yml new file mode 100644 index 0000000..5cada8a --- /dev/null +++ b/backend/config/locales/coverable.en.yml @@ -0,0 +1,5 @@ +en: + coverable: + errors: + invalid_type: "must be PNG, JPEG or WebP" + too_large: "exceeds the maximum size of 5 MB" diff --git a/backend/config/locales/coverable.it.yml b/backend/config/locales/coverable.it.yml new file mode 100644 index 0000000..b191d94 --- /dev/null +++ b/backend/config/locales/coverable.it.yml @@ -0,0 +1,5 @@ +it: + coverable: + errors: + invalid_type: "deve essere PNG, JPEG o WebP" + too_large: "supera la dimensione massima di 5 MB" diff --git a/backend/db/seeds/plans.rb b/backend/db/seeds/plans.rb index 9d2ab3b..febd9f5 100644 --- a/backend/db/seeds/plans.rb +++ b/backend/db/seeds/plans.rb @@ -44,7 +44,8 @@ seed_plan!("premium_full", "Premium Full", { "recording_days" => 90, "phone_download_enabled" => true, "youtube_enabled" => true, - "youtube_mode" => "team" + "youtube_mode" => "team", + "custom_cover" => true }, 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| diff --git a/backend/public/marketing.css b/backend/public/marketing.css index 7589945..e1c42be 100644 --- a/backend/public/marketing.css +++ b/backend/public/marketing.css @@ -449,7 +449,25 @@ body.nav-menu-open { overflow: hidden; } border-radius: 10px; object-fit: contain; } -.branding-logo-preview__placeholder { color: #777; font-size: 0.88rem; text-align: center; } +.branding-cover-preview { + min-height: 140px; +} +.branding-cover-preview__img { + max-width: 100%; + max-height: 180px; + width: auto; + height: auto; + border-radius: 8px; + object-fit: contain; +} +.cover-remove-label { + display: flex; + align-items: center; + gap: 8px; + margin-top: 10px; + font-size: 0.9rem; + color: #aaa; +} .branding-file-input { margin-bottom: 0 !important; } .branding-url-input { margin-bottom: 14px; } .branding-color-row { diff --git a/backend/spec/jobs/generate_cover_slate_job_spec.rb b/backend/spec/jobs/generate_cover_slate_job_spec.rb new file mode 100644 index 0000000..6e3f8e0 --- /dev/null +++ b/backend/spec/jobs/generate_cover_slate_job_spec.rb @@ -0,0 +1,34 @@ +require "rails_helper" + +RSpec.describe GenerateCoverSlateJob do + let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } + let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") } + let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") } + + before do + load Rails.root.join("db/seeds/plans.rb") + match.cover_image.attach( + io: StringIO.new("img"), + filename: "c.png", + content_type: "image/png" + ) + end + + it "usa coda critical per Match" do + expect(described_class).to receive(:set).with(queue: :critical).and_return(described_class) + expect(described_class).to receive(:perform_later).with("Match", match.id) + described_class.enqueue_for(match) + end + + it "usa coda default per Club" do + club.cover_image.attach(io: StringIO.new("img"), filename: "c.png", content_type: "image/png") + expect(described_class).to receive(:set).with(queue: :default).and_return(described_class) + expect(described_class).to receive(:perform_later).with("Club", club.id) + described_class.enqueue_for(club) + end + + it "invoca GenerateCoverSlate al perform" do + expect(Streams::GenerateCoverSlate).to receive(:call).with(match) + described_class.perform_now("Match", match.id) + end +end diff --git a/backend/spec/services/mediamtx/client_cover_spec.rb b/backend/spec/services/mediamtx/client_cover_spec.rb new file mode 100644 index 0000000..40df091 --- /dev/null +++ b/backend/spec/services/mediamtx/client_cover_spec.rb @@ -0,0 +1,50 @@ +require "rails_helper" + +RSpec.describe Mediamtx::Client do + let!(:user) { User.create!(email: "mtx@test.it", name: "U", password: "Password123", role: "coach") } + let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } + let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") } + let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") } + let!(:node) do + StreamNode.create!( + slug: "stream-home", + hostname: "home", + role: "home", + status: "ready", + provider: "local", + rtmp_base_url: "rtmp://localhost:1935", + hls_base_url: "http://localhost:8888", + api_base_url: "http://mediamtx:9997", + max_publishers: 6, + max_relays: 6 + ) + end + let!(:session) do + StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "idle", stream_node: node) + end + + before do + load Rails.root.join("db/seeds/plans.rb") + Billing::AssignPlan.call(club: club, plan_slug: "premium_full") + end + + it "usa SlateDistributor per alwaysAvailableFile" do + custom = "/slates/custom/club-test.mp4" + allow(Streams::SlateDistributor).to receive(:slate_path_for_session).with(session).and_return(custom) + + conn = instance_double(Faraday::Connection) + response = instance_double(Faraday::Response, success?: true, status: 200, body: {}) + allow(Faraday).to receive(:new).and_return(conn) + allow(conn).to receive(:post).and_return(response) + allow(conn).to receive(:get) + allow(Redis).to receive(:new).and_return(instance_double(Redis, get: nil, set: true)) + + client = described_class.new(base_url: "http://mediamtx:9997") + client.create_path(session) + + expect(conn).to have_received(:post).with( + "/v3/config/paths/add/live%2Fmatch_#{session.id}", + hash_including(alwaysAvailableFile: custom) + ) + end +end diff --git a/backend/spec/services/streams/cover_resolver_spec.rb b/backend/spec/services/streams/cover_resolver_spec.rb new file mode 100644 index 0000000..af046f4 --- /dev/null +++ b/backend/spec/services/streams/cover_resolver_spec.rb @@ -0,0 +1,71 @@ +require "rails_helper" + +RSpec.describe Streams::CoverResolver do + let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } + let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") } + let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") } + + before do + load Rails.root.join("db/seeds/plans.rb") + Billing::AssignPlan.call(club: club, plan_slug: "free") + end + + def attach_ready_cover(record, name) + record.cover_image.attach( + io: StringIO.new("fake-image"), + filename: "#{name}.png", + content_type: "image/png" + ) + record.cover_slate.attach( + io: StringIO.new("fake-mp4"), + filename: "#{name}.mp4", + content_type: "video/mp4" + ) + path = Streams::CoverSlatePaths.expected_path(record) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "fake mp4") + path + end + + it "restituisce default senza Premium Full" do + resolver = described_class.new(match) + expect(resolver.cover_source).to eq("default") + expect(resolver.effective_cover_url).to eq("/images/copertina-canale.png") + end + + context "con Premium Full" do + before { Billing::AssignPlan.call(club: club, plan_slug: "premium_full") } + + it "preferisce partita, poi squadra, poi società" do + attach_ready_cover(club, "club") + attach_ready_cover(team, "team") + attach_ready_cover(match, "match") + + resolver = described_class.new(match) + expect(resolver.cover_source).to eq("match") + end + + it "eredita dalla squadra se la partita non ha override" do + attach_ready_cover(club, "club") + attach_ready_cover(team, "team") + + resolver = described_class.new(match) + expect(resolver.cover_source).to eq("team") + end + + it "eredita dalla società se mancano squadra e partita" do + attach_ready_cover(club, "club") + + resolver = described_class.new(match) + expect(resolver.cover_source).to eq("club") + end + + it "ignora cover_image senza slate MP4 pronta su disco" do + attach_ready_cover(team, "team") + match.cover_image.attach(io: StringIO.new("img"), filename: "m.png", content_type: "image/png") + + resolver = described_class.new(match) + expect(resolver.cover_source).to eq("team") + end + end +end diff --git a/backend/spec/services/streams/cover_slate_ensurer_spec.rb b/backend/spec/services/streams/cover_slate_ensurer_spec.rb new file mode 100644 index 0000000..bf11367 --- /dev/null +++ b/backend/spec/services/streams/cover_slate_ensurer_spec.rb @@ -0,0 +1,38 @@ +require "rails_helper" + +RSpec.describe Streams::CoverSlateEnsurer do + let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } + let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") } + let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") } + + before do + load Rails.root.join("db/seeds/plans.rb") + Billing::AssignPlan.call(club: club, plan_slug: "premium_full") + match.cover_image.attach( + io: StringIO.new("img"), + filename: "c.png", + content_type: "image/png" + ) + end + + it "genera in sync se manca cover_slate" do + expect(Streams::GenerateCoverSlate).to receive(:call).with(match).and_return(match) + described_class.ensure_for!(match) + end + + it "non rigenera se slate pronta su disco" do + match.cover_slate.attach( + io: StringIO.new("mp4"), + filename: "s.mp4", + content_type: "video/mp4" + ) + path = Streams::CoverSlatePaths.expected_path(match) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "fake") + + expect(Streams::GenerateCoverSlate).not_to receive(:call) + described_class.ensure_for!(match) + ensure + FileUtils.rm_f(path) if defined?(path) && path + end +end diff --git a/backend/spec/services/streams/generate_cover_slate_spec.rb b/backend/spec/services/streams/generate_cover_slate_spec.rb new file mode 100644 index 0000000..5acc8d7 --- /dev/null +++ b/backend/spec/services/streams/generate_cover_slate_spec.rb @@ -0,0 +1,50 @@ +require "rails_helper" + +RSpec.describe Streams::GenerateCoverSlate do + let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } + + before do + load Rails.root.join("db/seeds/plans.rb") + Billing::AssignPlan.call(club: club, plan_slug: "premium_full") + club.cover_image.attach( + io: StringIO.new("fake-image"), + filename: "cover.png", + content_type: "image/png" + ) + end + + it "genera cover_slate e scrive su disco se ffmpeg ok" do + mp4 = Tempfile.new(["slate", ".mp4"]) + mp4.write("fake mp4") + mp4.flush + + allow(Open3).to receive(:capture3).and_return(["", "", instance_double(Process::Status, success?: true)]) + allow(File).to receive(:exist?).and_call_original + allow(File).to receive(:exist?).with(mp4.path).and_return(true) + allow(File).to receive(:size).with(mp4.path).and_return(8) + + service = described_class.new(club) + allow(service).to receive(:encode_slate).and_return(mp4.path) + allow(service).to receive(:download_cover_image).and_return("/tmp/in.png") + + slates_dir = Rails.root.join("tmp/slates-custom-test") + FileUtils.mkdir_p(slates_dir) + allow(Streams::CoverSlatePaths).to receive(:slates_custom_root).and_return(slates_dir.to_s) + + service.call + + expect(club.reload.cover_slate).to be_attached + expect(club.cover_slate.content_type).to eq("video/mp4") + ensure + mp4.close! + FileUtils.rm_rf(slates_dir) if defined?(slates_dir) + end + + it "solleva Error se ffmpeg fallisce" do + allow(Open3).to receive(:capture3).and_return(["", "boom", instance_double(Process::Status, success?: false)]) + + expect { + described_class.new(club).send(:encode_slate, "/tmp/in.png") + }.to raise_error(Streams::GenerateCoverSlate::Error, /ffmpeg slate failed/) + end +end diff --git a/backend/spec/services/streams/slate_distributor_spec.rb b/backend/spec/services/streams/slate_distributor_spec.rb new file mode 100644 index 0000000..c9cf071 --- /dev/null +++ b/backend/spec/services/streams/slate_distributor_spec.rb @@ -0,0 +1,80 @@ +require "rails_helper" + +RSpec.describe Streams::SlateDistributor do + let!(:user) { User.create!(email: "u@test.it", name: "U", password: "Password123", role: "coach") } + let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } + let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") } + let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") } + let!(:node) do + StreamNode.create!( + slug: "stream-home", + hostname: "home", + role: "home", + status: "ready", + provider: "local", + rtmp_base_url: "rtmp://localhost:1935", + hls_base_url: "http://localhost:8888", + api_base_url: "http://mediamtx:9997", + max_publishers: 6, + max_relays: 6 + ) + end + let!(:session) do + StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "idle", stream_node: node) + end + + before do + load Rails.root.join("db/seeds/plans.rb") + Billing::AssignPlan.call(club: club, plan_slug: "premium_full") + end + + def attach_ready_cover(record) + record.cover_image.attach(io: StringIO.new("img"), filename: "c.png", content_type: "image/png") + record.cover_slate.attach(io: StringIO.new("mp4"), filename: "s.mp4", content_type: "video/mp4") + path = Streams::CoverSlatePaths.expected_path(record) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "fake mp4") + path + end + + it "usa slate default senza copertina custom" do + expect(described_class.slate_path_for_session(session)).to eq("/slates/offline.mp4") + end + + it "usa path custom su nodo home quando la slate è su disco" do + attach_ready_cover(club) + custom = "/slates/custom/#{File.basename(Streams::CoverSlatePaths.expected_path(club))}" + expect(described_class.slate_path_for_session(session)).to eq(custom) + end + + it "pusha sul relay agent per nodi cloud" do + cloud = StreamNode.create!( + slug: "ingest-01", + hostname: "ingest-01", + role: "cloud", + status: "ready", + provider: "hetzner", + rtmp_base_url: "rtmp://ingest-01.test:1935", + hls_base_url: "http://localhost:8888", + api_base_url: "http://203.0.113.9:9997", + max_publishers: 4, + max_relays: 4, + metadata: { "public_ip" => "203.0.113.9" } + ) + session.update!(stream_node: cloud) + attach_ready_cover(club) + + http = instance_double(Net::HTTP) + response = instance_double(Net::HTTPResponse, body: "{}", code: "201") + allow(response).to receive(:is_a?).with(Net::HTTPSuccess).and_return(true) + allow(Net::HTTP).to receive(:new).and_return(http) + allow(http).to receive(:open_timeout=) + allow(http).to receive(:read_timeout=) + allow(http).to receive(:request).and_return(response) + + filename = File.basename(Streams::CoverSlatePaths.expected_path(club)) + path = described_class.slate_path_for_session(session) + expect(path).to eq("/slates/custom/#{filename}") + expect(http).to have_received(:request) + end +end diff --git a/backend/spec/services/teams/entitlements_cover_spec.rb b/backend/spec/services/teams/entitlements_cover_spec.rb new file mode 100644 index 0000000..b389882 --- /dev/null +++ b/backend/spec/services/teams/entitlements_cover_spec.rb @@ -0,0 +1,30 @@ +require "rails_helper" + +RSpec.describe Teams::Entitlements do + let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } + let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") } + + before do + load Rails.root.join("db/seeds/plans.rb") + Billing::AssignPlan.call(club: club, plan_slug: "free") + end + + it "non consente copertina custom su free" do + ent = described_class.new(team) + expect(ent.can_use_custom_cover?).to eq(false) + expect { ent.assert_can_upload_cover! }.to raise_error(Teams::EntitlementError, /Premium Full/) + end + + it "consente copertina custom su premium full" do + Billing::AssignPlan.call(club: club, plan_slug: "premium_full") + ent = described_class.new(team) + expect(ent.can_use_custom_cover?).to eq(true) + expect { ent.assert_can_upload_cover! }.not_to raise_error + end + + it "espone custom_cover_enabled in as_json" do + Billing::AssignPlan.call(club: club, plan_slug: "premium_full") + ent = described_class.new(team) + expect(ent.as_json[:custom_cover_enabled]).to eq(true) + end +end diff --git a/infra/docker-compose.prod.yml b/infra/docker-compose.prod.yml index a6dabe4..7164e42 100644 --- a/infra/docker-compose.prod.yml +++ b/infra/docker-compose.prod.yml @@ -299,6 +299,7 @@ services: - ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/recordings:/recordings - ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/active_storage:/app/storage - ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/log:/app/log + - ./slates:/slates - ${HCLOUD_USER_DATA_HOST_DIR:-./stream-node}:/opt/matchlivetv/infra/stream-node:ro garage: diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index bf5176c..bf7b35b 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -238,6 +238,7 @@ services: - ../backend:/app - recordings:/recordings - bundle_cache:/usr/local/bundle + - ./slates:/slates - ./stream-node:/opt/matchlivetv/infra/stream-node:ro # Worker ffmpeg per il secondo MediaMTX (nodo lab locale, RTMP host :11935). diff --git a/infra/slates/custom/.gitkeep b/infra/slates/custom/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/infra/stream-node/cloud-init.yaml b/infra/stream-node/cloud-init.yaml index 5666e43..f02edbd 100644 --- a/infra/stream-node/cloud-init.yaml +++ b/infra/stream-node/cloud-init.yaml @@ -56,6 +56,7 @@ write_files: STREAM_NODE_AGENT_LISTEN=0.0.0.0:9100 STREAM_NODE_LOCAL_HLS_URL=http://127.0.0.1:8888 STREAM_NODE_RELAY_LOG_DIR=/var/log + STREAM_NODE_SLATES_DIR=/slates/custom - path: /opt/stream-node/relay-agent.py permissions: "0755" content: | @@ -77,6 +78,7 @@ write_files: LISTEN = os.environ.get("STREAM_NODE_AGENT_LISTEN", "0.0.0.0:9100") HLS_BASE = os.environ.get("STREAM_NODE_LOCAL_HLS_URL", "http://127.0.0.1:8888").rstrip("/") LOG_DIR = os.environ.get("STREAM_NODE_RELAY_LOG_DIR", "/var/log") + SLATES_DIR = os.environ.get("STREAM_NODE_SLATES_DIR", "/slates/custom") _lock = threading.Lock() # session_id -> {"pid": int, "path": str, "proc": Popen} @@ -250,6 +252,29 @@ write_files: _stop_session(session_id) self._json(200, {"stopped": True, "session_id": session_id}) + def do_PUT(self) -> None: + if not _authorized(self): + self._json(401, {"error": "unauthorized"}) + return + parsed = urlparse(self.path) + if not parsed.path.startswith("/slates/"): + self._json(404, {"error": "not found"}) + return + name = parsed.path.split("/slates/", 1)[1].strip("/") + if not name or "/" in name or ".." in name: + self._json(400, {"error": "invalid slate name"}) + return + length = int(self.headers.get("Content-Length") or 0) + if length <= 0: + self._json(400, {"error": "empty body"}) + return + data = self.rfile.read(length) + os.makedirs(SLATES_DIR, exist_ok=True) + dest = os.path.join(SLATES_DIR, name) + with open(dest, "wb") as out: + out.write(data) + self._json(201, {"path": dest, "bytes": len(data), "name": name}) + def main() -> None: host, port_s = LISTEN.rsplit(":", 1) @@ -265,7 +290,7 @@ write_files: content: | #!/bin/bash set -euo pipefail - mkdir -p /recordings /slates /var/log + mkdir -p /recordings /slates/custom /var/log systemctl enable --now docker for i in $(seq 1 60); do if docker info >/dev/null 2>&1; then break; fi diff --git a/infra/stream-node/relay-agent.py b/infra/stream-node/relay-agent.py index c0d876c..0fbc475 100755 --- a/infra/stream-node/relay-agent.py +++ b/infra/stream-node/relay-agent.py @@ -16,6 +16,7 @@ SECRET = os.environ.get("STREAM_NODE_AGENT_SECRET", "") LISTEN = os.environ.get("STREAM_NODE_AGENT_LISTEN", "0.0.0.0:9100") HLS_BASE = os.environ.get("STREAM_NODE_LOCAL_HLS_URL", "http://127.0.0.1:8888").rstrip("/") LOG_DIR = os.environ.get("STREAM_NODE_RELAY_LOG_DIR", "/var/log") +SLATES_DIR = os.environ.get("STREAM_NODE_SLATES_DIR", "/slates/custom") _lock = threading.Lock() # session_id -> {"pid": int, "path": str, "proc": Popen} @@ -189,6 +190,29 @@ class Handler(BaseHTTPRequestHandler): _stop_session(session_id) self._json(200, {"stopped": True, "session_id": session_id}) + def do_PUT(self) -> None: + if not _authorized(self): + self._json(401, {"error": "unauthorized"}) + return + parsed = urlparse(self.path) + if not parsed.path.startswith("/slates/"): + self._json(404, {"error": "not found"}) + return + name = parsed.path.split("/slates/", 1)[1].strip("/") + if not name or "/" in name or ".." in name: + self._json(400, {"error": "invalid slate name"}) + return + length = int(self.headers.get("Content-Length") or 0) + if length <= 0: + self._json(400, {"error": "empty body"}) + return + data = self.rfile.read(length) + os.makedirs(SLATES_DIR, exist_ok=True) + dest = os.path.join(SLATES_DIR, name) + with open(dest, "wb") as out: + out.write(data) + self._json(201, {"path": dest, "bytes": len(data), "name": name}) + def main() -> None: host, port_s = LISTEN.rsplit(":", 1) diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt index f4afaff..e9b8fbe 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt @@ -85,6 +85,7 @@ data class TeamDto( @Json(name = "phone_download_enabled") val phoneDownloadEnabled: Boolean? = false, @Json(name = "billing_url") val billingUrl: String? = null, @Json(name = "staff_manage_url") val staffManageUrl: String? = null, + @Json(name = "custom_cover_enabled") val customCoverEnabled: Boolean? = false, @Json(name = "logo_url") val logoUrl: String? = null, @Json(name = "primary_color") val primaryColor: String? = null, @Json(name = "secondary_color") val secondaryColor: String? = null, @@ -111,6 +112,7 @@ data class TeamDto( phoneDownloadEnabled = phoneDownloadEnabled ?: false, billingUrl = billingUrl, staffManageUrl = staffManageUrl, + customCoverEnabled = customCoverEnabled ?: false, ) } @@ -140,6 +142,9 @@ data class MatchDto( @Json(name = "home_logo_url") val homeLogoUrl: String? = null, @Json(name = "opponent_primary_color") val opponentPrimaryColor: String? = null, @Json(name = "opponent_logo_url") val opponentLogoUrl: String? = null, + @Json(name = "effective_cover_url") val effectiveCoverUrl: String? = null, + @Json(name = "cover_source") val coverSource: String? = null, + @Json(name = "custom_cover_enabled") val customCoverEnabled: Boolean? = null, ) { fun toDomain() = Match( id = id, @@ -165,6 +170,9 @@ data class MatchDto( homeLogoUrl = homeLogoUrl, opponentPrimaryColor = opponentPrimaryColor, opponentLogoUrl = opponentLogoUrl, + effectiveCoverUrl = effectiveCoverUrl, + coverSource = coverSource ?: "default", + customCoverEnabled = customCoverEnabled ?: false, ) } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt index 3afee9a..9f9b032 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt @@ -91,6 +91,8 @@ interface MatchLiveApi { @Part("match[scoring_rules][points_deciding_set]") pointsDecidingSet: okhttp3.RequestBody?, @Part("match[scoring_rules][min_point_lead]") minPointLead: okhttp3.RequestBody?, @Part opponentLogoFile: MultipartBody.Part?, + @Part coverImage: MultipartBody.Part?, + @Part("remove_cover") removeCover: okhttp3.RequestBody?, ): MatchDto @DELETE("matches/{matchId}") diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt index c03c891..4ac0b28 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt @@ -122,6 +122,8 @@ class MatchRepository( sportKey: String? = null, overlayKind: String? = null, opponentLogoUri: Uri? = null, + coverImageUri: Uri? = null, + removeCover: Boolean = false, ): Match { val body = UpdateMatchBodyFull( opponentName = opponentName, @@ -134,7 +136,7 @@ class MatchRepository( opponentPrimaryColor = opponentPrimaryColor, scoringRules = scoringRules?.toMap().takeIf { !it.isNullOrEmpty() }, ) - if (opponentLogoUri != null) { + if (opponentLogoUri != null || coverImageUri != null || removeCover) { return api.updateMatchMultipart( matchId = matchId, opponentName = MultipartBodies.textPart(opponentName), @@ -146,7 +148,13 @@ class MatchRepository( pointsPerSet = scoringRules?.pointsPerSet?.let { MultipartBodies.textPart(it.toString()) }, pointsDecidingSet = scoringRules?.pointsDecidingSet?.let { MultipartBodies.textPart(it.toString()) }, minPointLead = scoringRules?.minPointLead?.let { MultipartBodies.textPart(it.toString()) }, - opponentLogoFile = MultipartBodies.logoPart(appContext, opponentLogoUri, "opponent_logo_file"), + opponentLogoFile = opponentLogoUri?.let { + MultipartBodies.logoPart(appContext, it, "opponent_logo_file") + }, + coverImage = coverImageUri?.let { + MultipartBodies.logoPart(appContext, it, "cover_image") + }, + removeCover = if (removeCover) MultipartBodies.textPart("1") else null, ).toDomain() } return api.updateMatch( diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt index 2a5b993..be6a914 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt @@ -43,6 +43,7 @@ data class Team( val phoneDownloadEnabled: Boolean = false, val billingUrl: String? = null, val staffManageUrl: String? = null, + val customCoverEnabled: Boolean = false, ) { val canUseYoutube: Boolean get() = youtubeEnabled val isYoutubeReady: Boolean get() = youtubeSelectable @@ -100,6 +101,9 @@ data class Match( val homeLogoUrl: String? = null, val opponentPrimaryColor: String? = null, val opponentLogoUrl: String? = null, + val effectiveCoverUrl: String? = null, + val coverSource: String = "default", + val customCoverEnabled: Boolean = false, ) { val hasActiveSession: Boolean get() = activeSessionId != null diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/MatchCoverSection.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/MatchCoverSection.kt new file mode 100644 index 0000000..e8d2b37 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/MatchCoverSection.kt @@ -0,0 +1,105 @@ +package com.matchlivetv.match_live_tv.ui.wizard + +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.matchlivetv.match_live_tv.R +import com.matchlivetv.match_live_tv.core.resolveMediaUrl +import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton +import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton +import com.matchlivetv.match_live_tv.ui.theme.MatchColors + +@Composable +fun coverSourceLabel(source: String): String = when (source) { + "match" -> stringResource(R.string.wizard_match_cover_source_match) + "team" -> stringResource(R.string.wizard_match_cover_source_team) + "club" -> stringResource(R.string.wizard_match_cover_source_club) + else -> stringResource(R.string.wizard_match_cover_source_default) +} + +@Composable +fun MatchCoverSection( + effectiveCoverUrl: String?, + coverSource: String, + customCoverEnabled: Boolean, + localCoverUri: Uri?, + hasMatchOverride: Boolean, + onPickCover: () -> Unit, + onClearOverride: () -> Unit, + modifier: Modifier = Modifier, +) { + val previewModel = localCoverUri ?: resolveMediaUrl(effectiveCoverUrl) + + Column(modifier.fillMaxWidth()) { + Text( + stringResource(R.string.wizard_match_cover_title), + style = MaterialTheme.typography.labelLarge, + color = MatchColors.TextSecondary, + ) + Text( + stringResource(R.string.wizard_match_cover_source_label, coverSourceLabel(coverSource)), + style = MaterialTheme.typography.bodyMedium, + color = MatchColors.TextSecondary, + modifier = Modifier.padding(top = 4.dp), + ) + Box( + Modifier + .fillMaxWidth() + .padding(top = 12.dp) + .aspectRatio(16f / 9f) + .clip(RoundedCornerShape(12.dp)) + .background(MatchColors.Surface), + ) { + if (previewModel != null) { + AsyncImage( + model = previewModel, + contentDescription = stringResource(R.string.wizard_match_cover_preview_cd), + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.Crop, + ) + } + } + Text( + stringResource(R.string.wizard_match_cover_hint), + style = MaterialTheme.typography.bodyMedium, + color = MatchColors.TextSecondary, + modifier = Modifier.padding(top = 8.dp), + ) + if (customCoverEnabled) { + MatchPrimaryButton( + label = stringResource(R.string.wizard_match_cover_change), + onClick = onPickCover, + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + ) + if (hasMatchOverride || localCoverUri != null) { + MatchSecondaryButton( + label = stringResource(R.string.wizard_match_cover_reset), + onClick = onClearOverride, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + } + } else { + Text( + stringResource(R.string.wizard_match_cover_premium_required), + style = MaterialTheme.typography.bodyMedium, + color = MatchColors.TextSecondary, + modifier = Modifier.padding(top = 12.dp), + ) + } + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepMatchScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepMatchScreen.kt index 7727d4b..554d12e 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepMatchScreen.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepMatchScreen.kt @@ -105,6 +105,11 @@ fun StepMatchScreen( } var homeLogoUri by remember { mutableStateOf(null) } var opponentLogoUri by remember { mutableStateOf(null) } + var coverImageUri by remember { mutableStateOf(null) } + var removeCover by remember { mutableStateOf(false) } + var effectiveCoverUrl by remember { mutableStateOf(match.effectiveCoverUrl) } + var coverSource by remember { mutableStateOf(match.coverSource) } + var customCoverEnabled by remember { mutableStateOf(match.customCoverEnabled) } LaunchedEffect(match.teamId) { runCatching { container.matchRepository.fetchTeam(match.teamId) } @@ -113,10 +118,20 @@ fun StepMatchScreen( if (team != null) { homePrimaryColor = normalizeHexColor(team.primaryColor ?: match.homePrimaryColor) homeLogoUrl = team.logoUrl ?: match.homeLogoUrl + if (!match.customCoverEnabled) { + customCoverEnabled = team.customCoverEnabled + } } } } + val pickCover = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia(), + ) { uri -> + coverImageUri = uri + removeCover = false + } + val pickHomeLogo = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickVisualMedia(), ) { uri -> homeLogoUri = uri } @@ -230,6 +245,24 @@ fun StepMatchScreen( onClearLogo = { opponentLogoUri = null }, ) Spacer(Modifier.height(16.dp)) + MatchCoverSection( + effectiveCoverUrl = effectiveCoverUrl, + coverSource = when { + coverImageUri != null -> "match" + else -> coverSource + }, + customCoverEnabled = customCoverEnabled, + localCoverUri = coverImageUri, + hasMatchOverride = match.coverSource == "match", + onPickCover = { + pickCover.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + }, + onClearOverride = { + coverImageUri = null + removeCover = true + }, + ) + Spacer(Modifier.height(16.dp)) MatchOutlinedField( value = location, onValueChange = { location = it }, @@ -500,9 +533,13 @@ fun StepMatchScreen( "" }, opponentLogoUri = opponentLogoUri, + coverImageUri = coverImageUri, + removeCover = removeCover, ) }.onSuccess { updated -> container.wizardSession.match = updated + effectiveCoverUrl = updated.effectiveCoverUrl + coverSource = updated.coverSource onNext() }.onFailure { onError(it.message ?: saveGenericError) diff --git a/native/android/app/src/main/res/values-de/strings.xml b/native/android/app/src/main/res/values-de/strings.xml index fae7a49..caf9f9b 100644 --- a/native/android/app/src/main/res/values-de/strings.xml +++ b/native/android/app/src/main/res/values-de/strings.xml @@ -64,6 +64,17 @@ Ort Liga (optional) Z. B. Serie C, Sommerturnier — wird in Beschreibung und Overlay verwendet. + Pausenbild + Quelle: %1$s + Match Live TV + Verein + Team + Dieses Spiel + Wird bei Pause oder ohne Signal angezeigt, bis der Stream weitergeht. + Bild für dieses Spiel ändern + Standardbild verwenden + Individuelles Sponsor-Bild mit Premium Full + Bildvorschau Geplant für Individuelles Video-Overlay Individuelle Punkteregeln diff --git a/native/android/app/src/main/res/values-en/strings.xml b/native/android/app/src/main/res/values-en/strings.xml index 31bd734..fe44dfa 100644 --- a/native/android/app/src/main/res/values-en/strings.xml +++ b/native/android/app/src/main/res/values-en/strings.xml @@ -64,6 +64,17 @@ Location Category (optional) E.g. Serie C, summer tournament — we\'ll use it in the description and overlay. + Pause cover + Source: %1$s + Match Live TV + Club + Team + This match + Shown while paused or offline until the stream resumes. + Change cover for this match + Use default cover + Custom sponsor cover requires Premium Full + Cover preview Scheduled for Custom video overlay Custom scoring rules diff --git a/native/android/app/src/main/res/values-es/strings.xml b/native/android/app/src/main/res/values-es/strings.xml index 97aa6b1..4cc36fb 100644 --- a/native/android/app/src/main/res/values-es/strings.xml +++ b/native/android/app/src/main/res/values-es/strings.xml @@ -64,6 +64,17 @@ Lugar Categoría (opcional) Ej. Serie C, torneo de verano — lo usaremos en la descripción y el overlay. + Imagen de pausa + Origen: %1$s + Match Live TV + Club + Equipo + Este partido + Se muestra en pausa o sin señal hasta que retome la transmisión. + Cambiar imagen para este partido + Usar imagen predeterminada + Imagen de patrocinio personalizada con Premium Full + Vista previa de la imagen Programado para Overlay de vídeo personalizado Reglas de puntuación personalizadas diff --git a/native/android/app/src/main/res/values-fr/strings.xml b/native/android/app/src/main/res/values-fr/strings.xml index 7ac9dfd..c8ec168 100644 --- a/native/android/app/src/main/res/values-fr/strings.xml +++ b/native/android/app/src/main/res/values-fr/strings.xml @@ -64,6 +64,17 @@ Lieu Championnat (facultatif) Ex. Serie C, tournoi d\'été — utilisé dans la description et l\'overlay. + Image de pause + Source : %1$s + Match Live TV + Club + Équipe + Ce match + Affichée en pause ou sans signal jusqu\'à la reprise du direct. + Changer l\'image pour ce match + Utiliser l\'image par défaut + Image sponsor personnalisée avec Premium Full + Aperçu de l\'image Programmé pour Overlay vidéo personnalisé Règles de score personnalisées diff --git a/native/android/app/src/main/res/values/strings.xml b/native/android/app/src/main/res/values/strings.xml index d547316..b9b5ad8 100644 --- a/native/android/app/src/main/res/values/strings.xml +++ b/native/android/app/src/main/res/values/strings.xml @@ -65,6 +65,17 @@ Luogo Campionato (facoltativo) Es. Serie C, torneo estivo — lo useremo in descrizione e overlay. + Copertina in pausa + Origine: %1$s + Match Live TV + Società + Squadra + Questa partita + Mostrata in pausa o senza segnale finché non riparte la diretta. + Cambia copertina per questa partita + Usa quella predefinita + Copertina sponsor personalizzata con Premium Full + Anteprima copertina Programmata per Overlay video personalizzato Regole punteggio personalizzate diff --git a/native/ios/MatchLiveTv.xcodeproj/project.pbxproj b/native/ios/MatchLiveTv.xcodeproj/project.pbxproj index 113872e..1f40e5a 100644 --- a/native/ios/MatchLiveTv.xcodeproj/project.pbxproj +++ b/native/ios/MatchLiveTv.xcodeproj/project.pbxproj @@ -57,6 +57,7 @@ 713E0129656649FC8630FDC6 /* LivePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46CE696CAB994E2C8E399776 /* LivePreviewView.swift */; }; 7575030B9AEB4DEBAD986A23 /* MatchColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2996990D08341ABA52BE287 /* MatchColors.swift */; }; 777B73E69F8A4DC6A8E7C1DF /* StepMatchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB037B1369B454D84F6850D /* StepMatchScreen.swift */; }; + A1B2C3D4E5F60718293A4B5D /* MatchCoverSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4E5F60718293A4B5D6E /* MatchCoverSection.swift */; }; 8723E1F8443A46B3AF49E8A3 /* ScoreState.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA08E23008964D02AB59377E /* ScoreState.swift */; }; 8C8FEABCD83C422BA5A5DEA3 /* MatchLiveTv/Resources/de.lproj in Resources */ = {isa = PBXBuildFile; fileRef = EEFE07D4B6FD44199A5B9C25 /* MatchLiveTv/Resources/de.lproj */; }; 9391C9EA0B834DC8956483DF /* ActionCableClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98526C26B2584F55AADF0FFF /* ActionCableClient.swift */; }; @@ -194,6 +195,7 @@ E860A0E35362427DA84F8972 /* BroadcastControlsOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastControlsOverlay.swift; path = MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift; sourceTree = ""; }; EA08E23008964D02AB59377E /* ScoreState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreState.swift; path = MatchLiveTv/Domain/ScoreState.swift; sourceTree = ""; }; EAB037B1369B454D84F6850D /* StepMatchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StepMatchScreen.swift; path = MatchLiveTv/UI/Wizard/StepMatchScreen.swift; sourceTree = ""; }; + B2C3D4E5F60718293A4B5D6E /* MatchCoverSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchCoverSection.swift; path = MatchLiveTv/UI/Wizard/MatchCoverSection.swift; sourceTree = ""; }; EE0A49E8D59947448DAA7851 /* AuthRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AuthRepository.swift; path = MatchLiveTv/Data/Repository/AuthRepository.swift; sourceTree = ""; }; EEFE07D4B6FD44199A5B9C25 /* MatchLiveTv/Resources/de.lproj */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MatchLiveTv/Resources/de.lproj; sourceTree = ""; }; F438E7E164F44C7B856C4808 /* LiveBroadcastCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveBroadcastCoordinator.swift; path = MatchLiveTv/Streaming/LiveBroadcastCoordinator.swift; sourceTree = ""; }; @@ -324,6 +326,7 @@ 2B5C00419D09442FA8ED60F3 /* ShareSheet.swift */, D2996990D08341ABA52BE287 /* MatchColors.swift */, EAB037B1369B454D84F6850D /* StepMatchScreen.swift */, + B2C3D4E5F60718293A4B5D6E /* MatchCoverSection.swift */, 1079231B9A4E446AA9AD6C0A /* StepNetworkTestScreen.swift */, 7EC0179FE6BC4323B1F9E1D3 /* StepTransmissionScreen.swift */, 6FBBE0F1399840FFA0E7EF2E /* TeamBrandingEditor.swift */, @@ -502,6 +505,7 @@ 1A82C6F343F54284AFA8FECE /* ShareSheet.swift in Sources */, 7575030B9AEB4DEBAD986A23 /* MatchColors.swift in Sources */, 777B73E69F8A4DC6A8E7C1DF /* StepMatchScreen.swift in Sources */, + A1B2C3D4E5F60718293A4B5D /* MatchCoverSection.swift in Sources */, F67C0C88237F41DE8EDF6CB8 /* StepNetworkTestScreen.swift in Sources */, 2B8E5D9945364094B74165DE /* StepTransmissionScreen.swift in Sources */, 45776A6A76D04EF5A0C0D187 /* TeamBrandingEditor.swift in Sources */, diff --git a/native/ios/MatchLiveTv/Core/AppLanguage.swift b/native/ios/MatchLiveTv/Core/AppLanguage.swift index a470006..9100ac7 100644 --- a/native/ios/MatchLiveTv/Core/AppLanguage.swift +++ b/native/ios/MatchLiveTv/Core/AppLanguage.swift @@ -323,6 +323,16 @@ enum L10n { "wizard.error.youtube.unavailable": "YouTube non disponibile per questa squadra", "wizard.match.away.team.label": "Squadra avversaria", "wizard.match.category.hint": "Es. Serie C, torneo estivo — lo useremo in descrizione e overlay.", + "wizard.match.cover.title": "Copertina in pausa", + "wizard.match.cover.source.label": "Origine: %1$@", + "wizard.match.cover.source.default": "Match Live TV", + "wizard.match.cover.source.club": "Società", + "wizard.match.cover.source.team": "Squadra", + "wizard.match.cover.source.match": "Questa partita", + "wizard.match.cover.hint": "Mostrata in pausa o senza segnale finché non riparte la diretta.", + "wizard.match.cover.change": "Cambia copertina per questa partita", + "wizard.match.cover.reset": "Usa quella predefinita", + "wizard.match.cover.premium.required": "Copertina sponsor personalizzata con Premium Full", "wizard.match.category.label": "Campionato (facoltativo)", "wizard.match.custom.overlay.label": "Overlay video personalizzato", "wizard.match.custom.rules.label": "Regole punteggio personalizzate", @@ -628,6 +638,16 @@ enum L10n { "wizard.error.youtube.unavailable": "YouTube not available for this team", "wizard.match.away.team.label": "Away team", "wizard.match.category.hint": "E.g. Serie C, summer tournament — we'll use it in the description and overlay.", + "wizard.match.cover.title": "Pause cover", + "wizard.match.cover.source.label": "Source: %1$@", + "wizard.match.cover.source.default": "Match Live TV", + "wizard.match.cover.source.club": "Club", + "wizard.match.cover.source.team": "Team", + "wizard.match.cover.source.match": "This match", + "wizard.match.cover.hint": "Shown while paused or offline until the stream resumes.", + "wizard.match.cover.change": "Change cover for this match", + "wizard.match.cover.reset": "Use default cover", + "wizard.match.cover.premium.required": "Custom sponsor cover requires Premium Full", "wizard.match.category.label": "Category (optional)", "wizard.match.custom.overlay.label": "Custom video overlay", "wizard.match.custom.rules.label": "Custom scoring rules", @@ -933,6 +953,16 @@ enum L10n { "wizard.error.youtube.unavailable": "YouTube non disponible pour cette équipe", "wizard.match.away.team.label": "Équipe adverse", "wizard.match.category.hint": "Ex. Serie C, tournoi d'été — utilisé dans la description et l'overlay.", + "wizard.match.cover.title": "Image de pause", + "wizard.match.cover.source.label": "Source : %1$@", + "wizard.match.cover.source.default": "Match Live TV", + "wizard.match.cover.source.club": "Club", + "wizard.match.cover.source.team": "Équipe", + "wizard.match.cover.source.match": "Ce match", + "wizard.match.cover.hint": "Affichée en pause ou sans signal jusqu'à la reprise du direct.", + "wizard.match.cover.change": "Changer l'image pour ce match", + "wizard.match.cover.reset": "Utiliser l'image par défaut", + "wizard.match.cover.premium.required": "Image sponsor personnalisée avec Premium Full", "wizard.match.category.label": "Championnat (facultatif)", "wizard.match.custom.overlay.label": "Overlay vidéo personnalisé", "wizard.match.custom.rules.label": "Règles de score personnalisées", @@ -1238,6 +1268,16 @@ enum L10n { "wizard.error.youtube.unavailable": "YouTube für dieses Team nicht verfügbar", "wizard.match.away.team.label": "Gastteam", "wizard.match.category.hint": "Z. B. Serie C, Sommerturnier — wird in Beschreibung und Overlay verwendet.", + "wizard.match.cover.title": "Pausenbild", + "wizard.match.cover.source.label": "Quelle: %1$@", + "wizard.match.cover.source.default": "Match Live TV", + "wizard.match.cover.source.club": "Verein", + "wizard.match.cover.source.team": "Team", + "wizard.match.cover.source.match": "Dieses Spiel", + "wizard.match.cover.hint": "Wird bei Pause oder ohne Signal angezeigt, bis der Stream weitergeht.", + "wizard.match.cover.change": "Bild für dieses Spiel ändern", + "wizard.match.cover.reset": "Standardbild verwenden", + "wizard.match.cover.premium.required": "Individuelles Sponsor-Bild mit Premium Full", "wizard.match.category.label": "Liga (optional)", "wizard.match.custom.overlay.label": "Individuelles Video-Overlay", "wizard.match.custom.rules.label": "Individuelle Punkteregeln", @@ -1543,6 +1583,16 @@ enum L10n { "wizard.error.youtube.unavailable": "YouTube no disponible para este equipo", "wizard.match.away.team.label": "Equipo visitante", "wizard.match.category.hint": "Ej. Serie C, torneo de verano — lo usaremos en la descripción y el overlay.", + "wizard.match.cover.title": "Imagen de pausa", + "wizard.match.cover.source.label": "Origen: %1$@", + "wizard.match.cover.source.default": "Match Live TV", + "wizard.match.cover.source.club": "Club", + "wizard.match.cover.source.team": "Equipo", + "wizard.match.cover.source.match": "Este partido", + "wizard.match.cover.hint": "Se muestra en pausa o sin señal hasta que retome la transmisión.", + "wizard.match.cover.change": "Cambiar imagen para este partido", + "wizard.match.cover.reset": "Usar imagen predeterminada", + "wizard.match.cover.premium.required": "Imagen de patrocinio personalizada con Premium Full", "wizard.match.category.label": "Categoría (opcional)", "wizard.match.custom.overlay.label": "Overlay de vídeo personalizado", "wizard.match.custom.rules.label": "Reglas de puntuación personalizadas", diff --git a/native/ios/MatchLiveTv/Data/API/ApiDtos.swift b/native/ios/MatchLiveTv/Data/API/ApiDtos.swift index daf75fb..b2a8eb3 100644 --- a/native/ios/MatchLiveTv/Data/API/ApiDtos.swift +++ b/native/ios/MatchLiveTv/Data/API/ApiDtos.swift @@ -101,6 +101,7 @@ struct TeamDto: Decodable { let phoneDownloadEnabled: Bool? let billingUrl: String? let staffManageUrl: String? + let customCoverEnabled: Bool? let logoUrl: String? let primaryColor: String? let secondaryColor: String? @@ -127,7 +128,8 @@ struct TeamDto: Decodable { recordingsEnabled: recordingsEnabled ?? false, phoneDownloadEnabled: phoneDownloadEnabled ?? false, billingUrl: billingUrl, - staffManageUrl: staffManageUrl + staffManageUrl: staffManageUrl, + customCoverEnabled: customCoverEnabled ?? false ) } } @@ -157,6 +159,9 @@ struct MatchDto: Decodable { let homeLogoUrl: String? let opponentPrimaryColor: String? let opponentLogoUrl: String? + let effectiveCoverUrl: String? + let coverSource: String? + let customCoverEnabled: Bool? func toDomain() -> Match { Match( @@ -182,7 +187,10 @@ struct MatchDto: Decodable { homeSecondaryColor: homeSecondaryColor, homeLogoUrl: homeLogoUrl, opponentPrimaryColor: opponentPrimaryColor, - opponentLogoUrl: opponentLogoUrl + opponentLogoUrl: opponentLogoUrl, + effectiveCoverUrl: effectiveCoverUrl, + coverSource: coverSource ?? "default", + customCoverEnabled: customCoverEnabled ?? false ) } } diff --git a/native/ios/MatchLiveTv/Data/Repository/MatchRepository.swift b/native/ios/MatchLiveTv/Data/Repository/MatchRepository.swift index c8c77be..a0b6f5d 100644 --- a/native/ios/MatchLiveTv/Data/Repository/MatchRepository.swift +++ b/native/ios/MatchLiveTv/Data/Repository/MatchRepository.swift @@ -101,7 +101,9 @@ final class MatchRepository { scoringRules: ScoringRules?, sportKey: String? = nil, overlayKind: String? = nil, - opponentLogoImage: UIImage? = nil + opponentLogoImage: UIImage? = nil, + coverImage: UIImage? = nil, + removeCover: Bool = false ) async throws -> Match { let rulesMap = scoringRules?.toMap(setsToWin: setsToWin) let body = UpdateMatchBodyFull( @@ -115,7 +117,8 @@ final class MatchRepository { opponentPrimaryColor: opponentPrimaryColor, scoringRules: rulesMap ) - if let opponentLogoImage, let data = opponentLogoImage.pngData() { + let needsMultipart = opponentLogoImage != nil || coverImage != nil || removeCover + if needsMultipart { var fields: [MultipartField] = [ .text("match[opponent_name]", opponentName), .text("match[sets_to_win]", "\(setsToWin)"), @@ -129,7 +132,15 @@ final class MatchRepository { fields.append(.text("match[scoring_rules][points_deciding_set]", "\(rules.pointsDecidingSet)")) fields.append(.text("match[scoring_rules][min_point_lead]", "\(rules.minPointLead)")) } - fields.append(.file("opponent_logo_file", data, filename: "opponent.png", mime: "image/png")) + if let opponentLogoImage, let data = opponentLogoImage.pngData() { + fields.append(.file("opponent_logo_file", data, filename: "opponent.png", mime: "image/png")) + } + if let coverImage, let data = coverImage.pngData() { + fields.append(.file("cover_image", data, filename: "cover.png", mime: "image/png")) + } + if removeCover { + fields.append(.text("remove_cover", "1")) + } return try await api.updateMatchMultipart(id: matchId, fields: fields).toDomain() } return try await api.updateMatch(id: matchId, body: UpdateMatchRequestFull(match: body)).toDomain() diff --git a/native/ios/MatchLiveTv/Domain/Models.swift b/native/ios/MatchLiveTv/Domain/Models.swift index db00d8c..b659bc5 100644 --- a/native/ios/MatchLiveTv/Domain/Models.swift +++ b/native/ios/MatchLiveTv/Domain/Models.swift @@ -44,6 +44,7 @@ struct Team: Identifiable, Equatable, Sendable { let phoneDownloadEnabled: Bool let billingUrl: String? let staffManageUrl: String? + let customCoverEnabled: Bool var canUseYoutube: Bool { youtubeEnabled } var isYoutubeReady: Bool { youtubeSelectable } @@ -95,6 +96,9 @@ struct Match: Identifiable, Equatable, Sendable { let homeLogoUrl: String? let opponentPrimaryColor: String? let opponentLogoUrl: String? + let effectiveCoverUrl: String? + let coverSource: String + let customCoverEnabled: Bool var hasActiveSession: Bool { activeSessionId != nil } diff --git a/native/ios/MatchLiveTv/UI/Wizard/MatchCoverSection.swift b/native/ios/MatchLiveTv/UI/Wizard/MatchCoverSection.swift new file mode 100644 index 0000000..7e4078f --- /dev/null +++ b/native/ios/MatchLiveTv/UI/Wizard/MatchCoverSection.swift @@ -0,0 +1,97 @@ +import PhotosUI +import SwiftUI + +struct MatchCoverSection: View { + let effectiveCoverUrl: String? + let coverSource: String + let customCoverEnabled: Bool + @Binding var localCoverImage: UIImage? + let hasMatchOverride: Bool + let onClearOverride: () -> Void + + @State private var coverPickerItem: PhotosPickerItem? + + private var sourceLabel: String { + switch coverSource { + case "match": return L10n.t("wizard.match.cover.source.match") + case "team": return L10n.t("wizard.match.cover.source.team") + case "club": return L10n.t("wizard.match.cover.source.club") + default: return L10n.t("wizard.match.cover.source.default") + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text(L10n.t("wizard.match.cover.title")) + .font(MatchTypography.labelLarge) + .foregroundStyle(MatchColors.textSecondary) + Text(L10n.t("wizard.match.cover.source.label", sourceLabel)) + .font(MatchTypography.bodyMedium) + .foregroundStyle(MatchColors.textSecondary) + .padding(.top, 4) + + Group { + if let localCoverImage { + Image(uiImage: localCoverImage) + .resizable() + .scaledToFill() + } else if let urlString = MediaUrl.resolve(effectiveCoverUrl), let url = URL(string: urlString) { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image.resizable().scaledToFill() + default: + Color.clear + } + } + } else { + Color.clear + } + } + .frame(maxWidth: .infinity) + .aspectRatio(16 / 9, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .background(MatchColors.surface) + .padding(.top, 12) + + Text(L10n.t("wizard.match.cover.hint")) + .font(MatchTypography.bodyMedium) + .foregroundStyle(MatchColors.textSecondary) + .padding(.top, 8) + + if customCoverEnabled { + PhotosPicker(selection: $coverPickerItem, matching: .images) { + Text(L10n.t("wizard.match.cover.change")) + .font(MatchTypography.labelLarge) + .frame(maxWidth: .infinity) + .frame(height: 52) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1)) + } + .buttonStyle(.plain) + .padding(.top, 12) + + if hasMatchOverride || localCoverImage != nil { + MatchSecondaryButton( + label: L10n.t("wizard.match.cover.reset"), + action: onClearOverride + ) + .padding(.top, 8) + } + } else { + Text(L10n.t("wizard.match.cover.premium.required")) + .font(MatchTypography.bodyMedium) + .foregroundStyle(MatchColors.textSecondary) + .padding(.top, 12) + } + } + .onChange(of: coverPickerItem) { item in + guard let item else { return } + Task { + if let data = try? await item.loadTransferable(type: Data.self), + let image = UIImage(data: data) { + await MainActor.run { localCoverImage = image } + } + } + } + } +} diff --git a/native/ios/MatchLiveTv/UI/Wizard/StepMatchScreen.swift b/native/ios/MatchLiveTv/UI/Wizard/StepMatchScreen.swift index 4c95812..714622b 100644 --- a/native/ios/MatchLiveTv/UI/Wizard/StepMatchScreen.swift +++ b/native/ios/MatchLiveTv/UI/Wizard/StepMatchScreen.swift @@ -53,6 +53,11 @@ struct StepMatchScreen: View { @State private var opponentPrimaryColor: String @State private var homeLogoImage: UIImage? @State private var opponentLogoImage: UIImage? + @State private var coverImage: UIImage? + @State private var removeCover = false + @State private var effectiveCoverUrl: String? + @State private var coverSource: String + @State private var customCoverEnabled: Bool @State private var saving = false @State private var sports: [SportOption] = [] @State private var customRules = false @@ -84,6 +89,9 @@ struct StepMatchScreen: View { _opponentLogoUrl = State(initialValue: match.opponentLogoUrl) _homePrimaryColor = State(initialValue: ColorHex.normalizeHexColor(match.homePrimaryColor)) _opponentPrimaryColor = State(initialValue: ColorHex.normalizeHexColor(match.opponentPrimaryColor, fallback: WizardDefaults.opponentColor)) + _effectiveCoverUrl = State(initialValue: match.effectiveCoverUrl) + _coverSource = State(initialValue: match.coverSource) + _customCoverEnabled = State(initialValue: match.customCoverEnabled) _setsToWin = State(initialValue: min(max(match.setsToWin, 2), 3)) _pointsPerSet = State(initialValue: rules?.pointsPerSet ?? WizardDefaults.pointsPerSet) _pointsDecidingSet = State(initialValue: rules?.pointsDecidingSet ?? WizardDefaults.pointsDecidingSet) @@ -150,6 +158,19 @@ struct StepMatchScreen: View { ) .padding(.bottom, 16) + MatchCoverSection( + effectiveCoverUrl: effectiveCoverUrl, + coverSource: coverImage != nil ? "match" : coverSource, + customCoverEnabled: customCoverEnabled, + localCoverImage: $coverImage, + hasMatchOverride: match.coverSource == "match", + onClearOverride: { + coverImage = nil + removeCover = true + } + ) + .padding(.bottom, 16) + WizardOutlinedField(label: L10n.t("wizard.match.location.label"), text: $location) .padding(.bottom, 12) @@ -213,9 +234,12 @@ struct StepMatchScreen: View { .task(id: match.id) { if let cached = container.wizardSession.team { applyHomeTeam(cached) - } else if let team = await container.matchRepository.fetchTeam(teamId: match.teamId) { + } else if let team = await container.matchRepository.fetchTeam(teamId: match.teamId) { container.wizardSession.team = team applyHomeTeam(team) + if !match.customCoverEnabled { + customCoverEnabled = team.customCoverEnabled + } } if let loaded = try? await container.matchRepository.fetchSports() { sports = loaded @@ -228,6 +252,9 @@ struct StepMatchScreen: View { selectedOverlay = defaultOverlay } } + .onChange(of: coverImage) { image in + if image != nil { removeCover = false } + } .onChange(of: customOverlay) { enabled in if !enabled { selectedOverlay = defaultOverlay @@ -414,9 +441,13 @@ struct StepMatchScreen: View { scoringRules: resolvedRules, sportKey: effectiveSportKey, overlayKind: overlayKind, - opponentLogoImage: opponentLogoImage + opponentLogoImage: opponentLogoImage, + coverImage: coverImage, + removeCover: removeCover ) container.wizardSession.match = updated + effectiveCoverUrl = updated.effectiveCoverUrl + coverSource = updated.coverSource onNext() } catch { if let message = UserFacingError.message(for: error) { diff --git a/native/ios/MatchLiveTvTests/MatchScoringRulesTests.swift b/native/ios/MatchLiveTvTests/MatchScoringRulesTests.swift index f1694ab..6703133 100644 --- a/native/ios/MatchLiveTvTests/MatchScoringRulesTests.swift +++ b/native/ios/MatchLiveTvTests/MatchScoringRulesTests.swift @@ -26,7 +26,10 @@ final class MatchScoringRulesTests: XCTestCase { homeSecondaryColor: nil, homeLogoUrl: nil, opponentPrimaryColor: "#1E3A8A", - opponentLogoUrl: nil + opponentLogoUrl: nil, + effectiveCoverUrl: nil, + coverSource: "default", + customCoverEnabled: false ) } @@ -87,7 +90,10 @@ final class MatchScoringRulesTests: XCTestCase { homeSecondaryColor: nil, homeLogoUrl: nil, opponentPrimaryColor: nil, - opponentLogoUrl: nil + opponentLogoUrl: nil, + effectiveCoverUrl: nil, + coverSource: "default", + customCoverEnabled: false ) XCTAssertFalse(MatchScoringContext(match: basket).usesSetLogic) }