Riduce reload MediaMTX in live e allinea ingest mobile al preset qualità.
Evita patch recording ripetute che distruggevano i muxer HLS (500 ops) e fa encoderare 1080p/720p con codec e bitrate coerenti al wizard. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,6 +2,8 @@ module Mediamtx
|
|||||||
# MediaMTX in produzione è distroless: runOnReady con wget non funziona.
|
# MediaMTX in produzione è distroless: runOnReady con wget non funziona.
|
||||||
# Sincronizziamo lo stato Rails leggendo l'API paths (poll da /live/:id/status.json).
|
# Sincronizziamo lo stato Rails leggendo l'API paths (poll da /live/:id/status.json).
|
||||||
class PublisherSync
|
class PublisherSync
|
||||||
|
RECORDING_PATCH_COOLDOWN = -> { ENV.fetch("MEDIAMTX_RECORDING_PATCH_COOLDOWN_SECS", "45").to_i.seconds }
|
||||||
|
|
||||||
def initialize(session)
|
def initialize(session)
|
||||||
@session = session
|
@session = session
|
||||||
end
|
end
|
||||||
@@ -9,17 +11,15 @@ module Mediamtx
|
|||||||
def call
|
def call
|
||||||
return @session if @session.terminal?
|
return @session if @session.terminal?
|
||||||
|
|
||||||
info = Mediamtx::Client.new.list_paths.find { |i| i["name"] == @session.mediamtx_path_name }
|
path_info = Mediamtx::PublisherOnline.path_info(@session)
|
||||||
publisher_online = Mediamtx::PublisherOnline.active_path?(info)
|
publisher_online = Mediamtx::PublisherOnline.active_path?(path_info)
|
||||||
|
|
||||||
if publisher_online
|
if publisher_online
|
||||||
clear_publisher_misses!(@session.id)
|
clear_publisher_misses!(@session.id)
|
||||||
if @session.paused?
|
if @session.paused?
|
||||||
# RTMP ancora connesso in pausa: non forzare live/reconnect.
|
# RTMP ancora connesso in pausa: non forzare live/reconnect.
|
||||||
sync_path_recording!(@session, enabled: false)
|
|
||||||
else
|
else
|
||||||
sync_path_recording!(@session, enabled: true)
|
enable_live_path_once!(@session)
|
||||||
enable_live_path!(@session)
|
|
||||||
if @session.may_go_live?
|
if @session.may_go_live?
|
||||||
@session.go_live!
|
@session.go_live!
|
||||||
@session.reload
|
@session.reload
|
||||||
@@ -34,17 +34,18 @@ module Mediamtx
|
|||||||
elsif !@session.paused? && @session.live? && @session.may_lose_connection?
|
elsif !@session.paused? && @session.live? && @session.may_lose_connection?
|
||||||
if publisher_offline_sustained?(@session.id)
|
if publisher_offline_sustained?(@session.id)
|
||||||
clear_publisher_misses!(@session.id)
|
clear_publisher_misses!(@session.id)
|
||||||
sync_path_recording!(@session, enabled: false)
|
disable_recording_on_path!(@session, path_info: path_info)
|
||||||
restore_slate_path!(@session)
|
restore_slate_path!(@session)
|
||||||
@session.lose_connection!
|
@session.lose_connection!
|
||||||
schedule_timeout unless @session.paused?
|
schedule_timeout unless @session.paused?
|
||||||
end
|
end
|
||||||
elsif !publisher_online && @session.status.in?(%w[reconnecting connecting]) && !@session.paused?
|
elsif !publisher_online && @session.status.in?(%w[reconnecting connecting]) && !@session.paused?
|
||||||
sync_path_recording!(@session, enabled: false)
|
# Non togliere recording qui: un gap RTMP breve non deve ricaricare la config MediaMTX.
|
||||||
restore_slate_path!(@session)
|
restore_slate_path!(@session)
|
||||||
end
|
end
|
||||||
|
|
||||||
sync_path_recording!(@session, enabled: false) if @session.paused? && !publisher_online
|
desired_recording = recording_should_be_active?(publisher_online)
|
||||||
|
sync_path_recording!(@session, enabled: desired_recording, path_info: path_info)
|
||||||
|
|
||||||
@session.reload.tap do |session|
|
@session.reload.tap do |session|
|
||||||
schedule_youtube_relay_if_needed(session)
|
schedule_youtube_relay_if_needed(session)
|
||||||
@@ -79,14 +80,15 @@ module Mediamtx
|
|||||||
Youtube::LivePipeline.schedule!(session, force: force)
|
Youtube::LivePipeline.schedule!(session, force: force)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Match Live TV: lascia alwaysAvailable attivo — MediaMTX usa il publisher quando c'è
|
def enable_live_path_once!(session)
|
||||||
# e la copertina nei gap RTMP (telefono instabile). Disattivare la slate lasciava HLS
|
|
||||||
# senza stream ("no stream is available") e il player web in pausa/spinner.
|
|
||||||
def enable_live_path!(session)
|
|
||||||
return unless session.platform == "youtube"
|
return unless session.platform == "youtube"
|
||||||
|
|
||||||
|
key = format("youtube:slate_disabled:%s", session.id)
|
||||||
|
return unless redis.set(key, "1", nx: true, ex: 48.hours.to_i)
|
||||||
|
|
||||||
Client.new.set_always_available(session, enabled: false)
|
Client.new.set_always_available(session, enabled: false)
|
||||||
rescue Client::Error => e
|
rescue Client::Error => e
|
||||||
|
redis.del(format("youtube:slate_disabled:%s", session.id))
|
||||||
Rails.logger.warn("[PublisherSync] disable slate session=#{session.id}: #{e.message}")
|
Rails.logger.warn("[PublisherSync] disable slate session=#{session.id}: #{e.message}")
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -98,23 +100,62 @@ module Mediamtx
|
|||||||
Rails.logger.warn("[PublisherSync] enable slate session=#{session.id}: #{e.message}")
|
Rails.logger.warn("[PublisherSync] enable slate session=#{session.id}: #{e.message}")
|
||||||
end
|
end
|
||||||
|
|
||||||
# In produzione i webhook MediaMTX (wget) non girano: la registrazione va accesa qui.
|
def recording_should_be_active?(publisher_online)
|
||||||
def sync_path_recording!(session, enabled:)
|
return false unless @session.match.team.entitlements.recording_enabled_for_mediamtx?
|
||||||
|
return false if @session.paused? || @session.terminal?
|
||||||
|
return false unless publisher_online
|
||||||
|
return false unless @session.status.in?(%w[live reconnecting])
|
||||||
|
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def disable_recording_on_path!(session, path_info:)
|
||||||
|
sync_path_recording!(session, enabled: false, path_info: path_info, force: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
# In produzione i webhook MediaMTX (wget) non girano: la registrazione va accesa al primo go-live.
|
||||||
|
# Evitiamo patch ripetute: ogni reload config distrugge i muxer HLS (500 sul proxy /hls/).
|
||||||
|
def sync_path_recording!(session, enabled:, path_info: nil, force: false)
|
||||||
return unless session.match.team.entitlements.recording_enabled_for_mediamtx?
|
return unless session.match.team.entitlements.recording_enabled_for_mediamtx?
|
||||||
|
|
||||||
key = recording_state_key(session.id)
|
key = recording_state_key(session.id)
|
||||||
desired = enabled ? "1" : "0"
|
desired = enabled ? "1" : "0"
|
||||||
return if redis.get(key) == desired
|
return if redis.get(key) == desired
|
||||||
|
return if path_info && path_recording_active?(path_info) == enabled
|
||||||
|
|
||||||
|
if !enabled && !force && recording_patch_on_cooldown?(session.id)
|
||||||
|
Rails.logger.debug("[PublisherSync] skip recording patch session=#{session.id} (cooldown)")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
Client.new.set_path_recording(session, enabled: enabled)
|
Client.new.set_path_recording(session, enabled: enabled)
|
||||||
redis.set(key, desired, ex: 48.hours.to_i)
|
redis.set(key, desired, ex: 48.hours.to_i)
|
||||||
|
mark_recording_patch!(session.id)
|
||||||
rescue Client::Error => e
|
rescue Client::Error => e
|
||||||
Rails.logger.warn("[PublisherSync] recording enabled=#{enabled} session=#{session.id}: #{e.message}")
|
Rails.logger.warn("[PublisherSync] recording enabled=#{enabled} session=#{session.id}: #{e.message}")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def path_recording_active?(path_info)
|
||||||
|
path_info["record"] == true
|
||||||
|
end
|
||||||
|
|
||||||
|
def recording_patch_on_cooldown?(session_id)
|
||||||
|
last = redis.get(recording_patch_at_key(session_id)).to_i
|
||||||
|
last.positive? && Time.at(last) > RECORDING_PATCH_COOLDOWN.call.ago
|
||||||
|
end
|
||||||
|
|
||||||
|
def mark_recording_patch!(session_id)
|
||||||
|
redis.set(recording_patch_at_key(session_id), Time.current.to_i, ex: 48.hours.to_i)
|
||||||
|
end
|
||||||
|
|
||||||
|
def recording_patch_at_key(session_id)
|
||||||
|
format("mediamtx:recording:patched_at:%s", session_id)
|
||||||
|
end
|
||||||
|
|
||||||
def self.forget_recording_state!(session_id)
|
def self.forget_recording_state!(session_id)
|
||||||
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
||||||
redis.del("mediamtx:recording:#{session_id}")
|
redis.del("mediamtx:recording:#{session_id}")
|
||||||
|
redis.del(format("mediamtx:recording:patched_at:%s", session_id))
|
||||||
rescue Redis::BaseError
|
rescue Redis::BaseError
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ module Sessions
|
|||||||
# Sceglie il preset più alto supportato dall'upload misurato (test rete).
|
# Sceglie il preset più alto supportato dall'upload misurato (test rete).
|
||||||
class SelectQuality
|
class SelectQuality
|
||||||
UPLOAD_HEADROOM = 0.8
|
UPLOAD_HEADROOM = 0.8
|
||||||
|
UPLOAD_HEADROOM_1080P = 1.0
|
||||||
|
|
||||||
PRESETS = [
|
PRESETS = [
|
||||||
{ id: "1080p_30_4.5mbps", target_bitrate: 4_500_000, target_fps: 30 },
|
{ id: "1080p_30_4.5mbps", target_bitrate: 4_500_000, target_fps: 30 },
|
||||||
@@ -23,7 +24,8 @@ module Sessions
|
|||||||
end
|
end
|
||||||
|
|
||||||
def upload_sufficient?(preset)
|
def upload_sufficient?(preset)
|
||||||
@upload_mbps >= (preset[:target_bitrate] / 1_000_000.0 * UPLOAD_HEADROOM)
|
headroom = preset[:id].start_with?("1080p") ? UPLOAD_HEADROOM_1080P : UPLOAD_HEADROOM
|
||||||
|
@upload_mbps >= (preset[:target_bitrate] / 1_000_000.0 * headroom)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -13,30 +13,66 @@ RSpec.describe Mediamtx::PublisherSync do
|
|||||||
end
|
end
|
||||||
let(:client) { instance_double(Mediamtx::Client) }
|
let(:client) { instance_double(Mediamtx::Client) }
|
||||||
let(:redis) { instance_double(Redis, get: nil, set: true, incr: 1, expire: true, del: 1) }
|
let(:redis) { instance_double(Redis, get: nil, set: true, incr: 1, expire: true, del: 1) }
|
||||||
|
let(:path_info) do
|
||||||
|
{ "name" => session.mediamtx_path_name, "online" => true, "source" => { "type" => "rtmpConn" }, "record" => false }
|
||||||
|
end
|
||||||
|
|
||||||
before do
|
before do
|
||||||
Billing::AssignPlan.call(club: club, plan_slug: "premium_full")
|
Billing::AssignPlan.call(club: club, plan_slug: "premium_full")
|
||||||
allow(Mediamtx::Client).to receive(:new).and_return(client)
|
allow(Mediamtx::Client).to receive(:new).and_return(client)
|
||||||
allow(client).to receive_messages(list_paths: [ {
|
allow(Mediamtx::PublisherOnline).to receive(:path_info).and_return(path_info)
|
||||||
"name" => session.mediamtx_path_name, "online" => true, "source" => { "type" => "rtmpConn" }
|
|
||||||
} ])
|
|
||||||
allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(true)
|
allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(true)
|
||||||
allow_any_instance_of(described_class).to receive(:redis).and_return(redis)
|
allow_any_instance_of(described_class).to receive(:redis).and_return(redis)
|
||||||
allow(client).to receive(:set_path_recording)
|
allow(client).to receive(:set_path_recording)
|
||||||
end
|
end
|
||||||
|
|
||||||
it "abilita la registrazione quando il publisher è online" do
|
it "abilita la registrazione quando il publisher è online e la sessione è live" do
|
||||||
|
session.update!(status: "live")
|
||||||
|
|
||||||
described_class.new(session).call
|
described_class.new(session).call
|
||||||
|
|
||||||
expect(client).to have_received(:set_path_recording).with(session, enabled: true)
|
expect(client).to have_received(:set_path_recording).with(session, enabled: true)
|
||||||
end
|
end
|
||||||
|
|
||||||
it "disabilita la registrazione in pausa" do
|
it "non abilita la registrazione in connecting senza publisher online" do
|
||||||
session.update!(status: "paused")
|
allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(false)
|
||||||
allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(true)
|
path_info["online"] = false
|
||||||
|
|
||||||
described_class.new(session).call
|
described_class.new(session).call
|
||||||
|
|
||||||
expect(client).to have_received(:set_path_recording).with(session, enabled: false)
|
expect(client).not_to have_received(:set_path_recording)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "abilita la registrazione quando connecting diventa live con publisher online" do
|
||||||
|
described_class.new(session).call
|
||||||
|
|
||||||
|
expect(session.reload.status).to eq("live")
|
||||||
|
expect(client).to have_received(:set_path_recording).with(session, enabled: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "non disabilita la registrazione su reconnecting con publisher offline" do
|
||||||
|
session.update!(status: "reconnecting")
|
||||||
|
allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(false)
|
||||||
|
|
||||||
|
described_class.new(session).call
|
||||||
|
|
||||||
|
expect(client).not_to have_received(:set_path_recording)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "non patcha la registrazione in pausa (gestita da Sessions::Pause)" do
|
||||||
|
session.update!(status: "paused")
|
||||||
|
|
||||||
|
described_class.new(session).call
|
||||||
|
|
||||||
|
expect(client).not_to have_received(:set_path_recording)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "salta la patch se MediaMTX ha già lo stato record desiderato" do
|
||||||
|
session.update!(status: "live")
|
||||||
|
path_info["record"] = true
|
||||||
|
|
||||||
|
described_class.new(session).call
|
||||||
|
|
||||||
|
expect(client).not_to have_received(:set_path_recording)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
14
backend/spec/services/sessions/select_quality_spec.rb
Normal file
14
backend/spec/services/sessions/select_quality_spec.rb
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Sessions::SelectQuality do
|
||||||
|
it "richiede più upload per 1080p rispetto a 720p" do
|
||||||
|
expect(described_class.call(upload_mbps: 4.4)[:id]).to eq("720p_30_4mbps")
|
||||||
|
expect(described_class.call(upload_mbps: 5.0)[:id]).to eq("1080p_30_4.5mbps")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "consente 1080p solo con upload sufficiente" do
|
||||||
|
preset = described_class.call(upload_mbps: 4.5)
|
||||||
|
|
||||||
|
expect(preset[:id]).to eq("1080p_30_4.5mbps")
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -166,6 +166,7 @@ data class StreamSessionDto(
|
|||||||
@Json(name = "privacy_status") val privacyStatus: String? = null,
|
@Json(name = "privacy_status") val privacyStatus: String? = null,
|
||||||
@Json(name = "target_bitrate") val targetBitrate: Int? = null,
|
@Json(name = "target_bitrate") val targetBitrate: Int? = null,
|
||||||
@Json(name = "target_fps") val targetFps: Int? = null,
|
@Json(name = "target_fps") val targetFps: Int? = null,
|
||||||
|
@Json(name = "quality_preset") val qualityPreset: String? = null,
|
||||||
@Json(name = "started_at") val startedAt: String? = null,
|
@Json(name = "started_at") val startedAt: String? = null,
|
||||||
val score: ScoreStateDto? = null,
|
val score: ScoreStateDto? = null,
|
||||||
) {
|
) {
|
||||||
@@ -183,6 +184,7 @@ data class StreamSessionDto(
|
|||||||
privacyStatus = privacyStatus ?: "public",
|
privacyStatus = privacyStatus ?: "public",
|
||||||
targetBitrate = targetBitrate ?: 2_500_000,
|
targetBitrate = targetBitrate ?: 2_500_000,
|
||||||
targetFps = targetFps ?: 30,
|
targetFps = targetFps ?: 30,
|
||||||
|
qualityPreset = qualityPreset ?: "720p_30_2.5mbps",
|
||||||
startedAt = startedAt,
|
startedAt = startedAt,
|
||||||
score = score?.toDomain(),
|
score = score?.toDomain(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ data class StreamSession(
|
|||||||
val privacyStatus: String = "public",
|
val privacyStatus: String = "public",
|
||||||
val targetBitrate: Int = 2_500_000,
|
val targetBitrate: Int = 2_500_000,
|
||||||
val targetFps: Int = 30,
|
val targetFps: Int = 30,
|
||||||
|
val qualityPreset: String = "720p_30_2.5mbps",
|
||||||
val startedAt: String? = null,
|
val startedAt: String? = null,
|
||||||
val score: ScoreState? = null,
|
val score: ScoreState? = null,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -286,16 +286,21 @@ class LiveBroadcastEngine(
|
|||||||
if (stream.isStreaming) stream.stopStream()
|
if (stream.isStreaming) stream.stopStream()
|
||||||
if (stream.isOnPreview) stream.stopPreview()
|
if (stream.isOnPreview) stream.stopPreview()
|
||||||
|
|
||||||
|
val h264Level = if (cfg.height > 720) {
|
||||||
|
MediaCodecInfo.CodecProfileLevel.AVCLevel41
|
||||||
|
} else {
|
||||||
|
MediaCodecInfo.CodecProfileLevel.AVCLevel31
|
||||||
|
}
|
||||||
val prepared = runCatching {
|
val prepared = runCatching {
|
||||||
stream.prepareVideo(
|
stream.prepareVideo(
|
||||||
width = cfg.width,
|
width = cfg.width,
|
||||||
height = cfg.height,
|
height = cfg.height,
|
||||||
bitrate = cfg.videoBitrate,
|
bitrate = cfg.videoBitrate,
|
||||||
fps = cfg.fps,
|
fps = cfg.fps,
|
||||||
iFrameInterval = 1,
|
iFrameInterval = 2,
|
||||||
rotation = cfg.rotation,
|
rotation = cfg.rotation,
|
||||||
profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline,
|
profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline,
|
||||||
level = MediaCodecInfo.CodecProfileLevel.AVCLevel31,
|
level = h264Level,
|
||||||
) && stream.prepareAudio(48_000, false, cfg.audioBitrate)
|
) && stream.prepareAudio(48_000, false, cfg.audioBitrate)
|
||||||
}.getOrElse {
|
}.getOrElse {
|
||||||
Log.w(TAG, "preparePipeline: ${it.message}")
|
Log.w(TAG, "preparePipeline: ${it.message}")
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.matchlivetv.match_live_tv.streaming
|
||||||
|
|
||||||
|
import com.matchlivetv.match_live_tv.domain.StreamSession
|
||||||
|
|
||||||
|
/** Risoluzione encoder allineata al preset qualità scelto in wizard. */
|
||||||
|
object StreamVideoPreset {
|
||||||
|
data class Dimensions(val width: Int, val height: Int)
|
||||||
|
|
||||||
|
fun dimensions(qualityPreset: String?): Dimensions =
|
||||||
|
if (qualityPreset?.startsWith("1080p") == true) {
|
||||||
|
Dimensions(1920, 1080)
|
||||||
|
} else {
|
||||||
|
Dimensions(1280, 720)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun broadcastConfig(session: StreamSession, rtmpUrl: String): BroadcastConfig {
|
||||||
|
val dims = dimensions(session.qualityPreset)
|
||||||
|
return BroadcastConfig(
|
||||||
|
rtmpUrl = rtmpUrl,
|
||||||
|
width = dims.width,
|
||||||
|
height = dims.height,
|
||||||
|
videoBitrate = session.targetBitrate,
|
||||||
|
fps = session.targetFps,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ import com.matchlivetv.match_live_tv.domain.Match
|
|||||||
import com.matchlivetv.match_live_tv.domain.MatchScoringRules
|
import com.matchlivetv.match_live_tv.domain.MatchScoringRules
|
||||||
import com.matchlivetv.match_live_tv.domain.StreamSession
|
import com.matchlivetv.match_live_tv.domain.StreamSession
|
||||||
import com.matchlivetv.match_live_tv.streaming.BroadcastConfig
|
import com.matchlivetv.match_live_tv.streaming.BroadcastConfig
|
||||||
|
import com.matchlivetv.match_live_tv.streaming.StreamVideoPreset
|
||||||
import com.matchlivetv.match_live_tv.streaming.BroadcastPhase
|
import com.matchlivetv.match_live_tv.streaming.BroadcastPhase
|
||||||
import com.matchlivetv.match_live_tv.streaming.LivePreviewView
|
import com.matchlivetv.match_live_tv.streaming.LivePreviewView
|
||||||
import com.matchlivetv.match_live_tv.streaming.overlay.CompactScoreboardState
|
import com.matchlivetv.match_live_tv.streaming.overlay.CompactScoreboardState
|
||||||
@@ -83,13 +84,8 @@ fun BroadcastScreen(
|
|||||||
scoringRules?.let { LiveScoreActions(it, container.scoreController, dialogHost) }
|
scoringRules?.let { LiveScoreActions(it, container.scoreController, dialogHost) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun broadcastConfig(loaded: StreamSession): BroadcastConfig = BroadcastConfig(
|
fun broadcastConfig(loaded: StreamSession): BroadcastConfig =
|
||||||
rtmpUrl = loaded.rtmpIngestUrl.orEmpty(),
|
StreamVideoPreset.broadcastConfig(loaded, loaded.rtmpIngestUrl.orEmpty())
|
||||||
width = 1280,
|
|
||||||
height = 720,
|
|
||||||
videoBitrate = loaded.targetBitrate,
|
|
||||||
fps = loaded.targetFps,
|
|
||||||
)
|
|
||||||
|
|
||||||
suspend fun stopStreamPermanently() {
|
suspend fun stopStreamPermanently() {
|
||||||
runCatching { container.sessionRepository.stopSession(sessionId) }
|
runCatching { container.sessionRepository.stopSession(sessionId) }
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
81DB6B2543BC407EBB056C4B /* BroadcastScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 985215153D0A44DC8B429731 /* BroadcastScreen.swift */; };
|
81DB6B2543BC407EBB056C4B /* BroadcastScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 985215153D0A44DC8B429731 /* BroadcastScreen.swift */; };
|
||||||
833558A9A0A043ECB8E146F6 /* ScoreActionDecodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3A1338FBA0411587BB5F4B /* ScoreActionDecodeTests.swift */; };
|
833558A9A0A043ECB8E146F6 /* ScoreActionDecodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3A1338FBA0411587BB5F4B /* ScoreActionDecodeTests.swift */; };
|
||||||
87DB3FBE122F444B87B9D73B /* LiveBroadcastEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8B7397C0BD4EC59851185D /* LiveBroadcastEngine.swift */; };
|
87DB3FBE122F444B87B9D73B /* LiveBroadcastEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8B7397C0BD4EC59851185D /* LiveBroadcastEngine.swift */; };
|
||||||
|
F1A2B3C4D5E6478990ABCDEF /* StreamVideoPreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6478990ABCDEF /* StreamVideoPreset.swift */; };
|
||||||
8841513B4E1743E983E7D2C8 /* BroadcastControlsOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF2832197EE409AA0B0334C /* BroadcastControlsOverlay.swift */; };
|
8841513B4E1743E983E7D2C8 /* BroadcastControlsOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF2832197EE409AA0B0334C /* BroadcastControlsOverlay.swift */; };
|
||||||
8C32CAC69405444DA4BB1E36 /* ScoreController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F41CF197E5D543CE9A949497 /* ScoreController.swift */; };
|
8C32CAC69405444DA4BB1E36 /* ScoreController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F41CF197E5D543CE9A949497 /* ScoreController.swift */; };
|
||||||
8D7FCD0052674C0EB4F5C811 /* MatchesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63D6F174CE084074972CABF6 /* MatchesScreen.swift */; };
|
8D7FCD0052674C0EB4F5C811 /* MatchesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63D6F174CE084074972CABF6 /* MatchesScreen.swift */; };
|
||||||
@@ -154,6 +155,7 @@
|
|||||||
B8736A8A330D4815A3D56104 /* MatchSecondaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchSecondaryButton.swift; path = MatchLiveTv/UI/Components/MatchSecondaryButton.swift; sourceTree = "<group>"; };
|
B8736A8A330D4815A3D56104 /* MatchSecondaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchSecondaryButton.swift; path = MatchLiveTv/UI/Components/MatchSecondaryButton.swift; sourceTree = "<group>"; };
|
||||||
BA288DB764E24438AE8826DC /* OverlayState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayState.swift; path = MatchLiveTv/Streaming/Overlay/OverlayState.swift; sourceTree = "<group>"; };
|
BA288DB764E24438AE8826DC /* OverlayState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayState.swift; path = MatchLiveTv/Streaming/Overlay/OverlayState.swift; sourceTree = "<group>"; };
|
||||||
BC8B7397C0BD4EC59851185D /* LiveBroadcastEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveBroadcastEngine.swift; path = MatchLiveTv/Streaming/LiveBroadcastEngine.swift; sourceTree = "<group>"; };
|
BC8B7397C0BD4EC59851185D /* LiveBroadcastEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveBroadcastEngine.swift; path = MatchLiveTv/Streaming/LiveBroadcastEngine.swift; sourceTree = "<group>"; };
|
||||||
|
A1B2C3D4E5F6478990ABCDEF /* StreamVideoPreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StreamVideoPreset.swift; path = MatchLiveTv/Streaming/StreamVideoPreset.swift; sourceTree = "<group>"; };
|
||||||
BCA50054BF694B9D82EA97ED /* BroadcastVideoOrientation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastVideoOrientation.swift; path = MatchLiveTv/Streaming/BroadcastVideoOrientation.swift; sourceTree = "<group>"; };
|
BCA50054BF694B9D82EA97ED /* BroadcastVideoOrientation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastVideoOrientation.swift; path = MatchLiveTv/Streaming/BroadcastVideoOrientation.swift; sourceTree = "<group>"; };
|
||||||
BDC3D4B19E974898AA9CAC76 /* SponsorElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SponsorElement.swift; path = MatchLiveTv/Streaming/Overlay/SponsorElement.swift; sourceTree = "<group>"; };
|
BDC3D4B19E974898AA9CAC76 /* SponsorElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SponsorElement.swift; path = MatchLiveTv/Streaming/Overlay/SponsorElement.swift; sourceTree = "<group>"; };
|
||||||
BFE4871BCA51457CB720047B /* BroadcastOrientationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastOrientationPolicy.swift; path = MatchLiveTv/Streaming/BroadcastOrientationPolicy.swift; sourceTree = "<group>"; };
|
BFE4871BCA51457CB720047B /* BroadcastOrientationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastOrientationPolicy.swift; path = MatchLiveTv/Streaming/BroadcastOrientationPolicy.swift; sourceTree = "<group>"; };
|
||||||
@@ -243,6 +245,7 @@
|
|||||||
BCA50054BF694B9D82EA97ED /* BroadcastVideoOrientation.swift */,
|
BCA50054BF694B9D82EA97ED /* BroadcastVideoOrientation.swift */,
|
||||||
A3F2049B408743AA82AF43F3 /* LiveBroadcastCoordinator.swift */,
|
A3F2049B408743AA82AF43F3 /* LiveBroadcastCoordinator.swift */,
|
||||||
BC8B7397C0BD4EC59851185D /* LiveBroadcastEngine.swift */,
|
BC8B7397C0BD4EC59851185D /* LiveBroadcastEngine.swift */,
|
||||||
|
A1B2C3D4E5F6478990ABCDEF /* StreamVideoPreset.swift */,
|
||||||
AC36378EDDDF44F29ACAD3C6 /* CompactScoreboardElement.swift */,
|
AC36378EDDDF44F29ACAD3C6 /* CompactScoreboardElement.swift */,
|
||||||
1B5BF42CCF934AC7A9516374 /* OverlayCanvasRenderer.swift */,
|
1B5BF42CCF934AC7A9516374 /* OverlayCanvasRenderer.swift */,
|
||||||
10BEC6633F9F446587F1818C /* OverlayLogoCache.swift */,
|
10BEC6633F9F446587F1818C /* OverlayLogoCache.swift */,
|
||||||
@@ -417,6 +420,7 @@
|
|||||||
C1D2EA29B25C4B06B03B6FFE /* BroadcastVideoOrientation.swift in Sources */,
|
C1D2EA29B25C4B06B03B6FFE /* BroadcastVideoOrientation.swift in Sources */,
|
||||||
539D6877DF3C478E86200649 /* LiveBroadcastCoordinator.swift in Sources */,
|
539D6877DF3C478E86200649 /* LiveBroadcastCoordinator.swift in Sources */,
|
||||||
87DB3FBE122F444B87B9D73B /* LiveBroadcastEngine.swift in Sources */,
|
87DB3FBE122F444B87B9D73B /* LiveBroadcastEngine.swift in Sources */,
|
||||||
|
F1A2B3C4D5E6478990ABCDEF /* StreamVideoPreset.swift in Sources */,
|
||||||
AA9BCB0DA5164D43BD7A43FA /* CompactScoreboardElement.swift in Sources */,
|
AA9BCB0DA5164D43BD7A43FA /* CompactScoreboardElement.swift in Sources */,
|
||||||
ED730C9B9068449E8D8AF6C8 /* OverlayCanvasRenderer.swift in Sources */,
|
ED730C9B9068449E8D8AF6C8 /* OverlayCanvasRenderer.swift in Sources */,
|
||||||
8F6D3E3D61584BBA865AD2D7 /* OverlayLogoCache.swift in Sources */,
|
8F6D3E3D61584BBA865AD2D7 /* OverlayLogoCache.swift in Sources */,
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ struct StreamSessionDto: Decodable {
|
|||||||
let privacyStatus: String?
|
let privacyStatus: String?
|
||||||
let targetBitrate: Int?
|
let targetBitrate: Int?
|
||||||
let targetFps: Int?
|
let targetFps: Int?
|
||||||
|
let qualityPreset: String?
|
||||||
let startedAt: String?
|
let startedAt: String?
|
||||||
let score: ScoreStateDto?
|
let score: ScoreStateDto?
|
||||||
|
|
||||||
@@ -191,6 +192,7 @@ struct StreamSessionDto: Decodable {
|
|||||||
privacyStatus: privacyStatus ?? "public",
|
privacyStatus: privacyStatus ?? "public",
|
||||||
targetBitrate: targetBitrate ?? 2_500_000,
|
targetBitrate: targetBitrate ?? 2_500_000,
|
||||||
targetFps: targetFps ?? 30,
|
targetFps: targetFps ?? 30,
|
||||||
|
qualityPreset: qualityPreset ?? "720p_30_2.5mbps",
|
||||||
startedAt: startedAt,
|
startedAt: startedAt,
|
||||||
score: score?.toDomain()
|
score: score?.toDomain()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ struct StreamSession: Identifiable, Equatable, Sendable {
|
|||||||
let privacyStatus: String
|
let privacyStatus: String
|
||||||
let targetBitrate: Int
|
let targetBitrate: Int
|
||||||
let targetFps: Int
|
let targetFps: Int
|
||||||
|
let qualityPreset: String
|
||||||
let startedAt: String?
|
let startedAt: String?
|
||||||
let score: ScoreState?
|
let score: ScoreState?
|
||||||
|
|
||||||
|
|||||||
@@ -334,7 +334,9 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
videoSettings.videoSize = .init(width: config.width, height: config.height)
|
videoSettings.videoSize = .init(width: config.width, height: config.height)
|
||||||
videoSettings.bitRate = config.videoBitrate
|
videoSettings.bitRate = config.videoBitrate
|
||||||
videoSettings.maxKeyFrameIntervalDuration = 2
|
videoSettings.maxKeyFrameIntervalDuration = 2
|
||||||
videoSettings.profileLevel = kVTProfileLevel_H264_Baseline_3_1 as String
|
videoSettings.profileLevel = config.height > 720
|
||||||
|
? (kVTProfileLevel_H264_Baseline_4_1 as String)
|
||||||
|
: (kVTProfileLevel_H264_Baseline_3_1 as String)
|
||||||
videoSettings.expectedFrameRate = Double(config.fps)
|
videoSettings.expectedFrameRate = Double(config.fps)
|
||||||
try await stream.setVideoSettings(videoSettings)
|
try await stream.setVideoSettings(videoSettings)
|
||||||
|
|
||||||
|
|||||||
27
native/ios/MatchLiveTv/Streaming/StreamVideoPreset.swift
Normal file
27
native/ios/MatchLiveTv/Streaming/StreamVideoPreset.swift
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum StreamVideoPreset {
|
||||||
|
struct Dimensions: Sendable {
|
||||||
|
let width: Int
|
||||||
|
let height: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
static func dimensions(for qualityPreset: String?) -> Dimensions {
|
||||||
|
if qualityPreset?.hasPrefix("1080p") == true {
|
||||||
|
return Dimensions(width: 1920, height: 1080)
|
||||||
|
}
|
||||||
|
return Dimensions(width: 1280, height: 720)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func broadcastConfig(for session: StreamSession, rtmpUrl: String) -> BroadcastConfig {
|
||||||
|
let dims = dimensions(for: session.qualityPreset)
|
||||||
|
return BroadcastConfig(
|
||||||
|
rtmpUrl: rtmpUrl,
|
||||||
|
width: dims.width,
|
||||||
|
height: dims.height,
|
||||||
|
videoBitrate: session.targetBitrate,
|
||||||
|
audioBitrate: 128_000,
|
||||||
|
fps: session.targetFps
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -505,13 +505,9 @@ struct BroadcastScreen: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func broadcastConfig(for loaded: StreamSession, rtmpUrl: String) -> BroadcastConfig {
|
private func broadcastConfig(for loaded: StreamSession, rtmpUrl: String) -> BroadcastConfig {
|
||||||
BroadcastConfig(
|
StreamVideoPreset.broadcastConfig(
|
||||||
rtmpUrl: RtmpIngestUrl.resolveForDevice(rtmpUrl),
|
for: loaded,
|
||||||
width: 1280,
|
rtmpUrl: RtmpIngestUrl.resolveForDevice(rtmpUrl)
|
||||||
height: 720,
|
|
||||||
videoBitrate: loaded.targetBitrate,
|
|
||||||
audioBitrate: 128_000,
|
|
||||||
fps: loaded.targetFps
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user