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:
@@ -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?
|
||||
}
|
||||
|
||||
78
mlbTVOS/Services/VideoShuffle.swift
Normal file
78
mlbTVOS/Services/VideoShuffle.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
80
mlbTVOS/Views/Components/AtBatTimelineView.swift
Normal file
80
mlbTVOS/Views/Components/AtBatTimelineView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
68
mlbTVOS/Views/Components/PitchSequenceView.swift
Normal file
68
mlbTVOS/Views/Components/PitchSequenceView.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
126
mlbTVOS/Views/Components/SprayChartView.swift
Normal file
126
mlbTVOS/Views/Components/SprayChartView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
104
mlbTVOS/Views/Components/StrikeZoneView.swift
Normal file
104
mlbTVOS/Views/Components/StrikeZoneView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user