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:
Trey t
2026-04-11 11:02:46 -05:00
parent 58e4c36963
commit 88308b46f5
17 changed files with 2069 additions and 313 deletions

View File

@@ -0,0 +1,80 @@
import SwiftUI
struct AtBatTimelineView: View {
let pitches: [LiveFeedPlayEvent]
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("AT-BAT")
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.5))
.kerning(1.2)
HStack(spacing: 6) {
ForEach(Array(pitches.enumerated()), id: \.offset) { index, pitch in
let isLatest = index == pitches.count - 1
let code = pitchCallLetter(pitch.callCode)
let color = pitchCallColor(pitch.callCode)
Text(code)
.font(.system(size: isLatest ? 14 : 13, weight: .black, design: .rounded))
.foregroundStyle(color)
.padding(.horizontal, isLatest ? 12 : 10)
.padding(.vertical, isLatest ? 7 : 6)
.background(color.opacity(isLatest ? 0.25 : 0.15))
.clipShape(Capsule())
.overlay {
if isLatest {
Capsule()
.strokeBorder(color.opacity(0.5), lineWidth: 1)
}
}
}
}
if let last = pitches.last?.count {
Text("\(last.balls)-\(last.strikes), \(last.outs) out\(last.outs == 1 ? "" : "s")")
.font(.system(size: 14, weight: .semibold, design: .rounded))
.foregroundStyle(.white.opacity(0.6))
}
}
}
}
func pitchCallLetter(_ code: String) -> String {
switch code {
case "B": return "B"
case "C": return "S"
case "S": return "S"
case "F": return "F"
case "X", "D", "E": return "X"
default: return "·"
}
}
func pitchCallColor(_ code: String) -> Color {
switch code {
case "B": return .green
case "C": return .red
case "S": return .orange
case "F": return .yellow
case "X", "D", "E": return .blue
default: return .white.opacity(0.5)
}
}
func shortPitchType(_ code: String) -> String {
switch code {
case "FF": return "4SM"
case "SI": return "SNK"
case "FC": return "CUT"
case "SL": return "SLD"
case "CU", "KC": return "CRV"
case "CH": return "CHG"
case "FS": return "SPL"
case "KN": return "KNK"
case "ST": return "SWP"
case "SV": return "SLV"
default: return code
}
}

View File

@@ -0,0 +1,68 @@
import SwiftUI
struct PitchSequenceView: View {
let pitches: [LiveFeedPlayEvent]
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("PITCH SEQUENCE")
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.5))
.kerning(1.2)
ForEach(Array(pitches.enumerated()), id: \.offset) { index, pitch in
HStack(spacing: 10) {
Text("#\(pitch.pitchNumber ?? (index + 1))")
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.5))
.frame(width: 28, alignment: .leading)
Text(pitch.pitchTypeDescription)
.font(.system(size: 14, weight: .semibold, design: .rounded))
.foregroundStyle(.white)
.lineLimit(1)
Spacer()
if let speed = pitch.speedMPH {
Text("\(speed, specifier: "%.1f") mph")
.font(.system(size: 13, weight: .medium).monospacedDigit())
.foregroundStyle(.white.opacity(0.7))
}
pitchResultPill(code: pitch.callCode, description: pitch.callDescription)
}
.padding(.vertical, 4)
.padding(.horizontal, 8)
.background(
index == pitches.count - 1
? RoundedRectangle(cornerRadius: 8, style: .continuous).fill(.white.opacity(0.06))
: RoundedRectangle(cornerRadius: 8, style: .continuous).fill(.clear)
)
}
}
}
private func pitchResultPill(code: String, description: String) -> some View {
let color = pitchCallColor(code)
let label: String
switch code {
case "B": label = "Ball"
case "C": label = "Called Strike"
case "S": label = "Swinging Strike"
case "F": label = "Foul"
case "X": label = "In Play"
case "D": label = "In Play (Out)"
case "E": label = "In Play (Error)"
default: label = description
}
return Text(label)
.font(.system(size: 11, weight: .bold, design: .rounded))
.foregroundStyle(color)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(color.opacity(0.15))
.clipShape(Capsule())
}
}

View File

