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

@@ -42,6 +42,10 @@ actor MLBStatsAPI {
try await fetchJSON("\(baseURL).1/game/\(gamePk)/feed/live")
}
func fetchWinProbability(gamePk: String) async throws -> [WinProbabilityEntry] {
try await fetchJSON("\(baseURL).1/game/\(gamePk)/winProbability")
}
func fetchLeagueTeams(season: String) async throws -> [LeagueTeamSummary] {
let response: TeamsResponse = try await fetchJSON(
"\(baseURL)/teams?sportId=1&season=\(season)&hydrate=league,division,venue,record"
@@ -828,6 +832,19 @@ struct LiveGameFeed: Codable, Sendable {
var homeLineup: [LiveFeedBoxscorePlayer] {
liveData.boxscore?.teams?.home.lineupPlayers ?? []
}
var currentAtBatPitches: [LiveFeedPlayEvent] {
currentPlay?.pitches ?? []
}
var allGameHits: [(play: LiveFeedPlay, hitData: LiveFeedHitData)] {
liveData.plays.allPlays.compactMap { play in
guard let lastEvent = play.playEvents?.last,
let hitData = lastEvent.hitData,
hitData.coordinates?.coordX != nil else { return nil }
return (play: play, hitData: hitData)
}
}
}
struct LiveFeedMetaData: Codable, Sendable {
@@ -890,7 +907,7 @@ struct LiveFeedVenue: Codable, Sendable {
struct LiveFeedWeather: Codable, Sendable {
let condition: String?
let temp: Int?
let temp: String?
let wind: String?
}
@@ -911,6 +928,11 @@ struct LiveFeedPlay: Codable, Sendable, Identifiable {
let about: LiveFeedPlayAbout?
let count: LiveFeedPlayCount?
let matchup: LiveFeedPlayMatchup?
let playEvents: [LiveFeedPlayEvent]?
var pitches: [LiveFeedPlayEvent] {
playEvents?.filter { $0.isPitch == true } ?? []
}
var id: String {
let inning = about?.inning ?? -1
@@ -958,6 +980,11 @@ struct LiveFeedPlayerReference: Codable, Sendable, Identifiable {
var displayName: String {
fullName ?? "TBD"
}
var headshotURL: URL? {
guard let id else { return nil }
return URL(string: "https://img.mlbstatic.com/mlb-photos/image/upload/w_213,q_auto:best/v1/people/\(id)/headshot/67/current")
}
}
struct LiveFeedLinescore: Codable, Sendable {
@@ -1054,3 +1081,87 @@ struct LiveFeedDecisions: Codable, Sendable {
let loser: LiveFeedPlayerReference?
let save: LiveFeedPlayerReference?
}
// MARK: - Play Event / Pitch Data Models
struct LiveFeedPlayEvent: Codable, Sendable, Identifiable {
let details: LiveFeedPlayEventDetails?
let count: LiveFeedPlayCount?
let pitchData: LiveFeedPitchData?
let hitData: LiveFeedHitData?
let isPitch: Bool?
let pitchNumber: Int?
let index: Int?
var id: Int { index ?? pitchNumber ?? 0 }
var callCode: String {
details?.call?.code ?? ""
}
var callDescription: String {
details?.call?.description ?? details?.description ?? ""
}
var pitchTypeDescription: String {
details?.type?.description ?? "Unknown"
}
var pitchTypeCode: String {
details?.type?.code ?? ""
}
var speedMPH: Double? {
pitchData?.startSpeed
}
}
struct LiveFeedPlayEventDetails: Codable, Sendable {
let call: LiveFeedCallInfo?
let description: String?
let type: LiveFeedPitchType?
}
struct LiveFeedCallInfo: Codable, Sendable {
let code: String?
let description: String?
}
struct LiveFeedPitchType: Codable, Sendable {
let code: String?
let description: String?
}
struct LiveFeedPitchData: Codable, Sendable {
let startSpeed: Double?
let endSpeed: Double?
let strikeZoneTop: Double?
let strikeZoneBottom: Double?
let coordinates: LiveFeedPitchCoordinates?
let zone: Int?
}
struct LiveFeedPitchCoordinates: Codable, Sendable {
let pX: Double?
let pZ: Double?
}
struct LiveFeedHitData: Codable, Sendable {
let launchSpeed: Double?
let launchAngle: Double?
let totalDistance: Double?
let coordinates: LiveFeedHitCoordinates?
}
struct LiveFeedHitCoordinates: Codable, Sendable {
let coordX: Double?
let coordY: Double?
}
// MARK: - Win Probability
struct WinProbabilityEntry: Codable, Sendable {
let atBatIndex: Int?
let homeTeamWinProbability: Double?
let awayTeamWinProbability: Double?
}

View File

@@ -0,0 +1,78 @@
import Foundation
enum VideoShuffle {
/// Groups items by key, shuffling within each group.
///
/// Items whose key is `nil` are grouped under the empty string `""` so they
/// form their own bucket rather than being dropped.
static func groupByModel<T>(
_ items: [T],
keyFor: (T) -> String?,
using rng: inout some RandomNumberGenerator
) -> [String: [T]] {
var groups: [String: [T]] = [:]
for item in items {
let key = keyFor(item) ?? ""
groups[key, default: []].append(item)
}
for key in groups.keys {
groups[key]!.shuffle(using: &rng)
}
return groups
}
/// Picks a random item from a random non-empty bucket, avoiding
/// `excludingKey` when possible.
///
/// Returned buckets are a mutated copy of the input with the picked item
/// removed (pure input is not modified). When the excluded bucket is the
/// only non-empty one, it is still picked (falling back gracefully).
///
/// - Returns: the picked item, the key of the bucket it came from, and the
/// updated bucket dictionary or `nil` if all buckets are empty.
static func pickRandomFromBuckets<T>(
_ buckets: [String: [T]],
excludingKey: String? = nil,
using rng: inout some RandomNumberGenerator
) -> (item: T, key: String, remaining: [String: [T]])? {
// Collect keys with at least one item. Sort for deterministic ordering
// before the random pick so test output is reproducible when a seeded
// RNG is used.
let nonEmptyKeys = buckets
.filter { !$0.value.isEmpty }
.keys
.sorted()
guard !nonEmptyKeys.isEmpty else { return nil }
// Prefer keys other than the excluded one, but fall back if excluding
// would leave no candidates.
let candidateKeys: [String]
if let excludingKey, nonEmptyKeys.contains(excludingKey), nonEmptyKeys.count > 1 {
candidateKeys = nonEmptyKeys.filter { $0 != excludingKey }
} else {
candidateKeys = nonEmptyKeys
}
guard let pickedKey = candidateKeys.randomElement(using: &rng),
var items = buckets[pickedKey],
!items.isEmpty else {
return nil
}
// Items were shuffled during groupByModel, so removeFirst is a
// uniform random pick among the remaining items in this bucket.
let item = items.removeFirst()
var remaining = buckets
remaining[pickedKey] = items
return (item, pickedKey, remaining)
}
/// Convenience overload using the system random generator.
static func pickRandomFromBuckets<T>(
_ buckets: [String: [T]],
excludingKey: String? = nil
) -> (item: T, key: String, remaining: [String: [T]])? {
var rng = SystemRandomNumberGenerator()
return pickRandomFromBuckets(buckets, excludingKey: excludingKey, using: &rng)
}
}

View File

@@ -1,24 +1,41 @@
import Foundation
import Observation
import OSLog
private let gameCenterLogger = Logger(subsystem: "com.treyt.mlbTVOS", category: "GameCenter")
private func logGameCenter(_ message: String) {
gameCenterLogger.debug("\(message, privacy: .public)")
print("[GameCenter] \(message)")
}
@Observable
@MainActor
final class GameCenterViewModel {
var feed: LiveGameFeed?
var highlights: [Highlight] = []
var winProbabilityHome: Double?
var winProbabilityAway: Double?
var isLoading = false
var errorMessage: String?
var lastUpdated: Date?
private let statsAPI = MLBStatsAPI()
@ObservationIgnored
private var lastHighlightsFetch: Date?
func watch(game: Game) async {
guard let gamePk = game.gamePk else {
logGameCenter("watch: no gamePk for game id=\(game.id)")
errorMessage = "No live game feed is available for this matchup."
return
}
logGameCenter("watch: starting for gamePk=\(gamePk)")
while !Task.isCancelled {
await refresh(gamePk: gamePk)
await refreshHighlightsIfNeeded(gamePk: gamePk, gameDate: game.gameDate)
await refreshWinProbability(gamePk: gamePk)
let liveState = feed?.gameData.status?.abstractGameState == "Live"
if !liveState {
@@ -34,12 +51,44 @@ final class GameCenterViewModel {
errorMessage = nil
do {
logGameCenter("refresh: fetching feed for gamePk=\(gamePk)")
feed = try await statsAPI.fetchGameFeed(gamePk: gamePk)
logGameCenter("refresh: success playEvents=\(feed?.currentPlay?.playEvents?.count ?? 0) allPlays=\(feed?.liveData.plays.allPlays.count ?? 0)")
lastUpdated = Date()
} catch {
logGameCenter("refresh: FAILED gamePk=\(gamePk) error=\(error)")
errorMessage = "Failed to load game center."
}
isLoading = false
}
private func refreshHighlightsIfNeeded(gamePk: String, gameDate: String) async {
// Only fetch highlights every 60 seconds
if let last = lastHighlightsFetch, Date().timeIntervalSince(last) < 60 {
return
}
let serverAPI = MLBServerAPI()
do {
highlights = try await serverAPI.fetchHighlights(gamePk: gamePk, gameDate: gameDate)
lastHighlightsFetch = Date()
} catch {
// Highlights are supplementary don't surface errors
}
}
private func refreshWinProbability(gamePk: String) async {
do {
let entries = try await statsAPI.fetchWinProbability(gamePk: gamePk)
if let latest = entries.last,
let home = latest.homeTeamWinProbability,
let away = latest.awayTeamWinProbability {
winProbabilityHome = home
winProbabilityAway = away
}
} catch {
// Win probability is supplementary don't surface errors
}
}
}

View File

@@ -55,7 +55,7 @@ final class GamesViewModel {
@ObservationIgnored
private var authenticatedVideoFeedCache: [String: AuthenticatedVideoFeedCacheEntry] = [:]
@ObservationIgnored
private var videoShuffleBags: [String: [URL]] = [:]
private var videoShuffleBagsByModel: [String: [String: [URL]]] = [:]
@ObservationIgnored
private var cachedStandings: (date: String, standings: [Int: TeamStanding])?
@@ -536,11 +536,14 @@ final class GamesViewModel {
func attachPlayer(_ player: AVPlayer, to streamID: String) {
guard let index = activeStreams.firstIndex(where: { $0.id == streamID }) else { return }
let alreadyAttached = activeStreams[index].player === player
activeStreams[index].player = player
activeStreams[index].isPlaying = true
let shouldMute = shouldMuteAudio(for: activeStreams[index])
activeStreams[index].isMuted = shouldMute
player.isMuted = shouldMute
if !alreadyAttached {
let shouldMute = shouldMuteAudio(for: activeStreams[index])
activeStreams[index].isMuted = shouldMute
player.isMuted = shouldMute
}
}
func updateStreamOverrideSource(id: String, url: URL, headers: [String: String] = [:]) {
@@ -614,30 +617,79 @@ final class GamesViewModel {
excluding excludedURL: URL? = nil
) async -> URL? {
guard let urls = await fetchAuthenticatedVideoFeedURLs(feedURL: feedURL, headers: headers) else {
logGamesViewModel("resolveAuthenticatedVideoFeedURL FAILED reason=fetchReturned-nil")
return nil
}
let cacheKey = authenticatedVideoFeedCacheKey(feedURL: feedURL, headers: headers)
var bag = videoShuffleBags[cacheKey] ?? []
var buckets = videoShuffleBagsByModel[cacheKey] ?? [:]
// Refill the bag when empty (or first time), shuffled
if bag.isEmpty {
bag = urls.shuffled()
logGamesViewModel("resolveAuthenticatedVideoFeedURL reshuffled bag count=\(bag.count)")
// Refill ANY model bucket that is empty this keeps all models in
// rotation so small models cycle through their videos while large
// models continue drawing from unplayed ones.
var refilledKeys: [String] = []
if buckets.isEmpty {
// First access: build every bucket from scratch.
var rng = SystemRandomNumberGenerator()
buckets = VideoShuffle.groupByModel(
urls,
keyFor: Self.modelKey(from:),
using: &rng
)
refilledKeys = buckets.keys.sorted()
} else {
// Refill just the depleted buckets.
var rng = SystemRandomNumberGenerator()
for key in buckets.keys where buckets[key]?.isEmpty ?? true {
let modelURLs = urls.filter { Self.modelKey(from: $0) ?? "" == key }
guard !modelURLs.isEmpty else { continue }
buckets[key] = modelURLs.shuffled(using: &rng)
refilledKeys.append(key)
}
}
if !refilledKeys.isEmpty {
let counts = buckets
.map { "\($0.key):\($0.value.count)" }
.sorted()
.joined(separator: ",")
logGamesViewModel(
"resolveAuthenticatedVideoFeedURL refilled buckets=[\(refilledKeys.joined(separator: ","))] currentCounts=[\(counts)]"
)
}
// If the next video is the one we just played, push it to the back
if let excludedURL, bag.count > 1, bag.first == excludedURL {
bag.append(bag.removeFirst())
let excludedModel = excludedURL.flatMap(Self.modelKey(from:))
guard let pick = VideoShuffle.pickRandomFromBuckets(
buckets,
excludingKey: excludedModel
) else {
logGamesViewModel("resolveAuthenticatedVideoFeedURL FAILED reason=all-buckets-empty")
return nil
}
let selectedURL = bag.removeFirst()
videoShuffleBags[cacheKey] = bag
videoShuffleBagsByModel[cacheKey] = pick.remaining
let remainingTotal = pick.remaining.values.map(\.count).reduce(0, +)
logGamesViewModel(
"resolveAuthenticatedVideoFeedURL success resolvedURL=\(gamesViewModelDebugURLDescription(selectedURL)) remaining=\(bag.count) excludedURL=\(excludedURL.map(gamesViewModelDebugURLDescription) ?? "nil")"
"resolveAuthenticatedVideoFeedURL pop model=\(pick.key) remainingTotal=\(remainingTotal) excludedModel=\(excludedModel ?? "nil")"
)
return selectedURL
return pick.item
}
/// Extracts the model/folder identifier from an authenticated feed URL.
/// Expected path shapes:
/// /api/hls/<model>/<file>/master.m3u8
/// /data/media/<model>/<file>.mp4
private static func modelKey(from url: URL) -> String? {
let components = url.pathComponents
if let hlsIndex = components.firstIndex(of: "hls"),
components.index(after: hlsIndex) < components.endIndex {
return components[components.index(after: hlsIndex)]
}
if let mediaIndex = components.firstIndex(of: "media"),
components.index(after: mediaIndex) < components.endIndex {
return components[components.index(after: mediaIndex)]
}
return nil
}
private func fetchAuthenticatedVideoFeedURLs(

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)
}
}

View File

@@ -12,8 +12,8 @@ enum SpecialPlaybackChannelConfig {
static let werkoutNSFWStreamID = "WKNSFW"
static let werkoutNSFWTitle = "Werkout NSFW"
static let werkoutNSFWSubtitle = "Authenticated OF media feed"
static let werkoutNSFWFeedURLString = "https://ofapp.treytartt.com/api/media?users=tabatachang,werkout&type=video"
static let werkoutNSFWCookie = "ofapp_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImlhdCI6MTc3NDY0MjEyNiwiZXhwIjoxNzc1MjQ2OTI2fQ.rDvZzLQ70MM9drHwA8hRxmqcTTgBGBHXxv1Cc55HSqc"
static let werkoutNSFWFeedURLString = "https://ofapp.treytartt.com/api/media?users=tabatachang,werkout,kayla-lauren,ray-mattos,josie-hamming-2,dani-speegle-2&type=video"
static let werkoutNSFWCookie = "ofapp_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImlhdCI6MTc3NDY0MjEyNiwiZXhwIjoxNzc3MjM0MTI2fQ.RdYGvyBPbR6tmK0kaEVhSnIVW6TZlm3Ef42Lxpvl_oQ"
static let werkoutNSFWTeamCode = "WK"
static var werkoutNSFWHeaders: [String: String] {
@@ -56,6 +56,7 @@ struct DashboardView: View {
@State private var pendingFullScreenBroadcast: BroadcastSelection?
@State private var showMLBNetworkSheet = false
@State private var showWerkoutNSFWSheet = false
@State private var isPiPActive = false
private var horizontalPadding: CGFloat {
#if os(iOS)
@@ -218,7 +219,11 @@ struct DashboardView: View {
await resolveFullScreenSource(for: selection)
},
resolveNextSource: nextFullScreenSourceResolver(for: selection),
tickerGames: tickerGames(for: selection)
tickerGames: tickerGames(for: selection),
game: selection.game,
onPiPActiveChanged: { active in
isPiPActive = active
}
)
.ignoresSafeArea()
.onAppear {

View File

@@ -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))

View File

@@ -77,6 +77,8 @@ struct StreamOptionsSheet: View {
.padding(.vertical, 18)
.background(panelBackground)
}
GameCenterView(game: game)
}
.padding(.horizontal, horizontalPadding)
.padding(.vertical, verticalPadding)
@@ -129,8 +131,6 @@ struct StreamOptionsSheet: View {
.padding(22)
.background(panelBackground)
}
GameCenterView(game: game)
}
}

View File

@@ -353,8 +353,13 @@ private struct MultiStreamTile: View {
@State private var hasError = false
@State private var startupPlaybackTask: Task<Void, Never>?
@State private var qualityUpgradeTask: Task<Void, Never>?
@State private var clipTimeLimitObserver: Any?
@State private var isAdvancingClip = false
@StateObject private var playbackDiagnostics = MultiStreamPlaybackDiagnostics()
private static let maxClipDuration: Double = 15.0
private static var audioSessionConfigured = false
var body: some View {
ZStack {
videoLayer
@@ -442,6 +447,7 @@ private struct MultiStreamTile: View {
startupPlaybackTask = nil
qualityUpgradeTask?.cancel()
qualityUpgradeTask = nil
if let player { removeClipTimeLimit(from: player) }
playbackDiagnostics.clear(streamID: stream.id, reason: "tile disappeared")
}
#if os(tvOS)
@@ -536,7 +542,6 @@ private struct MultiStreamTile: View {
)
if let player {
player.isMuted = stream.forceMuteAudio || viewModel.audioFocusStreamID != stream.id
playbackDiagnostics.attach(
to: player,
streamID: stream.id,
@@ -545,20 +550,23 @@ private struct MultiStreamTile: View {
)
scheduleStartupPlaybackRecovery(for: player)
scheduleQualityUpgrade(for: player)
installClipTimeLimit(on: player)
logMultiView("startStream reused inline player id=\(stream.id) muted=\(player.isMuted)")
return
}
do {
try AVAudioSession.sharedInstance().setCategory(.playback)
try AVAudioSession.sharedInstance().setActive(true)
logMultiView("startStream audio session configured id=\(stream.id)")
} catch {
logMultiView("startStream audio session failed id=\(stream.id) error=\(error.localizedDescription)")
if !Self.audioSessionConfigured {
do {
try AVAudioSession.sharedInstance().setCategory(.playback)
try AVAudioSession.sharedInstance().setActive(true)
Self.audioSessionConfigured = true
logMultiView("startStream audio session configured id=\(stream.id)")
} catch {
logMultiView("startStream audio session failed id=\(stream.id) error=\(error.localizedDescription)")
}
}
if let existingPlayer = stream.player {
existingPlayer.isMuted = stream.forceMuteAudio || viewModel.audioFocusStreamID != stream.id
self.player = existingPlayer
hasError = false
playbackDiagnostics.attach(
@@ -569,6 +577,7 @@ private struct MultiStreamTile: View {
)
scheduleStartupPlaybackRecovery(for: existingPlayer)
scheduleQualityUpgrade(for: existingPlayer)
installClipTimeLimit(on: existingPlayer)
logMultiView("startStream reused shared player id=\(stream.id) muted=\(existingPlayer.isMuted)")
return
}
@@ -606,6 +615,7 @@ private struct MultiStreamTile: View {
scheduleQualityUpgrade(for: avPlayer)
logMultiView("startStream attached player id=\(stream.id) muted=\(avPlayer.isMuted) startupResolution=\(multiViewStartupResolution) fastStart=true calling playImmediately(atRate: 1.0)")
avPlayer.playImmediately(atRate: 1.0)
installClipTimeLimit(on: avPlayer)
}
private func makePlayer(url: URL, headers: [String: String]?) -> AVPlayer {
@@ -754,28 +764,73 @@ private struct MultiStreamTile: View {
.value
}
private func installClipTimeLimit(on player: AVPlayer) {
removeClipTimeLimit(from: player)
guard stream.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID else { return }
let limit = CMTime(seconds: Self.maxClipDuration, preferredTimescale: 600)
logMultiView("installClipTimeLimit id=\(stream.id) limit=\(Self.maxClipDuration)s")
clipTimeLimitObserver = player.addBoundaryTimeObserver(
forTimes: [NSValue(time: limit)],
queue: .main
) { [weak player] in
guard let player else {
logMultiView("clipTimeLimit STOPPED id=\(stream.id) reason=player-deallocated")
return
}
let currentTime = CMTimeGetSeconds(player.currentTime())
logMultiView("clipTimeLimit fired id=\(stream.id) currentTime=\(String(format: "%.1f", currentTime))s rate=\(player.rate) — advancing")
Task { @MainActor in
await playNextWerkoutClip(on: player)
}
}
}
private func removeClipTimeLimit(from player: AVPlayer) {
if let observer = clipTimeLimitObserver {
player.removeTimeObserver(observer)
clipTimeLimitObserver = nil
}
}
private func playbackEndedHandler(for player: AVPlayer) -> (@MainActor @Sendable () async -> Void)? {
guard stream.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID else { return nil }
return {
let currentTime = CMTimeGetSeconds(player.currentTime())
logMultiView("playbackEnded (didPlayToEnd) id=\(stream.id) currentTime=\(String(format: "%.1f", currentTime))s rate=\(player.rate)")
await playNextWerkoutClip(on: player)
}
}
private func playNextWerkoutClip(on player: AVPlayer) async {
guard !isAdvancingClip else {
logMultiView("playNextWerkoutClip SKIPPED id=\(stream.id) reason=already-advancing")
return
}
isAdvancingClip = true
defer { isAdvancingClip = false }
let currentURL = currentStreamURL(for: player)
let playerRate = player.rate
let playerStatus = player.status.rawValue
let itemStatus = player.currentItem?.status.rawValue ?? -1
let timeControl = player.timeControlStatus.rawValue
logMultiView(
"playNextWerkoutClip begin id=\(stream.id) currentURL=\(currentURL?.absoluteString ?? "nil")"
"playNextWerkoutClip begin id=\(stream.id) currentURL=\(currentURL?.absoluteString ?? "nil") playerRate=\(playerRate) playerStatus=\(playerStatus) itemStatus=\(itemStatus) timeControl=\(timeControl)"
)
let resolveStart = Date()
guard let nextURL = await viewModel.resolveNextAuthenticatedFeedURLForActiveStream(
id: stream.id,
feedURL: SpecialPlaybackChannelConfig.werkoutNSFWFeedURL,
headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders,
maxRetries: 3
) else {
logMultiView("playNextWerkoutClip failed id=\(stream.id) reason=resolve-nil-after-retries")
let elapsedMs = Int(Date().timeIntervalSince(resolveStart) * 1000)
logMultiView("playNextWerkoutClip STOPPED id=\(stream.id) reason=resolve-nil-after-retries elapsedMs=\(elapsedMs)")
return
}
let resolveMs = Int(Date().timeIntervalSince(resolveStart) * 1000)
logMultiView("playNextWerkoutClip resolved id=\(stream.id) resolveMs=\(resolveMs) nextURL=\(nextURL.lastPathComponent)")
let nextItem = makePlayerItem(
url: nextURL,
@@ -790,10 +845,27 @@ private struct MultiStreamTile: View {
label: stream.label,
onPlaybackEnded: playbackEndedHandler(for: player)
)
viewModel.attachPlayer(player, to: stream.id)
scheduleStartupPlaybackRecovery(for: player)
logMultiView("playNextWerkoutClip replay id=\(stream.id) url=\(nextURL.absoluteString)")
logMultiView("playNextWerkoutClip replay id=\(stream.id) url=\(nextURL.lastPathComponent)")
player.playImmediately(atRate: 1.0)
installClipTimeLimit(on: player)
// Monitor for failure and auto-skip to next clip
Task { @MainActor in
for checkDelay in [1.0, 3.0] {
try? await Task.sleep(for: .seconds(checkDelay))
let postItemStatus = player.currentItem?.status
let error = player.currentItem?.error?.localizedDescription ?? "nil"
logMultiView(
"playNextWerkoutClip postCheck id=\(stream.id) delay=\(checkDelay)s rate=\(player.rate) itemStatus=\(postItemStatus?.rawValue ?? -1) error=\(error)"
)
if postItemStatus == .failed {
logMultiView("playNextWerkoutClip AUTO-SKIP id=\(stream.id) reason=item-failed error=\(error)")
await playNextWerkoutClip(on: player)
return
}
}
}
}
}

View File

@@ -80,28 +80,104 @@ struct SingleStreamPlaybackScreen: View {
let resolveSource: @Sendable () async -> SingleStreamPlaybackSource?
var resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)? = nil
let tickerGames: [Game]
var game: Game? = nil
var onPiPActiveChanged: ((Bool) -> Void)? = nil
@State private var showGameCenter = false
@State private var showPitchInfo = false
@State private var pitchViewModel = GameCenterViewModel()
@State private var isPiPActive = false
var body: some View {
ZStack(alignment: .bottom) {
SingleStreamPlayerView(resolveSource: resolveSource, resolveNextSource: resolveNextSource)
.ignoresSafeArea()
SingleStreamPlayerView(
resolveSource: resolveSource,
resolveNextSource: resolveNextSource,
hasGamePk: game?.gamePk != nil,
onTogglePitchInfo: {
showPitchInfo.toggle()
if showPitchInfo { showGameCenter = false }
},
onToggleGameCenter: {
showGameCenter.toggle()
if showGameCenter { showPitchInfo = false }
},
onPiPStateChanged: { active in
isPiPActive = active
onPiPActiveChanged?(active)
},
showPitchInfo: showPitchInfo,
showGameCenter: showGameCenter
)
.ignoresSafeArea()
SingleStreamScoreStripView(games: tickerGames)
.allowsHitTesting(false)
.padding(.horizontal, 18)
.padding(.bottom, 14)
}
.overlay(alignment: .topTrailing) {
#if os(iOS)
Button {
dismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(.white.opacity(0.9))
.padding(20)
if !showGameCenter && !showPitchInfo {
SingleStreamScoreStripView(games: tickerGames)
.allowsHitTesting(false)
.padding(.horizontal, 18)
.padding(.bottom, 14)
.transition(.opacity)
}
if showGameCenter, let game {
gameCenterOverlay(game: game)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.animation(.easeInOut(duration: 0.3), value: showGameCenter)
.animation(.easeInOut(duration: 0.3), value: showPitchInfo)
.overlay(alignment: .bottomLeading) {
if showPitchInfo, let feed = pitchViewModel.feed {
pitchInfoBox(feed: feed)
.transition(.move(edge: .leading).combined(with: .opacity))
}
}
#if os(iOS)
.overlay(alignment: .topTrailing) {
HStack(spacing: 12) {
if game?.gamePk != nil {
Button {
showPitchInfo.toggle()
if showPitchInfo { showGameCenter = false }
} label: {
Image(systemName: showPitchInfo ? "xmark.circle.fill" : "baseball.fill")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(.white.opacity(0.9))
.padding(6)
.background(.black.opacity(0.5))
.clipShape(Circle())
}
Button {
showGameCenter.toggle()
if showGameCenter { showPitchInfo = false }
} label: {
Image(systemName: showGameCenter ? "xmark.circle.fill" : "chart.bar.fill")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(.white.opacity(0.9))
.padding(6)
.background(.black.opacity(0.5))
.clipShape(Circle())
}
Button {
dismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(.white.opacity(0.9))
}
}
}
.padding(20)
}
#endif
.task(id: game?.gamePk) {
guard let gamePk = game?.gamePk else { return }
while !Task.isCancelled {
await pitchViewModel.refresh(gamePk: gamePk)
try? await Task.sleep(for: .seconds(5))
}
#endif
}
.ignoresSafeArea()
.onAppear {
@@ -111,6 +187,135 @@ struct SingleStreamPlaybackScreen: View {
logSingleStream("SingleStreamPlaybackScreen disappeared")
}
}
private func gameCenterOverlay(game: Game) -> some View {
ScrollView {
GameCenterView(game: game)
.padding(.horizontal, 20)
.padding(.top, 60)
.padding(.bottom, 40)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.black.opacity(0.82))
}
private func pitchInfoBox(feed: LiveGameFeed) -> some View {
let pitches = feed.currentAtBatPitches
let batter = feed.currentBatter?.displayName ?? ""
let pitcher = feed.currentPitcher?.displayName ?? ""
let countText = feed.currentCountText ?? ""
return VStack(alignment: .leading, spacing: 8) {
// Matchup header use last name only to save space
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
Text("AB")
.font(.system(size: 10, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.4))
Text(batter)
.font(.system(size: 14, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.7)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("P")
.font(.system(size: 10, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.4))
Text(pitcher)
.font(.system(size: 14, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.7)
}
}
if !countText.isEmpty {
Text(countText)
.font(.system(size: 13, weight: .semibold, design: .rounded))
.foregroundStyle(.white.opacity(0.7))
}
if !pitches.isEmpty {
// Latest pitch bold and prominent
if let last = pitches.last {
let color = pitchCallColor(last.callCode)
HStack(spacing: 6) {
if let speed = last.speedMPH {
Text("\(speed, specifier: "%.1f")")
.font(.system(size: 24, weight: .black).monospacedDigit())
.foregroundStyle(.white)
Text("mph")
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.5))
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(last.pitchTypeDescription)
.font(.system(size: 14, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.lineLimit(1)
Text(last.callDescription)
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(color)
}
}
}
// Strike zone + previous pitches side by side
HStack(alignment: .top, spacing: 14) {
StrikeZoneView(pitches: pitches, size: 120)
// Previous pitches compact rows
if pitches.count > 1 {
VStack(alignment: .leading, spacing: 3) {
ForEach(Array(pitches.dropLast().reversed().prefix(8).enumerated()), id: \.offset) { _, pitch in
let color = pitchCallColor(pitch.callCode)
HStack(spacing: 4) {
Text("\(pitch.pitchNumber ?? 0)")
.font(.system(size: 10, weight: .bold).monospacedDigit())
.foregroundStyle(.white.opacity(0.35))
.frame(width: 14, alignment: .trailing)
Text(shortPitchType(pitch.pitchTypeCode))
.font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundStyle(.white.opacity(0.7))
if let speed = pitch.speedMPH {
Text("\(speed, specifier: "%.0f")")
.font(.system(size: 11, weight: .bold).monospacedDigit())
.foregroundStyle(.white.opacity(0.45))
}
Spacer(minLength: 0)
Circle()
.fill(color)
.frame(width: 6, height: 6)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
} else {
Text("Waiting for pitch data...")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.white.opacity(0.5))
}
}
.frame(width: 300)
.padding(16)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(.black.opacity(0.78))
.overlay {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.strokeBorder(.white.opacity(0.1), lineWidth: 1)
}
)
.padding(.leading, 24)
.padding(.bottom, 50)
}
}
struct SingleStreamPlaybackSource: Sendable {
@@ -290,6 +495,12 @@ private final class SingleStreamMarqueeContainerView: UIView {
struct SingleStreamPlayerView: UIViewControllerRepresentable {
let resolveSource: @Sendable () async -> SingleStreamPlaybackSource?
var resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)? = nil
var hasGamePk: Bool = false
var onTogglePitchInfo: (() -> Void)? = nil
var onToggleGameCenter: (() -> Void)? = nil
var onPiPStateChanged: ((Bool) -> Void)? = nil
var showPitchInfo: Bool = false
var showGameCenter: Bool = false
func makeCoordinator() -> Coordinator {
Coordinator()
@@ -300,10 +511,24 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
let controller = AVPlayerViewController()
controller.allowsPictureInPicturePlayback = true
controller.showsPlaybackControls = true
context.coordinator.onPiPStateChanged = onPiPStateChanged
controller.delegate = context.coordinator
#if os(iOS)
controller.canStartPictureInPictureAutomaticallyFromInline = true
#endif
logSingleStream("AVPlayerViewController configured without contentOverlayView ticker")
#if os(tvOS)
if hasGamePk {
context.coordinator.onTogglePitchInfo = onTogglePitchInfo
context.coordinator.onToggleGameCenter = onToggleGameCenter
controller.transportBarCustomMenuItems = context.coordinator.buildTransportBarItems(
showPitchInfo: showPitchInfo,
showGameCenter: showGameCenter
)
}
#endif
logSingleStream("AVPlayerViewController configured")
Task { @MainActor in
let resolveStartedAt = Date()
@@ -335,26 +560,102 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
controller.player = player
logSingleStream("AVPlayer assigned to controller; calling playImmediately(atRate: 1.0)")
player.playImmediately(atRate: 1.0)
context.coordinator.installClipTimeLimit(on: player, resolveNextSource: resolveNextSource)
context.coordinator.scheduleStartupRecovery(for: player)
}
return controller
}
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {}
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {
context.coordinator.onPiPStateChanged = onPiPStateChanged
#if os(tvOS)
if hasGamePk {
context.coordinator.onTogglePitchInfo = onTogglePitchInfo
context.coordinator.onToggleGameCenter = onToggleGameCenter
uiViewController.transportBarCustomMenuItems = context.coordinator.buildTransportBarItems(
showPitchInfo: showPitchInfo,
showGameCenter: showGameCenter
)
}
#endif
}
static func dismantleUIViewController(_ uiViewController: AVPlayerViewController, coordinator: Coordinator) {
logSingleStream("dismantleUIViewController start")
logSingleStream("dismantleUIViewController start isPiPActive=\(coordinator.isPiPActive)")
if coordinator.isPiPActive {
logSingleStream("dismantleUIViewController skipped — PiP is active")
return
}
coordinator.clearDebugObservers()
uiViewController.player?.pause()
uiViewController.player = nil
logSingleStream("dismantleUIViewController complete")
}
final class Coordinator: NSObject, @unchecked Sendable {
final class Coordinator: NSObject, @unchecked Sendable, AVPlayerViewControllerDelegate {
private var playerObservations: [NSKeyValueObservation] = []
private var notificationTokens: [NSObjectProtocol] = []
private var startupRecoveryTask: Task<Void, Never>?
private var clipTimeLimitObserver: Any?
private static let maxClipDuration: Double = 15.0
var onTogglePitchInfo: (() -> Void)?
var onToggleGameCenter: (() -> Void)?
var isPiPActive = false
var onPiPStateChanged: ((Bool) -> Void)?
func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_ playerViewController: AVPlayerViewController) -> Bool {
logSingleStream("PiP: shouldAutomaticallyDismiss returning false")
return false
}
func playerViewControllerWillStartPictureInPicture(_ playerViewController: AVPlayerViewController) {
logSingleStream("PiP: willStart")
isPiPActive = true
onPiPStateChanged?(true)
}
func playerViewControllerDidStartPictureInPicture(_ playerViewController: AVPlayerViewController) {
logSingleStream("PiP: didStart")
}
func playerViewControllerWillStopPictureInPicture(_ playerViewController: AVPlayerViewController) {
logSingleStream("PiP: willStop")
}
func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) {
logSingleStream("PiP: didStop")
isPiPActive = false
onPiPStateChanged?(false)
}
func playerViewController(
_ playerViewController: AVPlayerViewController,
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
) {
logSingleStream("PiP: restoreUserInterface")
completionHandler(true)
}
#if os(tvOS)
func buildTransportBarItems(showPitchInfo: Bool, showGameCenter: Bool) -> [UIAction] {
let pitchAction = UIAction(
title: showPitchInfo ? "Hide Pitch Info" : "Pitch Info",
image: UIImage(systemName: showPitchInfo ? "xmark.circle.fill" : "baseball.fill")
) { [weak self] _ in
self?.onTogglePitchInfo?()
}
let gcAction = UIAction(
title: showGameCenter ? "Hide Game Center" : "Game Center",
image: UIImage(systemName: showGameCenter ? "xmark.circle.fill" : "chart.bar.fill")
) { [weak self] _ in
self?.onToggleGameCenter?()
}
return [pitchAction, gcAction]
}
#endif
func attachDebugObservers(
to player: AVPlayer,
@@ -456,6 +757,7 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
self.attachDebugObservers(to: player, url: nextSource.url, resolveNextSource: resolveNextSource)
logSingleStream("Autoplay replacing item and replaying url=\(singleStreamDebugURLDescription(nextSource.url))")
player.playImmediately(atRate: 1.0)
self.installClipTimeLimit(on: player, resolveNextSource: resolveNextSource)
self.scheduleStartupRecovery(for: player)
}
}
@@ -522,6 +824,45 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
}
}
func installClipTimeLimit(
on player: AVPlayer,
resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)?
) {
removeClipTimeLimit(from: player)
guard resolveNextSource != nil else { return }
let limit = CMTime(seconds: Self.maxClipDuration, preferredTimescale: 600)
clipTimeLimitObserver = player.addBoundaryTimeObserver(
forTimes: [NSValue(time: limit)],
queue: .main
) { [weak self, weak player] in
guard let self, let player, let resolveNextSource else { return }
logSingleStream("clipTimeLimit hit \(Self.maxClipDuration)s — advancing to next clip")
Task { @MainActor [weak self] in
guard let self else { return }
let currentURL = (player.currentItem?.asset as? AVURLAsset)?.url
guard let nextSource = await resolveNextSource(currentURL) else {
logSingleStream("clipTimeLimit next source nil")
return
}
let nextItem = makeSingleStreamPlayerItem(from: nextSource)
player.replaceCurrentItem(with: nextItem)
player.automaticallyWaitsToMinimizeStalling = false
player.isMuted = nextSource.forceMuteAudio
self.attachDebugObservers(to: player, url: nextSource.url, resolveNextSource: resolveNextSource)
player.playImmediately(atRate: 1.0)
self.installClipTimeLimit(on: player, resolveNextSource: resolveNextSource)
self.scheduleStartupRecovery(for: player)
}
}
}
private func removeClipTimeLimit(from player: AVPlayer) {
if let observer = clipTimeLimitObserver {
player.removeTimeObserver(observer)
clipTimeLimitObserver = nil
}
}
func clearDebugObservers() {
startupRecoveryTask?.cancel()
startupRecoveryTask = nil