Initial commit

This commit is contained in:
Trey t
2026-03-26 15:37:31 -05:00
commit bae265b132
41 changed files with 8596 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.459",
"green" : "0.231",
"red" : "0.000"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,4 @@
{
"images" : [ { "idiom" : "tv", "scale" : "1x" } ],
"info" : { "author" : "xcode", "version" : 1 }
}

View File

@@ -0,0 +1,3 @@
{
"info" : { "author" : "xcode", "version" : 1 }
}

View File

@@ -0,0 +1,11 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"layers" : [
{ "filename" : "Front.imagestacklayer" },
{ "filename" : "Middle.imagestacklayer" },
{ "filename" : "Back.imagestacklayer" }
]
}

View File

@@ -0,0 +1,4 @@
{
"images" : [ { "idiom" : "tv", "scale" : "1x" } ],
"info" : { "author" : "xcode", "version" : 1 }
}

View File

@@ -0,0 +1,3 @@
{
"info" : { "author" : "xcode", "version" : 1 }
}

View File

@@ -0,0 +1,4 @@
{
"images" : [ { "idiom" : "tv", "scale" : "1x" } ],
"info" : { "author" : "xcode", "version" : 1 }
}

View File

@@ -0,0 +1,3 @@
{
"info" : { "author" : "xcode", "version" : 1 }
}

View File

