diff --git a/backend/app/services/mediamtx/publisher_sync.rb b/backend/app/services/mediamtx/publisher_sync.rb index 053dd5a..fcc5c43 100644 --- a/backend/app/services/mediamtx/publisher_sync.rb +++ b/backend/app/services/mediamtx/publisher_sync.rb @@ -2,6 +2,8 @@ module Mediamtx # MediaMTX in produzione è distroless: runOnReady con wget non funziona. # Sincronizziamo lo stato Rails leggendo l'API paths (poll da /live/:id/status.json). class PublisherSync + RECORDING_PATCH_COOLDOWN = -> { ENV.fetch("MEDIAMTX_RECORDING_PATCH_COOLDOWN_SECS", "45").to_i.seconds } + def initialize(session) @session = session end @@ -9,17 +11,15 @@ module Mediamtx def call return @session if @session.terminal? - info = Mediamtx::Client.new.list_paths.find { |i| i["name"] == @session.mediamtx_path_name } - publisher_online = Mediamtx::PublisherOnline.active_path?(info) + path_info = Mediamtx::PublisherOnline.path_info(@session) + publisher_online = Mediamtx::PublisherOnline.active_path?(path_info) if publisher_online clear_publisher_misses!(@session.id) if @session.paused? # RTMP ancora connesso in pausa: non forzare live/reconnect. - sync_path_recording!(@session, enabled: false) else - sync_path_recording!(@session, enabled: true) - enable_live_path!(@session) + enable_live_path_once!(@session) if @session.may_go_live? @session.go_live! @session.reload @@ -34,17 +34,18 @@ module Mediamtx elsif !@session.paused? && @session.live? && @session.may_lose_connection? if publisher_offline_sustained?(@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) @session.lose_connection! schedule_timeout unless @session.paused? end 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) 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| schedule_youtube_relay_if_needed(session) @@ -79,14 +80,15 @@ module Mediamtx Youtube::LivePipeline.schedule!(session, force: force) end - # Match Live TV: lascia alwaysAvailable attivo — MediaMTX usa il publisher quando c'è - # 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) + def enable_live_path_once!(session) 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) rescue Client::Error => e + redis.del(format("youtube:slate_disabled:%s", session.id)) Rails.logger.warn("[PublisherSync] disable slate session=#{session.id}: #{e.message}") end @@ -98,23 +100,62 @@ module Mediamtx Rails.logger.warn("[PublisherSync] enable slate session=#{session.id}: #{e.message}") end - # In produzione i webhook MediaMTX (wget) non girano: la registrazione va accesa qui. - def sync_path_recording!(session, enabled:) + def recording_should_be_active?(publisher_online) + 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? key = recording_state_key(session.id) desired = enabled ? "1" : "0" 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) redis.set(key, desired, ex: 48.hours.to_i) + mark_recording_patch!(session.id) rescue Client::Error => e Rails.logger.warn("[PublisherSync] recording enabled=#{enabled} session=#{session.id}: #{e.message}") 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) redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0")) redis.del("mediamtx:recording:#{session_id}") + redis.del(format("mediamtx:recording:patched_at:%s", session_id)) rescue Redis::BaseError nil end diff --git a/backend/app/services/sessions/select_quality.rb b/backend/app/services/sessions/select_quality.rb index 69d22a5..ec9b9ae 100644 --- a/backend/app/services/sessions/select_quality.rb +++ b/backend/app/services/sessions/select_quality.rb @@ -2,6 +2,7 @@ module Sessions # Sceglie il preset più alto supportato dall'upload misurato (test rete). class SelectQuality UPLOAD_HEADROOM = 0.8 + UPLOAD_HEADROOM_1080P = 1.0 PRESETS = [ { id: "1080p_30_4.5mbps", target_bitrate: 4_500_000, target_fps: 30 }, @@ -23,7 +24,8 @@ module Sessions end 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 diff --git a/backend/spec/services/mediamtx/publisher_sync_spec.rb b/backend/spec/services/mediamtx/publisher_sync_spec.rb index ef43a19..6a0ebe3 100644 --- a/backend/spec/services/mediamtx/publisher_sync_spec.rb +++ b/backend/spec/services/mediamtx/publisher_sync_spec.rb @@ -13,30 +13,66 @@ RSpec.describe Mediamtx::PublisherSync do end let(:client) { instance_double(Mediamtx::Client) } 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 Billing::AssignPlan.call(club: club, plan_slug: "premium_full") allow(Mediamtx::Client).to receive(:new).and_return(client) - allow(client).to receive_messages(list_paths: [ { - "name" => session.mediamtx_path_name, "online" => true, "source" => { "type" => "rtmpConn" } - } ]) + allow(Mediamtx::PublisherOnline).to receive(:path_info).and_return(path_info) allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(true) allow_any_instance_of(described_class).to receive(:redis).and_return(redis) allow(client).to receive(:set_path_recording) 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 expect(client).to have_received(:set_path_recording).with(session, enabled: true) end - it "disabilita la registrazione in pausa" do - session.update!(status: "paused") - allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(true) + it "non abilita la registrazione in connecting senza publisher online" do + allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(false) + path_info["online"] = false 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 diff --git a/backend/spec/services/sessions/select_quality_spec.rb b/backend/spec/services/sessions/select_quality_spec.rb new file mode 100644 index 0000000..2f0e537 --- /dev/null +++ b/backend/spec/services/sessions/select_quality_spec.rb @@ -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 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 3cc5cec..d6635d3 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 @@ -166,6 +166,7 @@ data class StreamSessionDto( @Json(name = "privacy_status") val privacyStatus: String? = null, @Json(name = "target_bitrate") val targetBitrate: 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, val score: ScoreStateDto? = null, ) { @@ -183,6 +184,7 @@ data class StreamSessionDto( privacyStatus = privacyStatus ?: "public", targetBitrate = targetBitrate ?: 2_500_000, targetFps = targetFps ?: 30, + qualityPreset = qualityPreset ?: "720p_30_2.5mbps", startedAt = startedAt, score = score?.toDomain(), ) 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 ace84c1..bea0ffe 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 @@ -125,6 +125,7 @@ data class StreamSession( val privacyStatus: String = "public", val targetBitrate: Int = 2_500_000, val targetFps: Int = 30, + val qualityPreset: String = "720p_30_2.5mbps", val startedAt: String? = null, val score: ScoreState? = null, ) { diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt index ec22575..63866ed 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt @@ -286,16 +286,21 @@ class LiveBroadcastEngine( if (stream.isStreaming) stream.stopStream() if (stream.isOnPreview) stream.stopPreview() + val h264Level = if (cfg.height > 720) { + MediaCodecInfo.CodecProfileLevel.AVCLevel41 + } else { + MediaCodecInfo.CodecProfileLevel.AVCLevel31 + } val prepared = runCatching { stream.prepareVideo( width = cfg.width, height = cfg.height, bitrate = cfg.videoBitrate, fps = cfg.fps, - iFrameInterval = 1, + iFrameInterval = 2, rotation = cfg.rotation, profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline, - level = MediaCodecInfo.CodecProfileLevel.AVCLevel31, + level = h264Level, ) && stream.prepareAudio(48_000, false, cfg.audioBitrate) }.getOrElse { Log.w(TAG, "preparePipeline: ${it.message}") diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/StreamVideoPreset.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/StreamVideoPreset.kt new file mode 100644 index 0000000..e57a2fc --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/StreamVideoPreset.kt @@ -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, + ) + } +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt index 52c970e..3225a96 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt @@ -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.StreamSession 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.LivePreviewView import com.matchlivetv.match_live_tv.streaming.overlay.CompactScoreboardState @@ -83,13 +84,8 @@ fun BroadcastScreen( scoringRules?.let { LiveScoreActions(it, container.scoreController, dialogHost) } } - fun broadcastConfig(loaded: StreamSession): BroadcastConfig = BroadcastConfig( - rtmpUrl = loaded.rtmpIngestUrl.orEmpty(), - width = 1280, - height = 720, - videoBitrate = loaded.targetBitrate, - fps = loaded.targetFps, - ) + fun broadcastConfig(loaded: StreamSession): BroadcastConfig = + StreamVideoPreset.broadcastConfig(loaded, loaded.rtmpIngestUrl.orEmpty()) suspend fun stopStreamPermanently() { runCatching { container.sessionRepository.stopSession(sessionId) } diff --git a/native/ios/MatchLiveTv.xcodeproj/project.pbxproj b/native/ios/MatchLiveTv.xcodeproj/project.pbxproj index 7c01099..e9a4ef5 100644 --- a/native/ios/MatchLiveTv.xcodeproj/project.pbxproj +++ b/native/ios/MatchLiveTv.xcodeproj/project.pbxproj @@ -51,6 +51,7 @@ 81DB6B2543BC407EBB056C4B /* BroadcastScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 985215153D0A44DC8B429731 /* BroadcastScreen.swift */; }; 833558A9A0A043ECB8E146F6 /* ScoreActionDecodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3A1338FBA0411587BB5F4B /* ScoreActionDecodeTests.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 */; }; 8C32CAC69405444DA4BB1E36 /* ScoreController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F41CF197E5D543CE9A949497 /* ScoreController.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 = ""; }; BA288DB764E24438AE8826DC /* OverlayState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayState.swift; path = MatchLiveTv/Streaming/Overlay/OverlayState.swift; sourceTree = ""; }; BC8B7397C0BD4EC59851185D /* LiveBroadcastEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveBroadcastEngine.swift; path = MatchLiveTv/Streaming/LiveBroadcastEngine.swift; sourceTree = ""; }; + A1B2C3D4E5F6478990ABCDEF /* StreamVideoPreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StreamVideoPreset.swift; path = MatchLiveTv/Streaming/StreamVideoPreset.swift; sourceTree = ""; }; BCA50054BF694B9D82EA97ED /* BroadcastVideoOrientation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastVideoOrientation.swift; path = MatchLiveTv/Streaming/BroadcastVideoOrientation.swift; sourceTree = ""; }; BDC3D4B19E974898AA9CAC76 /* SponsorElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SponsorElement.swift; path = MatchLiveTv/Streaming/Overlay/SponsorElement.swift; sourceTree = ""; }; BFE4871BCA51457CB720047B /* BroadcastOrientationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastOrientationPolicy.swift; path = MatchLiveTv/Streaming/BroadcastOrientationPolicy.swift; sourceTree = ""; }; @@ -243,6 +245,7 @@ BCA50054BF694B9D82EA97ED /* BroadcastVideoOrientation.swift */, A3F2049B408743AA82AF43F3 /* LiveBroadcastCoordinator.swift */, BC8B7397C0BD4EC59851185D /* LiveBroadcastEngine.swift */, + A1B2C3D4E5F6478990ABCDEF /* StreamVideoPreset.swift */, AC36378EDDDF44F29ACAD3C6 /* CompactScoreboardElement.swift */, 1B5BF42CCF934AC7A9516374 /* OverlayCanvasRenderer.swift */, 10BEC6633F9F446587F1818C /* OverlayLogoCache.swift */, @@ -417,6 +420,7 @@ C1D2EA29B25C4B06B03B6FFE /* BroadcastVideoOrientation.swift in Sources */, 539D6877DF3C478E86200649 /* LiveBroadcastCoordinator.swift in Sources */, 87DB3FBE122F444B87B9D73B /* LiveBroadcastEngine.swift in Sources */, + F1A2B3C4D5E6478990ABCDEF /* StreamVideoPreset.swift in Sources */, AA9BCB0DA5164D43BD7A43FA /* CompactScoreboardElement.swift in Sources */, ED730C9B9068449E8D8AF6C8 /* OverlayCanvasRenderer.swift in Sources */, 8F6D3E3D61584BBA865AD2D7 /* OverlayLogoCache.swift in Sources */, diff --git a/native/ios/MatchLiveTv/Data/API/ApiDtos.swift b/native/ios/MatchLiveTv/Data/API/ApiDtos.swift index 2b47a8b..f6632b4 100644 --- a/native/ios/MatchLiveTv/Data/API/ApiDtos.swift +++ b/native/ios/MatchLiveTv/Data/API/ApiDtos.swift @@ -173,6 +173,7 @@ struct StreamSessionDto: Decodable { let privacyStatus: String? let targetBitrate: Int? let targetFps: Int? + let qualityPreset: String? let startedAt: String? let score: ScoreStateDto? @@ -191,6 +192,7 @@ struct StreamSessionDto: Decodable { privacyStatus: privacyStatus ?? "public", targetBitrate: targetBitrate ?? 2_500_000, targetFps: targetFps ?? 30, + qualityPreset: qualityPreset ?? "720p_30_2.5mbps", startedAt: startedAt, score: score?.toDomain() ) diff --git a/native/ios/MatchLiveTv/Domain/Models.swift b/native/ios/MatchLiveTv/Domain/Models.swift index dc5a522..0096469 100644 --- a/native/ios/MatchLiveTv/Domain/Models.swift +++ b/native/ios/MatchLiveTv/Domain/Models.swift @@ -118,6 +118,7 @@ struct StreamSession: Identifiable, Equatable, Sendable { let privacyStatus: String let targetBitrate: Int let targetFps: Int + let qualityPreset: String let startedAt: String? let score: ScoreState? diff --git a/native/ios/MatchLiveTv/Streaming/LiveBroadcastEngine.swift b/native/ios/MatchLiveTv/Streaming/LiveBroadcastEngine.swift index 89b9b6e..3db8384 100644 --- a/native/ios/MatchLiveTv/Streaming/LiveBroadcastEngine.swift +++ b/native/ios/MatchLiveTv/Streaming/LiveBroadcastEngine.swift @@ -334,7 +334,9 @@ final class LiveBroadcastEngine: ObservableObject { videoSettings.videoSize = .init(width: config.width, height: config.height) videoSettings.bitRate = config.videoBitrate 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) try await stream.setVideoSettings(videoSettings) diff --git a/native/ios/MatchLiveTv/Streaming/StreamVideoPreset.swift b/native/ios/MatchLiveTv/Streaming/StreamVideoPreset.swift new file mode 100644 index 0000000..e3f23c8 --- /dev/null +++ b/native/ios/MatchLiveTv/Streaming/StreamVideoPreset.swift @@ -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 + ) + } +} diff --git a/native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift b/native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift index 89db562..b23e2e7 100644 --- a/native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift +++ b/native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift @@ -505,13 +505,9 @@ struct BroadcastScreen: View { } private func broadcastConfig(for loaded: StreamSession, rtmpUrl: String) -> BroadcastConfig { - BroadcastConfig( - rtmpUrl: RtmpIngestUrl.resolveForDevice(rtmpUrl), - width: 1280, - height: 720, - videoBitrate: loaded.targetBitrate, - audioBitrate: 128_000, - fps: loaded.targetFps + StreamVideoPreset.broadcastConfig( + for: loaded, + rtmpUrl: RtmpIngestUrl.resolveForDevice(rtmpUrl) ) }