Initial commit
This commit is contained in:
108
mlbTVOS/Views/Components/LinescoreView.swift
Normal file
108
mlbTVOS/Views/Components/LinescoreView.swift
Normal file
@@ -0,0 +1,108 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LinescoreView: View {
|
||||
let linescore: StatsLinescore
|
||||
let awayCode: String
|
||||
let homeCode: String
|
||||
|
||||
private var totalInnings: Int {
|
||||
linescore.scheduledInnings ?? 9
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack(spacing: 0) {
|
||||
Text("")
|
||||
.frame(width: 70, alignment: .leading)
|
||||
|
||||
ForEach(1...totalInnings, id: \.self) { inning in
|
||||
let isCurrent = inning == linescore.currentInning
|
||||
HStack(spacing: 0) {
|
||||
if inning > 1 && inning % 3 == 1 {
|
||||
Divider()
|
||||
.frame(width: 1, height: 18)
|
||||
.background(.secondary.opacity(0.3))
|
||||
.padding(.trailing, 4)
|
||||
}
|
||||
Text("\(inning)")
|
||||
.font(.callout.weight(.semibold).monospacedDigit())
|
||||
.foregroundStyle(isCurrent ? .primary : .secondary)
|
||||
.frame(width: 44)
|
||||
}
|
||||
}
|
||||
|
||||
Divider().frame(width: 1, height: 20).padding(.horizontal, 6)
|
||||
|
||||
ForEach(["R", "H", "E"], id: \.self) { label in
|
||||
Text(label)
|
||||
.font(.callout.weight(.bold).monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 48)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.background(.ultraThinMaterial)
|
||||
|
||||
Divider()
|
||||
|
||||
teamRow(code: awayCode, innings: linescore.innings ?? [], side: .away, totals: linescore.teams?.away)
|
||||
Divider()
|
||||
teamRow(code: homeCode, innings: linescore.innings ?? [], side: .home, totals: linescore.teams?.home)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.background(.regularMaterial)
|
||||
}
|
||||
|
||||
private enum Side { case away, home }
|
||||
|
||||
@ViewBuilder
|
||||
private func teamRow(code: String, innings: [StatsInningScore], side: Side, totals: StatsLinescoreTotals?) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
Text(code)
|
||||
.font(.callout.weight(.bold))
|
||||
.foregroundStyle(TeamAssets.color(for: code))
|
||||
.frame(width: 70, alignment: .leading)
|
||||
|
||||
ForEach(1...totalInnings, id: \.self) { inning in
|
||||
let runs = inningRuns(innings: innings, inning: inning, side: side)
|
||||
let isCurrent = inning == linescore.currentInning
|
||||
HStack(spacing: 0) {
|
||||
if inning > 1 && inning % 3 == 1 {
|
||||
Divider()
|
||||
.frame(width: 1, height: 22)
|
||||
.background(.secondary.opacity(0.2))
|
||||
.padding(.trailing, 4)
|
||||
}
|
||||
Text(runs.map { "\($0)" } ?? "-")
|
||||
.font(.callout.weight(runs != nil ? .semibold : .regular).monospacedDigit())
|
||||
.foregroundStyle(runs == nil ? .tertiary : isCurrent ? .primary : .secondary)
|
||||
.frame(width: 44)
|
||||
}
|
||||
}
|
||||
|
||||
Divider().frame(width: 1, height: 24).padding(.horizontal, 6)
|
||||
|
||||
Text(totals?.runs.map { "\($0)" } ?? "-")
|
||||
.font(.callout.weight(.bold).monospacedDigit())
|
||||
.frame(width: 48)
|
||||
Text(totals?.hits.map { "\($0)" } ?? "-")
|
||||
.font(.callout.weight(.medium).monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 48)
|
||||
Text(totals?.errors.map { "\($0)" } ?? "-")
|
||||
.font(.callout.weight(.medium).monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 48)
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
private func inningRuns(innings: [StatsInningScore], inning: Int, side: Side) -> Int? {
|
||||
guard let data = innings.first(where: { $0.num == inning }) else { return nil }
|
||||
switch side {
|
||||
case .away: return data.away?.runs
|
||||
case .home: return data.home?.runs
|
||||
}
|
||||
}
|
||||
}
|
||||
66
mlbTVOS/Views/Components/LiveIndicator.swift
Normal file
66
mlbTVOS/Views/Components/LiveIndicator.swift
Normal file
@@ -0,0 +1,66 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LiveIndicator: View {
|
||||
@State private var isPulsing = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(.red)
|
||||
.frame(width: 12, height: 12)
|
||||
.scaleEffect(isPulsing ? 1.4 : 1.0)
|
||||
.opacity(isPulsing ? 0.6 : 1.0)
|
||||
.animation(
|
||||
.easeInOut(duration: 0.8).repeatForever(autoreverses: true),
|
||||
value: isPulsing
|
||||
)
|
||||
Text("LIVE")
|
||||
.font(.subheadline.weight(.black))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
.onAppear { isPulsing = true }
|
||||
}
|
||||
}
|
||||
|
||||
struct StatusBadge: View {
|
||||
let status: GameStatus
|
||||
|
||||
var body: some View {
|
||||
switch status {
|
||||
case .live(let inning):
|
||||
HStack(spacing: 8) {
|
||||
LiveIndicator()
|
||||
if let inning {
|
||||
Text(inning)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(.red.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
|
||||
case .scheduled(let time):
|
||||
Text(time)
|
||||
.font(.body.weight(.bold))
|
||||
.foregroundStyle(.green)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(.green.opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
|
||||
case .final_:
|
||||
Text("FINAL")
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(.secondary.opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
|
||||
case .unknown:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
53
mlbTVOS/Views/Components/PitcherHeadshotView.swift
Normal file
53
mlbTVOS/Views/Components/PitcherHeadshotView.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PitcherHeadshotView: View {
|
||||
let url: URL?
|
||||
var teamCode: String?
|
||||
var name: String?
|
||||
var size: CGFloat = 56
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
case .failure:
|
||||
fallbackImage
|
||||
default:
|
||||
fallbackImage
|
||||
.redacted(reason: .placeholder)
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(Circle())
|
||||
.overlay(
|
||||
Circle()
|
||||
.strokeBorder(
|
||||
teamCode.map { TeamAssets.color(for: $0) } ?? .gray,
|
||||
lineWidth: 2
|
||||
)
|
||||
)
|
||||
|
||||
if let name {
|
||||
Text(name)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var fallbackImage: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(.gray.opacity(0.3))
|
||||
Image(systemName: "person.fill")
|
||||
.font(.system(size: size * 0.4))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
67
mlbTVOS/Views/Components/ScoreOverlayView.swift
Normal file
67
mlbTVOS/Views/Components/ScoreOverlayView.swift
Normal file
@@ -0,0 +1,67 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ScoreOverlayView: View {
|
||||
let game: Game
|
||||
|
||||
var body: some View {
|
||||
// Don't show for non-game streams (e.g., MLB Network)
|
||||
if game.id == "MLBN" {
|
||||
EmptyView()
|
||||
} else {
|
||||
HStack(spacing: 10) {
|
||||
// Away
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(TeamAssets.color(for: game.awayTeam.code))
|
||||
.frame(width: 8, height: 8)
|
||||
Text(game.awayTeam.code)
|
||||
.font(.system(size: 15, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
if !game.status.isScheduled, let score = game.awayTeam.score {
|
||||
Text("\(score)")
|
||||
.font(.system(size: 17, weight: .bold, design: .rounded).monospacedDigit())
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
|
||||
Text("-")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.4))
|
||||
|
||||
// Home
|
||||
HStack(spacing: 6) {
|
||||
if !game.status.isScheduled, let score = game.homeTeam.score {
|
||||
Text("\(score)")
|
||||
.font(.system(size: 17, weight: .bold, design: .rounded).monospacedDigit())
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
Text(game.homeTeam.code)
|
||||
.font(.system(size: 15, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
Circle()
|
||||
.fill(TeamAssets.color(for: game.homeTeam.code))
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
|
||||
// Inning/status
|
||||
if game.isLive, let inning = game.currentInningDisplay {
|
||||
Text(inning)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
.padding(.leading, 4)
|
||||
} else if game.isFinal {
|
||||
Text("F")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.black.opacity(0.7))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
167
mlbTVOS/Views/Components/ScoresTickerView.swift
Normal file
167
mlbTVOS/Views/Components/ScoresTickerView.swift
Normal file
@@ -0,0 +1,167 @@
|
||||
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(.black.opacity(0.72))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.08), 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")
|
||||
}
|
||||
}
|
||||
38
mlbTVOS/Views/Components/TeamLogoView.swift
Normal file
38
mlbTVOS/Views/Components/TeamLogoView.swift
Normal file
@@ -0,0 +1,38 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TeamLogoView: View {
|
||||
let team: TeamInfo
|
||||
var size: CGFloat = 64
|
||||
|
||||
var body: some View {
|
||||
AsyncImage(url: team.logoURL) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
case .failure:
|
||||
fallbackLogo
|
||||
default:
|
||||
fallbackLogo
|
||||
.redacted(reason: .placeholder)
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.overlay(
|
||||
Circle()
|
||||
.strokeBorder(.white.opacity(0.1), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var fallbackLogo: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(TeamAssets.color(for: team.code).gradient)
|
||||
Text(team.code.prefix(3))
|
||||
.font(.system(size: size * 0.3, weight: .heavy, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user