@@ -0,0 +1,24 @@
{
"assets" : [
{
"filename" : "App Icon.imagestack",
"idiom" : "tv",
"role" : "primary-app-icon",
"size" : "1280x768"
},
{
"idiom" : "tv",
"role" : "top-shelf-image",
"size" : "1920x720"
},
{
"idiom" : "tv",
"role" : "top-shelf-image-wide",
"size" : "2320x720"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

27
mlbTVOS/Info.plist Normal file
View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>

200
mlbTVOS/Models/Game.swift Normal file
View File

@@ -0,0 +1,200 @@
import Foundation
struct Game: Identifiable, Sendable {
let id: String
let awayTeam: TeamInfo
let homeTeam: TeamInfo
let status: GameStatus
let gameType: String?
let startTime: String?
let venue: String?
let pitchers: String?
let gamePk: String?
let gameDate: String
let broadcasts: [Broadcast]
let isBlackedOut: Bool
// Rich data from Stats API
var linescore: StatsLinescore?
var currentInningDisplay: String?
var awayPitcherId: Int?
var homePitcherId: Int?
var awayPitcherHeadshotURL: URL? {
guard let id = awayPitcherId 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")
}
var homePitcherHeadshotURL: URL? {
guard let id = homePitcherId 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")
}
var displayTitle: String {
"\(awayTeam.displayName) @ \(homeTeam.displayName)"
}
var scoreDisplay: String? {
guard let awayScore = awayTeam.score, let homeScore = homeTeam.score else {
return nil
}
return "\(awayScore) - \(homeScore)"
}
var hasStreams: Bool {
!broadcasts.isEmpty && !isBlackedOut
}
var isLive: Bool { status.isLive }
var isFinal: Bool { if case .final_ = status { return true }; return false }
}
struct TeamInfo: Sendable {
let code: String
let name: String
let score: Int?
var teamId: Int?
var record: String?
var divisionRank: String?
var gamesBack: String?
var streak: String?
var displayName: String {
if !name.isEmpty { return name }
return TeamDirectory.name(for: code)
}
var logoURL: URL? {
if let teamId { return TeamAssets.logoURL(forId: teamId) }
return TeamAssets.logoURL(for: code)
}
var standingSummary: String? {
var parts: [String] = []
if let rank = divisionRank {
let suffix: String
switch rank {
case "1": suffix = "st"
case "2": suffix = "nd"
case "3": suffix = "rd"
default: suffix = "th"
}
parts.append("\(rank)\(suffix)")
}
if let gb = gamesBack {
parts.append("\(gb) GB")
}
if let streak {
parts.append(streak)
}
return parts.isEmpty ? nil : parts.joined(separator: " · ")
}
}
struct Broadcast: Identifiable, Sendable {
let id: String
let teamCode: String
let name: String
let mediaId: String
let streamURL: String
var displayLabel: String {
"\(teamCode): \(name)"
}
}
enum GameStatus: Sendable {
case scheduled(String)
case live(String?)
case final_
case unknown
var label: String {
switch self {
case .scheduled(let time): time
case .live(let info): info ?? "Live"
case .final_: "Final"
case .unknown: ""
}
}
var isLive: Bool {
if case .live = self { return true }
return false
}
var isScheduled: Bool {
if case .scheduled = self { return true }
return false
}
}
struct StreamConfig: Sendable {
let mediaId: String?
let team: String?
let resolution: String
let date: String?
let level: String?
let skip: String?
let audioTrack: String?
let mediaType: String?
let game: Int?
init(
mediaId: String,
resolution: String = "best",
audioTrack: String? = nil
) {
self.mediaId = mediaId
self.team = nil
self.resolution = resolution
self.date = nil
self.level = nil
self.skip = nil
self.audioTrack = audioTrack
self.mediaType = nil
self.game = nil
}
init(
team: String,
resolution: String = "best",
date: String? = nil,
level: String? = nil,
skip: String? = nil,
audioTrack: String? = nil,
mediaType: String? = nil,
game: Int? = nil
) {
self.mediaId = nil
self.team = team
self.resolution = resolution
self.date = date
self.level = level
self.skip = skip
self.audioTrack = audioTrack
self.mediaType = mediaType
self.game = game
}
}
enum TeamDirectory {
static let teams: [String: String] = [
"ARI": "D-backs", "AZ": "D-backs",
"ATL": "Braves", "BAL": "Orioles", "BOS": "Red Sox",
"CHC": "Cubs", "CWS": "White Sox", "CIN": "Reds",
"CLE": "Guardians", "COL": "Rockies", "DET": "Tigers",
"HOU": "Astros", "KC": "Royals", "LAA": "Angels",
"LAD": "Dodgers", "MIA": "Marlins", "MIL": "Brewers",
"MIN": "Twins", "NYM": "Mets", "NYY": "Yankees",
"OAK": "Athletics", "ATH": "Athletics",
"PHI": "Phillies", "PIT": "Pirates", "SD": "Padres",
"SF": "Giants", "SEA": "Mariners", "STL": "Cardinals",
"TB": "Rays", "TEX": "Rangers", "TOR": "Blue Jays",
"WSH": "Nationals",
]
static func name(for code: String) -> String {
teams[code.uppercased()] ?? code
}
}

View File

@@ -0,0 +1,62 @@
import SwiftUI
enum TeamAssets {
// MLB team ID mapping for logo URLs
static let teamIds: [String: Int] = [
"ARI": 109, "AZ": 109, "ATL": 144, "BAL": 110, "BOS": 111,
"CHC": 112, "CWS": 145, "CIN": 113, "CLE": 114, "COL": 115,
"DET": 116, "HOU": 117, "KC": 118, "LAA": 108, "LAD": 119,
"MIA": 146, "MIL": 158, "MIN": 142, "NYM": 121, "NYY": 147,
"OAK": 133, "ATH": 133, "PHI": 143, "PIT": 134, "SD": 135,
"SF": 137, "SEA": 136, "STL": 138, "TB": 139, "TEX": 140,
"TOR": 141, "WSH": 120,
]
// Primary team colors
static let teamColors: [String: Color] = [
"ARI": Color(red: 0.65, green: 0.10, blue: 0.19),
"ATL": Color(red: 0.81, green: 0.07, blue: 0.26),
"BAL": Color(red: 0.87, green: 0.31, blue: 0.07),
"BOS": Color(red: 0.74, green: 0.09, blue: 0.13),
"CHC": Color(red: 0.00, green: 0.19, blue: 0.56),
"CWS": Color(red: 0.15, green: 0.15, blue: 0.15),
"CIN": Color(red: 0.77, green: 0.06, blue: 0.15),
"CLE": Color(red: 0.00, green: 0.17, blue: 0.38),
"COL": Color(red: 0.20, green: 0.11, blue: 0.35),
"DET": Color(red: 0.00, green: 0.18, blue: 0.42),
"HOU": Color(red: 0.00, green: 0.18, blue: 0.39),
"KC": Color(red: 0.00, green: 0.22, blue: 0.53),
"LAA": Color(red: 0.73, green: 0.07, blue: 0.15),
"LAD": Color(red: 0.00, green: 0.22, blue: 0.56),
"MIA": Color(red: 0.00, green: 0.63, blue: 0.79),
"MIL": Color(red: 0.07, green: 0.15, blue: 0.34),
"MIN": Color(red: 0.00, green: 0.17, blue: 0.38),
"NYM": Color(red: 0.00, green: 0.18, blue: 0.48),
"NYY": Color(red: 0.00, green: 0.14, blue: 0.30),
"OAK": Color(red: 0.00, green: 0.30, blue: 0.18),
"ATH": Color(red: 0.00, green: 0.30, blue: 0.18),
"PHI": Color(red: 0.76, green: 0.08, blue: 0.18),
"PIT": Color(red: 0.99, green: 0.73, blue: 0.01),
"SD": Color(red: 0.18, green: 0.12, blue: 0.07),
"SF": Color(red: 0.99, green: 0.31, blue: 0.07),
"SEA": Color(red: 0.00, green: 0.22, blue: 0.36),
"STL": Color(red: 0.76, green: 0.10, blue: 0.15),
"TB": Color(red: 0.00, green: 0.18, blue: 0.46),
"TEX": Color(red: 0.00, green: 0.21, blue: 0.52),
"TOR": Color(red: 0.08, green: 0.25, blue: 0.52),
"WSH": Color(red: 0.67, green: 0.09, blue: 0.19),
]
static func color(for code: String) -> Color {
teamColors[code.uppercased()] ?? .gray
}
static func logoURL(for code: String) -> URL? {
guard let id = teamIds[code.uppercased()] else { return nil }
return URL(string: "https://midfield.mlbstatic.com/v1/team/\(id)/spots/72")
}
static func logoURL(forId id: Int) -> URL {
URL(string: "https://midfield.mlbstatic.com/v1/team/\(id)/spots/72")!
}
}

View File

@@ -0,0 +1,336 @@
import Foundation
actor MLBServerAPI {
let baseURL: String
init(baseURL: String = "http://10.3.3.11:5714") {
self.baseURL = baseURL
}
// MARK: - Stream URL Construction
func streamURL(for config: StreamConfig) -> URL {
var components = URLComponents(string: "\(baseURL)/stream.m3u8")!
var items: [URLQueryItem] = []
if let mediaId = config.mediaId {
items.append(URLQueryItem(name: "mediaId", value: mediaId))
} else if let team = config.team {
items.append(URLQueryItem(name: "team", value: team))
}
items.append(URLQueryItem(name: "resolution", value: config.resolution))
if let date = config.date {
items.append(URLQueryItem(name: "date", value: date))
}
if let level = config.level {
items.append(URLQueryItem(name: "level", value: level))
}
if let skip = config.skip {
items.append(URLQueryItem(name: "skip", value: skip))
}
if let audioTrack = config.audioTrack {
items.append(URLQueryItem(name: "audio_track", value: audioTrack))
}
if let mediaType = config.mediaType {
items.append(URLQueryItem(name: "mediaType", value: mediaType))
}
if let game = config.game {
items.append(URLQueryItem(name: "game", value: String(game)))
}
components.queryItems = items
return components.url!
}
func eventStreamURL(event: String, resolution: String = "best") -> URL {
var components = URLComponents(string: "\(baseURL)/stream.m3u8")!
components.queryItems = [
URLQueryItem(name: "event", value: event),
URLQueryItem(name: "resolution", value: resolution),
]
return components.url!
}
// MARK: - Fetch Games
func fetchGames(date: String? = nil) async throws -> [Game] {
var urlString = baseURL + "/?scores=Show"
if let date {
urlString += "&date=\(date)"
}
guard let url = URL(string: urlString) else {
throw APIError.invalidURL
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw APIError.serverError
}
guard let html = String(data: data, encoding: .utf8) else {
throw APIError.invalidData
}
return parseGames(from: html, date: date)
}
// MARK: - Fetch Highlights
func fetchHighlights(gamePk: String, gameDate: String) async throws -> [Highlight] {
let urlString = "\(baseURL)/highlights?gamePk=\(gamePk)&gameDate=\(gameDate)"
guard let url = URL(string: urlString) else {
throw APIError.invalidURL
}
let (data, _) = try await URLSession.shared.data(from: url)
let highlights = try JSONDecoder().decode([Highlight].self, from: data)
return highlights
}
// MARK: - HTML Parsing
private func parseGames(from html: String, date: String?) -> [Game] {
var games: [Game] = []
let todayDate = date ?? {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
return f.string(from: Date())
}()
// Split HTML into table rows
// Game rows have the pattern: <tr...><td>AWAY @ HOME...</td><td>...streams...</td></tr>
let rowPattern = #"<tr[^>]*>\s*<td>([^<]*(?:<(?!/?tr)[^>]*>[^<]*)*)</td>\s*<td>(.*?)</td>"#
guard let rowRegex = try? NSRegularExpression(pattern: rowPattern, options: .dotMatchesLineSeparators) else {
return games
}
let matches = rowRegex.matches(in: html, range: NSRange(html.startIndex..., in: html))
for match in matches {
guard let gameInfoRange = Range(match.range(at: 1), in: html),
let streamInfoRange = Range(match.range(at: 2), in: html) else { continue }
let gameInfo = String(html[gameInfoRange])
let streamInfo = String(html[streamInfoRange])
// Must contain " @ " to be a game row (not MLB Network, not docs)
guard gameInfo.contains(" @ ") else { continue }
if let game = parseGameRow(
gameInfo: gameInfo,
streamInfo: streamInfo,
gameDate: todayDate
) {
games.append(game)
}
}
return games
}
private func parseGameRow(gameInfo: String, streamInfo: String, gameDate: String) -> Game? {
// Strip HTML tags for text parsing
let cleanInfo = gameInfo
.replacingOccurrences(of: "<br/>", with: "\n")
.replacingOccurrences(of: "<br>", with: "\n")
.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
let lines = cleanInfo.split(separator: "\n").map { $0.trimmingCharacters(in: .whitespaces) }
guard !lines.isEmpty else { return nil }
// Line 1: "[Type: ]AWAY [score] @ HOME [score]"
let matchupLine = lines[0]
// Extract game type prefix (e.g. "Spring Training: ", "Exhibition Game: ")
var gameType: String?
var matchupText = matchupLine
if let colonRange = matchupLine.range(of: ": ") {
let prefix = String(matchupLine[matchupLine.startIndex..<colonRange.lowerBound])
// Only treat as game type if it doesn't look like a team name
if prefix.contains(" ") || prefix.count > 5 {
gameType = prefix
matchupText = String(matchupLine[colonRange.upperBound...])
}
}
// Parse "AWAY [score] @ HOME [score]"
let atParts = matchupText.components(separatedBy: " @ ")
guard atParts.count == 2 else { return nil }
let (awayName, awayScore) = parseTeamAndScore(atParts[0].trimmingCharacters(in: .whitespaces))
let (homeName, homeScore) = parseTeamAndScore(atParts[1].trimmingCharacters(in: .whitespaces))
// Line 2: "Pitcher vs Pitcher" (optional)
let pitchers: String? = lines.count > 1 ? lines[1] : nil
// Line 3+: Status ("Final", "7:05 PM", "In Progress", "Top 5th", etc.) and venue
var statusText = ""
var venue: String?
for i in 2..<lines.count {
let line = lines[i]
if line.hasPrefix("at ") {
venue = line
} else if !line.isEmpty {
statusText = line
}
}
// Determine game status
let status: GameStatus
let lower = statusText.lowercased()
if lower.contains("final") || lower.contains("game over") || lower.contains("completed") {
status = .final_
} else if lower.contains("progress") || lower.contains("top") || lower.contains("bot")
|| lower.contains("mid") || lower.contains("end") || lower.contains("delay") {
status = .live(statusText)
} else if statusText.contains("PM") || statusText.contains("AM") || statusText.contains(":") {
status = .scheduled(statusText)
} else if statusText.isEmpty {
// If no status line, check if scores exist probably final or live
if awayScore != nil {
status = .final_
} else {
status = .unknown
}
} else {
status = .unknown
}
// Check for blackout
let isBlackedOut = streamInfo.contains("blackout")
// Extract broadcasts from stream info
let broadcasts = parseBroadcasts(from: streamInfo)
// Extract gamePk from highlights link
let gamePk: String? = {
let pattern = #"showhighlights\('(\d+)'"#
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: streamInfo, range: NSRange(streamInfo.startIndex..., in: streamInfo)),
let range = Range(match.range(at: 1), in: streamInfo) else { return nil }
return String(streamInfo[range])
}()
// Extract team codes from addmultiview
let teamCodes = extractTeamCodes(from: streamInfo)
let awayCode = teamCodes.count > 0 ? teamCodes[0] : awayName
let homeCode = teamCodes.count > 1 ? teamCodes[1] : homeName
let id = gamePk ?? "\(awayCode)-\(homeCode)-\(gameDate)"
return Game(
id: id,
awayTeam: TeamInfo(code: awayCode, name: awayName, score: awayScore),
homeTeam: TeamInfo(code: homeCode, name: homeName, score: homeScore),
status: status,
gameType: gameType,
startTime: {
if case .scheduled(let t) = status { return t }
return nil
}(),
venue: venue,
pitchers: pitchers,
gamePk: gamePk,
gameDate: gameDate,
broadcasts: broadcasts,
isBlackedOut: isBlackedOut
)
}
/// Parse "TeamName 5" or just "TeamName" → (name, score?)
private func parseTeamAndScore(_ text: String) -> (String, Int?) {
let parts = text.split(separator: " ")
guard parts.count >= 2,
let score = Int(parts.last!) else {
return (text, nil)
}
let name = parts.dropLast().joined(separator: " ")
return (name, score)
}
/// Extract broadcast info from the stream cell HTML
private func parseBroadcasts(from html: String) -> [Broadcast] {
var broadcasts: [Broadcast] = []
// Pattern: TEAM: <a href="/embed.html?mediaId=UUID">NAME</a><input ... value="streamURL" onclick="addmultiview(this, ['X', 'Y'])">
// Split by broadcast entries: look for "CODE: <a" patterns
let broadcastPattern = #"(\w+):\s*<a[^>]*href="/embed\.html\?mediaId=([^"&]+)"[^>]*>([^<]+)</a>\s*<input[^>]*value="([^"]*)"#
guard let regex = try? NSRegularExpression(pattern: broadcastPattern) else {
return broadcasts
}
let matches = regex.matches(in: html, range: NSRange(html.startIndex..., in: html))
for match in matches {
guard let teamRange = Range(match.range(at: 1), in: html),
let mediaIdRange = Range(match.range(at: 2), in: html),
let nameRange = Range(match.range(at: 3), in: html),
let urlRange = Range(match.range(at: 4), in: html) else { continue }
let broadcast = Broadcast(
id: String(html[mediaIdRange]),
teamCode: String(html[teamRange]),
name: String(html[nameRange]),
mediaId: String(html[mediaIdRange]),
streamURL: String(html[urlRange])
)
broadcasts.append(broadcast)
}
return broadcasts
}
/// Extract team codes from addmultiview onclick
private func extractTeamCodes(from html: String) -> [String] {
let pattern = #"addmultiview\(this,\s*\[([^\]]*)\]"#
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: html, range: NSRange(html.startIndex..., in: html)),
let range = Range(match.range(at: 1), in: html) else { return [] }
return String(html[range])
.replacingOccurrences(of: "'", with: "")
.replacingOccurrences(of: "\"", with: "")
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces) }
}
}
// MARK: - Types
struct Highlight: Codable, Sendable, Identifiable {
let id: String?
let headline: String?
let playbacks: [Playback]?
struct Playback: Codable, Sendable {
let name: String?
let url: String?
}
var hlsURL: String? {
playbacks?.first(where: { $0.name == "HTTP_CLOUD_WIRED_60" })?.url
}
var mp4URL: String? {
playbacks?.first(where: { $0.name == "mp4Avc" })?.url
}
}
enum APIError: Error, LocalizedError {
case invalidURL
case serverError
case invalidData
case noStreamsAvailable
var errorDescription: String? {
switch self {
case .invalidURL: "Invalid server URL"
case .serverError: "Server returned an error"
case .invalidData: "Could not parse server response"
case .noStreamsAvailable: "No streams available"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
import Foundation
import Observation
@Observable
@MainActor
final class GameCenterViewModel {
var feed: LiveGameFeed?
var isLoading = false
var errorMessage: String?
var lastUpdated: Date?
private let statsAPI = MLBStatsAPI()
func watch(game: Game) async {
guard let gamePk = game.gamePk else {
errorMessage = "No live game feed is available for this matchup."
return
}
while !Task.isCancelled {
await refresh(gamePk: gamePk)
let liveState = feed?.gameData.status?.abstractGameState == "Live"
if !liveState {
break
}
try? await Task.sleep(for: .seconds(12))
}
}
func refresh(gamePk: String) async {
isLoading = true
errorMessage = nil
do {
feed = try await statsAPI.fetchGameFeed(gamePk: gamePk)
lastUpdated = Date()
} catch {
errorMessage = "Failed to load game center."
}
isLoading = false
}
}

View File

@@ -0,0 +1,640 @@
import AVFoundation
import Foundation
import Observation
import OSLog
private let gamesViewModelLogger = Logger(subsystem: "com.treyt.mlbTVOS", category: "GamesViewModel")
private func logGamesViewModel(_ message: String) {
gamesViewModelLogger.debug("\(message, privacy: .public)")
print("[GamesViewModel] \(message)")
}
private func gamesViewModelDebugURLDescription(_ url: URL) -> String {
var host = url.host ?? "unknown-host"
if let port = url.port {
host += ":\(port)"
}
let queryKeys = URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?
.map(\.name) ?? []
let querySuffix = queryKeys.isEmpty ? "" : "?\(queryKeys.joined(separator: "&"))"
return "\(url.scheme ?? "unknown")://\(host)\(url.path)\(querySuffix)"
}
@Observable
@MainActor
final class GamesViewModel {
var games: [Game] = []
var isLoading = false
var errorMessage: String?
var selectedDate: String?
var activeStreams: [ActiveStream] = []
var multiViewLayoutMode: MultiViewLayoutMode = .balanced
var audioFocusStreamID: String?
var serverBaseURL: String = "http://10.3.3.11:5714"
var defaultResolution: String = "best"
@ObservationIgnored
private var refreshTask: Task<Void, Never>?
// Computed properties for dashboard
var liveGames: [Game] { games.filter(\.isLive) }
var scheduledGames: [Game] { games.filter { $0.status.isScheduled } }
var finalGames: [Game] { games.filter(\.isFinal) }
var featuredGame: Game? {
let astrosGame = games.first { $0.awayTeam.code == "HOU" || $0.homeTeam.code == "HOU" }
return astrosGame ?? liveGames.first ?? scheduledGames.first ?? games.first
}
var activeAudioStream: ActiveStream? {
guard let audioFocusStreamID else { return nil }
return activeStreams.first { $0.id == audioFocusStreamID }
}
private var mlbServerAPI: MLBServerAPI { MLBServerAPI(baseURL: serverBaseURL) }
private let statsAPI = MLBStatsAPI()
private static let dateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
return f
}()
private var currentDate: Date {
if let s = selectedDate, let d = Self.dateFormatter.date(from: s) { return d }
return Date()
}
var todayDateString: String {
Self.dateFormatter.string(from: currentDate)
}
var displayDateString: String {
let display = DateFormatter()
display.dateFormat = "EEEE, MMMM d"
return display.string(from: currentDate)
}
var isToday: Bool {
Calendar.current.isDateInToday(currentDate)
}
// MARK: - Auto-Refresh
func startAutoRefresh() {
stopAutoRefresh()
refreshTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(60))
guard !Task.isCancelled else { break }
guard let self else { break }
// Refresh if there are live games or active streams
if !self.liveGames.isEmpty || !self.activeStreams.isEmpty {
await self.refreshScores()
}
}
}
}
func stopAutoRefresh() {
refreshTask?.cancel()
refreshTask = nil
}
private func refreshScores() async {
let statsGames = await fetchStatsGames()
guard !statsGames.isEmpty else { return }
// Update scores/innings on existing games without full reload
for sg in statsGames {
let pkStr = String(sg.gamePk)
if let idx = games.firstIndex(where: { $0.id == pkStr }) {
if !sg.isScheduled {
games[idx] = Game(
id: games[idx].id,
awayTeam: TeamInfo(
code: games[idx].awayTeam.code,
name: games[idx].awayTeam.name,
score: sg.teams.away.score,
teamId: games[idx].awayTeam.teamId,
record: games[idx].awayTeam.record
),
homeTeam: TeamInfo(
code: games[idx].homeTeam.code,
name: games[idx].homeTeam.name,
score: sg.teams.home.score,
teamId: games[idx].homeTeam.teamId,
record: games[idx].homeTeam.record
),
status: sg.isLive ? .live(sg.linescore?.currentInningDisplay) : sg.isFinal ? .final_ : games[idx].status,
gameType: games[idx].gameType,
startTime: games[idx].startTime,
venue: games[idx].venue,
pitchers: games[idx].pitchers,
gamePk: games[idx].gamePk,
gameDate: games[idx].gameDate,
broadcasts: games[idx].broadcasts,
isBlackedOut: games[idx].isBlackedOut,
linescore: sg.linescore,
currentInningDisplay: sg.linescore?.currentInningDisplay,
awayPitcherId: games[idx].awayPitcherId,
homePitcherId: games[idx].homePitcherId
)
}
// Also update the game reference in active streams
for streamIdx in activeStreams.indices {
if activeStreams[streamIdx].game.id == pkStr {
activeStreams[streamIdx] = ActiveStream(
id: activeStreams[streamIdx].id,
game: games[idx],
label: activeStreams[streamIdx].label,
mediaId: activeStreams[streamIdx].mediaId,
streamURLString: activeStreams[streamIdx].streamURLString,
config: activeStreams[streamIdx].config,
overrideURL: activeStreams[streamIdx].overrideURL,
player: activeStreams[streamIdx].player,
isPlaying: activeStreams[streamIdx].isPlaying,
isMuted: activeStreams[streamIdx].isMuted
)
}
}
}
}
}
// MARK: - Date Navigation
func goToPreviousDay() async {
let prev = Calendar.current.date(byAdding: .day, value: -1, to: currentDate)!
selectedDate = Self.dateFormatter.string(from: prev)
await loadGames()
}
func goToNextDay() async {
let next = Calendar.current.date(byAdding: .day, value: 1, to: currentDate)!
selectedDate = Self.dateFormatter.string(from: next)
await loadGames()
}
func goToToday() async {
selectedDate = nil
await loadGames()
}
// MARK: - Load Games
func loadGames() async {
isLoading = true
errorMessage = nil
// Fetch all sources concurrently
async let serverGamesTask = fetchServerGames()
async let statsGamesTask = fetchStatsGames()
async let standingsTask = fetchStandings()
let serverGames = await serverGamesTask
let statsGames = await statsGamesTask
let standings = await standingsTask
// Merge: Stats API is primary for rich data, mlbserver provides stream links
games = mergeGames(serverGames: serverGames, statsGames: statsGames, standings: standings)
if games.isEmpty {
errorMessage = "No games found"
}
isLoading = false
}
private func fetchServerGames() async -> [Game] {
do {
return try await mlbServerAPI.fetchGames(date: selectedDate)
} catch {
return []
}
}
private func fetchStatsGames() async -> [StatsGame] {
do {
return try await statsAPI.fetchSchedule(date: todayDateString)
} catch {
return []
}
}
private func fetchStandings() async -> [Int: TeamStanding] {
let year = String(todayDateString.prefix(4))
do {
return try await statsAPI.fetchStandings(season: year)
} catch {
return [:]
}
}
private func mergeGames(serverGames: [Game], statsGames: [StatsGame], standings: [Int: TeamStanding] = [:]) -> [Game] {
// Build lookup from server games by gamePk
var serverByPk: [String: Game] = [:]
for g in serverGames {
if let pk = g.gamePk { serverByPk[pk] = g }
}
var merged: [Game] = []
for sg in statsGames {
let pkStr = String(sg.gamePk)
let serverGame = serverByPk[pkStr]
let awayAbbr = sg.teams.away.team.abbreviation ?? "???"
let homeAbbr = sg.teams.home.team.abbreviation ?? "???"
let awayRecord: String? = {
guard let r = sg.teams.away.leagueRecord else { return nil }
return "\(r.wins)-\(r.losses)"
}()
let homeRecord: String? = {
guard let r = sg.teams.home.leagueRecord else { return nil }
return "\(r.wins)-\(r.losses)"
}()
let pitchers: String? = {
let away = sg.teams.away.probablePitcher?.fullName
let home = sg.teams.home.probablePitcher?.fullName
if let away, let home { return "\(away) vs \(home)" }
return away ?? home
}()
let status: GameStatus
if sg.isLive {
status = .live(sg.linescore?.currentInningDisplay)
} else if sg.isFinal {
status = .final_
} else if let time = sg.startTime {
status = .scheduled(time)
} else {
status = .unknown
}
let awayStanding = standings[sg.teams.away.team.id]
let homeStanding = standings[sg.teams.home.team.id]
let game = Game(
id: pkStr,
awayTeam: TeamInfo(
code: awayAbbr,
name: sg.teams.away.team.name ?? awayAbbr,
score: sg.isScheduled ? nil : sg.teams.away.score,
teamId: sg.teams.away.team.id,
record: awayRecord,
divisionRank: awayStanding?.divisionRank,
gamesBack: awayStanding?.gamesBack,
streak: awayStanding?.streak
),
homeTeam: TeamInfo(
code: homeAbbr,
name: sg.teams.home.team.name ?? homeAbbr,
score: sg.isScheduled ? nil : sg.teams.home.score,
teamId: sg.teams.home.team.id,
record: homeRecord,
divisionRank: homeStanding?.divisionRank,
gamesBack: homeStanding?.gamesBack,
streak: homeStanding?.streak
),
status: status,
gameType: sg.seriesDescription ?? sg.gameType,
startTime: sg.startTime,
venue: sg.venue?.name,
pitchers: pitchers ?? serverGame?.pitchers,
gamePk: pkStr,
gameDate: todayDateString,
broadcasts: serverGame?.broadcasts ?? [],
isBlackedOut: serverGame?.isBlackedOut ?? false,
linescore: sg.linescore,
currentInningDisplay: sg.linescore?.currentInningDisplay,
awayPitcherId: sg.teams.away.probablePitcher?.id,
homePitcherId: sg.teams.home.probablePitcher?.id
)
merged.append(game)
serverByPk.removeValue(forKey: pkStr)
}
// Add any server-only games not in Stats API
for (_, serverGame) in serverByPk {
merged.append(serverGame)
}
// Sort: live first, then scheduled by time, then final
merged.sort { a, b in
let order: (Game) -> Int = { g in
if g.isLive { return 0 }
if g.status.isScheduled { return 1 }
return 2
}
return order(a) < order(b)
}
return merged
}
// MARK: - Stream Management
func addStream(broadcast: Broadcast, game: Game) {
guard activeStreams.count < 4 else { return }
guard !activeStreams.contains(where: { $0.id == broadcast.id }) else { return }
let shouldCaptureAudio = activeStreams.isEmpty && audioFocusStreamID == nil
let stream = ActiveStream(
id: broadcast.id,
game: game,
label: broadcast.displayLabel,
mediaId: broadcast.mediaId,
streamURLString: broadcast.streamURL
)
activeStreams.append(stream)
if shouldCaptureAudio {
audioFocusStreamID = stream.id
}
syncAudioFocus()
}
func addStreamByTeam(teamCode: String, game: Game) {
guard activeStreams.count < 4 else { return }
let shouldCaptureAudio = activeStreams.isEmpty && audioFocusStreamID == nil
let config = StreamConfig(team: teamCode, resolution: defaultResolution, date: selectedDate)
let stream = ActiveStream(
id: "\(teamCode)-\(game.id)",
game: game,
label: teamCode,
config: config
)
activeStreams.append(stream)
if shouldCaptureAudio {
audioFocusStreamID = stream.id
}
syncAudioFocus()
}
func removeStream(id: String) {
if let index = activeStreams.firstIndex(where: { $0.id == id }) {
let removedWasAudioFocus = activeStreams[index].id == audioFocusStreamID
activeStreams[index].player?.pause()
activeStreams.remove(at: index)
if activeStreams.isEmpty {
audioFocusStreamID = nil
} else if removedWasAudioFocus {
let replacementIndex = min(index, activeStreams.count - 1)
audioFocusStreamID = activeStreams[replacementIndex].id
}
syncAudioFocus()
}
}
func clearAllStreams() {
for s in activeStreams { s.player?.pause() }
activeStreams.removeAll()
audioFocusStreamID = nil
}
func promoteStream(id: String) {
guard let index = activeStreams.firstIndex(where: { $0.id == id }), index > 0 else { return }
let stream = activeStreams.remove(at: index)
activeStreams.insert(stream, at: 0)
}
func canMoveStream(id: String, direction: Int) -> Bool {
guard let index = activeStreams.firstIndex(where: { $0.id == id }) else { return false }
let newIndex = index + direction
return activeStreams.indices.contains(newIndex)
}
func moveStream(id: String, direction: Int) {
guard let index = activeStreams.firstIndex(where: { $0.id == id }) else { return }
let newIndex = index + direction
guard activeStreams.indices.contains(newIndex) else { return }
activeStreams.swapAt(index, newIndex)
}
func setAudioFocus(streamID: String?) {
if let streamID, activeStreams.contains(where: { $0.id == streamID }) {
audioFocusStreamID = streamID
} else {
audioFocusStreamID = nil
}
syncAudioFocus()
}
func toggleAudioFocus(streamID: String) {
setAudioFocus(streamID: audioFocusStreamID == streamID ? nil : streamID)
}
func attachPlayer(_ player: AVPlayer, to streamID: String) {
guard let index = activeStreams.firstIndex(where: { $0.id == streamID }) else { return }
activeStreams[index].player = player
activeStreams[index].isPlaying = true
let shouldMute = audioFocusStreamID != streamID
activeStreams[index].isMuted = shouldMute
player.isMuted = shouldMute
}
func isPrimaryStream(_ streamID: String) -> Bool {
activeStreams.first?.id == streamID
}
private func syncAudioFocus() {
for index in activeStreams.indices {
let shouldMute = activeStreams[index].id != audioFocusStreamID
activeStreams[index].isMuted = shouldMute
activeStreams[index].player?.isMuted = shouldMute
}
}
func buildStreamURL(for config: StreamConfig) async -> URL {
let startedAt = Date()
logGamesViewModel("buildStreamURL start mediaId=\(config.mediaId) resolution=\(config.resolution)")
let url = await mlbServerAPI.streamURL(for: config)
let elapsedMs = Int(Date().timeIntervalSince(startedAt) * 1000)
logGamesViewModel("buildStreamURL success mediaId=\(config.mediaId) elapsedMs=\(elapsedMs) url=\(gamesViewModelDebugURLDescription(url))")
return url
}
func buildEventStreamURL(event: String, resolution: String = "best") async -> URL {
let startedAt = Date()
logGamesViewModel("buildEventStreamURL start event=\(event) resolution=\(resolution)")
let url = await mlbServerAPI.eventStreamURL(event: event, resolution: resolution)
let elapsedMs = Int(Date().timeIntervalSince(startedAt) * 1000)
logGamesViewModel("buildEventStreamURL success event=\(event) elapsedMs=\(elapsedMs) url=\(gamesViewModelDebugURLDescription(url))")
return url
}
func resolveStreamURL(for stream: ActiveStream) async -> URL? {
await resolveStreamURLImpl(
for: stream,
resolutionOverride: nil,
preserveServerResolutionWhenBest: true
)
}
func resolveStreamURL(
for stream: ActiveStream,
resolutionOverride: String,
preserveServerResolutionWhenBest: Bool = false
) async -> URL? {
await resolveStreamURLImpl(
for: stream,
resolutionOverride: resolutionOverride,
preserveServerResolutionWhenBest: preserveServerResolutionWhenBest
)
}
private func resolveStreamURLImpl(
for stream: ActiveStream,
resolutionOverride: String?,
preserveServerResolutionWhenBest: Bool
) async -> URL? {
let requestedResolution = resolutionOverride ?? defaultResolution
logGamesViewModel(
"resolveStreamURL start id=\(stream.id) label=\(stream.label) requestedResolution=\(requestedResolution) hasDirectURL=\(stream.streamURLString != nil) hasMediaId=\(stream.mediaId != nil) hasConfig=\(stream.config != nil) hasOverride=\(stream.overrideURL != nil)"
)
if let urlStr = stream.streamURLString {
logGamesViewModel("resolveStreamURL using direct stream URL id=\(stream.id) raw=\(urlStr)")
let rewritten = urlStr.replacingOccurrences(of: "http://127.0.0.1:9999", with: serverBaseURL)
if let url = URL(string: rewritten), var components = URLComponents(url: url, resolvingAgainstBaseURL: false) {
var items = components.queryItems ?? []
let hasResolution = items.contains { $0.name == "resolution" }
if requestedResolution == "best", hasResolution, preserveServerResolutionWhenBest {
logGamesViewModel("resolveStreamURL preserving server resolution for direct URL id=\(stream.id) because requestedResolution=best")
} else if let idx = items.firstIndex(where: { $0.name == "resolution" }) {
items[idx] = URLQueryItem(name: "resolution", value: requestedResolution)
} else if resolutionOverride != nil || requestedResolution != "best" {
items.append(URLQueryItem(name: "resolution", value: requestedResolution))
}
components.queryItems = items
if let resolvedURL = components.url {
logGamesViewModel(
"resolveStreamURL direct success id=\(stream.id) url=\(gamesViewModelDebugURLDescription(resolvedURL))"
)
return resolvedURL
}
logGamesViewModel("resolveStreamURL direct failed id=\(stream.id) could not rebuild URL components")
} else {
logGamesViewModel("resolveStreamURL direct failed id=\(stream.id) invalid rewritten URL")
}
}
if let mediaId = stream.mediaId {
let startedAt = Date()
logGamesViewModel("resolveStreamURL fetching mediaId id=\(stream.id) mediaId=\(mediaId) resolution=\(requestedResolution)")
let url = await mlbServerAPI.streamURL(for: StreamConfig(mediaId: mediaId, resolution: requestedResolution))
let elapsedMs = Int(Date().timeIntervalSince(startedAt) * 1000)
logGamesViewModel(
"resolveStreamURL mediaId success id=\(stream.id) mediaId=\(mediaId) elapsedMs=\(elapsedMs) url=\(gamesViewModelDebugURLDescription(url))"
)
return url
}
if let config = stream.config {
let startedAt = Date()
let configResolution = resolutionOverride ?? config.resolution
logGamesViewModel("resolveStreamURL fetching config id=\(stream.id) mediaId=\(config.mediaId) resolution=\(configResolution)")
let requestConfig: StreamConfig
if let mediaId = config.mediaId {
requestConfig = StreamConfig(
mediaId: mediaId,
resolution: configResolution,
audioTrack: config.audioTrack
)
} else if let team = config.team {
requestConfig = StreamConfig(
team: team,
resolution: configResolution,
date: config.date,
level: config.level,
skip: config.skip,
audioTrack: config.audioTrack,
mediaType: config.mediaType,
game: config.game
)
} else {
logGamesViewModel("resolveStreamURL config failed id=\(stream.id) missing mediaId and team")
return nil
}
let url = await mlbServerAPI.streamURL(for: requestConfig)
let elapsedMs = Int(Date().timeIntervalSince(startedAt) * 1000)
logGamesViewModel(
"resolveStreamURL config success id=\(stream.id) mediaId=\(config.mediaId) elapsedMs=\(elapsedMs) url=\(gamesViewModelDebugURLDescription(url))"
)
return url
}
logGamesViewModel("resolveStreamURL failed id=\(stream.id) no usable stream source")
return nil
}
func addMLBNetwork() async {
guard activeStreams.count < 4 else { return }
guard !activeStreams.contains(where: { $0.id == "MLBN" }) else { return }
let shouldCaptureAudio = activeStreams.isEmpty && audioFocusStreamID == nil
let dummyGame = Game(
id: "MLBN",
awayTeam: TeamInfo(code: "MLBN", name: "MLB Network", score: nil),
homeTeam: TeamInfo(code: "MLBN", name: "MLB Network", score: nil),
status: .live(nil), gameType: nil, startTime: nil, venue: nil,
pitchers: nil, gamePk: nil, gameDate: "",
broadcasts: [], isBlackedOut: false
)
let url = await mlbServerAPI.eventStreamURL(event: "MLBN", resolution: defaultResolution)
let stream = ActiveStream(
id: "MLBN", game: dummyGame, label: "MLB Network", overrideURL: url
)
activeStreams.append(stream)
if shouldCaptureAudio {
audioFocusStreamID = stream.id
}
syncAudioFocus()
}
}
enum MultiViewLayoutMode: String, CaseIterable, Identifiable, Sendable {
case balanced
case spotlight
var id: String { rawValue }
var title: String {
switch self {
case .balanced: "Balanced"
case .spotlight: "Spotlight"
}
}
var systemImage: String {
switch self {
case .balanced: "square.grid.2x2"
case .spotlight: "rectangle.leadinghalf.filled"
}
}
}
struct ActiveStream: Identifiable, @unchecked Sendable {
let id: String
let game: Game
let label: String
var mediaId: String?
var streamURLString: String?
var config: StreamConfig?
var overrideURL: URL?
var player: AVPlayer?
var isPlaying = false
var isMuted = false
}