@@ -0,0 +1,126 @@
import SwiftUI
struct SprayChartHit: Identifiable {
let id: String
let coordX: Double
let coordY: Double
let event: String?
var color: Color {
switch event?.lowercased() {
case "single": return .blue
case "double": return .green
case "triple": return .orange
case "home_run", "home run": return .red
default: return .white.opacity(0.4)
}
}
var label: String {
switch event?.lowercased() {
case "single": return "1B"
case "double": return "2B"
case "triple": return "3B"
case "home_run", "home run": return "HR"
default: return "Out"
}
}
}
struct SprayChartView: View {
let hits: [SprayChartHit]
var size: CGFloat = 280
// MLB API coordinate system: home plate ~(125, 200), field extends upward
// coordX: 0-250 (left to right), coordY: 0-250 (top to bottom, lower Y = deeper)
private let coordRange: ClosedRange<Double> = 0...250
private let homePlate = CGPoint(x: 125, y: 200)
var body: some View {
Canvas { context, canvasSize in
let w = canvasSize.width
let h = canvasSize.height
let scale = min(w, h) / 250.0
let homeX = homePlate.x * scale
let homeY = homePlate.y * scale
// Draw foul lines from home plate
let foulLineLength: CGFloat = 200 * scale
// Left field foul line (~135 degrees from horizontal)
let leftEnd = CGPoint(
x: homeX - foulLineLength * cos(.pi / 4),
y: homeY - foulLineLength * sin(.pi / 4)
)
var leftLine = Path()
leftLine.move(to: CGPoint(x: homeX, y: homeY))
leftLine.addLine(to: leftEnd)
context.stroke(leftLine, with: .color(.white.opacity(0.15)), lineWidth: 1)
// Right field foul line
let rightEnd = CGPoint(
x: homeX + foulLineLength * cos(.pi / 4),
y: homeY - foulLineLength * sin(.pi / 4)
)
var rightLine = Path()
rightLine.move(to: CGPoint(x: homeX, y: homeY))
rightLine.addLine(to: rightEnd)
context.stroke(rightLine, with: .color(.white.opacity(0.15)), lineWidth: 1)
// Outfield arc
var arc = Path()
arc.addArc(
center: CGPoint(x: homeX, y: homeY),
radius: foulLineLength,
startAngle: .degrees(-135),
endAngle: .degrees(-45),
clockwise: false
)
context.stroke(arc, with: .color(.white.opacity(0.1)), lineWidth: 1)
// Infield arc (smaller)
var infieldArc = Path()
let infieldRadius: CGFloat = 60 * scale
infieldArc.addArc(
center: CGPoint(x: homeX, y: homeY),
radius: infieldRadius,
startAngle: .degrees(-135),
endAngle: .degrees(-45),
clockwise: false
)
context.stroke(infieldArc, with: .color(.white.opacity(0.08)), lineWidth: 0.5)
// Infield diamond
let baseDistance: CGFloat = 40 * scale
let first = CGPoint(x: homeX + baseDistance * cos(.pi / 4), y: homeY - baseDistance * sin(.pi / 4))
let second = CGPoint(x: homeX, y: homeY - baseDistance * sqrt(2))
let third = CGPoint(x: homeX - baseDistance * cos(.pi / 4), y: homeY - baseDistance * sin(.pi / 4))
var diamond = Path()
diamond.move(to: CGPoint(x: homeX, y: homeY))
diamond.addLine(to: first)
diamond.addLine(to: second)
diamond.addLine(to: third)
diamond.closeSubpath()
context.stroke(diamond, with: .color(.white.opacity(0.12)), lineWidth: 0.5)
// Home plate marker
let plateSize: CGFloat = 4
let plateRect = CGRect(x: homeX - plateSize, y: homeY - plateSize, width: plateSize * 2, height: plateSize * 2)
context.fill(Path(plateRect), with: .color(.white.opacity(0.3)))
// Draw hit dots
for hit in hits {
let x = hit.coordX * scale
let y = hit.coordY * scale
let dotRadius: CGFloat = 5
let dotRect = CGRect(x: x - dotRadius, y: y - dotRadius, width: dotRadius * 2, height: dotRadius * 2)
context.fill(Circle().path(in: dotRect), with: .color(hit.color))
context.stroke(Circle().path(in: dotRect), with: .color(.white.opacity(0.2)), lineWidth: 0.5)
}
}
.frame(width: size, height: size)
}
}

View File

