Fix regia basket e stabilità diretta iOS.
La regia remota non va più in 500 su basket/timed; iOS gestisce chiusura set, riconnessione RTMP e avanzamento quarti in modo affidabile. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -17,7 +17,10 @@ module Public
|
|||||||
@score = @session.score_state || Scoring::Engine.ensure_score_for(@session)
|
@score = @session.score_state || Scoring::Engine.ensure_score_for(@session)
|
||||||
@rules = Scoring::Rules.from_match(@match)
|
@rules = Scoring::Rules.from_match(@match)
|
||||||
@stream_closed = @session.terminal?
|
@stream_closed = @session.terminal?
|
||||||
@on_air = !@stream_closed && mediamtx_stream_playable?(@session)
|
@on_air = !@stream_closed && (
|
||||||
|
mediamtx_stream_playable?(@session) ||
|
||||||
|
@session.status.in?(%w[connecting live reconnecting paused])
|
||||||
|
)
|
||||||
@regia_share_url = request.original_url
|
@regia_share_url = request.original_url
|
||||||
@regia_share_title = "Regia — #{@team.name} vs #{@match.opponent_name}"
|
@regia_share_title = "Regia — #{@team.name} vs #{@match.opponent_name}"
|
||||||
@live_share_url = @session.share_url
|
@live_share_url = @session.share_url
|
||||||
@@ -70,17 +73,18 @@ module Public
|
|||||||
score = @session.score_state
|
score = @session.score_state
|
||||||
closed = @session.terminal?
|
closed = @session.terminal?
|
||||||
playable = !closed && mediamtx_stream_playable?(@session)
|
playable = !closed && mediamtx_stream_playable?(@session)
|
||||||
|
publisher_online = !closed && mediamtx_publisher_online?(@session)
|
||||||
{
|
{
|
||||||
status: @session.status,
|
status: @session.status,
|
||||||
paused: @session.paused?,
|
paused: @session.paused?,
|
||||||
stream_closed: closed,
|
stream_closed: closed,
|
||||||
live: !closed && (@session.live? || @session.paused? || mediamtx_publisher_online?(@session)),
|
live: !closed && (@session.live? || @session.paused? || publisher_online),
|
||||||
on_air: playable,
|
on_air: playable || (!closed && @session.status.in?(%w[connecting live reconnecting paused])),
|
||||||
publisher_online: !closed && mediamtx_publisher_online?(@session),
|
publisher_online: publisher_online,
|
||||||
ended_at: @session.ended_at,
|
ended_at: @session.ended_at,
|
||||||
home_name: @session.match.team.name,
|
home_name: @session.match.team.name,
|
||||||
away_name: @session.match.opponent_name,
|
away_name: @session.match.opponent_name,
|
||||||
points_target: Scoring::Rules.from_match(@session.match).points_target(score&.current_set || 1),
|
points_target: Scoring::Rules.points_target_for_match(@session.match, current_set: score&.current_set || 1),
|
||||||
score: score&.as_cable_payload
|
score: score&.as_cable_payload
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ module Scoring
|
|||||||
current_set >= max_sets ? @points_deciding_set : @points_per_set
|
current_set >= max_sets ? @points_deciding_set : @points_per_set
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.points_target_for_match(match, current_set: 1)
|
||||||
|
rules = from_match(match)
|
||||||
|
rules.is_a?(Rules) ? rules.points_target(current_set) : 0
|
||||||
|
end
|
||||||
|
|
||||||
def set_winner_from_points(home_points:, away_points:, current_set:)
|
def set_winner_from_points(home_points:, away_points:, current_set:)
|
||||||
target = points_target(current_set)
|
target = points_target(current_set)
|
||||||
if home_points >= target && (home_points - away_points) >= @min_point_lead
|
if home_points >= target && (home_points - away_points) >= @min_point_lead
|
||||||
|
|||||||
@@ -93,4 +93,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/regia.js?v=6"></script>
|
<script src="/regia.js?v=7"></script>
|
||||||
|
|||||||
@@ -174,7 +174,10 @@
|
|||||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
body: JSON.stringify({ cmd: action })
|
body: JSON.stringify({ cmd: action })
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("Errore aggiornamento punteggio");
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || "Errore aggiornamento punteggio");
|
||||||
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,43 @@ RSpec.describe "Public regia", type: :request do
|
|||||||
expect(session.score_state.reload.home_points).to eq(1)
|
expect(session.score_state.reload.home_points).to eq(1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "aggiorna il punteggio basket via API senza errore 500" do
|
||||||
|
basket_team = club.teams.create!(name: "U13 Basket", sport_key: "basket")
|
||||||
|
basket_match = basket_team.matches.create!(
|
||||||
|
opponent_name: "Avversario",
|
||||||
|
sport: "basket",
|
||||||
|
scoring_rules: { "periods" => 4, "period_duration_secs" => 600, "overtime_duration_secs" => 300 }
|
||||||
|
)
|
||||||
|
basket_session = StreamSession.create!(match: basket_match, user: user, platform: "matchlivetv", status: "live")
|
||||||
|
basket_session.create_score_state!(
|
||||||
|
board_type: "basket",
|
||||||
|
home_sets: 0,
|
||||||
|
away_sets: 0,
|
||||||
|
home_points: 0,
|
||||||
|
away_points: 0,
|
||||||
|
current_set: 1,
|
||||||
|
data: {
|
||||||
|
"period" => 1,
|
||||||
|
"clock_secs" => 600,
|
||||||
|
"clock_running" => false,
|
||||||
|
"home_score" => 0,
|
||||||
|
"away_score" => 0,
|
||||||
|
"overtime" => false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
token = Sessions::RegiaAccess.new(basket_session).issue_token!
|
||||||
|
|
||||||
|
patch public_regia_score_path(token), params: { cmd: "home_point" }, as: :json
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
body = response.parsed_body
|
||||||
|
expect(body.dig("score", "home_points")).to eq(1)
|
||||||
|
expect(body["points_target"]).to eq(0)
|
||||||
|
|
||||||
|
get public_regia_status_path(token)
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.parsed_body.dig("score", "board_type")).to eq("basket")
|
||||||
|
end
|
||||||
|
|
||||||
it "mette in pausa e riprende la diretta" do
|
it "mette in pausa e riprende la diretta" do
|
||||||
mtx = instance_double(
|
mtx = instance_double(
|
||||||
Mediamtx::Client,
|
Mediamtx::Client,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,28 +3,28 @@
|
|||||||
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
|
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
|
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
|
||||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="553940DB807C4DBFB63E9FFE" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="AE6E9D532CE6492D8B82687F" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
||||||
</BuildActionEntry>
|
</BuildActionEntry>
|
||||||
<BuildActionEntry buildForTesting="YES" buildForRunning="NO" buildForProfiling="NO" buildForArchiving="NO" buildForAnalyzing="NO">
|
<BuildActionEntry buildForTesting="YES" buildForRunning="NO" buildForProfiling="NO" buildForArchiving="NO" buildForAnalyzing="NO">
|
||||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="79FDBB021E5045C5A353AEE8" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="64F84AC8A424490D945D5B65" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
||||||
</BuildActionEntry>
|
</BuildActionEntry>
|
||||||
</BuildActionEntries>
|
</BuildActionEntries>
|
||||||
</BuildAction>
|
</BuildAction>
|
||||||
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES">
|
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES">
|
||||||
<Testables>
|
<Testables>
|
||||||
<TestableReference skipped="NO">
|
<TestableReference skipped="NO">
|
||||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="79FDBB021E5045C5A353AEE8" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="64F84AC8A424490D945D5B65" BuildableName="MatchLiveTvTests.xctest" BlueprintName="MatchLiveTvTests" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
||||||
</TestableReference>
|
</TestableReference>
|
||||||
</Testables>
|
</Testables>
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" debugServiceExtension="internal" allowLocationSimulation="YES">
|
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" debugServiceExtension="internal" allowLocationSimulation="YES">
|
||||||
<BuildableProductRunnable runnableDebuggingMode="0">
|
<BuildableProductRunnable runnableDebuggingMode="0">
|
||||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="553940DB807C4DBFB63E9FFE" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="AE6E9D532CE6492D8B82687F" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
<ProfileAction buildConfiguration="Release" shouldUseLaunchSchemeArgsEnv="YES" savedToolIdentifier="" useCustomWorkingDirectory="NO" debugDocumentVersioning="YES">
|
<ProfileAction buildConfiguration="Release" shouldUseLaunchSchemeArgsEnv="YES" savedToolIdentifier="" useCustomWorkingDirectory="NO" debugDocumentVersioning="YES">
|
||||||
<BuildableProductRunnable runnableDebuggingMode="0">
|
<BuildableProductRunnable runnableDebuggingMode="0">
|
||||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="553940DB807C4DBFB63E9FFE" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="AE6E9D532CE6492D8B82687F" BuildableName="MatchLiveTv.app" BlueprintName="MatchLiveTv" ReferencedContainer="container:MatchLiveTv.xcodeproj"/>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
</ProfileAction>
|
</ProfileAction>
|
||||||
<AnalyzeAction buildConfiguration="Debug"/>
|
<AnalyzeAction buildConfiguration="Debug"/>
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ final class ScoreController: ObservableObject {
|
|||||||
syncGeneration += 1
|
syncGeneration += 1
|
||||||
guard let sessionId else { return false }
|
guard let sessionId else { return false }
|
||||||
|
|
||||||
|
let previous = score
|
||||||
let optimistic = closedSetState(from: score)
|
let optimistic = closedSetState(from: score)
|
||||||
score = optimistic
|
score = optimistic
|
||||||
sessionCable.sendScoreUpdate(optimistic)
|
sessionCable.sendScoreUpdate(optimistic)
|
||||||
@@ -112,9 +113,11 @@ final class ScoreController: ObservableObject {
|
|||||||
sessionCable.sendScoreUpdate(updated)
|
sessionCable.sendScoreUpdate(updated)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
suppressRemoteUntil = Date().addingTimeInterval(3)
|
score = previous
|
||||||
return true
|
lastActionError = "Chiusura set non confermata dal server"
|
||||||
|
return false
|
||||||
} catch {
|
} catch {
|
||||||
|
score = previous
|
||||||
lastActionError = UserFacingError.message(for: error)
|
lastActionError = UserFacingError.message(for: error)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -161,10 +164,22 @@ final class ScoreController: ObservableObject {
|
|||||||
case "away_point_3": state.awayPoints += 3
|
case "away_point_3": state.awayPoints += 3
|
||||||
case "home_undo": state.homePoints = max(0, state.homePoints - 1)
|
case "home_undo": state.homePoints = max(0, state.homePoints - 1)
|
||||||
case "away_undo": state.awayPoints = max(0, state.awayPoints - 1)
|
case "away_undo": state.awayPoints = max(0, state.awayPoints - 1)
|
||||||
|
case "advance_period": applyOptimisticAdvancePeriod(to: &state)
|
||||||
default: break
|
default: break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func applyOptimisticAdvancePeriod(to state: inout ScoreState) {
|
||||||
|
guard ScoreState.periodBoards.contains(state.boardType) else { return }
|
||||||
|
state.period += 1
|
||||||
|
state.clockRunning = false
|
||||||
|
if state.boardType == "basket" {
|
||||||
|
state.periodLabel = "Q\(state.period)"
|
||||||
|
} else if state.boardType == "timed" {
|
||||||
|
state.periodLabel = "\(state.period)° tempo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func scheduleAction(_ action: String) {
|
private func scheduleAction(_ action: String) {
|
||||||
syncTask?.cancel()
|
syncTask?.cancel()
|
||||||
syncGeneration += 1
|
syncGeneration += 1
|
||||||
@@ -174,9 +189,12 @@ final class ScoreController: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
if let updated = try await scoreRepository.applyScoreAction(sessionId: sessionId, action: action) {
|
if let updated = try await scoreRepository.applyScoreAction(sessionId: sessionId, action: action) {
|
||||||
guard generation == syncGeneration else { return }
|
guard generation == syncGeneration else { return }
|
||||||
suppressRemoteUntil = Date().addingTimeInterval(0.5)
|
suppressRemoteUntil = Date().addingTimeInterval(0.8)
|
||||||
score = updated
|
score = updated
|
||||||
sessionCable.sendScoreUpdate(updated)
|
sessionCable.sendScoreUpdate(updated)
|
||||||
|
} else {
|
||||||
|
guard generation == syncGeneration else { return }
|
||||||
|
lastActionError = "Risposta punteggio non valida"
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
guard generation == syncGeneration else { return }
|
guard generation == syncGeneration else { return }
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ struct ScoreState: Equatable, Sendable {
|
|||||||
key &+= Int64(homePoints) * 10
|
key &+= Int64(homePoints) * 10
|
||||||
key &+= Int64(awayPoints)
|
key &+= Int64(awayPoints)
|
||||||
key &+= Int64(clockSecs)
|
key &+= Int64(clockSecs)
|
||||||
|
key &+= Int64(period) * 100_000_000
|
||||||
|
key &+= overtime ? 50_000_000 : 0
|
||||||
key &+= Int64(setPartials.count) * 1_000_000
|
key &+= Int64(setPartials.count) * 1_000_000
|
||||||
for partial in setPartials {
|
for partial in setPartials {
|
||||||
key &+= Int64(partial.set) * 10_000
|
key &+= Int64(partial.set) * 10_000
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
/// Regole orientamento in diretta (allineate al debounce Android).
|
/// Regole orientamento in diretta (allineate al debounce Android).
|
||||||
enum BroadcastOrientationPolicy {
|
enum BroadcastOrientationPolicy {
|
||||||
/// Attesa prima di applicare rotazione al mixer (evita burst da giro iPad).
|
/// Attesa prima di applicare rotazione al mixer (evita burst da giro iPad).
|
||||||
static let debounceNanoseconds: UInt64 = 350_000_000
|
static let debounceNanoseconds: UInt64 = 350_000_000
|
||||||
/// Finestra in cui ignorare `closed` RTMP dopo rotazione encoder.
|
/// Finestra in cui ignorare `closed` RTMP dopo rotazione encoder.
|
||||||
static let suppressDisconnectSeconds: TimeInterval = 1.0
|
static var suppressDisconnectSeconds: TimeInterval {
|
||||||
|
UIDevice.current.userInterfaceIdiom == .pad ? 2.5 : 1.0
|
||||||
|
}
|
||||||
|
|
||||||
static func shouldApplyMixerOrientation(
|
static func shouldApplyMixerOrientation(
|
||||||
new: AVCaptureVideoOrientation,
|
new: AVCaptureVideoOrientation,
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
private var broadcastGeneration = 0
|
private var broadcastGeneration = 0
|
||||||
private var suppressDisconnectError = false
|
private var suppressDisconnectError = false
|
||||||
private var orientationTransitionUntil: Date = .distantPast
|
private var orientationTransitionUntil: Date = .distantPast
|
||||||
|
private var reconnectAttempts = 0
|
||||||
|
private var reconnectTask: Task<Void, Never>?
|
||||||
private var lastAppliedCaptureOrientation: AVCaptureVideoOrientation?
|
private var lastAppliedCaptureOrientation: AVCaptureVideoOrientation?
|
||||||
private var orientationDebounceTask: Task<Void, Never>?
|
private var orientationDebounceTask: Task<Void, Never>?
|
||||||
private var rtmpSession: (any Session)?
|
private var rtmpSession: (any Session)?
|
||||||
@@ -121,6 +123,9 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
self.suppressDisconnectError = suppressDisconnectError
|
self.suppressDisconnectError = suppressDisconnectError
|
||||||
defer { self.suppressDisconnectError = false }
|
defer { self.suppressDisconnectError = false }
|
||||||
broadcastGeneration += 1
|
broadcastGeneration += 1
|
||||||
|
reconnectTask?.cancel()
|
||||||
|
reconnectTask = nil
|
||||||
|
reconnectAttempts = 0
|
||||||
publishPending = false
|
publishPending = false
|
||||||
publishTask?.cancel()
|
publishTask?.cancel()
|
||||||
publishTask = nil
|
publishTask = nil
|
||||||
@@ -130,6 +135,9 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
|
|
||||||
func resumeBroadcast(config: BroadcastConfig) async throws {
|
func resumeBroadcast(config: BroadcastConfig) async throws {
|
||||||
self.config = config
|
self.config = config
|
||||||
|
reconnectAttempts = 0
|
||||||
|
reconnectTask?.cancel()
|
||||||
|
reconnectTask = nil
|
||||||
publishPending = true
|
publishPending = true
|
||||||
if !pipelineConfigured {
|
if !pipelineConfigured {
|
||||||
try await configurePipeline(config)
|
try await configurePipeline(config)
|
||||||
@@ -144,6 +152,9 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
self.suppressDisconnectError = suppressDisconnectError
|
self.suppressDisconnectError = suppressDisconnectError
|
||||||
defer { self.suppressDisconnectError = false }
|
defer { self.suppressDisconnectError = false }
|
||||||
broadcastGeneration += 1
|
broadcastGeneration += 1
|
||||||
|
reconnectTask?.cancel()
|
||||||
|
reconnectTask = nil
|
||||||
|
reconnectAttempts = 0
|
||||||
publishTask?.cancel()
|
publishTask?.cancel()
|
||||||
publishTask = nil
|
publishTask = nil
|
||||||
publishPending = false
|
publishPending = false
|
||||||
@@ -254,10 +265,15 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
try await publish(config: config, generation: generation)
|
try await publish(config: config, generation: generation)
|
||||||
if generation == broadcastGeneration {
|
if generation == broadcastGeneration {
|
||||||
publishPending = false
|
publishPending = false
|
||||||
|
reconnectAttempts = 0
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
guard generation == broadcastGeneration else { return }
|
guard generation == broadcastGeneration else { return }
|
||||||
publishPending = false
|
publishPending = false
|
||||||
|
if phase == .reconnecting || reconnectAttempts > 0 {
|
||||||
|
scheduleReconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
let message = UserFacingError.message(for: error) ?? "Connessione RTMP fallita"
|
let message = UserFacingError.message(for: error) ?? "Connessione RTMP fallita"
|
||||||
setPhase(.error, error: message)
|
setPhase(.error, error: message)
|
||||||
}
|
}
|
||||||
@@ -300,16 +316,16 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
try await session.connect { [weak self] in
|
try await session.connect { [weak self] in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let self,
|
guard let self,
|
||||||
!self.shouldSuppressDisconnectError,
|
|
||||||
self.broadcastGeneration == generation,
|
self.broadcastGeneration == generation,
|
||||||
self.phase == .live else { return }
|
self.phase == .live || self.phase == .reconnecting else { return }
|
||||||
self.setPhase(.error, error: "Connessione RTMP interrotta")
|
self.scheduleReconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
guard generation == broadcastGeneration else {
|
guard generation == broadcastGeneration else {
|
||||||
await teardownRTMP(keepPreview: true)
|
await teardownRTMP(keepPreview: true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
reconnectAttempts = 0
|
||||||
setPhase(.live)
|
setPhase(.live)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,9 +357,8 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
switch state {
|
switch state {
|
||||||
case .open:
|
case .open:
|
||||||
setPhase(.live)
|
setPhase(.live)
|
||||||
case .closed where phase == .live:
|
case .closed where phase == .live || phase == .reconnecting:
|
||||||
guard !shouldSuppressDisconnectError else { return }
|
scheduleReconnect()
|
||||||
setPhase(.error, error: "Connessione RTMP interrotta")
|
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -369,6 +384,33 @@ final class LiveBroadcastEngine: ObservableObject {
|
|||||||
suppressDisconnectError || Date() < orientationTransitionUntil
|
suppressDisconnectError || Date() < orientationTransitionUntil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Riconnessione automatica RTMP (come Android): evita falsi errori su iPad.
|
||||||
|
private func scheduleReconnect() {
|
||||||
|
guard !shouldSuppressDisconnectError else { return }
|
||||||
|
guard phase == .live || phase == .reconnecting else { return }
|
||||||
|
guard let config else { return }
|
||||||
|
|
||||||
|
reconnectAttempts += 1
|
||||||
|
if reconnectAttempts > config.maxReconnectAttempts {
|
||||||
|
reconnectTask = nil
|
||||||
|
setPhase(.error, error: "Connessione RTMP interrotta")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setPhase(.reconnecting)
|
||||||
|
reconnectTask?.cancel()
|
||||||
|
let generation = broadcastGeneration
|
||||||
|
let delayMs = config.reconnectDelayMs
|
||||||
|
reconnectTask = Task {
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
||||||
|
guard !Task.isCancelled, generation == broadcastGeneration else { return }
|
||||||
|
guard phase == .reconnecting else { return }
|
||||||
|
publishPending = true
|
||||||
|
publishInFlight = false
|
||||||
|
await startPendingPublish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func applyVideoOrientationIfNeeded(force: Bool = false) async {
|
private func applyVideoOrientationIfNeeded(force: Bool = false) async {
|
||||||
let orientation = BroadcastVideoOrientation.captureOrientation()
|
let orientation = BroadcastVideoOrientation.captureOrientation()
|
||||||
let landscapeLocked = AppOrientation.mode == .landscape
|
let landscapeLocked = AppOrientation.mode == .landscape
|
||||||
|
|||||||
@@ -117,7 +117,10 @@ struct BroadcastScreen: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.alert("Errore", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
|
.alert("Errore", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
|
||||||
Button("OK") { onFinished() }
|
Button("Riprova") {
|
||||||
|
Task { await retryBroadcast() }
|
||||||
|
}
|
||||||
|
Button("Esci", role: .destructive) { onFinished() }
|
||||||
} message: {
|
} message: {
|
||||||
Text(error ?? "")
|
Text(error ?? "")
|
||||||
}
|
}
|
||||||
@@ -240,7 +243,12 @@ struct BroadcastScreen: View {
|
|||||||
onCloseSet: usesSetScoring ? {
|
onCloseSet: usesSetScoring ? {
|
||||||
Task { await liveScoreActions(for: match).requestCloseSet() }
|
Task { await liveScoreActions(for: match).requestCloseSet() }
|
||||||
} : nil,
|
} : nil,
|
||||||
onAdvancePeriod: usesActionScoring ? { scoreController.applyAction("advance_period") } : nil,
|
onAdvancePeriod: usesActionScoring ? {
|
||||||
|
Task {
|
||||||
|
guard await scoreController.applyBoardAction("advance_period") else { return }
|
||||||
|
updateOverlay()
|
||||||
|
}
|
||||||
|
} : nil,
|
||||||
onPauseOrResume: {
|
onPauseOrResume: {
|
||||||
if isPaused {
|
if isPaused {
|
||||||
Task { await resumeStream() }
|
Task { await resumeStream() }
|
||||||
@@ -285,6 +293,7 @@ struct BroadcastScreen: View {
|
|||||||
if isPaused { return MatchColors.accentYellow }
|
if isPaused { return MatchColors.accentYellow }
|
||||||
switch metrics.phase {
|
switch metrics.phase {
|
||||||
case .live: return MatchColors.successGreen
|
case .live: return MatchColors.successGreen
|
||||||
|
case .reconnecting: return MatchColors.accentYellow
|
||||||
case .error: return MatchColors.primaryRed
|
case .error: return MatchColors.primaryRed
|
||||||
default: return MatchColors.accentYellow
|
default: return MatchColors.accentYellow
|
||||||
}
|
}
|
||||||
@@ -500,6 +509,18 @@ struct BroadcastScreen: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func retryBroadcast() async {
|
||||||
|
error = nil
|
||||||
|
guard let session, let url = session.rtmpIngestUrl, !url.isEmpty else { return }
|
||||||
|
let config = broadcastConfig(for: session, rtmpUrl: url)
|
||||||
|
do {
|
||||||
|
try await container.broadcastCoordinator.resumeBroadcast(config: config)
|
||||||
|
snackbarMessage = "Riconnessione avviata"
|
||||||
|
} catch {
|
||||||
|
self.error = UserFacingError.message(for: error) ?? "Riconnessione non riuscita"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func stopStream() async {
|
private func stopStream() async {
|
||||||
_ = try? await container.sessionRepository.stopSession(id: sessionId)
|
_ = try? await container.sessionRepository.stopSession(id: sessionId)
|
||||||
await teardown()
|
await teardown()
|
||||||
|
|||||||
@@ -31,9 +31,10 @@ final class LiveScoreDialogHost: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func resolve(_ value: Bool) {
|
func resolve(_ value: Bool) {
|
||||||
|
guard let continuation = waiter else { return }
|
||||||
pending = nil
|
pending = nil
|
||||||
waiter?.resume(returning: value)
|
|
||||||
waiter = nil
|
waiter = nil
|
||||||
|
continuation.resume(returning: value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,10 +132,9 @@ struct ScoreDialogRouter: View {
|
|||||||
private var dialogBinding: Binding<ScoreDialogState?> {
|
private var dialogBinding: Binding<ScoreDialogState?> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { host.pending },
|
get: { host.pending },
|
||||||
set: { newValue in
|
set: { _ in
|
||||||
if newValue == nil, host.pending != nil {
|
// Non risolvere qui: su iPad la dismiss dell'alert può azzerare l'item
|
||||||
host.resolve(false)
|
// dopo il tap su «Chiudi set» e sovrascrivere resolve(true) con false.
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
25
native/ios/MatchLiveTvTests/LiveScoreDialogHostTests.swift
Normal file
25
native/ios/MatchLiveTvTests/LiveScoreDialogHostTests.swift
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import MatchLiveTv
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LiveScoreDialogHostTests: XCTestCase {
|
||||||
|
func testResolveTrueCompletesShow() async {
|
||||||
|
let host = LiveScoreDialogHost()
|
||||||
|
let task = Task { await host.show(ScoreDialogState(kind: .setWon)) }
|
||||||
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||||
|
host.resolve(true)
|
||||||
|
let result = await task.value
|
||||||
|
XCTAssertTrue(result)
|
||||||
|
XCTAssertNil(host.pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSecondResolveIsIgnored() async {
|
||||||
|
let host = LiveScoreDialogHost()
|
||||||
|
let task = Task { await host.show(ScoreDialogState(kind: .setWon)) }
|
||||||
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||||
|
host.resolve(true)
|
||||||
|
host.resolve(false)
|
||||||
|
let result = await task.value
|
||||||
|
XCTAssertTrue(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,4 +65,38 @@ final class ScoreActionDecodeTests: XCTestCase {
|
|||||||
XCTAssertEqual(score.setPartials[0].home, 25)
|
XCTAssertEqual(score.setPartials[0].home, 25)
|
||||||
XCTAssertEqual(score.setPartials[0].away, 18)
|
XCTAssertEqual(score.setPartials[0].away, 18)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testDecodesBasketAdvancePeriodResponse() throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"id": "821ddb81-9dc2-466a-9ad5-2555e78f4653",
|
||||||
|
"match_id": "e887ce1d-10ee-4680-8323-79bb8f11e0aa",
|
||||||
|
"status": "live",
|
||||||
|
"platform": "matchlivetv",
|
||||||
|
"score": {
|
||||||
|
"type": "score_update",
|
||||||
|
"board_type": "basket",
|
||||||
|
"home_points": 12,
|
||||||
|
"away_points": 10,
|
||||||
|
"current_set": 1,
|
||||||
|
"set_partials": [],
|
||||||
|
"timeout_home": false,
|
||||||
|
"timeout_away": false,
|
||||||
|
"period": "Q2",
|
||||||
|
"clock_secs": 600,
|
||||||
|
"clock_running": false,
|
||||||
|
"data": { "home_score": 12, "away_score": 10, "period": 2, "clock_secs": 600, "clock_running": false, "overtime": false }
|
||||||
|
},
|
||||||
|
"set_won": false,
|
||||||
|
"match_won": false,
|
||||||
|
"winner": null
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
let dto = try ApiInstant.decoder.decode(StreamSessionDto.self, from: json)
|
||||||
|
let score = try XCTUnwrap(dto.score?.toDomain())
|
||||||
|
XCTAssertEqual(score.periodLabel, "Q2")
|
||||||
|
XCTAssertEqual(score.period, 2)
|
||||||
|
XCTAssertEqual(score.clockSecs, 600)
|
||||||
|
XCTAssertFalse(score.clockRunning)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,4 +55,30 @@ final class ScoreControllerTests: XCTestCase {
|
|||||||
controller.applyAction("home_point")
|
controller.applyAction("home_point")
|
||||||
XCTAssertEqual(controller.score.homePoints, 1)
|
XCTAssertEqual(controller.score.homePoints, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testAdvancePeriodOptimisticUpdatesQuarter() {
|
||||||
|
let controller = ScoreController(
|
||||||
|
scoreRepository: ScoreRepository(api: MatchLiveAPI()),
|
||||||
|
sessionCable: SessionCableService()
|
||||||
|
)
|
||||||
|
var initial = ScoreState()
|
||||||
|
initial.boardType = "basket"
|
||||||
|
initial.period = 1
|
||||||
|
initial.periodLabel = "Q1"
|
||||||
|
controller.bind(sessionId: "session-1", initial: initial)
|
||||||
|
controller.applyAction("advance_period")
|
||||||
|
XCTAssertEqual(controller.score.period, 2)
|
||||||
|
XCTAssertEqual(controller.score.periodLabel, "Q2")
|
||||||
|
XCTAssertFalse(controller.score.clockRunning)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testProgressKeyChangesWhenPeriodAdvances() {
|
||||||
|
var q1 = ScoreState(boardType: "basket", period: 1, periodLabel: "Q1", clockSecs: 600)
|
||||||
|
var q2 = ScoreState(boardType: "basket", period: 2, periodLabel: "Q2", clockSecs: 600)
|
||||||
|
q1.homePoints = 10
|
||||||
|
q1.awayPoints = 8
|
||||||
|
q2.homePoints = 10
|
||||||
|
q2.awayPoints = 8
|
||||||
|
XCTAssertGreaterThan(q2.progressKey(), q1.progressKey())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user