The app was crashing from memory pressure on tvOS. Three causes fixed: 1. Feed was rendering all 418 highlights at once — capped to 50 items. 2. FeaturedGameCard had 3 blur effects (radius 80-120) on large circles for team color glow — replaced with a single LinearGradient. Same visual effect, fraction of the GPU memory. 3. BroadcastBackground had 3 blurred circles (radius 120-140, 680-900px) rendering on every screen — replaced with RadialGradients which are composited by the GPU natively without offscreen render passes. Also fixed iOS build: replaced tvOS-only font refs (tvSectionTitle, tvBody) with cross-platform equivalents in DashboardView fallback state. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
168 lines
5.8 KiB
Swift
168 lines
5.8 KiB
Swift
import SwiftUI
|
|
import UIKit
|
|
|
|
struct ScoresTickerView: View {
|
|
var gamesOverride: [Game]? = nil
|
|
|
|
@Environment(GamesViewModel.self) private var viewModel
|
|
|
|
private var entries: [Game] {
|
|
gamesOverride ?? viewModel.games
|
|
}
|
|
|
|
var body: some View {
|
|
if !entries.isEmpty {
|
|
TickerMarqueeView(text: tickerText)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.frame(height: 22)
|
|
.padding(.horizontal, 18)
|
|
.padding(.vertical, 14)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
.fill(DS.Colors.navFill)
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
|
|
}
|
|
)
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|
|
|
|
private var tickerText: String {
|
|
entries.map(tickerSummary(for:)).joined(separator: " | ")
|
|
}
|
|
|
|
private func tickerSummary(for game: Game) -> String {
|
|
let matchup = "\(game.awayTeam.code) \(game.awayTeam.score.map(String.init) ?? "-") - \(game.homeTeam.code) \(game.homeTeam.score.map(String.init) ?? "-")"
|
|
|
|
if game.isLive, let linescore = game.linescore {
|
|
let inning = linescore.currentInningOrdinal ?? game.currentInningDisplay ?? "LIVE"
|
|
let state = (linescore.inningState ?? linescore.inningHalf ?? "Live").uppercased()
|
|
let outs = linescore.outs ?? 0
|
|
return "\(matchup) \(state) \(inning.uppercased()) • \(outs) OUT\(outs == 1 ? "" : "S")"
|
|
}
|
|
|
|
if game.isFinal {
|
|
return "\(matchup) FINAL"
|
|
}
|
|
|
|
if let startTime = game.startTime {
|
|
return "\(matchup) \(startTime.uppercased())"
|
|
}
|
|
|
|
return "\(matchup) \(game.status.label.uppercased())"
|
|
}
|
|
}
|
|
|
|
private struct TickerMarqueeView: UIViewRepresentable {
|
|
let text: String
|
|
|
|
func makeUIView(context: Context) -> TickerMarqueeContainerView {
|
|
let view = TickerMarqueeContainerView()
|
|
view.setText(text)
|
|
return view
|
|
}
|
|
|
|
func updateUIView(_ uiView: TickerMarqueeContainerView, context: Context) {
|
|
uiView.setText(text)
|
|
}
|
|
}
|
|
|
|
private final class TickerMarqueeContainerView: UIView {
|
|
private let trackView = UIView()
|
|
private let primaryLabel = UILabel()
|
|
private let secondaryLabel = UILabel()
|
|
private let spacing: CGFloat = 48
|
|
private let pointsPerSecond: CGFloat = 64
|
|
|
|
private var currentText = ""
|
|
private var previousBoundsWidth: CGFloat = 0
|
|
private var previousContentWidth: CGFloat = 0
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
|
|
clipsToBounds = true
|
|
isUserInteractionEnabled = false
|
|
|
|
let baseFont = UIFont.systemFont(ofSize: 15, weight: .bold)
|
|
let roundedFont = UIFont(descriptor: baseFont.fontDescriptor.withDesign(.rounded) ?? baseFont.fontDescriptor, size: 15)
|
|
|
|
[primaryLabel, secondaryLabel].forEach { label in
|
|
label.font = roundedFont
|
|
label.textColor = UIColor.white.withAlphaComponent(0.92)
|
|
label.numberOfLines = 1
|
|
label.lineBreakMode = .byClipping
|
|
trackView.addSubview(label)
|
|
}
|
|
|
|
addSubview(trackView)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
func setText(_ text: String) {
|
|
guard currentText != text else { return }
|
|
currentText = text
|
|
primaryLabel.text = text
|
|
secondaryLabel.text = text
|
|
previousContentWidth = 0
|
|
setNeedsLayout()
|
|
}
|
|
|
|
override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
|
|
guard bounds.width > 0, bounds.height > 0 else { return }
|
|
|
|
let contentWidth = ceil(primaryLabel.sizeThatFits(CGSize(width: .greatestFiniteMagnitude, height: bounds.height)).width)
|
|
let contentHeight = bounds.height
|
|
|
|
primaryLabel.frame = CGRect(x: 0, y: 0, width: contentWidth, height: contentHeight)
|
|
secondaryLabel.frame = CGRect(x: contentWidth + spacing, y: 0, width: contentWidth, height: contentHeight)
|
|
|
|
let cycleWidth = contentWidth + spacing
|
|
|
|
if contentWidth <= bounds.width {
|
|
trackView.layer.removeAllAnimations()
|
|
secondaryLabel.isHidden = true
|
|
trackView.frame = CGRect(x: 0, y: 0, width: bounds.width, height: contentHeight)
|
|
primaryLabel.frame = CGRect(x: 0, y: 0, width: bounds.width, height: contentHeight)
|
|
primaryLabel.textAlignment = .left
|
|
previousBoundsWidth = bounds.width
|
|
previousContentWidth = contentWidth
|
|
return
|
|
}
|
|
|
|
primaryLabel.textAlignment = .left
|
|
secondaryLabel.isHidden = false
|
|
trackView.frame = CGRect(x: 0, y: 0, width: contentWidth * 2 + spacing, height: contentHeight)
|
|
|
|
let shouldRestart = abs(previousBoundsWidth - bounds.width) > 0.5
|
|
|| abs(previousContentWidth - contentWidth) > 0.5
|
|
|| trackView.layer.animation(forKey: "tickerMarquee") == nil
|
|
|
|
previousBoundsWidth = bounds.width
|
|
previousContentWidth = contentWidth
|
|
|
|
guard shouldRestart else { return }
|
|
|
|
trackView.layer.removeAllAnimations()
|
|
trackView.transform = .identity
|
|
|
|
let animation = CABasicAnimation(keyPath: "transform.translation.x")
|
|
animation.fromValue = 0
|
|
animation.toValue = -cycleWidth
|
|
animation.duration = Double(cycleWidth / pointsPerSecond)
|
|
animation.repeatCount = .infinity
|
|
animation.timingFunction = CAMediaTimingFunction(name: .linear)
|
|
animation.isRemovedOnCompletion = false
|
|
|
|
trackView.layer.add(animation, forKey: "tickerMarquee")
|
|
}
|
|
}
|