@@ -0,0 +1,104 @@
import SwiftUI
struct StrikeZoneView: View {
let pitches: [LiveFeedPlayEvent]
var size: CGFloat = 200
// Visible range: enough to show balls just outside the zone
// pX: feet from center of plate. Zone is -0.83 to 0.83. Show -1.5 to 1.5.
// pZ: height in feet. Zone is ~1.5 to 3.5. Show 0.5 to 4.5.
private let viewMinX: Double = -1.5
private let viewMaxX: Double = 1.5
private let viewMinZ: Double = 0.5
private let viewMaxZ: Double = 4.5
private let zoneHalfWidth: Double = 0.83
private var strikeZoneTop: Double {
pitches.compactMap { $0.pitchData?.strikeZoneTop }.last ?? 3.4
}
private var strikeZoneBottom: Double {
pitches.compactMap { $0.pitchData?.strikeZoneBottom }.last ?? 1.6
}
var body: some View {
Canvas { context, canvasSize in
let w = canvasSize.width
let h = canvasSize.height
let rangeX = viewMaxX - viewMinX
let rangeZ = viewMaxZ - viewMinZ
func mapX(_ pX: Double) -> CGFloat {
CGFloat((pX - viewMinX) / rangeX) * w
}
func mapZ(_ pZ: Double) -> CGFloat {
// Higher pZ = higher on screen = lower canvas Y
h - CGFloat((pZ - viewMinZ) / rangeZ) * h
}
// Strike zone rectangle
let zoneLeft = mapX(-zoneHalfWidth)
let zoneRight = mapX(zoneHalfWidth)
let zoneTop = mapZ(strikeZoneTop)
let zoneBottom = mapZ(strikeZoneBottom)
let zoneRect = CGRect(x: zoneLeft, y: zoneTop, width: zoneRight - zoneLeft, height: zoneBottom - zoneTop)
context.fill(Path(zoneRect), with: .color(.white.opacity(0.06)))
context.stroke(Path(zoneRect), with: .color(.white.opacity(0.3)), lineWidth: 1.5)
// 3x3 grid
let zoneW = zoneRight - zoneLeft
let zoneH = zoneBottom - zoneTop
for i in 1...2 {
let xLine = zoneLeft + zoneW * CGFloat(i) / 3.0
var vPath = Path()
vPath.move(to: CGPoint(x: xLine, y: zoneTop))
vPath.addLine(to: CGPoint(x: xLine, y: zoneBottom))
context.stroke(vPath, with: .color(.white.opacity(0.12)), lineWidth: 0.5)
let yLine = zoneTop + zoneH * CGFloat(i) / 3.0
var hPath = Path()
hPath.move(to: CGPoint(x: zoneLeft, y: yLine))
hPath.addLine(to: CGPoint(x: zoneRight, y: yLine))
context.stroke(hPath, with: .color(.white.opacity(0.12)), lineWidth: 0.5)
}
// Pitch dots
let dotScale = min(size / 200.0, 1.0)
for (index, pitch) in pitches.enumerated() {
guard let coords = pitch.pitchData?.coordinates,
let pX = coords.pX, let pZ = coords.pZ else { continue }
let cx = mapX(pX)
let cy = mapZ(pZ)
let isLatest = index == pitches.count - 1
let dotRadius: CGFloat = (isLatest ? 9 : 7) * dotScale
let color = pitchCallColor(pitch.callCode)
let dotRect = CGRect(
x: cx - dotRadius,
y: cy - dotRadius,
width: dotRadius * 2,
height: dotRadius * 2
)
if isLatest {
let glowRect = dotRect.insetBy(dx: -4 * dotScale, dy: -4 * dotScale)
context.fill(Circle().path(in: glowRect), with: .color(color.opacity(0.3)))
}
context.fill(Circle().path(in: dotRect), with: .color(color))
context.stroke(Circle().path(in: dotRect), with: .color(.white.opacity(0.4)), lineWidth: 0.5)
// Pitch number
let fontSize: CGFloat = max(8 * dotScale, 7)
let numText = Text("\(pitch.pitchNumber ?? (index + 1))")
.font(.system(size: fontSize, weight: .bold, design: .rounded))
.foregroundColor(.white)
context.draw(context.resolve(numText), at: CGPoint(x: cx, y: cy), anchor: .center)
}
}
.frame(width: size, height: size * 1.33)
}
}