View File

@@ -0,0 +1,132 @@
import Foundation
import Observation
@Observable
@MainActor
final class LeagueCenterViewModel {
var scheduleGames: [StatsGame] = []
var standings: [StandingsDivisionRecord] = []
var teams: [LeagueTeamSummary] = []
var selectedTeam: TeamProfile?
var roster: [RosterPlayerSummary] = []
var selectedPlayer: PlayerProfile?
var isLoadingOverview = false
var isLoadingTeam = false
var isLoadingPlayer = false
var overviewErrorMessage: String?
var teamErrorMessage: String?
var playerErrorMessage: String?
private let statsAPI = MLBStatsAPI()
private(set) var scheduleDate = Date()
private var seasonString: String {
String(Calendar.current.component(.year, from: scheduleDate))
}
private var scheduleDateString: String {
Self.scheduleFormatter.string(from: scheduleDate)
}
var displayDateString: String {
Self.displayFormatter.string(from: scheduleDate)
}
func loadInitial() async {
isLoadingOverview = true
overviewErrorMessage = nil
async let scheduleTask = statsAPI.fetchSchedule(date: scheduleDateString)
async let standingsTask = statsAPI.fetchStandingsRecords(season: seasonString)
async let teamsTask = statsAPI.fetchLeagueTeams(season: seasonString)
do {
scheduleGames = try await scheduleTask
standings = try await standingsTask
teams = try await teamsTask
if selectedTeam == nil, let firstTeam = teams.first {
await selectTeam(firstTeam.id)
}
} catch {
overviewErrorMessage = "Failed to load league information."
}
isLoadingOverview = false
}
func goToPreviousDay() async {
scheduleDate = Calendar.current.date(byAdding: .day, value: -1, to: scheduleDate) ?? scheduleDate
await loadSchedule()
}
func goToNextDay() async {
scheduleDate = Calendar.current.date(byAdding: .day, value: 1, to: scheduleDate) ?? scheduleDate
await loadSchedule()
}
func goToToday() async {
scheduleDate = Date()
await loadSchedule()
}
func loadSchedule() async {
do {
scheduleGames = try await statsAPI.fetchSchedule(date: scheduleDateString)
} catch {
overviewErrorMessage = "Failed to load schedule."
}
}
func selectTeam(_ teamID: Int) async {
isLoadingTeam = true
teamErrorMessage = nil
selectedPlayer = nil
playerErrorMessage = nil
do {
async let profileTask = statsAPI.fetchTeamProfile(teamID: teamID, season: seasonString)
async let rosterTask = statsAPI.fetchTeamRoster(teamID: teamID, season: seasonString)
selectedTeam = try await profileTask
roster = try await rosterTask
if let firstPlayer = roster.first {
await selectPlayer(firstPlayer.id)
}
} catch {
teamErrorMessage = "Failed to load team details."
roster = []
}
isLoadingTeam = false
}
func selectPlayer(_ playerID: Int) async {
isLoadingPlayer = true
playerErrorMessage = nil
do {
selectedPlayer = try await statsAPI.fetchPlayerProfile(personID: playerID, season: seasonString)
} catch {
playerErrorMessage = "Failed to load player details."
}
isLoadingPlayer = false
}
private static let scheduleFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
return formatter
}()
private static let displayFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "EEEE, MMM d"
return formatter
}()
}

View File

@@ -0,0 +1,108 @@
import SwiftUI
struct LinescoreView: View {
let linescore: StatsLinescore
let awayCode: String
let homeCode: String
private var totalInnings: Int {
linescore.scheduledInnings ?? 9
}
var body: some View {
VStack(spacing: 0) {
// Header
HStack(spacing: 0) {
Text("")
.frame(width: 70, alignment: .leading)
ForEach(1...totalInnings, id: \.self) { inning in
let isCurrent = inning == linescore.currentInning
HStack(spacing: 0) {
if inning > 1 && inning % 3 == 1 {
Divider()
.frame(width: 1, height: 18)
.background(.secondary.opacity(0.3))
.padding(.trailing, 4)
}
Text("\(inning)")
.font(.callout.weight(.semibold).monospacedDigit())
.foregroundStyle(isCurrent ? .primary : .secondary)
.frame(width: 44)
}
}
Divider().frame(width: 1, height: 20).padding(.horizontal, 6)
ForEach(["R", "H", "E"], id: \.self) { label in
Text(label)
.font(.callout.weight(.bold).monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 48)
}
}
.padding(.vertical, 10)
.background(.ultraThinMaterial)
Divider()
teamRow(code: awayCode, innings: linescore.innings ?? [], side: .away, totals: linescore.teams?.away)
Divider()
teamRow(code: homeCode, innings: linescore.innings ?? [], side: .home, totals: linescore.teams?.home)
}
.clipShape(RoundedRectangle(cornerRadius: 10))
.background(.regularMaterial)
}
private enum Side { case away, home }
@ViewBuilder
private func teamRow(code: String, innings: [StatsInningScore], side: Side, totals: StatsLinescoreTotals?) -> some View {
HStack(spacing: 0) {
Text(code)
.font(.callout.weight(.bold))
.foregroundStyle(TeamAssets.color(for: code))
.frame(width: 70, alignment: .leading)
ForEach(1...totalInnings, id: \.self) { inning in
let runs = inningRuns(innings: innings, inning: inning, side: side)
let isCurrent = inning == linescore.currentInning
HStack(spacing: 0) {
if inning > 1 && inning % 3 == 1 {
Divider()
.frame(width: 1, height: 22)
.background(.secondary.opacity(0.2))
.padding(.trailing, 4)
}
Text(runs.map { "\($0)" } ?? "-")
.font(.callout.weight(runs != nil ? .semibold : .regular).monospacedDigit())
.foregroundStyle(runs == nil ? .tertiary : isCurrent ? .primary : .secondary)
.frame(width: 44)
}
}
Divider().frame(width: 1, height: 24).padding(.horizontal, 6)
Text(totals?.runs.map { "\($0)" } ?? "-")
.font(.callout.weight(.bold).monospacedDigit())
.frame(width: 48)
Text(totals?.hits.map { "\($0)" } ?? "-")
.font(.callout.weight(.medium).monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 48)
Text(totals?.errors.map { "\($0)" } ?? "-")
.font(.callout.weight(.medium).monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 48)
}
.padding(.vertical, 12)
}
private func inningRuns(innings: [StatsInningScore], inning: Int, side: Side) -> Int? {
guard let data = innings.first(where: { $0.num == inning }) else { return nil }
switch side {
case .away: return data.away?.runs
case .home: return data.home?.runs
}
}
}

View File

@@ -0,0 +1,66 @@
import SwiftUI
struct LiveIndicator: View {
@State private var isPulsing = false
var body: some View {
HStack(spacing: 6) {
Circle()
.fill(.red)
.frame(width: 12, height: 12)
.scaleEffect(isPulsing ? 1.4 : 1.0)
.opacity(isPulsing ? 0.6 : 1.0)
.animation(
.easeInOut(duration: 0.8).repeatForever(autoreverses: true),
value: isPulsing
)
Text("LIVE")
.font(.subheadline.weight(.black))
.foregroundStyle(.red)
}
.onAppear { isPulsing = true }
}
}
struct StatusBadge: View {
let status: GameStatus
var body: some View {
switch status {
case .live(let inning):
HStack(spacing: 8) {
LiveIndicator()
if let inning {
Text(inning)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.red.opacity(0.15))
.clipShape(Capsule())
case .scheduled(let time):
Text(time)
.font(.body.weight(.bold))
.foregroundStyle(.green)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.green.opacity(0.12))
.clipShape(Capsule())
case .final_:
Text("FINAL")
.font(.subheadline.weight(.bold))
.foregroundStyle(.secondary)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.secondary.opacity(0.12))
.clipShape(Capsule())
case .unknown:
EmptyView()
}
}
}

View File

@@ -0,0 +1,53 @@
import SwiftUI
struct PitcherHeadshotView: View {
let url: URL?
var teamCode: String?
var name: String?
var size: CGFloat = 56
var body: some View {
VStack(spacing: 6) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fill)
case .failure:
fallbackImage
default:
fallbackImage
.redacted(reason: .placeholder)
}
}
.frame(width: size, height: size)
.clipShape(Circle())
.overlay(
Circle()
.strokeBorder(
teamCode.map { TeamAssets.color(for: $0) } ?? .gray,
lineWidth: 2
)
)
if let name {
Text(name)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
@ViewBuilder
private var fallbackImage: some View {
ZStack {
Circle()
.fill(.gray.opacity(0.3))
Image(systemName: "person.fill")
.font(.system(size: size * 0.4))
.foregroundStyle(.secondary)
}
}
}

View File

