Files
MLBApp/mlbTVOS/Views/GameCenterView.swift
Trey t bf44a7b7eb Fix memory leaks, stale game data, and audio volume fluctuation
Memory: clean observers even during PiP, nil player on tile disappear,
track/cancel Werkout monitor tasks, add highlight player cleanup.
Data: add scenePhase-triggered reload on day change, unconditional
10-minute full schedule refresh, keep fast 60s score refresh for live games.
Audio: set mute state before playback starts, use consistent .moviePlayback
mode, add audio session interruption recovery handler.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:21:21 -05:00

593 lines
22 KiB
Swift

import SwiftUI
import AVKit
struct GameCenterView: View {
let game: Game
@State private var viewModel = GameCenterViewModel()
@State private var highlightPlayer: AVPlayer?
var body: some View {
VStack(alignment: .leading, spacing: 18) {
header
if viewModel.isLoading && viewModel.feed == nil {
loadingState
} else if let feed = viewModel.feed {
situationStrip(feed: feed)
matchupPanel(feed: feed)
if !feed.currentAtBatPitches.isEmpty {
atBatPanel(feed: feed)
}
if let wpHome = viewModel.winProbabilityHome, let wpAway = viewModel.winProbabilityAway {
winProbabilityPanel(home: wpHome, away: wpAway)
}
if !feed.decisionsSummary.isEmpty || !feed.officialSummary.isEmpty || feed.weatherSummary != nil {
contextPanel(feed: feed)
}
if !feed.awayLineup.isEmpty || !feed.homeLineup.isEmpty {
lineupPanel(feed: feed)
}
if !viewModel.highlights.isEmpty {
highlightsPanel
}
if !feed.allGameHits.isEmpty {
sprayChartPanel(feed: feed)
}
if !feed.scoringPlays.isEmpty {
timelineSection(
title: "Scoring Plays",
subtitle: "Every run-scoring swing, plate appearance, and sequence.",
plays: Array(feed.scoringPlays.suffix(6).reversed())
)
}
timelineSection(
title: "Recent Plays",
subtitle: "The latest plate appearances and game-state changes.",
plays: Array(feed.recentPlays.prefix(8))
)
} else {
errorState
}
}
.task(id: game.id) {
await viewModel.watch(game: game)
}
.fullScreenCover(isPresented: Binding(
get: { highlightPlayer != nil },
set: { if !$0 { highlightPlayer?.pause(); highlightPlayer = nil } }
)) {
if let player = highlightPlayer {
VideoPlayer(player: player)
.ignoresSafeArea()
.onAppear { player.play() }
.onDisappear { player.pause() }
}
}
}
private var header: some View {
HStack(alignment: .top, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text("Game Center")
.font(.system(size: 28, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text("Live matchup context, lineup state, and inning-by-inning events from MLB's live feed.")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.58))
}
Spacer()
Button {
if let gamePk = game.gamePk {
Task { await viewModel.refresh(gamePk: gamePk) }
}
} label: {
HStack(spacing: 8) {
Image(systemName: "arrow.clockwise")
.font(.system(size: 12, weight: .bold))
Text(refreshLabel)
.font(.system(size: 14, weight: .semibold))
}
.foregroundStyle(.white.opacity(0.82))
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(.white.opacity(0.08))
.clipShape(Capsule())
}
.platformCardStyle()
.disabled(game.gamePk == nil)
}
.padding(22)
.background(panelBackground)
}
private func situationStrip(feed: LiveGameFeed) -> some View {
HStack(spacing: 14) {
situationTile(
title: "Situation",
value: feed.liveData.linescore?.inningDisplay ?? game.currentInningDisplay ?? game.status.label,
accent: .red
)
situationTile(
title: "Count",
value: feed.currentCountText ?? "No active count",
accent: .blue
)
situationTile(
title: "Bases",
value: feed.occupiedBases.isEmpty ? "Bases Empty" : "On \(feed.occupiedBases.joined(separator: ", "))",
accent: .green
)
}
}
private func situationTile(title: String, value: String, accent: Color) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title.uppercased())
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(accent.opacity(0.9))
.kerning(1.2)
Text(value)
.font(.system(size: 19, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.lineLimit(2)
.minimumScaleFactor(0.82)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.background(panelBackground)
}
private func matchupPanel(feed: LiveGameFeed) -> some View {
VStack(alignment: .leading, spacing: 16) {
HStack(alignment: .top, spacing: 18) {
HStack(spacing: 14) {
PitcherHeadshotView(
url: feed.currentBatter?.headshotURL,
teamCode: game.status.isLive ? nil : game.awayTeam.code,
size: 46
)
VStack(alignment: .leading, spacing: 5) {
Text("At Bat")
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.5))
Text(feed.currentBatter?.displayName ?? "Awaiting matchup")
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundStyle(.white)
if let onDeck = feed.onDeckBatter?.displayName {
Text("On deck: \(onDeck)")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.55))
}
if let inHole = feed.inHoleBatter?.displayName {
Text("In hole: \(inHole)")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.46))
}
}
}
Spacer()
HStack(spacing: 14) {
VStack(alignment: .trailing, spacing: 5) {
Text("Pitching")
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.5))
Text(feed.currentPitcher?.displayName ?? "Awaiting pitcher")
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.multilineTextAlignment(.trailing)
if let play = feed.currentPlay {
Text(play.summaryText)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.58))
.frame(maxWidth: 420, alignment: .trailing)
.multilineTextAlignment(.trailing)
}
}
PitcherHeadshotView(
url: feed.currentPitcher?.headshotURL,
teamCode: nil,
size: 46
)
}
}
}
.padding(22)
.background(panelBackground)
}
private func atBatPanel(feed: LiveGameFeed) -> some View {
let pitches = feed.currentAtBatPitches
return VStack(alignment: .leading, spacing: 16) {
HStack(alignment: .top, spacing: 24) {
StrikeZoneView(pitches: pitches, size: 160)
VStack(alignment: .leading, spacing: 16) {
AtBatTimelineView(pitches: pitches)
PitchSequenceView(pitches: pitches)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(22)
.background(panelBackground)
}
private func sprayChartPanel(feed: LiveGameFeed) -> some View {
let hits = feed.allGameHits.map { item in
SprayChartHit(
id: item.play.id,
coordX: item.hitData.coordinates?.coordX ?? 0,
coordY: item.hitData.coordinates?.coordY ?? 0,
event: item.play.result?.eventType ?? item.play.result?.event
)
}
return VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
Text("Spray Chart")
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text("Batted ball locations this game.")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.55))
}
HStack(spacing: 24) {
SprayChartView(hits: hits, size: 220)
VStack(alignment: .leading, spacing: 8) {
sprayChartLegendItem(color: .red, label: "Home Run")
sprayChartLegendItem(color: .orange, label: "Triple")
sprayChartLegendItem(color: .green, label: "Double")
sprayChartLegendItem(color: .blue, label: "Single")
sprayChartLegendItem(color: .white.opacity(0.4), label: "Out")
}
}
}
.padding(22)
.background(panelBackground)
}
private func sprayChartLegendItem(color: Color, label: String) -> some View {
HStack(spacing: 8) {
Circle()
.fill(color)
.frame(width: 10, height: 10)
Text(label)
.font(.system(size: 13, weight: .medium, design: .rounded))
.foregroundStyle(.white.opacity(0.7))
}
}
private var highlightsPanel: some View {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 4) {
Text("Highlights")
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text("Key moments and plays from this game.")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.55))
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(viewModel.highlights) { highlight in
Button {
if let urlStr = highlight.hlsURL ?? highlight.mp4URL,
let url = URL(string: urlStr) {
highlightPlayer = AVPlayer(url: url)
}
} label: {
HStack(spacing: 10) {
Image(systemName: "play.fill")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(.purple)
Text(highlight.headline ?? "Highlight")
.font(.system(size: 14, weight: .semibold, design: .rounded))
.foregroundStyle(.white)
.lineLimit(2)
.multilineTextAlignment(.leading)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(.white.opacity(0.06))
)
}
.platformCardStyle()
}
}
}
}
.padding(22)
.background(panelBackground)
}
private func winProbabilityPanel(home: Double, away: Double) -> some View {
let homeColor = TeamAssets.color(for: game.homeTeam.code)
let awayColor = TeamAssets.color(for: game.awayTeam.code)
let homePct = home / 100.0
let awayPct = away / 100.0
return VStack(alignment: .leading, spacing: 14) {
Text("WIN PROBABILITY")
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.5))
.kerning(1.2)
HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text(game.awayTeam.code)
.font(.system(size: 16, weight: .black, design: .rounded))
.foregroundStyle(.white)
Text("\(Int(away))%")
.font(.system(size: 28, weight: .bold).monospacedDigit())
.foregroundStyle(awayColor)
}
.frame(width: 80)
GeometryReader { geo in
HStack(spacing: 2) {
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(awayColor)
.frame(width: max(geo.size.width * awayPct, 4))
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(homeColor)
.frame(width: max(geo.size.width * homePct, 4))
}
.frame(height: 24)
}
.frame(height: 24)
VStack(alignment: .trailing, spacing: 6) {
Text(game.homeTeam.code)
.font(.system(size: 16, weight: .black, design: .rounded))
.foregroundStyle(.white)
Text("\(Int(home))%")
.font(.system(size: 28, weight: .bold).monospacedDigit())
.foregroundStyle(homeColor)
}
.frame(width: 80)
}
}
.padding(22)
.background(panelBackground)
}
private func contextPanel(feed: LiveGameFeed) -> some View {
VStack(alignment: .leading, spacing: 16) {
if let weatherSummary = feed.weatherSummary {
contextRow(title: "Weather", values: [weatherSummary], accent: .blue)
}
if !feed.decisionsSummary.isEmpty {
contextRow(title: "Decisions", values: feed.decisionsSummary, accent: .green)
}
if !feed.officialSummary.isEmpty {
contextRow(title: "Officials", values: Array(feed.officialSummary.prefix(4)), accent: .orange)
}
}
.padding(22)
.background(panelBackground)
}
private func contextRow(title: String, values: [String], accent: Color) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text(title.uppercased())
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(accent.opacity(0.9))
.kerning(1.2)
HStack(spacing: 10) {
ForEach(values, id: \.self) { value in
Text(value)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white.opacity(0.82))
.padding(.horizontal, 12)
.padding(.vertical, 9)
.background(.white.opacity(0.08))
.clipShape(Capsule())
}
}
}
}
private func lineupPanel(feed: LiveGameFeed) -> some View {
HStack(alignment: .top, spacing: 16) {
lineupColumn(
title: game.awayTeam.code,
teamName: game.awayTeam.displayName,
color: TeamAssets.color(for: game.awayTeam.code),
players: Array(feed.awayLineup.prefix(9))
)
lineupColumn(
title: game.homeTeam.code,
teamName: game.homeTeam.displayName,
color: TeamAssets.color(for: game.homeTeam.code),
players: Array(feed.homeLineup.prefix(9))
)
}
}
private func lineupColumn(title: String, teamName: String, color: Color, players: [LiveFeedBoxscorePlayer]) -> some View {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 10) {
Circle()
.fill(color)
.frame(width: 10, height: 10)
Text(title)
.font(.system(size: 18, weight: .black, design: .rounded))
.foregroundStyle(.white)
Text(teamName)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.white.opacity(0.55))
.lineLimit(1)
}
VStack(alignment: .leading, spacing: 10) {
ForEach(Array(players.enumerated()), id: \.element.id) { index, player in
HStack(spacing: 10) {
Text("\(index + 1)")
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.6))
.frame(width: 18, alignment: .leading)
PitcherHeadshotView(
url: player.person.headshotURL,
size: 28
)
Text(player.person.displayName)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white.opacity(0.88))
.lineLimit(1)
Spacer()
Text(player.position?.abbreviation ?? "")
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.45))
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(20)
.background(panelBackground)
}
private func timelineSection(title: String, subtitle: String, plays: [LiveFeedPlay]) -> some View {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text(subtitle)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.55))
}
VStack(spacing: 12) {
ForEach(plays) { play in
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(play.about?.halfInning?.uppercased() ?? "PLAY")
.font(.system(size: 11, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.4))
if let inning = play.about?.inning {
Text("\(inning)")
.font(.system(size: 16, weight: .black, design: .rounded))
.foregroundStyle(.white)
}
}
.frame(width: 54, alignment: .leading)
VStack(alignment: .leading, spacing: 6) {
Text(play.summaryText)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
if let event = play.result?.event {
Text(event)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white.opacity(0.5))
}
}
Spacer()
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(.white.opacity(0.05))
)
}
}
}
.padding(22)
.background(panelBackground)
}
private var loadingState: some View {
HStack {
Spacer()
ProgressView("Loading game center...")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white.opacity(0.7))
Spacer()
}
.padding(.vertical, 28)
.background(panelBackground)
}
private var errorState: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Game center is unavailable.")
.font(.system(size: 20, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text(viewModel.errorMessage ?? "No live feed data was returned for this game.")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.6))
}
.padding(22)
.background(panelBackground)
}
private var refreshLabel: String {
if let lastUpdated = viewModel.lastUpdated {
let formatter = DateFormatter()
formatter.dateFormat = "h:mm:ss a"
return formatter.string(from: lastUpdated)
}
return "Refresh"
}
private var panelBackground: some View {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(.black.opacity(0.22))
.overlay {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
}
}
}