Files
MatchLiveTv/native/ios/MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift
T
Emiliano FrascaroandCursor 50b8a8c8e2 Allinea iOS ad Android sul dialog Configura/Più tardi e porta l'app a 2.0.12.
Aggiunge UI test sul simulatore (hub, lingue, wizard, diretta) e identificatori di accessibilità per poterli ripetere su collaudo e produzione.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 17:54:33 +02:00

678 lines
26 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
private let sideToolbarWidth: CGFloat = 44
private let iconButtonSize: CGFloat = 36
private let scoreButtonHeight: CGFloat = 32
private enum SideConfirmAction {
case shareLive
case shareRegia
case advancePeriod
case pauseOrResume
case toggleMute
case terminate
}
struct BroadcastControlsOverlay: View {
let controlsVisible: Bool
let onToggleControls: () -> Void
let statusText: String
let statusColor: Color
let cableConnected: Bool
let isPaused: Bool
let homeName: String
let awayName: String
let homeAccentColor: Color
let awayAccentColor: Color
let homeLogoUrl: String?
let awayLogoUrl: String?
let score: ScoreState
let boardType: String
let pointsTarget: Int
let onPointHome: () -> Void
let onPointAway: () -> Void
let onMinusHome: () -> Void
let onMinusAway: () -> Void
var onPoint2Home: (() -> Void)?
var onPoint3Home: (() -> Void)?
var onPoint2Away: (() -> Void)?
var onPoint3Away: (() -> Void)?
var onCloseSet: (() -> Void)?
var onAdvancePeriod: (() -> Void)?
let onPauseOrResume: () -> Void
let onToggleAudioMute: () -> Void
let audioMuted: Bool
let onTerminate: () -> Void
let onShareLive: () -> Void
let onShareRegia: () -> Void
let shareLiveEnabled: Bool
let fps: Int
let targetFps: Int
let bitrateKbps: Int
let networkType: String
let deviceHealth: DeviceHealth
var minQualityPreset: String = StreamQualityLadder.auto
var sessionQualityPreset: String = "720p_30_2.5mbps"
var onSelectMinQuality: (String) -> Void = { _ in }
@State private var pendingConfirm: SideConfirmAction?
@State private var showMinQualityPicker = false
var body: some View {
Color.clear
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
.overlay(alignment: .topLeading) {
MatchStatusBadge(
text: statusText,
backgroundColor: MatchColors.background.opacity(0.78),
textColor: statusColor
)
.padding(.leading, 8)
.padding(.top, 4)
}
.overlay(alignment: .topTrailing) {
VStack(alignment: .trailing, spacing: 6) {
SideIconButton(
systemName: controlsVisible ? "eye.slash.fill" : "eye.fill",
accessibilityLabel: controlsVisible ? L10n.t("broadcast.hide.controls.cd") : L10n.t("broadcast.show.controls.cd"),
action: onToggleControls
)
BroadcastTelemetryPanel(
cableConnected: cableConnected,
fps: fps,
targetFps: targetFps,
bitrateKbps: bitrateKbps,
networkType: networkType,
deviceHealth: deviceHealth,
audioMuted: audioMuted,
minQualityLabel: StreamQualityLadder.displayName(
id: minQualityPreset,
autoLabel: L10n.t("broadcast.min.quality.auto")
)
)
}
.padding(.trailing, 8)
.padding(.top, 4)
}
.overlay(alignment: .leading) {
if controlsVisible {
leftToolbar
.padding(.leading, 6)
}
}
.overlay(alignment: .trailing) {
if controlsVisible {
rightToolbar
.padding(.trailing, 6)
}
}
.overlay(alignment: .bottom) {
if controlsVisible {
scoreControlsRow
.padding(.horizontal, sideToolbarWidth)
.padding(.bottom, 8)
}
}
.overlay {
if showMinQualityPicker {
MinQualityPickerOverlay(
selectedId: minQualityPreset,
sessionQualityPreset: sessionQualityPreset,
onSelect: { id in
showMinQualityPicker = false
onSelectMinQuality(id)
},
onDismiss: { showMinQualityPicker = false }
)
}
}
.alert(
sideConfirmTitle,
isPresented: Binding(
get: { pendingConfirm != nil },
set: { if !$0 { pendingConfirm = nil } }
)
) {
Button(L10n.t("action.cancel"), role: .cancel) { pendingConfirm = nil }
if pendingConfirm == .terminate {
Button(L10n.t("broadcast.terminate.confirm"), role: .destructive, action: performPendingConfirm)
} else {
Button(L10n.t("action.confirm"), action: performPendingConfirm)
}
} message: {
Text(sideConfirmMessage)
}
}
private var sideConfirmTitle: String {
switch pendingConfirm {
case .shareLive: return L10n.t("broadcast.share.live.title")
case .shareRegia: return L10n.t("broadcast.share.regia.title")
case .advancePeriod: return L10n.t("broadcast.advance.period.title")
case .pauseOrResume:
return isPaused ? L10n.t("broadcast.resume.title") : L10n.t("broadcast.pause.title")
case .toggleMute:
return audioMuted ? L10n.t("broadcast.unmute.title") : L10n.t("broadcast.mute.title")
case .terminate: return L10n.t("broadcast.terminate.title")
case .none: return ""
}
}
private var sideConfirmMessage: String {
switch pendingConfirm {
case .shareLive: return L10n.t("broadcast.share.live.message")
case .shareRegia: return L10n.t("broadcast.share.regia.message")
case .advancePeriod: return L10n.t("broadcast.advance.period.message")
case .pauseOrResume:
return isPaused ? L10n.t("broadcast.resume.message") : L10n.t("broadcast.pause.message")
case .toggleMute:
return audioMuted ? L10n.t("broadcast.unmute.message") : L10n.t("broadcast.mute.message")
case .terminate: return L10n.t("broadcast.terminate.message")
case .none: return ""
}
}
private func performPendingConfirm() {
guard let action = pendingConfirm else { return }
pendingConfirm = nil
switch action {
case .shareLive: onShareLive()
case .shareRegia: onShareRegia()
case .advancePeriod: onAdvancePeriod?()
case .pauseOrResume: onPauseOrResume()
case .toggleMute: onToggleAudioMute()
case .terminate: onTerminate()
}
}
private var leftToolbar: some View {
VStack(spacing: 6) {
SideIconButton(
systemName: "square.and.arrow.up",
accessibilityLabel: L10n.t("broadcast.share.live.cd"),
action: { pendingConfirm = .shareLive },
enabled: shareLiveEnabled
)
.accessibilityIdentifier("broadcast.share")
SideIconButton(
systemName: "video.fill",
accessibilityLabel: L10n.t("broadcast.share.regia.cd"),
action: { pendingConfirm = .shareRegia }
)
.accessibilityIdentifier("broadcast.regia")
SideIconButton(
systemName: "slider.horizontal.3",
accessibilityLabel: L10n.t("broadcast.min.quality.cd"),
action: { showMinQualityPicker = true }
)
.accessibilityIdentifier("broadcast.abr")
if let onCloseSet {
SideIconButton(
systemName: "checkmark",
accessibilityLabel: L10n.t("score.action.close.set"),
action: onCloseSet
)
}
if onAdvancePeriod != nil {
SideIconButton(
systemName: "forward.end.fill",
accessibilityLabel: L10n.t("broadcast.next.period.cd"),
action: { pendingConfirm = .advancePeriod }
)
}
}
.frame(width: sideToolbarWidth)
.allowsHitTesting(true)
}
private var rightToolbar: some View {
VStack(spacing: 6) {
SideIconButton(
systemName: isPaused ? "play.fill" : "pause.fill",
accessibilityLabel: isPaused ? L10n.t("broadcast.resume.cd") : L10n.t("broadcast.pause.cd"),
action: { pendingConfirm = .pauseOrResume },
highlighted: isPaused
)
.accessibilityIdentifier("broadcast.pause")
SideIconButton(
systemName: audioMuted ? "speaker.slash.fill" : "speaker.wave.2.fill",
accessibilityLabel: audioMuted ? L10n.t("broadcast.unmute.cd") : L10n.t("broadcast.mute.cd"),
action: { pendingConfirm = .toggleMute },
highlighted: audioMuted
)
.accessibilityIdentifier("broadcast.mute")
SideIconButton(
systemName: "stop.fill",
accessibilityLabel: L10n.t("broadcast.terminate.cd"),
action: { pendingConfirm = .terminate },
danger: true
)
.accessibilityIdentifier("broadcast.terminate")
}
}
private var scoreControlsRow: some View {
HStack(alignment: .bottom, spacing: 0) {
TeamScoreColumn(
teamLabel: L10n.t("broadcast.team.home.label"),
teamName: homeName,
accentColor: homeAccentColor,
logoUrl: homeLogoUrl,
points: score.homePoints,
onPlus: onPointHome,
onMinus: onMinusHome,
onPlus2: onPoint2Home,
onPlus3: onPoint3Home,
showBasketButtons: boardType == "basket",
alignEnd: false
)
.frame(maxWidth: .infinity)
ScoreCenterPanel(score: score, boardType: boardType, pointsTarget: pointsTarget)
TeamScoreColumn(
teamLabel: L10n.t("broadcast.team.away.label"),
teamName: awayName,
accentColor: awayAccentColor,
logoUrl: awayLogoUrl,
points: score.awayPoints,
onPlus: onPointAway,
onMinus: onMinusAway,
onPlus2: onPoint2Away,
onPlus3: onPoint3Away,
showBasketButtons: boardType == "basket",
alignEnd: true
)
.frame(maxWidth: .infinity)
}
}
}
private struct MinQualityPickerOverlay: View {
let selectedId: String
let sessionQualityPreset: String
let onSelect: (String) -> Void
let onDismiss: () -> Void
private var options: [String] {
StreamQualityLadder.availableIds(sessionQualityPreset: sessionQualityPreset)
}
var body: some View {
ZStack {
Color.black.opacity(0.55)
.ignoresSafeArea()
.onTapGesture(perform: onDismiss)
VStack(alignment: .leading, spacing: 12) {
HStack {
Text(L10n.t("broadcast.min.quality.title"))
.font(.headline)
.foregroundStyle(.white)
Spacer()
Button(L10n.t("action.cancel"), action: onDismiss)
.foregroundStyle(MatchColors.textSecondary)
}
Text(L10n.t("broadcast.min.quality.hint"))
.font(.footnote)
.foregroundStyle(MatchColors.textSecondary)
HStack(spacing: 8) {
ForEach(options, id: \.self) { id in
let selected = id == selectedId
Button {
onSelect(id)
} label: {
VStack(spacing: 4) {
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(selected ? MatchColors.accentYellow : .clear)
Text(
StreamQualityLadder.displayName(
id: id,
autoLabel: L10n.t("broadcast.min.quality.auto")
)
)
.font(.system(size: 13, weight: selected ? .semibold : .medium))
.foregroundStyle(selected ? MatchColors.accentYellow : .white)
.multilineTextAlignment(.center)
.lineLimit(2)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 8)
.padding(.vertical, 12)
.background(
selected
? MatchColors.accentYellow.opacity(0.16)
: MatchColors.surfaceElevated,
in: RoundedRectangle(cornerRadius: 12)
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(selected ? MatchColors.accentYellow : MatchColors.outline, lineWidth: 1)
)
}
.buttonStyle(.plain)
}
}
}
.padding(16)
.frame(maxWidth: 720)
.background(MatchColors.surface, in: RoundedRectangle(cornerRadius: 16))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(MatchColors.outline, lineWidth: 1)
)
.padding(.horizontal, 24)
}
}
}
private struct BroadcastTelemetryPanel: View {
let cableConnected: Bool
let fps: Int
let targetFps: Int
let bitrateKbps: Int
let networkType: String
let deviceHealth: DeviceHealth
let audioMuted: Bool
let minQualityLabel: String
var body: some View {
VStack(alignment: .trailing, spacing: 2) {
Text(cableConnected ? L10n.t("broadcast.scoreboard.connected") : L10n.t("broadcast.scoreboard.offline"))
.font(.system(size: 11, weight: .medium))
.foregroundStyle(cableConnected ? MatchColors.successGreen : MatchColors.textSecondary)
let fpsLabel = fps > 0 ? "\(fps) fps" : "— fps"
let targetSuffix = targetFps > 0 ? " / \(targetFps)" : ""
Text(fpsLabel + targetSuffix)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.white)
if bitrateKbps > 0 {
Text(formatBitrateKbps(bitrateKbps))
.font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary)
}
Text(minQualityLabel)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.accentYellow)
Text("\(networkType) · \(deviceHealth.batteryPercent)%")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary)
if audioMuted {
Text(L10n.t("broadcast.audio.muted.label"))
.font(.system(size: 11, weight: .bold))
.foregroundStyle(MatchColors.accentYellow)
}
ThermalIndicator(state: deviceHealth.thermalState)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(MatchColors.background.opacity(0.78), in: RoundedRectangle(cornerRadius: 8))
}
}
private struct ThermalIndicator: View {
let state: ThermalState
private var color: Color {
switch state {
case .nominal: return MatchColors.successGreen
case .fair: return MatchColors.accentYellow
case .serious: return Color.orange
case .critical: return MatchColors.primaryRed
}
}
var body: some View {
let warningBg = state >= .fair ? color.opacity(0.18) : Color.clear
HStack(spacing: 4) {
Text(state.indicatorSymbol)
.font(.system(size: 11))
Text(state.displayLabel)
.font(.system(size: 11, weight: state >= .fair ? .bold : .medium))
.foregroundStyle(color)
.lineLimit(1)
.minimumScaleFactor(0.85)
}
.padding(.horizontal, 4)
.padding(.vertical, 1)
.background(warningBg, in: RoundedRectangle(cornerRadius: 4))
}
}
private struct ScoreCenterPanel: View {
let score: ScoreState
let boardType: String
let pointsTarget: Int
var body: some View {
VStack(spacing: 2) {
Text("\(score.homePoints) - \(score.awayPoints)")
.font(MatchTypography.titleMedium)
.foregroundStyle(.white)
.fontWeight(.bold)
switch boardType {
case "basket", "timed":
Text(score.periodLabel ?? (boardType == "basket" ? "Q\(score.period)" : L10n.t("broadcast.period.label.timed", score.period)))
.font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary)
case "generic":
EmptyView()
default:
Text(L10n.t("broadcast.set.progress", score.currentSet, pointsTarget))
.font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary)
if score.homeSets > 0 || score.awaySets > 0 {
Text(L10n.t("broadcast.sets.won", score.homeSets, score.awaySets))
.font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary)
}
}
}
.padding(.horizontal, 12)
.padding(.bottom, 8)
}
}
private struct TeamScoreColumn: View {
let teamLabel: String
let teamName: String
let accentColor: Color
let logoUrl: String?
let points: Int
let onPlus: () -> Void
let onMinus: () -> Void
var onPlus2: (() -> Void)?
var onPlus3: (() -> Void)?
let showBasketButtons: Bool
let alignEnd: Bool
var body: some View {
VStack(alignment: alignEnd ? .trailing : .leading, spacing: 0) {
TeamIdentityRow(
teamLabel: teamLabel,
teamName: teamName,
accentColor: accentColor,
logoUrl: logoUrl,
alignEnd: alignEnd
)
Spacer().frame(height: 6)
Text("\(points)")
.font(MatchTypography.headlineMedium)
.foregroundStyle(accentColor)
.fontWeight(.bold)
.padding(.vertical, 4)
Spacer().frame(height: 4)
let teamSide = alignEnd ? L10n.t("broadcast.side.away") : L10n.t("broadcast.side.home")
HStack(spacing: 6) {
if alignEnd {
if showBasketButtons {
if let onPlus3 {
ScoreIconButton(label: "+3", tooltip: L10n.t("broadcast.tooltip.plus.side", 3, teamSide), action: onPlus3, primary: true)
}
if let onPlus2 {
ScoreIconButton(label: "+2", tooltip: L10n.t("broadcast.tooltip.plus.side", 2, teamSide), action: onPlus2, primary: true)
}
}
ScoreIconButton(label: "+1", tooltip: L10n.t("broadcast.tooltip.add.point", teamSide), action: onPlus, primary: !showBasketButtons)
.accessibilityIdentifier("broadcast.score.away.plus")
ScoreIconButton(label: "", tooltip: L10n.t("broadcast.tooltip.remove.point", teamSide), action: onMinus)
} else {
ScoreIconButton(label: "", tooltip: L10n.t("broadcast.tooltip.remove.point", teamSide), action: onMinus)
ScoreIconButton(label: "+1", tooltip: L10n.t("broadcast.tooltip.add.point", teamSide), action: onPlus, primary: !showBasketButtons)
.accessibilityIdentifier(alignEnd ? "broadcast.score.away.plus" : "broadcast.score.home.plus")
if showBasketButtons {
if let onPlus2 {
ScoreIconButton(label: "+2", tooltip: L10n.t("broadcast.tooltip.plus.side", 2, teamSide), action: onPlus2, primary: true)
}
if let onPlus3 {
ScoreIconButton(label: "+3", tooltip: L10n.t("broadcast.tooltip.plus.side", 3, teamSide), action: onPlus3, primary: true)
}
}
}
}
.frame(maxWidth: .infinity, alignment: alignEnd ? .trailing : .leading)
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(MatchColors.background.opacity(0.72), in: RoundedRectangle(cornerRadius: 10))
}
}
private struct TeamIdentityRow: View {
let teamLabel: String
let teamName: String
let accentColor: Color
let logoUrl: String?
let alignEnd: Bool
var body: some View {
let resolvedLogoUrl = MediaUrl.resolve(logoUrl)
HStack(alignment: .center, spacing: 0) {
if alignEnd {
VStack(alignment: .trailing, spacing: 0) {
Text(teamLabel)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary)
.kerning(1)
Text(teamName)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white)
.lineLimit(1)
.multilineTextAlignment(.trailing)
}
.frame(maxWidth: .infinity, alignment: .trailing)
if let resolvedLogoUrl, let url = URL(string: resolvedLogoUrl) {
Spacer().frame(width: 8)
TeamLogoThumbnail(url: url)
}
Spacer().frame(width: 6)
TeamColorBar(color: accentColor)
} else {
TeamColorBar(color: accentColor)
if let resolvedLogoUrl, let url = URL(string: resolvedLogoUrl) {
Spacer().frame(width: 6)
TeamLogoThumbnail(url: url)
}
Spacer().frame(width: 8)
VStack(alignment: .leading, spacing: 0) {
Text(teamLabel)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary)
.kerning(1)
Text(teamName)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}
private struct TeamColorBar: View {
let color: Color
var body: some View {
RoundedRectangle(cornerRadius: 2)
.fill(color)
.frame(width: 4, height: 36)
}
}
private struct TeamLogoThumbnail: View {
let url: URL
var body: some View {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFill()
default:
Color.clear
}
}
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(MatchColors.outline, lineWidth: 1))
.background(MatchColors.surfaceElevated, in: RoundedRectangle(cornerRadius: 6))
}
}
private struct SideIconButton: View {
let systemName: String
let accessibilityLabel: String
let action: () -> Void
var enabled: Bool = true
var highlighted: Bool = false
var danger: Bool = false
var body: some View {
Button(action: action) {
Image(systemName: systemName)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(enabled ? .white : MatchColors.textSecondary)
.frame(width: iconButtonSize, height: iconButtonSize)
.contentShape(Rectangle())
.background(backgroundColor, in: RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.disabled(!enabled)
.accessibilityLabel(accessibilityLabel)
}
private var backgroundColor: Color {
if danger { return MatchColors.primaryRed.opacity(0.88) }
if highlighted { return MatchColors.successGreen.opacity(0.55) }
return MatchColors.background.opacity(0.78)
}
}
private struct ScoreIconButton: View {
let label: String
let tooltip: String
let action: () -> Void
var primary: Bool = false
var body: some View {
Button(action: action) {
Text(label)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white)
.frame(width: label == "+1" ? 40 : scoreButtonHeight, height: scoreButtonHeight)
.background(primary ? MatchColors.primaryRed : MatchColors.surfaceElevated, in: RoundedRectangle(cornerRadius: 6))
}
.buttonStyle(.plain)
.accessibilityLabel(tooltip)
}
}
private func formatBitrateKbps(_ kbps: Int) -> String {
if kbps >= 1000 {
return String(format: "%.1f Mbps", Double(kbps) / 1000.0)
}
return "\(kbps) kbps"
}