@@ -0,0 +1,67 @@
import SwiftUI
struct ScoreOverlayView: View {
let game: Game
var body: some View {
// Don't show for non-game streams (e.g., MLB Network)
if game.id == "MLBN" {
EmptyView()
} else {
HStack(spacing: 10) {
// Away
HStack(spacing: 6) {
Circle()
.fill(TeamAssets.color(for: game.awayTeam.code))
.frame(width: 8, height: 8)
Text(game.awayTeam.code)
.font(.system(size: 15, weight: .bold, design: .rounded))
.foregroundStyle(.white)
if !game.status.isScheduled, let score = game.awayTeam.score {
Text("\(score)")
.font(.system(size: 17, weight: .bold, design: .rounded).monospacedDigit())
.foregroundStyle(.white)
}
}
Text("-")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white.opacity(0.4))
// Home
HStack(spacing: 6) {
if !game.status.isScheduled, let score = game.homeTeam.score {
Text("\(score)")
.font(.system(size: 17, weight: .bold, design: .rounded).monospacedDigit())
.foregroundStyle(.white)
}
Text(game.homeTeam.code)
.font(.system(size: 15, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Circle()
.fill(TeamAssets.color(for: game.homeTeam.code))
.frame(width: 8, height: 8)
}
// Inning/status
if game.isLive, let inning = game.currentInningDisplay {
Text(inning)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white.opacity(0.6))
.padding(.leading, 4)
} else if game.isFinal {
Text("F")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(.white.opacity(0.5))
.padding(.leading, 4)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.black.opacity(0.7))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}

View File

@@ -0,0 +1,167 @@
import SwiftUI
import UIKit
struct ScoresTickerView: View {
var gamesOverride: [Game]? = nil
@Environment(GamesViewModel.self) private var viewModel
private var entries: [Game] {
gamesOverride ?? viewModel.games
}
var body: some View {
if !entries.isEmpty {
TickerMarqueeView(text: tickerText)
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 22)
.padding(.horizontal, 18)
.padding(.vertical, 14)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(.black.opacity(0.72))
.overlay {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
}
)
.accessibilityHidden(true)
}
}
private var tickerText: String {
entries.map(tickerSummary(for:)).joined(separator: " | ")
}
private func tickerSummary(for game: Game) -> String {
let matchup = "\(game.awayTeam.code) \(game.awayTeam.score.map(String.init) ?? "-") - \(game.homeTeam.code) \(game.homeTeam.score.map(String.init) ?? "-")"
if game.isLive, let linescore = game.linescore {
let inning = linescore.currentInningOrdinal ?? game.currentInningDisplay ?? "LIVE"
let state = (linescore.inningState ?? linescore.inningHalf ?? "Live").uppercased()
let outs = linescore.outs ?? 0
return "\(matchup) \(state) \(inning.uppercased())\(outs) OUT\(outs == 1 ? "" : "S")"
}
if game.isFinal {
return "\(matchup) FINAL"
}
if let startTime = game.startTime {
return "\(matchup) \(startTime.uppercased())"
}
return "\(matchup) \(game.status.label.uppercased())"
}
}
private struct TickerMarqueeView: UIViewRepresentable {
let text: String
func makeUIView(context: Context) -> TickerMarqueeContainerView {
let view = TickerMarqueeContainerView()
view.setText(text)
return view
}
func updateUIView(_ uiView: TickerMarqueeContainerView, context: Context) {
uiView.setText(text)
}
}
private final class TickerMarqueeContainerView: UIView {
private let trackView = UIView()
private let primaryLabel = UILabel()
private let secondaryLabel = UILabel()
private let spacing: CGFloat = 48
private let pointsPerSecond: CGFloat = 64
private var currentText = ""
private var previousBoundsWidth: CGFloat = 0
private var previousContentWidth: CGFloat = 0
override init(frame: CGRect) {
super.init(frame: frame)
clipsToBounds = true
isUserInteractionEnabled = false
let baseFont = UIFont.systemFont(ofSize: 15, weight: .bold)
let roundedFont = UIFont(descriptor: baseFont.fontDescriptor.withDesign(.rounded) ?? baseFont.fontDescriptor, size: 15)
[primaryLabel, secondaryLabel].forEach { label in
label.font = roundedFont
label.textColor = UIColor.white.withAlphaComponent(0.92)
label.numberOfLines = 1
label.lineBreakMode = .byClipping
trackView.addSubview(label)
}
addSubview(trackView)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setText(_ text: String) {
guard currentText != text else { return }
currentText = text
primaryLabel.text = text
secondaryLabel.text = text
previousContentWidth = 0
setNeedsLayout()
}
override func layoutSubviews() {
super.layoutSubviews()
guard bounds.width > 0, bounds.height > 0 else { return }
let contentWidth = ceil(primaryLabel.sizeThatFits(CGSize(width: .greatestFiniteMagnitude, height: bounds.height)).width)
let contentHeight = bounds.height
primaryLabel.frame = CGRect(x: 0, y: 0, width: contentWidth, height: contentHeight)
secondaryLabel.frame = CGRect(x: contentWidth + spacing, y: 0, width: contentWidth, height: contentHeight)
let cycleWidth = contentWidth + spacing
if contentWidth <= bounds.width {
trackView.layer.removeAllAnimations()
secondaryLabel.isHidden = true
trackView.frame = CGRect(x: 0, y: 0, width: bounds.width, height: contentHeight)
primaryLabel.frame = CGRect(x: 0, y: 0, width: bounds.width, height: contentHeight)
primaryLabel.textAlignment = .left
previousBoundsWidth = bounds.width
previousContentWidth = contentWidth
return
}
primaryLabel.textAlignment = .left
secondaryLabel.isHidden = false
trackView.frame = CGRect(x: 0, y: 0, width: contentWidth * 2 + spacing, height: contentHeight)
let shouldRestart = abs(previousBoundsWidth - bounds.width) > 0.5
|| abs(previousContentWidth - contentWidth) > 0.5
|| trackView.layer.animation(forKey: "tickerMarquee") == nil
previousBoundsWidth = bounds.width
previousContentWidth = contentWidth
guard shouldRestart else { return }
trackView.layer.removeAllAnimations()
trackView.transform = .identity
let animation = CABasicAnimation(keyPath: "transform.translation.x")
animation.fromValue = 0
animation.toValue = -cycleWidth
animation.duration = Double(cycleWidth / pointsPerSecond)
animation.repeatCount = .infinity
animation.timingFunction = CAMediaTimingFunction(name: .linear)
animation.isRemovedOnCompletion = false
trackView.layer.add(animation, forKey: "tickerMarquee")
}
}

View File

@@ -0,0 +1,38 @@
import SwiftUI
struct TeamLogoView: View {
let team: TeamInfo
var size: CGFloat = 64
var body: some View {
AsyncImage(url: team.logoURL) { phase in
switch phase {
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fit)
case .failure:
fallbackLogo
default:
fallbackLogo
.redacted(reason: .placeholder)
}
}
.frame(width: size, height: size)
.overlay(
Circle()
.strokeBorder(.white.opacity(0.1), lineWidth: 1)
)
}
@ViewBuilder
private var fallbackLogo: some View {
ZStack {
Circle()
.fill(TeamAssets.color(for: team.code).gradient)
Text(team.code.prefix(3))
.font(.system(size: size * 0.3, weight: .heavy, design: .rounded))
.foregroundStyle(.white)
}
}
}

View File

@@ -0,0 +1,33 @@
import SwiftUI
struct ContentView: View {
@Environment(GamesViewModel.self) private var viewModel
private var multiViewLabel: String {
let count = viewModel.activeStreams.count
if count > 0 {
return "Multi-View (\(count))"
}
return "Multi-View"
}
var body: some View {
TabView {
Tab("Games", systemImage: "sportscourt.fill") {
DashboardView()
}
Tab("League", systemImage: "list.bullet.rectangle.portrait.fill") {
LeagueCenterView()
}
Tab(multiViewLabel, systemImage: "rectangle.split.2x2.fill") {
MultiStreamView()
}
Tab("Settings", systemImage: "gearshape.fill") {
SettingsView()
}
}
.task {
await viewModel.loadGames()
}
}
}

View File

@@ -0,0 +1,340 @@
import SwiftUI
import OSLog
private let dashboardLogger = Logger(subsystem: "com.treyt.mlbTVOS", category: "Dashboard")
private func logDashboard(_ message: String) {
dashboardLogger.debug("\(message, privacy: .public)")
print("[Dashboard] \(message)")
}
struct DashboardView: View {
@Environment(GamesViewModel.self) private var viewModel
@State private var selectedGame: Game?
@State private var fullScreenBroadcast: BroadcastSelection?
@State private var pendingFullScreenBroadcast: BroadcastSelection?
@State private var showMLBNetworkSheet = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 50) {
headerSection
if viewModel.isLoading {
HStack {
Spacer()
ProgressView("Loading games...")
.font(.title3)
Spacer()
}
.padding(.top, 80)
} else if let error = viewModel.errorMessage, viewModel.games.isEmpty {
HStack {
Spacer()
VStack(spacing: 20) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 50))
.foregroundStyle(.secondary)
Text(error)
.font(.title3)
.foregroundStyle(.secondary)
Button("Retry") {
Task { await viewModel.loadGames() }
}
}
Spacer()
}
.padding(.top, 80)
} else {
// Hero featured game
if let featured = viewModel.featuredGame {
FeaturedGameCard(game: featured) {
selectedGame = featured
}
}
if !viewModel.liveGames.isEmpty {
gameShelf(title: "Live", icon: "antenna.radiowaves.left.and.right", games: viewModel.liveGames, excludeId: viewModel.featuredGame?.id)
}
if !viewModel.scheduledGames.isEmpty {
gameShelf(title: "Upcoming", icon: "calendar", games: viewModel.scheduledGames, excludeId: viewModel.featuredGame?.id)
}
if !viewModel.finalGames.isEmpty {
gameShelf(title: "Final", icon: "checkmark.circle", games: viewModel.finalGames, excludeId: viewModel.featuredGame?.id)
}
}
mlbNetworkCard
if !viewModel.activeStreams.isEmpty {
multiViewStatus
}
}
.padding(.horizontal, 60)
.padding(.vertical, 40)
}
.onAppear {
logDashboard("DashboardView appeared")
viewModel.startAutoRefresh()
}
.onDisappear {
logDashboard("DashboardView disappeared")
viewModel.stopAutoRefresh()
}
.sheet(item: $selectedGame, onDismiss: presentPendingFullScreenBroadcast) { game in
StreamOptionsSheet(game: game) { broadcast in
logDashboard("Queued fullscreen broadcast from game sheet broadcastId=\(broadcast.broadcast.id) gameId=\(broadcast.game.id)")
pendingFullScreenBroadcast = broadcast
selectedGame = nil
}
}
.fullScreenCover(item: $fullScreenBroadcast) { selection in
SingleStreamPlaybackScreen(
resolveURL: {
logDashboard("resolveURL closure invoked broadcastId=\(selection.broadcast.id) gameId=\(selection.game.id)")
if selection.broadcast.id == "MLBN" {
return await viewModel.buildEventStreamURL(event: "MLBN")
}
let s = ActiveStream(
id: selection.broadcast.id,
game: selection.game,
label: selection.broadcast.displayLabel,
mediaId: selection.broadcast.mediaId,
streamURLString: selection.broadcast.streamURL
)
return await viewModel.resolveStreamURL(for: s)
},
tickerGames: viewModel.games
)
.ignoresSafeArea()
.onAppear {
logDashboard("fullScreenCover content mounted broadcastId=\(selection.broadcast.id) gameId=\(selection.game.id)")
}
}
.onChange(of: fullScreenBroadcast?.id) { _, newValue in
logDashboard("fullScreenBroadcast changed newValue=\(newValue ?? "nil")")
}
.sheet(isPresented: $showMLBNetworkSheet, onDismiss: presentPendingFullScreenBroadcast) {
MLBNetworkSheet(
onWatchFullScreen: {
logDashboard("Queued fullscreen broadcast from MLB Network sheet")
showMLBNetworkSheet = false
pendingFullScreenBroadcast = mlbNetworkBroadcastSelection
}
)
}
}
private func presentPendingFullScreenBroadcast() {
guard selectedGame == nil, !showMLBNetworkSheet else {
logDashboard("Skipped pending fullscreen presentation because another sheet is still active")
return
}
guard let pendingFullScreenBroadcast else { return }
logDashboard(
"Presenting pending fullscreen broadcast broadcastId=\(pendingFullScreenBroadcast.broadcast.id) gameId=\(pendingFullScreenBroadcast.game.id)"
)
self.pendingFullScreenBroadcast = nil
DispatchQueue.main.async {
fullScreenBroadcast = pendingFullScreenBroadcast
}
}
private var mlbNetworkBroadcastSelection: BroadcastSelection {
let bc = Broadcast(
id: "MLBN",
teamCode: "MLBN",
name: "MLB Network",
mediaId: "",
streamURL: ""
)
let game = Game(
id: "MLBN",
awayTeam: TeamInfo(code: "MLBN", name: "MLB Network", score: nil),
homeTeam: TeamInfo(code: "MLBN", name: "MLB Network", score: nil),
status: .live(nil), gameType: nil, startTime: nil, venue: nil,
pitchers: nil, gamePk: nil, gameDate: "",
broadcasts: [], isBlackedOut: false
)
return BroadcastSelection(broadcast: bc, game: game)
}
// MARK: - Game Shelf (Horizontal)
@ViewBuilder
private func gameShelf(title: String, icon: String, games: [Game], excludeId: String?) -> some View {
let filtered = games.filter { $0.id != excludeId }
if !filtered.isEmpty {
VStack(alignment: .leading, spacing: 20) {
Label(title, systemImage: icon)
.font(.title3.weight(.bold))
.foregroundStyle(.secondary)
ScrollView(.horizontal) {
LazyHStack(spacing: 30) {
ForEach(filtered) { game in
GameCardView(game: game) {
selectedGame = game
}
.frame(width: 400)
}
}
.padding(.vertical, 20)
}
.scrollClipDisabled()
}
}
}
// MARK: - Header
@ViewBuilder
private var headerSection: some View {
VStack(spacing: 24) {
HStack(alignment: .bottom) {
VStack(alignment: .leading, spacing: 6) {
Text("MLB")
.font(.headline.weight(.black))
.foregroundStyle(.secondary)
.kerning(4)
Text(viewModel.displayDateString)
.font(.system(size: 40, weight: .bold))
.contentTransition(.numericText())
}
Spacer()
HStack(spacing: 16) {
statPill("\(viewModel.games.count)", label: "Games")
if !viewModel.liveGames.isEmpty {
statPill("\(viewModel.liveGames.count)", label: "Live", color: .red)
}
if !viewModel.activeStreams.isEmpty {
statPill("\(viewModel.activeStreams.count)/4", label: "Streams", color: .green)
}
}
}
HStack(spacing: 16) {
Button {
Task { await viewModel.goToPreviousDay() }
} label: {
Label("Previous Day", systemImage: "chevron.left")
}
if !viewModel.isToday {
Button {
Task { await viewModel.goToToday() }
} label: {
Label("Today", systemImage: "calendar")
}
.tint(.blue)
}
Button {
Task { await viewModel.goToNextDay() }
} label: {
HStack(spacing: 6) {
Text("Next Day")
Image(systemName: "chevron.right")
}
}
Spacer()
}
}
}
@ViewBuilder
private func statPill(_ value: String, label: String, color: Color = .blue) -> some View {
VStack(spacing: 2) {
Text(value)
.font(.title3.weight(.bold).monospacedDigit())
.foregroundStyle(color)
Text(label)
.font(.subheadline.weight(.medium))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
// MARK: - MLB Network
@ViewBuilder
private var mlbNetworkCard: some View {
let added = viewModel.activeStreams.contains(where: { $0.id == "MLBN" })
Button {
showMLBNetworkSheet = true
} label: {
HStack(spacing: 16) {
Image(systemName: "tv.fill")
.font(.title2)
.foregroundStyle(.blue)
.frame(width: 56, height: 56)
.background(.blue.opacity(0.2))
.clipShape(RoundedRectangle(cornerRadius: 14))
VStack(alignment: .leading, spacing: 4) {
Text("MLB Network")
.font(.title3.weight(.bold))
Text("Live coverage, analysis & highlights")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
if added {
Label("In Multi-View", systemImage: "checkmark.circle.fill")
.font(.subheadline.weight(.bold))
.foregroundStyle(.green)
}
}
.padding(24)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 20))
}
.buttonStyle(.card)
}
// MARK: - Multi-View Status
@ViewBuilder
private var multiViewStatus: some View {
VStack(alignment: .leading, spacing: 14) {
Label("Multi-View", systemImage: "rectangle.split.2x2")
.font(.title3.weight(.bold))
.foregroundStyle(.secondary)
HStack(spacing: 12) {
ForEach(viewModel.activeStreams) { stream in
HStack(spacing: 8) {
Circle().fill(.green).frame(width: 8, height: 8)
Text(stream.label)
.font(.subheadline.weight(.semibold))
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(.regularMaterial)
.clipShape(Capsule())
}
}
}
}
}
struct BroadcastSelection: Identifiable {
let id: String
let broadcast: Broadcast
let game: Game
init(broadcast: Broadcast, game: Game) {
self.id = broadcast.id
self.broadcast = broadcast
self.game = game
}
}

View File

@@ -0,0 +1,584 @@
import SwiftUI
struct FeaturedGameCard: View {
let game: Game
let onSelect: () -> Void
private var awayColor: Color { TeamAssets.color(for: game.awayTeam.code) }
private var homeColor: Color { TeamAssets.color(for: game.homeTeam.code) }
private var hasLinescore: Bool {
!game.status.isScheduled && (game.linescore?.hasData ?? false)
}
private var awayPitcherName: String? {
guard let pitchers = game.pitchers else { return nil }
let parts = pitchers.components(separatedBy: " vs ")
return parts.first
}
private var homePitcherName: String? {
guard let pitchers = game.pitchers else { return nil }
let parts = pitchers.components(separatedBy: " vs ")
return parts.count > 1 ? parts.last : nil
}
var body: some View {
Button(action: onSelect) {
VStack(spacing: 0) {
HStack(alignment: .top, spacing: 28) {
matchupColumn
.frame(maxWidth: .infinity, alignment: .leading)
sidePanel
.frame(width: 760, alignment: .leading)
}
.padding(.horizontal, 34)
.padding(.top, 30)
.padding(.bottom, 24)
footerBar
.padding(.horizontal, 34)
.padding(.vertical, 16)
.background(.white.opacity(0.04))
}
.background(cardBackground)
.overlay(alignment: .top) {
HStack(spacing: 0) {
Rectangle()
.fill(awayColor.opacity(0.92))
Rectangle()
.fill(homeColor.opacity(0.92))
}
.frame(height: 5)
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
}
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 28, style: .continuous)
.strokeBorder(borderColor, lineWidth: borderWidth)
)
.shadow(color: shadowColor, radius: 24, y: 10)
}
.buttonStyle(.card)
}
@ViewBuilder
private var matchupColumn: some View {
VStack(alignment: .leading, spacing: 18) {
headerBar
featuredTeamRow(
team: game.awayTeam,
color: awayColor,
pitcherURL: game.awayPitcherHeadshotURL,
pitcherName: awayPitcherName
)
scorePanel
featuredTeamRow(
team: game.homeTeam,
color: homeColor,
pitcherURL: game.homePitcherHeadshotURL,
pitcherName: homePitcherName
)
}
}
@ViewBuilder
private var headerBar: some View {
HStack(alignment: .top, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text((game.gameType ?? "Featured Game").uppercased())
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.6))
.kerning(1.8)
if let venue = game.venue {
Label(venue, systemImage: "mappin.and.ellipse")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.white.opacity(0.8))
}
}
Spacer()
HStack(spacing: 10) {
if game.hasStreams {
headerChip(title: "WATCH", color: .blue)
} else if game.isBlackedOut {
headerChip(title: "BLACKOUT", color: .red)
}
statusChip
}
}
}
@ViewBuilder
private func featuredTeamRow(team: TeamInfo, color: Color, pitcherURL: URL?, pitcherName: String?) -> some View {
HStack(spacing: 18) {
TeamLogoView(team: team, size: 82)
.frame(width: 88, height: 88)
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
Text(team.code)
.font(.system(size: 34, weight: .black, design: .rounded))
.foregroundStyle(.white)
if let record = team.record {
Text(record)
.font(.system(size: 13, weight: .bold, design: .monospaced))
.foregroundStyle(.white.opacity(0.72))
.padding(.horizontal, 11)
.padding(.vertical, 6)
.background(.white.opacity(0.08))
.clipShape(Capsule())
}
}
Text(team.displayName)
.font(.system(size: 26, weight: .bold))
.foregroundStyle(.white.opacity(0.96))
.lineLimit(1)
.minimumScaleFactor(0.82)
if let standing = team.standingSummary {
Text(standing)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.58))
.lineLimit(1)
}
}
Spacer(minLength: 14)
if pitcherURL != nil || pitcherName != nil {
HStack(spacing: 12) {
if pitcherURL != nil {
PitcherHeadshotView(
url: pitcherURL,
teamCode: team.code,
name: nil,
size: 46
)
}
VStack(alignment: .trailing, spacing: 4) {
Text("Probable")
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.42))
.textCase(.uppercase)
Text(pitcherName ?? "TBD")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white.opacity(0.86))
.lineLimit(1)
}
}
}
}
.padding(.horizontal, 22)
.padding(.vertical, 20)
.background {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(teamPanelBackground(color: color))
}
.overlay {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
}
}
@ViewBuilder
private var scorePanel: some View {
VStack(spacing: 10) {
if let summary = scoreSummaryText {
Text(summary)
.font(.system(size: 74, weight: .black, design: .rounded))
.foregroundStyle(.white)
.monospacedDigit()
.contentTransition(.numericText())
} else {
Text(game.status.label)
.font(.system(size: 54, weight: .black, design: .rounded))
.foregroundStyle(.white)
.multilineTextAlignment(.center)
}
Text(stateHeadline)
.font(.system(size: 22, weight: .semibold, design: .rounded))
.foregroundStyle(stateHeadlineColor)
if let detail = stateDetailText {
Text(detail)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.6))
.lineLimit(2)
.multilineTextAlignment(.center)
}
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 24)
.padding(.vertical, 26)
.background {
RoundedRectangle(cornerRadius: 26, style: .continuous)
.fill(.white.opacity(0.06))
}
.overlay {
RoundedRectangle(cornerRadius: 26, style: .continuous)
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
}
}
@ViewBuilder
private var sidePanel: some View {
VStack(alignment: .leading, spacing: 18) {
HStack(spacing: 14) {
detailTile(
title: "Venue",
value: game.venue ?? "TBD",
icon: "mappin.and.ellipse",
accent: nil
)
detailTile(
title: "Pitching",
value: game.pitchers ?? "Pitchers TBD",
icon: "baseball",
accent: nil
)
detailTile(
title: "Coverage",
value: coverageSummary,
icon: coverageIconName,
accent: coverageAccent
)
}
if hasLinescore,
let linescore = game.linescore {
VStack(alignment: .leading, spacing: 14) {
HStack {
Text("Linescore")
.font(.system(size: 18, weight: .bold))
.foregroundStyle(.white)
Spacer()
Text(linescoreLabel)
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.56))
}
LinescoreView(
linescore: linescore,
awayCode: game.awayTeam.code,
homeCode: game.homeTeam.code
)
}
.padding(20)
.background(panelBackground)
} else {
fallbackInfoPanel
}
}
}
@ViewBuilder
private func detailTile(title: String, value: String, icon: String, accent: Color?) -> some View {
VStack(alignment: .leading, spacing: 10) {
Label(title, systemImage: icon)
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.52))
Text(value)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(accent ?? .white)
.lineLimit(2)
.minimumScaleFactor(0.82)
}
.frame(maxWidth: .infinity, minHeight: 102, alignment: .topLeading)
.padding(18)
.background(panelBackground)
}
@ViewBuilder
private var fallbackInfoPanel: some View {
VStack(alignment: .leading, spacing: 12) {
Text(hasLinescore ? "Game Flow" : "Matchup Notes")
.font(.system(size: 18, weight: .bold))
.foregroundStyle(.white)
Text(fallbackSummary)
.font(.system(size: 18, weight: .medium))
.foregroundStyle(.white.opacity(0.78))
.lineLimit(3)
if let venue = game.venue {
Text(venue)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white.opacity(0.52))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(22)
.background(panelBackground)
}
@ViewBuilder
private var footerBar: some View {
HStack(spacing: 14) {
Label(footerSummary, systemImage: footerIconName)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white.opacity(0.72))
.lineLimit(1)
Spacer()
HStack(spacing: 8) {
Image(systemName: game.hasStreams ? "play.fill" : "rectangle.and.text.magnifyingglass")
.font(.system(size: 13, weight: .bold))
Text(game.hasStreams ? "Watch Game" : "Open Matchup")
.font(.system(size: 15, weight: .bold))
}
.foregroundStyle(game.hasStreams ? .blue : .white.opacity(0.82))
}
}
@ViewBuilder
private var statusChip: some View {
switch game.status {
case .live(let inning):
HStack(spacing: 8) {
Circle()
.fill(.red)
.frame(width: 8, height: 8)
Text(inning ?? "LIVE")
.font(.system(size: 15, weight: .black, design: .rounded))
.foregroundStyle(.white)
}
.padding(.horizontal, 15)
.padding(.vertical, 10)
.background(.red.opacity(0.18))
.clipShape(Capsule())
case .scheduled(let time):
Text(time)
.font(.system(size: 15, weight: .black, design: .rounded))
.foregroundStyle(.white)
.padding(.horizontal, 15)
.padding(.vertical, 10)
.background(.white.opacity(0.1))
.clipShape(Capsule())
case .final_:
Text("FINAL")
.font(.system(size: 15, weight: .black, design: .rounded))
.foregroundStyle(.white.opacity(0.96))
.padding(.horizontal, 15)
.padding(.vertical, 10)
.background(.white.opacity(0.12))
.clipShape(Capsule())
case .unknown:
EmptyView()
}
}
@ViewBuilder
private func headerChip(title: String, color: Color) -> some View {
Text(title)
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(color)
.padding(.horizontal, 12)
.padding(.vertical, 9)
.background(color.opacity(0.12))
.clipShape(Capsule())
}
private var scoreSummaryText: String? {
guard let away = game.awayTeam.score, let home = game.homeTeam.score else { return nil }
return "\(away) - \(home)"
}
private var stateHeadline: String {
switch game.status {
case .scheduled:
return "First Pitch"
case .live(let inning):
return inning ?? "Live"
case .final_:
return "Final"
case .unknown:
return "Game Day"
}
}
private var stateHeadlineColor: Color {
switch game.status {
case .live:
return .red.opacity(0.92)
case .scheduled:
return .blue.opacity(0.92)
case .final_:
return .white.opacity(0.82)
case .unknown:
return .white.opacity(0.72)
}
}
private var stateDetailText: String? {
if game.status.isScheduled {
return game.pitchers ?? game.venue
}
return game.venue
}
private var coverageSummary: String {
if game.isBlackedOut {
return "Unavailable in your area"
}
if !game.broadcasts.isEmpty {
let names = game.broadcasts.map(\.displayLabel)
return names.joined(separator: " / ")
}
if game.status.isScheduled {
return "Feeds closer to game time"
}
return "No feeds listed"
}
private var coverageIconName: String {
if game.isBlackedOut { return "eye.slash.fill" }
if !game.broadcasts.isEmpty { return "tv.fill" }
return "dot.radiowaves.left.and.right"
}
private var coverageAccent: Color {
if game.isBlackedOut { return .red }
if !game.broadcasts.isEmpty { return .blue }
return .white
}
private var fallbackSummary: String {
if let pitchers = game.pitchers, !pitchers.isEmpty {
return pitchers
}
if game.isBlackedOut {
return "This game is blacked out in your area."
}
if !game.broadcasts.isEmpty {
return game.broadcasts.map(\.displayLabel).joined(separator: " / ")
}
return "Select the matchup to view streams and full game details."
}
private var footerSummary: String {
if game.isBlackedOut {
return "Blackout restrictions apply"
}
if !game.broadcasts.isEmpty {
let feeds = game.broadcasts.map(\.teamCode).joined(separator: " / ")
return "Coverage: \(feeds)"
}
return game.venue ?? "Game details"
}
private var footerIconName: String {
if game.isBlackedOut { return "eye.slash.fill" }
if !game.broadcasts.isEmpty { return "tv.fill" }
return "sportscourt.fill"
}
private var linescoreLabel: String {
if let inning = game.currentInningDisplay, !inning.isEmpty {
return inning.uppercased()
}
if game.isFinal {
return "FINAL"
}
return "GAME"
}
private func teamPanelBackground(color: Color) -> some ShapeStyle {
LinearGradient(
colors: [
color.opacity(0.22),
.white.opacity(0.05)
],
startPoint: .leading,
endPoint: .trailing
)
}
@ViewBuilder
private var panelBackground: some View {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(.black.opacity(0.22))
.overlay {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
}
}
@ViewBuilder
private var cardBackground: some View {
ZStack {
RoundedRectangle(cornerRadius: 28, style: .continuous)
.fill(Color(red: 0.05, green: 0.07, blue: 0.11))
LinearGradient(
colors: [
awayColor.opacity(0.2),
Color(red: 0.05, green: 0.07, blue: 0.11),
homeColor.opacity(0.22)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Circle()
.fill(awayColor.opacity(0.18))
.frame(width: 320, height: 320)
.blur(radius: 64)
.offset(x: -280, y: -70)
Circle()
.fill(homeColor.opacity(0.18))
.frame(width: 360, height: 360)
.blur(radius: 72)
.offset(x: 320, y: 40)
Rectangle()
.fill(.white.opacity(0.03))
.frame(width: 1)
.padding(.vertical, 28)
.offset(x: 86)
}
}
private var borderColor: Color {
if game.isLive {
return .red.opacity(0.32)
}
if game.hasStreams {
return .blue.opacity(0.24)
}
return .white.opacity(0.08)
}
private var borderWidth: CGFloat {
game.isLive || game.hasStreams ? 2 : 1
}
private var shadowColor: Color {
if game.isLive {
return .red.opacity(0.18)
}
return .black.opacity(0.26)
}
}

View File

@@ -0,0 +1,302 @@
import SwiftUI
struct GameCardView: View {
let game: Game
let onSelect: () -> Void
@Environment(GamesViewModel.self) private var viewModel
private var inMultiView: Bool {
game.broadcasts.contains(where: { bc in
viewModel.activeStreams.contains(where: { $0.id == bc.id })
})
}
private var awayColor: Color { TeamAssets.color(for: game.awayTeam.code) }
private var homeColor: Color { TeamAssets.color(for: game.homeTeam.code) }
var body: some View {
Button(action: onSelect) {
VStack(alignment: .leading, spacing: 0) {
header
.padding(.horizontal, 22)
.padding(.top, 18)
.padding(.bottom, 16)
VStack(spacing: 12) {
teamRow(team: game.awayTeam, isWinning: isWinning(away: true))
teamRow(team: game.homeTeam, isWinning: isWinning(away: false))
}
.padding(.horizontal, 22)
Spacer(minLength: 14)
footer
.padding(.horizontal, 22)
.padding(.vertical, 16)
}
.frame(maxWidth: .infinity, minHeight: 320, maxHeight: 320, alignment: .topLeading)
.background(cardBackground)
.overlay(alignment: .top) {
HStack(spacing: 0) {
Rectangle()
.fill(awayColor.opacity(0.95))
Rectangle()
.fill(homeColor.opacity(0.95))
}
.frame(height: 4)
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
}
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.strokeBorder(
inMultiView ? .green.opacity(0.4) :
game.isLive ? .red.opacity(0.4) :
.white.opacity(0.08),
lineWidth: inMultiView || game.isLive ? 2 : 1
)
)
.shadow(
color: shadowColor,
radius: 18,
y: 8
)
}
.buttonStyle(.card)
}
@ViewBuilder
private var header: some View {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 6) {
Text((game.gameType ?? "Matchup").uppercased())
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.58))
.kerning(1.2)
.lineLimit(1)
Text(subtitleText)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.white.opacity(0.82))
.lineLimit(1)
}
Spacer(minLength: 12)
compactStatus
}
}
@ViewBuilder
private func teamRow(team: TeamInfo, isWinning: Bool) -> some View {
HStack(spacing: 14) {
TeamLogoView(team: team, size: 46)
.frame(width: 50, height: 50)
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 10) {
Text(team.code)
.font(.system(size: 28, weight: .black, design: .rounded))
.foregroundStyle(.white)
if let record = team.record {
Text(record)
.font(.system(size: 12, weight: .bold, design: .monospaced))
.foregroundStyle(.white.opacity(0.72))
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(.white.opacity(isWinning ? 0.12 : 0.07))
.clipShape(Capsule())
}
}
Text(team.displayName)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white.opacity(isWinning ? 0.88 : 0.68))
.lineLimit(1)
.minimumScaleFactor(0.75)
}
Spacer(minLength: 12)
if !game.status.isScheduled, let score = team.score {
Text("\(score)")
.font(.system(size: 42, weight: .black, design: .rounded))
.foregroundStyle(isWinning ? .white : .white.opacity(0.72))
.contentTransition(.numericText())
}
}
.padding(.horizontal, 14)
.padding(.vertical, 13)
.background {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(rowBackground(for: team, isWinning: isWinning))
}
}
private func isWinning(away: Bool) -> Bool {
guard let a = game.awayTeam.score, let h = game.homeTeam.score else { return false }
return away ? a > h : h > a
}
private var subtitleText: String {
if game.status.isScheduled {
return game.pitchers ?? game.venue ?? "Upcoming"
}
if game.isBlackedOut {
return "Regional blackout"
}
if !game.broadcasts.isEmpty {
let count = game.broadcasts.count
return "\(count) feed\(count == 1 ? "" : "s") available"
}
return game.venue ?? "No feeds listed yet"
}
@ViewBuilder
private var compactStatus: some View {
switch game.status {
case .live(let inning):
HStack(spacing: 7) {
Circle()
.fill(.red)
.frame(width: 8, height: 8)
Text(inning ?? "LIVE")
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.red.opacity(0.18))
.clipShape(Capsule())
case .scheduled(let time):
Text(time)
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.white.opacity(0.12))
.clipShape(Capsule())
case .final_:
Text("FINAL")
.font(.system(size: 13, weight: .black, design: .rounded))
.foregroundStyle(.white.opacity(0.92))
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.white.opacity(0.12))
.clipShape(Capsule())
case .unknown:
EmptyView()
}
}
@ViewBuilder
private var footer: some View {
HStack(spacing: 12) {
Label(footerText, systemImage: footerIconName)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white.opacity(0.66))
.lineLimit(1)
Spacer(minLength: 12)
if inMultiView {
footerBadge(title: "In Multi-View", color: .green)
} else if game.isBlackedOut {
footerBadge(title: "Blacked Out", color: .red)
} else if game.hasStreams {
footerBadge(title: "Watch", color: .blue)
}
}
.overlay(alignment: .top) {
Rectangle()
.fill(.white.opacity(0.08))
.frame(height: 1)
}
}
private var footerText: String {
if game.status.isScheduled {
return game.venue ?? (game.pitchers ?? "First pitch later today")
}
if game.isBlackedOut {
return "This game is unavailable in your area"
}
if let pitchers = game.pitchers, !pitchers.isEmpty {
return pitchers
}
if !game.broadcasts.isEmpty {
return game.broadcasts.map(\.teamCode).joined(separator: "")
}
return game.venue ?? "Tap for details"
}
private var footerIconName: String {
if game.isBlackedOut { return "eye.slash.fill" }
if game.hasStreams { return "tv.fill" }
if game.status.isScheduled { return "mappin.and.ellipse" }
return "sportscourt.fill"
}
@ViewBuilder
private func footerBadge(title: String, color: Color) -> some View {
Text(title)
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(color)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(color.opacity(0.12))
.clipShape(Capsule())
}
private func rowBackground(for team: TeamInfo, isWinning: Bool) -> some ShapeStyle {
let color = TeamAssets.color(for: team.code)
return LinearGradient(
colors: [
color.opacity(isWinning ? 0.22 : 0.12),
.white.opacity(isWinning ? 0.07 : 0.03)
],
startPoint: .leading,
endPoint: .trailing
)
}
@ViewBuilder
private var cardBackground: some View {
ZStack {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(Color(red: 0.08, green: 0.09, blue: 0.12))
LinearGradient(
colors: [
awayColor.opacity(0.18),
Color.clear,
homeColor.opacity(0.18)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Circle()
.fill(awayColor.opacity(0.18))
.frame(width: 180)
.blur(radius: 40)
.offset(x: -110, y: -90)
Circle()
.fill(homeColor.opacity(0.16))
.frame(width: 200)
.blur(radius: 44)
.offset(x: 140, y: 120)
}
}
private var shadowColor: Color {
if inMultiView { return .green.opacity(0.18) }
if game.isLive { return .red.opacity(0.22) }
return .black.opacity(0.22)
}
}

View File

@@ -0,0 +1,375 @@
import SwiftUI
struct GameCenterView: View {
let game: Game
@State private var viewModel = GameCenterViewModel()
var body: some View {
VStack(alignment: .leading, spacing: 18) {
header
if viewModel.isLoading && viewModel.feed == nil {
loadingState
} else if let feed = viewModel.feed {
situationStrip(feed: feed)
matchupPanel(feed: feed)
if !feed.decisionsSummary.isEmpty || !feed.officialSummary.isEmpty || feed.weatherSummary != nil {
contextPanel(feed: feed)
}
if !feed.awayLineup.isEmpty || !feed.homeLineup.isEmpty {
lineupPanel(feed: feed)
}
if !feed.scoringPlays.isEmpty {
timelineSection(
title: "Scoring Plays",
subtitle: "Every run-scoring swing, plate appearance, and sequence.",
plays: Array(feed.scoringPlays.suffix(6).reversed())
)
}
timelineSection(
title: "Recent Plays",
subtitle: "The latest plate appearances and game-state changes.",
plays: Array(feed.recentPlays.prefix(8))
)
} else {
errorState
}
}
.task(id: game.id) {
await viewModel.watch(game: game)
}
}
private var header: some View {
HStack(alignment: .top, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text("Game Center")
.font(.system(size: 28, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text("Live matchup context, lineup state, and inning-by-inning events from MLB's live feed.")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.58))
}
Spacer()
Button {
if let gamePk = game.gamePk {
Task { await viewModel.refresh(gamePk: gamePk) }
}
} label: {
HStack(spacing: 8) {
Image(systemName: "arrow.clockwise")
.font(.system(size: 12, weight: .bold))
Text(refreshLabel)
.font(.system(size: 14, weight: .semibold))
}
.foregroundStyle(.white.opacity(0.82))
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(.white.opacity(0.08))
.clipShape(Capsule())
}
.buttonStyle(.card)
.disabled(game.gamePk == nil)
}
.padding(22)
.background(panelBackground)
}
private func situationStrip(feed: LiveGameFeed) -> some View {
HStack(spacing: 14) {
situationTile(
title: "Situation",
value: feed.liveData.linescore?.inningDisplay ?? game.currentInningDisplay ?? game.status.label,
accent: .red
)
situationTile(
title: "Count",
value: feed.currentCountText ?? "No active count",
accent: .blue
)
situationTile(
title: "Bases",
value: feed.occupiedBases.isEmpty ? "Bases Empty" : "On \(feed.occupiedBases.joined(separator: ", "))",
accent: .green
)
}
}
private func situationTile(title: String, value: String, accent: Color) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title.uppercased())
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(accent.opacity(0.9))
.kerning(1.2)
Text(value)
.font(.system(size: 19, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.lineLimit(2)
.minimumScaleFactor(0.82)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.background(panelBackground)
}
private func matchupPanel(feed: LiveGameFeed) -> some View {
VStack(alignment: .leading, spacing: 16) {
HStack(alignment: .top, spacing: 18) {
VStack(alignment: .leading, spacing: 5) {
Text("At Bat")
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.5))
Text(feed.currentBatter?.displayName ?? "Awaiting matchup")
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundStyle(.white)
if let onDeck = feed.onDeckBatter?.displayName {
Text("On deck: \(onDeck)")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.55))
}
if let inHole = feed.inHoleBatter?.displayName {
Text("In hole: \(inHole)")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.46))
}
}
Spacer()
VStack(alignment: .trailing, spacing: 5) {
Text("Pitching")
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.5))
Text(feed.currentPitcher?.displayName ?? "Awaiting pitcher")
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.multilineTextAlignment(.trailing)
if let play = feed.currentPlay {
Text(play.summaryText)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.58))
.frame(maxWidth: 420, alignment: .trailing)
.multilineTextAlignment(.trailing)
}
}
}
}
.padding(22)
.background(panelBackground)
}
private func contextPanel(feed: LiveGameFeed) -> some View {
VStack(alignment: .leading, spacing: 16) {
if let weatherSummary = feed.weatherSummary {
contextRow(title: "Weather", values: [weatherSummary], accent: .blue)
}
if !feed.decisionsSummary.isEmpty {
contextRow(title: "Decisions", values: feed.decisionsSummary, accent: .green)
}
if !feed.officialSummary.isEmpty {
contextRow(title: "Officials", values: Array(feed.officialSummary.prefix(4)), accent: .orange)
}
}
.padding(22)
.background(panelBackground)
}
private func contextRow(title: String, values: [String], accent: Color) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text(title.uppercased())
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(accent.opacity(0.9))
.kerning(1.2)
HStack(spacing: 10) {
ForEach(values, id: \.self) { value in
Text(value)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white.opacity(0.82))
.padding(.horizontal, 12)
.padding(.vertical, 9)
.background(.white.opacity(0.08))
.clipShape(Capsule())
}
}
}
}
private func lineupPanel(feed: LiveGameFeed) -> some View {
HStack(alignment: .top, spacing: 16) {
lineupColumn(
title: game.awayTeam.code,
teamName: game.awayTeam.displayName,
color: TeamAssets.color(for: game.awayTeam.code),
players: Array(feed.awayLineup.prefix(9))
)
lineupColumn(
title: game.homeTeam.code,
teamName: game.homeTeam.displayName,
color: TeamAssets.color(for: game.homeTeam.code),
players: Array(feed.homeLineup.prefix(9))
)
}
}
private func lineupColumn(title: String, teamName: String, color: Color, players: [LiveFeedBoxscorePlayer]) -> some View {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 10) {
Circle()
.fill(color)
.frame(width: 10, height: 10)
Text(title)
.font(.system(size: 18, weight: .black, design: .rounded))
.foregroundStyle(.white)
Text(teamName)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.white.opacity(0.55))
.lineLimit(1)
}
VStack(alignment: .leading, spacing: 10) {
ForEach(Array(players.enumerated()), id: \.element.id) { index, player in
HStack(spacing: 10) {
Text("\(index + 1)")
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.6))
.frame(width: 18, alignment: .leading)
Text(player.person.displayName)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white.opacity(0.88))
.lineLimit(1)
Spacer()
Text(player.position?.abbreviation ?? "")
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.45))
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(20)
.background(panelBackground)
}
private func timelineSection(title: String, subtitle: String, plays: [LiveFeedPlay]) -> some View {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text(subtitle)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.55))
}
VStack(spacing: 12) {
ForEach(plays) { play in
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(play.about?.halfInning?.uppercased() ?? "PLAY")
.font(.system(size: 11, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.4))
if let inning = play.about?.inning {
Text("\(inning)")
.font(.system(size: 16, weight: .black, design: .rounded))
.foregroundStyle(.white)
}
}
.frame(width: 54, alignment: .leading)
VStack(alignment: .leading, spacing: 6) {
Text(play.summaryText)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
if let event = play.result?.event {
Text(event)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white.opacity(0.5))
}
}
Spacer()
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(.white.opacity(0.05))
)
}
}
}
.padding(22)
.background(panelBackground)
}
private var loadingState: some View {
HStack {
Spacer()
ProgressView("Loading game center...")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white.opacity(0.7))
Spacer()
}
.padding(.vertical, 28)
.background(panelBackground)
}
private var errorState: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Game center is unavailable.")
.font(.system(size: 20, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text(viewModel.errorMessage ?? "No live feed data was returned for this game.")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.6))
}
.padding(22)
.background(panelBackground)
}
private var refreshLabel: String {
if let lastUpdated = viewModel.lastUpdated {
let formatter = DateFormatter()
formatter.dateFormat = "h:mm:ss a"
return formatter.string(from: lastUpdated)
}
return "Refresh"
}
private var panelBackground: some View {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(.black.opacity(0.22))
.overlay {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
}
}
}

