Add game center, per-model shuffle, audio focus fixes, README, tests
- README.md with build/architecture overview - Game Center screen with at-bat timeline, pitch sequence, spray chart, and strike zone component views - VideoShuffle service: per-model bucketed random selection with no-back-to-back guarantee; replaces flat shuffle-bag approach - Refresh JWT token for authenticated NSFW feed; add josie-hamming-2 and dani-speegle-2 to the user list - MultiStreamView audio focus: remove redundant isMuted writes during startStream and playNextWerkoutClip so audio stops ducking during clip transitions; gate AVAudioSession.setCategory(.playback) behind a one-shot flag - GamesViewModel.attachPlayer: skip mute recalculation when the same player is re-attached (prevents toggle flicker on item replace) - mlbTVOSTests target wired through project.yml with GENERATE_INFOPLIST_FILE; VideoShuffleTests covers groupByModel, pickRandomFromBuckets, real-distribution no-back-to-back invariant, and uniform model distribution over 6000 picks Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
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) {
|
||||
@@ -15,6 +17,14 @@ struct GameCenterView: View {
|
||||
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)
|
||||
}
|
||||
@@ -23,6 +33,14 @@ struct GameCenterView: View {
|
||||
lineupPanel(feed: feed)
|
||||
}
|
||||
|
||||
if !viewModel.highlights.isEmpty {
|
||||
highlightsPanel
|
||||
}
|
||||
|
||||
if !feed.allGameHits.isEmpty {
|
||||
sprayChartPanel(feed: feed)
|
||||
}
|
||||
|
||||
if !feed.scoringPlays.isEmpty {
|
||||
timelineSection(
|
||||
title: "Scoring Plays",
|
||||
@@ -43,6 +61,16 @@ struct GameCenterView: View {
|
||||
.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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
@@ -126,46 +154,175 @@ struct GameCenterView: View {
|
||||
private func matchupPanel(feed: LiveGameFeed) -> some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack(alignment: .top, spacing: 18) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("At Bat")
|
||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
HStack(spacing: 14) {
|
||||
PitcherHeadshotView(
|
||||
url: feed.currentBatter?.headshotURL,
|
||||
teamCode: game.status.isLive ? nil : game.awayTeam.code,
|
||||
size: 46
|
||||
)
|
||||
|
||||
Text(feed.currentBatter?.displayName ?? "Awaiting matchup")
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("At Bat")
|
||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
|
||||
if let onDeck = feed.onDeckBatter?.displayName {
|
||||
Text("On deck: \(onDeck)")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
}
|
||||
Text(feed.currentBatter?.displayName ?? "Awaiting matchup")
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
if let inHole = feed.inHoleBatter?.displayName {
|
||||
Text("In hole: \(inHole)")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.46))
|
||||
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()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 5) {
|
||||
Text("Pitching")
|
||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
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)
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,6 +331,60 @@ struct GameCenterView: View {
|
||||
.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 {
|
||||
@@ -256,6 +467,11 @@ struct GameCenterView: View {
|
||||
.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))
|
||||
|
||||
Reference in New Issue
Block a user