View File

@@ -0,0 +1,635 @@
import SwiftUI
struct StreamOptionsSheet: View {
let game: Game
var onWatch: ((BroadcastSelection) -> Void)?
@Environment(GamesViewModel.self) private var viewModel
@Environment(\.dismiss) private var dismiss
private var awayColor: Color { TeamAssets.color(for: game.awayTeam.code) }
private var homeColor: Color { TeamAssets.color(for: game.homeTeam.code) }
private var hasLinescore: Bool {
!game.status.isScheduled && (game.linescore?.hasData ?? false)
}
private var canAddMoreStreams: Bool {
viewModel.activeStreams.count < 4
}
private var awayPitcherName: String? {
guard let pitchers = game.pitchers else { return nil }
let parts = pitchers.components(separatedBy: " vs ")
return parts.first
}
private var homePitcherName: String? {
guard let pitchers = game.pitchers else { return nil }
let parts = pitchers.components(separatedBy: " vs ")
return parts.count > 1 ? parts.last : nil
}
var body: some View {
ScrollView {
VStack(spacing: 28) {
HStack(alignment: .top, spacing: 28) {
matchupColumn
.frame(maxWidth: .infinity, alignment: .leading)
actionRail
.frame(width: 520, alignment: .leading)
}
if !canAddMoreStreams {
Label(
"Multi-view is full. Remove a stream in Multi-View or Settings before adding another.",
systemImage: "rectangle.split.2x2.fill"
)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.orange)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 22)
.padding(.vertical, 18)
.background(panelBackground)
}
}
.padding(.horizontal, 56)
.padding(.vertical, 42)
}
.background(sheetBackground.ignoresSafeArea())
}
@ViewBuilder
private var matchupColumn: some View {
VStack(alignment: .leading, spacing: 20) {
headerBar
featuredTeamRow(
team: game.awayTeam,
color: awayColor,
pitcherURL: game.awayPitcherHeadshotURL,
pitcherName: awayPitcherName
)
scorePanel
featuredTeamRow(
team: game.homeTeam,
color: homeColor,
pitcherURL: game.homePitcherHeadshotURL,
pitcherName: homePitcherName
)
if hasLinescore,
let linescore = game.linescore {
VStack(alignment: .leading, spacing: 14) {
HStack {
Text("Linescore")
.font(.system(size: 18, weight: .bold))
.foregroundStyle(.white)
Spacer()
Text(linescoreStatusText)
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.58))
}
LinescoreView(
linescore: linescore,
awayCode: game.awayTeam.code,
homeCode: game.homeTeam.code
)
}
.padding(22)
.background(panelBackground)
}
GameCenterView(game: game)
}
}
@ViewBuilder
private var headerBar: some View {
HStack(alignment: .top, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text((game.gameType ?? "Game Center").uppercased())
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.58))
.kerning(1.8)
if let venue = game.venue {
Label(venue, systemImage: "mappin.and.ellipse")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.82))
}
}
Spacer()
HStack(spacing: 10) {
if game.isBlackedOut {
chip(title: "BLACKOUT", color: .red)
} else if !game.broadcasts.isEmpty {
chip(title: "\(game.broadcasts.count) FEED\(game.broadcasts.count == 1 ? "" : "S")", color: .blue)
}
statusChip
}
}
}
@ViewBuilder
private func featuredTeamRow(team: TeamInfo, color: Color, pitcherURL: URL?, pitcherName: String?) -> some View {
HStack(spacing: 18) {
TeamLogoView(team: team, size: 72)
.frame(width: 78, height: 78)
VStack(alignment: .leading, spacing: 7) {
HStack(spacing: 10) {
Text(team.code)
.font(.system(size: 30, weight: .black, design: .rounded))
.foregroundStyle(.white)
if let record = team.record {
Text(record)
.font(.system(size: 13, weight: .bold, design: .monospaced))
.foregroundStyle(.white.opacity(0.72))
.padding(.horizontal, 11)
.padding(.vertical, 6)
.background(.white.opacity(0.08))
.clipShape(Capsule())
}
}
Text(team.displayName)
.font(.system(size: 28, weight: .bold))
.foregroundStyle(.white.opacity(0.96))
.lineLimit(1)
.minimumScaleFactor(0.82)
if let standing = team.standingSummary {
Text(standing)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.56))
.lineLimit(1)
}
}
Spacer(minLength: 14)
if pitcherURL != nil || pitcherName != nil {
HStack(spacing: 12) {
if pitcherURL != nil {
PitcherHeadshotView(
url: pitcherURL,
teamCode: team.code,
name: nil,
size: 46
)
}
VStack(alignment: .trailing, spacing: 4) {
Text("Probable")
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.42))
Text(pitcherName ?? "TBD")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white.opacity(0.84))
.lineLimit(1)
}
}
}
}
.padding(.horizontal, 22)
.padding(.vertical, 18)
.background {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(teamPanelBackground(color: color))
}
.overlay {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
}
}
@ViewBuilder
private var scorePanel: some View {
VStack(spacing: 12) {
if let summary = scoreSummaryText {
Text(summary)
.font(.system(size: 72, weight: .black, design: .rounded))
.foregroundStyle(.white)
.monospacedDigit()
.contentTransition(.numericText())
} else {
Text(game.status.label)
.font(.system(size: 52, weight: .black, design: .rounded))
.foregroundStyle(.white)
.multilineTextAlignment(.center)
}
Text(statusHeadline)
.font(.system(size: 22, weight: .semibold, design: .rounded))
.foregroundStyle(statusHeadlineColor)
if let detail = centerDetailText {
Text(detail)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.white.opacity(0.62))
.lineLimit(2)
.multilineTextAlignment(.center)
}
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 24)
.padding(.vertical, 26)
.background {
RoundedRectangle(cornerRadius: 26, style: .continuous)
.fill(.white.opacity(0.06))
}
.overlay {
RoundedRectangle(cornerRadius: 26, style: .continuous)
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
}
}
@ViewBuilder
private var actionRail: some View {
VStack(alignment: .leading, spacing: 18) {
actionRailHeader
if !game.broadcasts.isEmpty {
ForEach(game.broadcasts) { broadcast in
broadcastCard(broadcast)
}
} else if game.isBlackedOut {
blackoutCard
} else if game.status.isScheduled {
scheduledStateCard
} else {
fallbackActionsCard
}
}
}
@ViewBuilder
private var actionRailHeader: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Watch Options")
.font(.system(size: 28, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text(actionRailSubtitle)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.white.opacity(0.68))
.lineLimit(2)
}
.padding(22)
.background(panelBackground)
}
@ViewBuilder
private func broadcastCard(_ broadcast: Broadcast) -> some View {
let alreadyAdded = viewModel.activeStreams.contains(where: { $0.id == broadcast.id })
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 12) {
Circle()
.fill(TeamAssets.color(for: broadcast.teamCode))
.frame(width: 10, height: 10)
Text(broadcast.teamCode)
.font(.system(size: 13, weight: .black, design: .rounded))
.foregroundStyle(.white.opacity(0.78))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(.white.opacity(0.08))
.clipShape(Capsule())
Text(broadcast.name)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white)
.lineLimit(1)
Spacer()
if alreadyAdded {
chip(title: "ADDED", color: .green)
}
}
VStack(spacing: 14) {
railActionButton(
title: "Watch Full Screen",
subtitle: "Open \(broadcast.name) in the main player",
systemImage: "play.fill",
fill: .blue.opacity(0.18)
) {
let selection = BroadcastSelection(broadcast: broadcast, game: game)
if let onWatch { onWatch(selection) } else { dismiss() }
}
railActionButton(
title: alreadyAdded ? "Already Added to Multi-View" : "Add to Multi-View",
subtitle: alreadyAdded ? "This feed is already active in the grid" : "Send \(broadcast.name) into an open tile",
systemImage: alreadyAdded ? "checkmark.circle.fill" : "plus.circle.fill",
fill: alreadyAdded ? .green.opacity(0.14) : .white.opacity(0.08),
foreground: alreadyAdded ? .green : .white,
disabled: !canAddMoreStreams || alreadyAdded
) {
viewModel.addStream(broadcast: broadcast, game: game)
dismiss()
}
}
}
.padding(22)
.background(panelBackground)
}
@ViewBuilder
private var blackoutCard: some View {
VStack(alignment: .leading, spacing: 14) {
Image(systemName: "eye.slash.fill")
.font(.system(size: 36, weight: .bold))
.foregroundStyle(.red)
Text("Blacked Out")
.font(.system(size: 28, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text("This game is unavailable in your area. The matchup data is still live, but watch controls are disabled.")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(.white.opacity(0.72))
.lineLimit(3)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(24)
.background(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(.red.opacity(0.12))
)
.overlay {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.strokeBorder(.red.opacity(0.24), lineWidth: 1)
}
}
@ViewBuilder
private var scheduledStateCard: some View {
VStack(alignment: .leading, spacing: 14) {
Label("Feeds Not Posted Yet", systemImage: "calendar")
.font(.system(size: 16, weight: .bold))
.foregroundStyle(.white)
Text("Streams usually appear closer to first pitch. Check back around game time.")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(.white.opacity(0.72))
.lineLimit(3)
if let venue = game.venue {
Text(venue)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white.opacity(0.5))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(24)
.background(panelBackground)
}
@ViewBuilder
private var fallbackActionsCard: some View {
VStack(alignment: .leading, spacing: 16) {
Text("No Feeds Listed")
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text("You can still try building a stream from one of the team feeds.")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.white.opacity(0.68))
VStack(spacing: 14) {
fallbackButton(teamCode: game.awayTeam.code)
fallbackButton(teamCode: game.homeTeam.code)
}
}
.padding(22)
.background(panelBackground)
}
@ViewBuilder
private func fallbackButton(teamCode: String) -> some View {
railActionButton(
title: "Try \(teamCode) Feed",
subtitle: "Build a fallback stream for \(teamCode)",
systemImage: "play.circle.fill",
fill: .white.opacity(0.08),
disabled: !canAddMoreStreams
) {
viewModel.addStreamByTeam(teamCode: teamCode, game: game)
dismiss()
}
}
@ViewBuilder
private func railActionButton(
title: String,
subtitle: String,
systemImage: String,
fill: Color,
foreground: Color = .white,
disabled: Bool = false,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
HStack(alignment: .center, spacing: 14) {
Image(systemName: systemImage)
.font(.system(size: 20, weight: .bold))
.frame(width: 28)
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.system(size: 18, weight: .bold, design: .rounded))
.foregroundStyle(foreground)
.fixedSize(horizontal: false, vertical: true)
Text(subtitle)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(foreground.opacity(0.64))
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, minHeight: 84, alignment: .leading)
.padding(.horizontal, 20)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(fill)
)
}
.buttonStyle(.card)
.disabled(disabled)
}
@ViewBuilder
private var statusChip: some View {
switch game.status {
case .live(let inning):
HStack(spacing: 8) {
Circle()
.fill(.red)
.frame(width: 8, height: 8)
Text(inning ?? "LIVE")
.font(.system(size: 15, weight: .black, design: .rounded))
.foregroundStyle(.white)
}
.padding(.horizontal, 15)
.padding(.vertical, 10)
.background(.red.opacity(0.16))
.clipShape(Capsule())
case .scheduled(let time):
Text(time)
.font(.system(size: 15, weight: .black, design: .rounded))
.foregroundStyle(.white)
.padding(.horizontal, 15)
.padding(.vertical, 10)
.background(.white.opacity(0.1))
.clipShape(Capsule())
case .final_:
Text("FINAL")
.font(.system(size: 15, weight: .black, design: .rounded))
.foregroundStyle(.white)
.padding(.horizontal, 15)
.padding(.vertical, 10)
.background(.white.opacity(0.12))
.clipShape(Capsule())
case .unknown:
EmptyView()
}
}
@ViewBuilder
private func chip(title: String, color: Color) -> some View {
Text(title)
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(color)
.padding(.horizontal, 12)
.padding(.vertical, 9)
.background(color.opacity(0.12))
.clipShape(Capsule())
}
private var scoreSummaryText: String? {
guard let away = game.awayTeam.score, let home = game.homeTeam.score else { return nil }
return "\(away) - \(home)"
}
private var statusHeadline: String {
switch game.status {
case .scheduled:
return "First Pitch"
case .live(let inning):
return inning ?? "Live"
case .final_:
return "Final"
case .unknown:
return "Game Day"
}
}
private var statusHeadlineColor: Color {
switch game.status {
case .live:
return .red.opacity(0.92)
case .scheduled:
return .blue.opacity(0.92)
case .final_:
return .white.opacity(0.82)
case .unknown:
return .white.opacity(0.72)
}
}
private var centerDetailText: String? {
if game.status.isScheduled {
return game.pitchers ?? game.venue
}
return game.pitchers ?? game.venue
}
private var actionRailSubtitle: String {
if game.isBlackedOut {
return "Watch controls are unavailable for this matchup."
}
if !game.broadcasts.isEmpty {
return "Pick a feed to watch full screen or send it into Multi-View."
}
if game.status.isScheduled {
return "Streams normally appear closer to game time."
}
return "Try a team feed fallback for this matchup."
}
private var linescoreStatusText: String {
if let inning = game.currentInningDisplay, !inning.isEmpty {
return inning.uppercased()
}
if game.isFinal {
return "FINAL"
}
return "GAME"
}
private func teamPanelBackground(color: Color) -> some ShapeStyle {
LinearGradient(
colors: [
color.opacity(0.22),
.white.opacity(0.05)
],
startPoint: .leading,
endPoint: .trailing
)
}
@ViewBuilder
private var panelBackground: some View {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(.black.opacity(0.22))
.overlay {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
}
}
@ViewBuilder
private var sheetBackground: some View {
ZStack {
LinearGradient(
colors: [
Color(red: 0.05, green: 0.06, blue: 0.1),
Color(red: 0.08, green: 0.06, blue: 0.09)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Circle()
.fill(awayColor.opacity(0.16))
.frame(width: 520, height: 520)
.blur(radius: 90)
.offset(x: -360, y: -220)
Circle()
.fill(homeColor.opacity(0.18))
.frame(width: 520, height: 520)
.blur(radius: 92)
.offset(x: 420, y: 120)
}
}
}

View File

@@ -0,0 +1,665 @@
import SwiftUI
struct LeagueCenterView: View {
@Environment(GamesViewModel.self) private var gamesViewModel
@State private var viewModel = LeagueCenterViewModel()
@State private var selectedGame: Game?
private let rosterColumns = [
GridItem(.flexible(), spacing: 14),
GridItem(.flexible(), spacing: 14),
GridItem(.flexible(), spacing: 14),
]
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 34) {
header
if let overviewErrorMessage = viewModel.overviewErrorMessage {
messagePanel(overviewErrorMessage, tint: .orange)
}
scheduleSection
standingsSection
teamsSection
if let selectedTeam = viewModel.selectedTeam {
teamProfileSection(team: selectedTeam)
} else if let teamErrorMessage = viewModel.teamErrorMessage {
messagePanel(teamErrorMessage, tint: .orange)
}
if !viewModel.roster.isEmpty {
rosterSection
}
if let player = viewModel.selectedPlayer {
playerSection(player: player)
} else if let playerErrorMessage = viewModel.playerErrorMessage {
messagePanel(playerErrorMessage, tint: .orange)
}
}
.padding(.horizontal, 56)
.padding(.vertical, 40)
}
.background(screenBackground.ignoresSafeArea())
.task {
await viewModel.loadInitial()
}
.sheet(item: $selectedGame) { game in
StreamOptionsSheet(game: game)
}
}
private var header: some View {
HStack(alignment: .top, spacing: 24) {
VStack(alignment: .leading, spacing: 8) {
Text("Around MLB")
.font(.system(size: 42, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text("Schedules, standings, team context, roster access, and player snapshots in one control room.")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.white.opacity(0.58))
}
Spacer()
HStack(spacing: 12) {
infoPill(title: "\(viewModel.scheduleGames.count)", label: "Games", color: .blue)
infoPill(title: "\(viewModel.teams.count)", label: "Teams", color: .green)
infoPill(title: "\(viewModel.standings.count)", label: "Divisions", color: .orange)
}
}
}
private var scheduleSection: some View {
VStack(alignment: .leading, spacing: 18) {
HStack {
VStack(alignment: .leading, spacing: 6) {
Text("Schedule")
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text(viewModel.displayDateString)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.white.opacity(0.55))
}
Spacer()
HStack(spacing: 12) {
dateButton(title: "Previous", systemImage: "chevron.left") {
Task { await viewModel.goToPreviousDay() }
}
dateButton(title: "Today", systemImage: "calendar") {
Task { await viewModel.goToToday() }
}
dateButton(title: "Next", systemImage: "chevron.right") {
Task { await viewModel.goToNextDay() }
}
}
}
if viewModel.scheduleGames.isEmpty && viewModel.isLoadingOverview {
loadingPanel(title: "Loading schedule...")
} else {
VStack(spacing: 14) {
ForEach(viewModel.scheduleGames.prefix(10)) { game in
scheduleRow(game)
}
}
}
}
}
private func scheduleRow(_ game: StatsGame) -> some View {
let linkedGame = gamesViewModel.games.first { $0.gamePk == String(game.gamePk) }
return Button {
if let linkedGame {
selectedGame = linkedGame
}
} label: {
HStack(spacing: 18) {
teamMiniColumn(team: game.teams.away)
VStack(spacing: 6) {
Text(scoreText(for: game))
.font(.system(size: 28, weight: .black, design: .rounded))
.foregroundStyle(.white)
.monospacedDigit()
Text(statusText(for: game))
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(statusColor(for: game))
}
.frame(width: 160)
teamMiniColumn(team: game.teams.home, alignTrailing: true)
Spacer()
VStack(alignment: .trailing, spacing: 6) {
if let venue = game.venue?.name {
Text(venue)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.white.opacity(0.56))
.lineLimit(1)
}
Text(linkedGame != nil ? "Open game sheet" : "Info only")
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(linkedGame != nil ? .blue.opacity(0.95) : .white.opacity(0.34))
}
}
.padding(22)
.background(sectionPanel)
}
.buttonStyle(.card)
.disabled(linkedGame == nil)
}
private func teamMiniColumn(team: StatsTeamGameInfo, alignTrailing: Bool = false) -> some View {
let info = TeamInfo(
code: team.team.abbreviation ?? "MLB",
name: team.team.name ?? "MLB",
score: team.score,
teamId: team.team.id,
record: team.leagueRecord.map { "\($0.wins)-\($0.losses)" }
)
return HStack(spacing: 12) {
if !alignTrailing {
TeamLogoView(team: info, size: 56)
}
VStack(alignment: alignTrailing ? .trailing : .leading, spacing: 6) {
Text(info.code)
.font(.system(size: 22, weight: .black, design: .rounded))
.foregroundStyle(.white)
Text(info.displayName)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white.opacity(0.7))
.lineLimit(1)
if let record = info.record {
Text(record)
.font(.system(size: 13, weight: .bold, design: .monospaced))
.foregroundStyle(.white.opacity(0.46))
}
}
if alignTrailing {
TeamLogoView(team: info, size: 56)
}
}
.frame(maxWidth: .infinity, alignment: alignTrailing ? .trailing : .leading)
}
private var standingsSection: some View {
VStack(alignment: .leading, spacing: 18) {
Text("Standings")
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(.white)
if viewModel.standings.isEmpty && viewModel.isLoadingOverview {
loadingPanel(title: "Loading standings...")
} else {
ScrollView(.horizontal) {
HStack(spacing: 18) {
ForEach(viewModel.standings, id: \.division?.id) { record in
standingsCard(record)
.frame(width: 360)
}
}
.padding(.vertical, 4)
}
.focusSection()
.scrollClipDisabled()
}
}
}
private func standingsCard(_ record: StandingsDivisionRecord) -> some View {
VStack(alignment: .leading, spacing: 14) {
Text(record.division?.name ?? "Division")
.font(.system(size: 22, weight: .bold, design: .rounded))
.foregroundStyle(.white)
VStack(spacing: 10) {
ForEach(record.teamRecords.sorted(by: standingsSort), id: \.team.id) { team in
HStack(spacing: 10) {
Text(team.divisionRank ?? "-")
.font(.system(size: 12, weight: .black, design: .rounded))
.foregroundStyle(.white.opacity(0.52))
.frame(width: 22)
Text(team.team.abbreviation ?? team.team.name ?? "MLB")
.font(.system(size: 16, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Spacer()
if let wins = team.wins, let losses = team.losses {
Text("\(wins)-\(losses)")
.font(.system(size: 15, weight: .bold, design: .monospaced))
.foregroundStyle(.white.opacity(0.86))
}
Text(team.gamesBack ?? "-")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white.opacity(0.45))
.frame(width: 44, alignment: .trailing)
}
.padding(.vertical, 4)
}
}
}
.padding(22)
.background(sectionPanel)
}
private var teamsSection: some View {
VStack(alignment: .leading, spacing: 18) {
Text("Teams")
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(.white)
ScrollView(.horizontal) {
HStack(spacing: 16) {
ForEach(viewModel.teams) { team in
Button {
Task { await viewModel.selectTeam(team.id) }
} label: {
VStack(alignment: .leading, spacing: 14) {
TeamLogoView(team: team.teamInfo, size: 72)
VStack(alignment: .leading, spacing: 6) {
Text(team.abbreviation)
.font(.system(size: 20, weight: .black, design: .rounded))
.foregroundStyle(.white)
Text(team.name)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white.opacity(0.76))
.lineLimit(2)
}
Spacer()
Text(team.recordText ?? "Season")
.font(.system(size: 13, weight: .bold, design: .monospaced))
.foregroundStyle(.white.opacity(0.5))
}
.frame(width: 210, height: 220, alignment: .leading)
.padding(18)
.background(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(TeamAssets.color(for: team.abbreviation).opacity(0.16))
)
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.stroke(.white.opacity(0.08), lineWidth: 1)
)
}
.buttonStyle(.card)
}
}
.padding(.vertical, 4)
}
.focusSection()
.scrollClipDisabled()
}
}
private func teamProfileSection(team: TeamProfile) -> some View {
VStack(alignment: .leading, spacing: 18) {
Text("Team Profile")
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(.white)
if viewModel.isLoadingTeam {
loadingPanel(title: "Loading team profile...")
} else {
HStack(alignment: .top, spacing: 24) {
TeamLogoView(team: team.teamInfo, size: 96)
VStack(alignment: .leading, spacing: 12) {
Text(team.name)
.font(.system(size: 34, weight: .bold, design: .rounded))
.foregroundStyle(.white)
HStack(spacing: 10) {
detailChip(team.recordText ?? "Season", color: .blue)
if let gamesBack = team.gamesBackText {
detailChip(gamesBack, color: .orange)
}
if let division = team.divisionName {
detailChip(division, color: .green)
}
}
VStack(alignment: .leading, spacing: 8) {
profileLine(label: "Venue", value: team.venueName)
profileLine(label: "League", value: team.leagueName)
profileLine(label: "Franchise", value: team.franchiseName)
profileLine(label: "First Year", value: team.firstYearOfPlay)
}
}
Spacer()
}
.padding(24)
.background(sectionPanel)
.contentShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.focusable(true)
}
}
}
private var rosterSection: some View {
VStack(alignment: .leading, spacing: 18) {
Text("Roster")
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(.white)
LazyVGrid(columns: rosterColumns, spacing: 14) {
ForEach(viewModel.roster) { player in
Button {
Task { await viewModel.selectPlayer(player.id) }
} label: {
HStack(spacing: 14) {
playerHeadshot(url: player.headshotURL, size: 58)
VStack(alignment: .leading, spacing: 6) {
Text(player.fullName)
.font(.system(size: 16, weight: .bold))
.foregroundStyle(.white)
.lineLimit(2)
Text("\(player.positionAbbreviation ?? "P") · #\(player.jerseyNumber ?? "--")")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white.opacity(0.5))
}
Spacer()
}
.padding(16)
.background(sectionPanel)
}
.buttonStyle(.card)
}
}
}
}
private func playerSection(player: PlayerProfile) -> some View {
VStack(alignment: .leading, spacing: 18) {
Text("Player Profile")
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(.white)
if viewModel.isLoadingPlayer {
loadingPanel(title: "Loading player profile...")
} else {
VStack(alignment: .leading, spacing: 20) {
HStack(alignment: .top, spacing: 24) {
playerHeadshot(url: player.headshotURL, size: 112)
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 10) {
if let primaryNumber = player.primaryNumber {
Text("#\(primaryNumber)")
.font(.system(size: 15, weight: .black, design: .rounded))
.foregroundStyle(.white.opacity(0.7))
}
if let position = player.primaryPosition {
detailChip(position, color: .blue)
}
}
Text(player.fullName)
.font(.system(size: 34, weight: .bold, design: .rounded))
.foregroundStyle(.white)
VStack(alignment: .leading, spacing: 8) {
profileLine(label: "Age", value: player.currentAge.map(String.init))
profileLine(label: "Bats / Throws", value: [player.bats, player.throwsHand].compactMap { $0 }.joined(separator: " / "))
profileLine(label: "Height / Weight", value: [player.height, player.weight.map { "\($0) lbs" }].compactMap { $0 }.joined(separator: " / "))
profileLine(label: "Born", value: player.birthPlace)
profileLine(label: "Debut", value: player.debutDate)
}
}
Spacer()
}
if !player.statGroups.isEmpty {
HStack(alignment: .top, spacing: 16) {
ForEach(player.statGroups) { group in
VStack(alignment: .leading, spacing: 14) {
Text("\(group.title) \(player.seasonLabel)")
.font(.system(size: 18, weight: .bold, design: .rounded))
.foregroundStyle(.white)
VStack(alignment: .leading, spacing: 10) {
ForEach(group.items, id: \.label) { item in
HStack {
Text(item.label)
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.5))
Spacer()
Text(item.value)
.font(.system(size: 18, weight: .black, design: .rounded))
.foregroundStyle(.white)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(.white.opacity(0.05))
)
}
}
} else {
Text("No regular-season MLB stats available for \(player.seasonLabel).")
.font(.system(size: 18, weight: .semibold, design: .rounded))
.foregroundStyle(.white.opacity(0.7))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 4)
}
}
.padding(24)
.background(sectionPanel)
.contentShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.focusable(true)
}
}
}
private func playerHeadshot(url: URL, size: CGFloat) -> some View {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image
.resizable()
.scaledToFill()
default:
ZStack {
Circle()
.fill(.white.opacity(0.08))
Image(systemName: "person.fill")
.font(.system(size: size * 0.34, weight: .bold))
.foregroundStyle(.white.opacity(0.32))
}
}
}
.frame(width: size, height: size)
.clipShape(Circle())
.overlay(
Circle()
.stroke(.white.opacity(0.08), lineWidth: 1)
)
}
private func profileLine(label: String, value: String?) -> some View {
let displayValue: String
if let value, !value.isEmpty {
displayValue = value
} else {
displayValue = "Unavailable"
}
return HStack(spacing: 10) {
Text(label)
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.45))
.frame(width: 92, alignment: .leading)
Text(displayValue)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white.opacity(0.82))
}
}
private func messagePanel(_ text: String, tint: Color) -> some View {
Text(text)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(tint.opacity(0.95))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.background(sectionPanel)
}
private func detailChip(_ title: String, color: Color) -> some View {
Text(title)
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(color.opacity(0.95))
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(color.opacity(0.12))
.clipShape(Capsule())
}
private func infoPill(title: String, label: String, color: Color) -> some View {
VStack(spacing: 4) {
Text(title)
.font(.system(size: 22, weight: .black, design: .rounded))
.foregroundStyle(color)
.monospacedDigit()
Text(label)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white.opacity(0.5))
}
.padding(.horizontal, 18)
.padding(.vertical, 12)
.background(sectionPanel)
}
private func dateButton(title: String, systemImage: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 8) {
Image(systemName: systemImage)
.font(.system(size: 12, weight: .bold))
Text(title)
.font(.system(size: 14, weight: .semibold))
}
.foregroundStyle(.white.opacity(0.84))
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(.white.opacity(0.08))
.clipShape(Capsule())
}
.buttonStyle(.card)
}
private func loadingPanel(title: String) -> some View {
HStack {
Spacer()
ProgressView(title)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white.opacity(0.75))
Spacer()
}
.padding(.vertical, 34)
.background(sectionPanel)
}
private func scoreText(for game: StatsGame) -> String {
if game.isScheduled {
return game.startTime ?? "TBD"
}
let away = game.teams.away.score ?? 0
let home = game.teams.home.score ?? 0
return "\(away) - \(home)"
}
private func statusText(for game: StatsGame) -> String {
if game.isLive {
return game.linescore?.currentInningDisplay ?? "Live"
}
if game.isFinal {
return "Final"
}
return game.status.detailedState ?? "Scheduled"
}
private func statusColor(for game: StatsGame) -> Color {
if game.isLive {
return .red.opacity(0.9)
}
if game.isFinal {
return .white.opacity(0.72)
}
return .blue.opacity(0.9)
}
private func standingsSort(lhs: StandingsTeamRecord, rhs: StandingsTeamRecord) -> Bool {
Int(lhs.divisionRank ?? "99") ?? 99 < Int(rhs.divisionRank ?? "99") ?? 99
}
private var sectionPanel: some View {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(.black.opacity(0.22))
.overlay {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
}
}
private var screenBackground: some View {
ZStack {
LinearGradient(
colors: [
Color(red: 0.04, green: 0.05, blue: 0.09),
Color(red: 0.03, green: 0.06, blue: 0.1),
Color(red: 0.02, green: 0.03, blue: 0.06),
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Circle()
.fill(.blue.opacity(0.18))
.frame(width: 520, height: 520)
.blur(radius: 110)
.offset(x: -360, y: -260)
Circle()
.fill(.orange.opacity(0.16))
.frame(width: 560, height: 560)
.blur(radius: 120)
.offset(x: 420, y: -80)
}
}
}

View File

@@ -0,0 +1,294 @@
import SwiftUI
struct MLBNetworkSheet: View {
var onWatchFullScreen: () -> Void
@Environment(GamesViewModel.self) private var viewModel
@Environment(\.dismiss) private var dismiss
private var added: Bool {
viewModel.activeStreams.contains(where: { $0.id == "MLBN" })
}
private var canToggleIntoMultiview: Bool {
added || viewModel.activeStreams.count < 4
}
var body: some View {
ZStack {
sheetBackground
.ignoresSafeArea()
HStack(alignment: .top, spacing: 32) {
networkOverview
.frame(maxWidth: .infinity, alignment: .leading)
actionColumn
.frame(width: 360, alignment: .leading)
}
.padding(38)
.background(
RoundedRectangle(cornerRadius: 34, style: .continuous)
.fill(.black.opacity(0.46))
.overlay {
RoundedRectangle(cornerRadius: 34, style: .continuous)
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
}
)
.padding(.horizontal, 94)
.padding(.vertical, 70)
}
}
@ViewBuilder
private var networkOverview: some View {
VStack(alignment: .leading, spacing: 28) {
VStack(alignment: .leading, spacing: 18) {
HStack(spacing: 12) {
statusPill(title: "LIVE CHANNEL", color: .blue)
if added {
statusPill(title: "IN MULTI-VIEW", color: .green)
}
}
HStack(alignment: .center, spacing: 22) {
ZStack {
RoundedRectangle(cornerRadius: 26, style: .continuous)
.fill(.blue.opacity(0.16))
.frame(width: 110, height: 110)
Image(systemName: "tv.fill")
.font(.system(size: 46, weight: .bold))
.foregroundStyle(
LinearGradient(
colors: [.white, .blue.opacity(0.88)],
startPoint: .top,
endPoint: .bottom
)
)
}
VStack(alignment: .leading, spacing: 10) {
Text("MLB Network")
.font(.system(size: 46, weight: .black, design: .rounded))
.foregroundStyle(.white)
Text("Live coverage, studio hits, whip-around looks, analysis, and highlights all day.")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(.white.opacity(0.72))
.fixedSize(horizontal: false, vertical: true)
}
}
}
HStack(spacing: 14) {
featurePill(title: "Live Look-Ins", systemImage: "dot.radiowaves.left.and.right")
featurePill(title: "Studio Analysis", systemImage: "waveform.path.ecg.rectangle")
featurePill(title: "Highlights", systemImage: "sparkles.tv")
}
VStack(alignment: .leading, spacing: 14) {
Text("Best For")
.font(.system(size: 16, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.48))
.kerning(1.2)
VStack(alignment: .leading, spacing: 14) {
overviewLine(
icon: "rectangle.stack.badge.play.fill",
title: "Background baseball TV",
detail: "Keep a league-wide live channel running while you browse or build Multi-View."
)
overviewLine(
icon: "sportscourt.fill",
title: "Between-game coverage",
detail: "Use it when you want updates, reactions, and cut-ins instead of one locked game feed."
)
overviewLine(
icon: "square.grid.2x2.fill",
title: "Multi-View filler stream",
detail: "Drop it into an open tile to keep the board full when only one or two games matter."
)
}
}
.padding(24)
.background(panelBackground)
if !canToggleIntoMultiview {
Label(
"Multi-View is full. Remove a stream before adding MLB Network.",
systemImage: "rectangle.split.2x2.fill"
)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.orange)
.padding(.horizontal, 18)
.padding(.vertical, 16)
.background(panelBackground)
}
}
}
@ViewBuilder
private var actionColumn: some View {
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 8) {
Text("Actions")
.font(.system(size: 28, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text("Launch MLB Network full screen or place it in your Multi-View lineup.")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.white.opacity(0.68))
.fixedSize(horizontal: false, vertical: true)
}
.padding(24)
.background(panelBackground)
actionButton(
title: "Watch Full Screen",
subtitle: "Open the channel in the main player",
systemImage: "play.fill",
fill: .blue.opacity(0.18)
) {
onWatchFullScreen()
}
actionButton(
title: added ? "Remove From Multi-View" : "Add to Multi-View",
subtitle: added ? "Take MLB Network out of your active grid" : "Send MLB Network into an open tile",
systemImage: added ? "minus.circle.fill" : "plus.circle.fill",
fill: added ? .red.opacity(0.16) : .white.opacity(0.08),
foreground: added ? .red : .white,
disabled: !canToggleIntoMultiview
) {
if added {
viewModel.removeStream(id: "MLBN")
} else {
Task { await viewModel.addMLBNetwork() }
}
dismiss()
}
Spacer(minLength: 0)
}
}
@ViewBuilder
private func actionButton(
title: String,
subtitle: String,
systemImage: String,
fill: Color,
foreground: Color = .white,
disabled: Bool = false,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
HStack(alignment: .center, spacing: 16) {
Image(systemName: systemImage)
.font(.system(size: 22, weight: .bold))
.frame(width: 32)
VStack(alignment: .leading, spacing: 5) {
Text(title)
.font(.system(size: 21, weight: .bold, design: .rounded))
Text(subtitle)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(foreground.opacity(0.68))
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
}
.foregroundStyle(foreground)
.frame(maxWidth: .infinity, minHeight: 88, alignment: .leading)
.padding(.horizontal, 22)
.background(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(fill)
)
}
.buttonStyle(.card)
.disabled(disabled)
}
@ViewBuilder
private func overviewLine(icon: String, title: String, detail: String) -> some View {
HStack(alignment: .top, spacing: 14) {
Image(systemName: icon)
.font(.system(size: 18, weight: .bold))
.foregroundStyle(.blue.opacity(0.9))
.frame(width: 26)
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.system(size: 18, weight: .bold))
.foregroundStyle(.white)
Text(detail)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.66))
.fixedSize(horizontal: false, vertical: true)
}
}
}
@ViewBuilder
private func statusPill(title: String, color: Color) -> some View {
Text(title)
.font(.system(size: 13, weight: .black, design: .rounded))
.foregroundStyle(color)
.padding(.horizontal, 13)
.padding(.vertical, 9)
.background(color.opacity(0.14))
.clipShape(Capsule())
}
@ViewBuilder
private func featurePill(title: String, systemImage: String) -> some View {
Label(title, systemImage: systemImage)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white.opacity(0.84))
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(.white.opacity(0.06))
.clipShape(Capsule())
}
@ViewBuilder
private var panelBackground: some View {
RoundedRectangle(cornerRadius: 28, style: .continuous)
.fill(.black.opacity(0.24))
.overlay {
RoundedRectangle(cornerRadius: 28, style: .continuous)
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
}
}
@ViewBuilder
private var sheetBackground: some View {
ZStack {
LinearGradient(
colors: [
Color(red: 0.04, green: 0.05, blue: 0.08),
Color(red: 0.05, green: 0.07, blue: 0.11)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Circle()
.fill(.blue.opacity(0.22))
.frame(width: 540, height: 540)
.blur(radius: 100)
.offset(x: -280, y: -210)
Circle()
.fill(.cyan.opacity(0.14))
.frame(width: 460, height: 460)
.blur(radius: 100)
.offset(x: 350, y: 180)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,73 @@
import SwiftUI
struct SettingsView: View {
@Environment(GamesViewModel.self) private var viewModel
private let resolutions = [
("best", "Best Quality"),
("1080p60", "1080p 60fps"),
("720p60", "720p 60fps"),
("540p", "540p"),
("adaptive", "Adaptive"),
]
var body: some View {
@Bindable var vm = viewModel
NavigationStack {
Form {
Section("Server") {
LabeledContent("URL", value: viewModel.serverBaseURL)
}
Section("Default Quality") {
ForEach(resolutions, id: \.0) { res in
Button {
vm.defaultResolution = res.0
} label: {
HStack {
Text(res.1)
Spacer()
if viewModel.defaultResolution == res.0 {
Image(systemName: "checkmark")
.foregroundStyle(.blue)
}
}
}
}
}
Section("Active Streams (\(viewModel.activeStreams.count)/4)") {
if viewModel.activeStreams.isEmpty {
Text("No active streams")
.foregroundStyle(.secondary)
} else {
ForEach(viewModel.activeStreams) { stream in
HStack {
Text(stream.label)
.fontWeight(.bold)
Text(stream.game.displayTitle)
.foregroundStyle(.secondary)
Spacer()
Button(role: .destructive) {
viewModel.removeStream(id: stream.id)
} label: {
Image(systemName: "trash")
}
}
}
Button("Clear All Streams", role: .destructive) {
viewModel.clearAllStreams()
}
}
}
Section("About") {
LabeledContent("Version", value: "1.0")
LabeledContent("Server", value: "mlbserver")
}
}
.navigationTitle("Settings")
}
}
}

View File

@@ -0,0 +1,414 @@
import AVFoundation
import AVKit
import OSLog
import SwiftUI
private let singleStreamLogger = Logger(subsystem: "com.treyt.mlbTVOS", category: "SingleStreamPlayer")
private func logSingleStream(_ message: String) {
singleStreamLogger.debug("\(message, privacy: .public)")
print("[SingleStream] \(message)")
}
private func singleStreamDebugURLDescription(_ url: URL) -> String {
var host = url.host ?? "unknown-host"
if let port = url.port {
host += ":\(port)"
}
let queryKeys = URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?
.map(\.name) ?? []
let querySuffix = queryKeys.isEmpty ? "" : "?\(queryKeys.joined(separator: "&"))"
return "\(url.scheme ?? "unknown")://\(host)\(url.path)\(querySuffix)"
}
private func singleStreamStatusDescription(_ status: AVPlayer.Status) -> String {
switch status {
case .unknown: "unknown"
case .readyToPlay: "readyToPlay"
case .failed: "failed"
@unknown default: "unknown-future"
}
}
private func singleStreamItemStatusDescription(_ status: AVPlayerItem.Status) -> String {
switch status {
case .unknown: "unknown"
case .readyToPlay: "readyToPlay"
case .failed: "failed"
@unknown default: "unknown-future"
}
}
private func singleStreamTimeControlDescription(_ status: AVPlayer.TimeControlStatus) -> String {
switch status {
case .paused: "paused"
case .waitingToPlayAtSpecifiedRate: "waitingToPlayAtSpecifiedRate"
case .playing: "playing"
@unknown default: "unknown-future"
}
}
struct SingleStreamPlaybackScreen: View {
let resolveURL: @Sendable () async -> URL?
let tickerGames: [Game]
var body: some View {
ZStack(alignment: .bottom) {
SingleStreamPlayerView(resolveURL: resolveURL)
.ignoresSafeArea()
SingleStreamScoreStripView(games: tickerGames)
.allowsHitTesting(false)
.padding(.horizontal, 18)
.padding(.bottom, 14)
}
.ignoresSafeArea()
.onAppear {
logSingleStream("SingleStreamPlaybackScreen appeared tickerGames=\(tickerGames.count) tickerMode=marqueeOverlay")
}
.onDisappear {
logSingleStream("SingleStreamPlaybackScreen disappeared")
}
}
}
private struct SingleStreamScoreStripView: View {
let games: [Game]
private var summaries: [String] {
games.map { game in
let matchup = "\(game.awayTeam.code) \(game.awayTeam.score.map(String.init) ?? "-") - \(game.homeTeam.code) \(game.homeTeam.score.map(String.init) ?? "-")"
if game.isLive, let linescore = game.linescore {
let inning = linescore.currentInningOrdinal ?? game.currentInningDisplay ?? "LIVE"
let state = (linescore.inningState ?? linescore.inningHalf ?? "Live").uppercased()
let outs = linescore.outs ?? 0
return "\(matchup) \(state) \(inning.uppercased())\(outs) OUT\(outs == 1 ? "" : "S")"
}
if game.isFinal {
return "\(matchup) FINAL"
}
if let startTime = game.startTime {
return "\(matchup) \(startTime.uppercased())"
}
return "\(matchup) \(game.status.label.uppercased())"
}
}
private var stripText: String {
summaries.joined(separator: " | ")
}
var body: some View {
if !games.isEmpty {
SingleStreamMarqueeView(text: stripText)
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 22)
.padding(.horizontal, 18)
.padding(.vertical, 14)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(.black.opacity(0.72))
.overlay {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
}
)
.accessibilityHidden(true)
}
}
}
private struct SingleStreamMarqueeView: UIViewRepresentable {
let text: String
func makeUIView(context: Context) -> SingleStreamMarqueeContainerView {
let view = SingleStreamMarqueeContainerView()
view.setText(text)
return view
}
func updateUIView(_ uiView: SingleStreamMarqueeContainerView, context: Context) {
uiView.setText(text)
}
}
private final class SingleStreamMarqueeContainerView: UIView {
private let trackView = UIView()
private let primaryLabel = UILabel()
private let secondaryLabel = UILabel()
private let spacing: CGFloat = 48
private let pointsPerSecond: CGFloat = 64
private var currentText = ""
private var previousBoundsWidth: CGFloat = 0
private var previousContentWidth: CGFloat = 0
override init(frame: CGRect) {
super.init(frame: frame)
clipsToBounds = true
isUserInteractionEnabled = false
let baseFont = UIFont.systemFont(ofSize: 15, weight: .bold)
let roundedFont = UIFont(descriptor: baseFont.fontDescriptor.withDesign(.rounded) ?? baseFont.fontDescriptor, size: 15)
[primaryLabel, secondaryLabel].forEach { label in
label.font = roundedFont
label.textColor = UIColor.white.withAlphaComponent(0.92)
label.numberOfLines = 1
label.lineBreakMode = .byClipping
trackView.addSubview(label)
}
addSubview(trackView)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setText(_ text: String) {
guard currentText != text else { return }
currentText = text
primaryLabel.text = text
secondaryLabel.text = text
previousContentWidth = 0
setNeedsLayout()
}
override func layoutSubviews() {
super.layoutSubviews()
guard bounds.width > 0, bounds.height > 0 else { return }
let contentWidth = ceil(primaryLabel.sizeThatFits(CGSize(width: .greatestFiniteMagnitude, height: bounds.height)).width)
let contentHeight = bounds.height
primaryLabel.frame = CGRect(x: 0, y: 0, width: contentWidth, height: contentHeight)
secondaryLabel.frame = CGRect(x: contentWidth + spacing, y: 0, width: contentWidth, height: contentHeight)
let cycleWidth = contentWidth + spacing
if contentWidth <= bounds.width {
trackView.layer.removeAllAnimations()
secondaryLabel.isHidden = true
trackView.frame = CGRect(x: 0, y: 0, width: bounds.width, height: contentHeight)
primaryLabel.frame = CGRect(x: 0, y: 0, width: bounds.width, height: contentHeight)
primaryLabel.textAlignment = .left
previousBoundsWidth = bounds.width
previousContentWidth = contentWidth
return
}
primaryLabel.textAlignment = .left
secondaryLabel.isHidden = false
trackView.frame = CGRect(x: 0, y: 0, width: contentWidth * 2 + spacing, height: contentHeight)
let shouldRestart = abs(previousBoundsWidth - bounds.width) > 0.5
|| abs(previousContentWidth - contentWidth) > 0.5
|| trackView.layer.animation(forKey: "singleStreamMarquee") == nil
previousBoundsWidth = bounds.width
previousContentWidth = contentWidth
guard shouldRestart else { return }
trackView.layer.removeAllAnimations()
trackView.transform = .identity
let animation = CABasicAnimation(keyPath: "transform.translation.x")
animation.fromValue = 0
animation.toValue = -cycleWidth
animation.duration = Double(cycleWidth / pointsPerSecond)
animation.repeatCount = .infinity
animation.timingFunction = CAMediaTimingFunction(name: .linear)
animation.isRemovedOnCompletion = false
trackView.layer.add(animation, forKey: "singleStreamMarquee")
}
}
/// Full-screen player using AVPlayerViewController for PiP support on tvOS.
struct SingleStreamPlayerView: UIViewControllerRepresentable {
let resolveURL: @Sendable () async -> URL?
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeUIViewController(context: Context) -> AVPlayerViewController {
logSingleStream("makeUIViewController start")
let controller = AVPlayerViewController()
controller.allowsPictureInPicturePlayback = true
controller.showsPlaybackControls = true
logSingleStream("AVPlayerViewController configured without contentOverlayView ticker")
Task { @MainActor in
let resolveStartedAt = Date()
logSingleStream("Starting stream URL resolution")
guard let url = await resolveURL() else {
logSingleStream("resolveURL returned nil; aborting player startup")
return
}
let resolveElapsedMs = Int(Date().timeIntervalSince(resolveStartedAt) * 1000)
logSingleStream("Resolved stream URL elapsedMs=\(resolveElapsedMs) url=\(singleStreamDebugURLDescription(url))")
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
try AVAudioSession.sharedInstance().setActive(true)
logSingleStream("AVAudioSession configured for playback")
} catch {
logSingleStream("AVAudioSession configuration failed error=\(error.localizedDescription)")
}
let playerItem = AVPlayerItem(url: url)
playerItem.preferredForwardBufferDuration = 2
let player = AVPlayer(playerItem: playerItem)
player.automaticallyWaitsToMinimizeStalling = false
logSingleStream("Configured player for fast start preferredForwardBufferDuration=2 automaticallyWaitsToMinimizeStalling=false")
context.coordinator.attachDebugObservers(to: player, url: url)
controller.player = player
logSingleStream("AVPlayer assigned to controller; calling playImmediately(atRate: 1.0)")
player.playImmediately(atRate: 1.0)
}
return controller
}
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {}
static func dismantleUIViewController(_ uiViewController: AVPlayerViewController, coordinator: Coordinator) {
logSingleStream("dismantleUIViewController start")
coordinator.clearDebugObservers()
uiViewController.player?.pause()
uiViewController.player = nil
logSingleStream("dismantleUIViewController complete")
}
final class Coordinator: NSObject {
private var playerObservations: [NSKeyValueObservation] = []
private var notificationTokens: [NSObjectProtocol] = []
func attachDebugObservers(to player: AVPlayer, url: URL) {
clearDebugObservers()
logSingleStream("Attaching AVPlayer observers url=\(singleStreamDebugURLDescription(url))")
playerObservations.append(
player.observe(\.status, options: [.initial, .new]) { player, _ in
logSingleStream("Player status changed status=\(singleStreamStatusDescription(player.status)) error=\(player.error?.localizedDescription ?? "nil")")
}
)
playerObservations.append(
player.observe(\.timeControlStatus, options: [.initial, .new]) { player, _ in
let reason = player.reasonForWaitingToPlay?.rawValue ?? "nil"
logSingleStream("Player timeControlStatus=\(singleStreamTimeControlDescription(player.timeControlStatus)) reasonForWaiting=\(reason)")
}
)
playerObservations.append(
player.observe(\.reasonForWaitingToPlay, options: [.initial, .new]) { player, _ in
logSingleStream("Player reasonForWaitingToPlay changed value=\(player.reasonForWaitingToPlay?.rawValue ?? "nil")")
}
)
guard let item = player.currentItem else {
logSingleStream("Player currentItem missing immediately after creation")
return
}
playerObservations.append(
item.observe(\.status, options: [.initial, .new]) { item, _ in
logSingleStream("PlayerItem status changed status=\(singleStreamItemStatusDescription(item.status)) error=\(item.error?.localizedDescription ?? "nil")")
}
)
playerObservations.append(
item.observe(\.isPlaybackBufferEmpty, options: [.initial, .new]) { item, _ in
logSingleStream("PlayerItem isPlaybackBufferEmpty=\(item.isPlaybackBufferEmpty)")
}
)
playerObservations.append(
item.observe(\.isPlaybackLikelyToKeepUp, options: [.initial, .new]) { item, _ in
logSingleStream("PlayerItem isPlaybackLikelyToKeepUp=\(item.isPlaybackLikelyToKeepUp)")
}
)
playerObservations.append(
item.observe(\.isPlaybackBufferFull, options: [.initial, .new]) { item, _ in
logSingleStream("PlayerItem isPlaybackBufferFull=\(item.isPlaybackBufferFull)")
}
)
notificationTokens.append(
NotificationCenter.default.addObserver(
forName: .AVPlayerItemPlaybackStalled,
object: item,
queue: .main
) { _ in
logSingleStream("Notification AVPlayerItemPlaybackStalled")
}
)
notificationTokens.append(
NotificationCenter.default.addObserver(
forName: .AVPlayerItemFailedToPlayToEndTime,
object: item,
queue: .main
) { notification in
let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? NSError
logSingleStream("Notification AVPlayerItemFailedToPlayToEndTime error=\(error?.localizedDescription ?? "nil")")
}
)
notificationTokens.append(
NotificationCenter.default.addObserver(
forName: .AVPlayerItemNewErrorLogEntry,
object: item,
queue: .main
) { _ in
let event = item.errorLog()?.events.last
logSingleStream("Notification AVPlayerItemNewErrorLogEntry domain=\(event?.errorDomain ?? "nil") comment=\(event?.errorComment ?? "nil") statusCode=\(event?.errorStatusCode ?? 0)")
}
)
notificationTokens.append(
NotificationCenter.default.addObserver(
forName: .AVPlayerItemNewAccessLogEntry,
object: item,
queue: .main
) { _ in
if let event = item.accessLog()?.events.last {
logSingleStream(
"Notification AVPlayerItemNewAccessLogEntry indicatedBitrate=\(Int(event.indicatedBitrate)) observedBitrate=\(Int(event.observedBitrate)) segmentsDownloaded=\(event.segmentsDownloadedDuration)"
)
} else {
logSingleStream("Notification AVPlayerItemNewAccessLogEntry with no access log event")
}
}
)
}
func clearDebugObservers() {
playerObservations.removeAll()
for token in notificationTokens {
NotificationCenter.default.removeObserver(token)
}
notificationTokens.removeAll()
}
deinit {
clearDebugObservers()
}
}
}

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.avfoundation.multitasking-camera-access</key>
<true/>
</dict>
</plist>

28
mlbTVOS/mlbTVOSApp.swift Normal file
View File

@@ -0,0 +1,28 @@
import AVFoundation
import SwiftUI
@main
struct mlbTVOSApp: App {
@State private var viewModel = GamesViewModel()
init() {
configureAudioSession()
}
var body: some Scene {
WindowGroup {
ContentView()
.environment(viewModel)
}
}
private func configureAudioSession() {
// Start with .ambient so we don't interrupt other audio on launch
// Switch to .playback when user starts a stream
do {
try AVAudioSession.sharedInstance().setCategory(.ambient)
} catch {
print("Failed to set audio session: \(error)")
}
}
}