Initial commit
This commit is contained in:
20
mlbTVOS/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
20
mlbTVOS/Assets.xcassets/AccentColor.colorset/Contents.json
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images" : [ { "idiom" : "tv", "scale" : "1x" } ],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"layers" : [
|
||||
{ "filename" : "Front.imagestacklayer" },
|
||||
{ "filename" : "Middle.imagestacklayer" },
|
||||
{ "filename" : "Back.imagestacklayer" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images" : [ { "idiom" : "tv", "scale" : "1x" } ],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images" : [ { "idiom" : "tv", "scale" : "1x" } ],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
6
mlbTVOS/Assets.xcassets/Contents.json
Normal file
6
mlbTVOS/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
27
mlbTVOS/Info.plist
Normal file
27
mlbTVOS/Info.plist
Normal 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
200
mlbTVOS/Models/Game.swift
Normal 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
|
||||
}
|
||||
}
|
||||
62
mlbTVOS/Models/TeamAssets.swift
Normal file
62
mlbTVOS/Models/TeamAssets.swift
Normal 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")!
|
||||
}
|
||||
}
|
||||
336
mlbTVOS/Services/MLBServerAPI.swift
Normal file
336
mlbTVOS/Services/MLBServerAPI.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
1056
mlbTVOS/Services/MLBStatsAPI.swift
Normal file
1056
mlbTVOS/Services/MLBStatsAPI.swift
Normal file
File diff suppressed because it is too large
Load Diff
45
mlbTVOS/ViewModels/GameCenterViewModel.swift
Normal file
45
mlbTVOS/ViewModels/GameCenterViewModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
640
mlbTVOS/ViewModels/GamesViewModel.swift
Normal file
640
mlbTVOS/ViewModels/GamesViewModel.swift
Normal 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
|
||||
}
|
||||
132
mlbTVOS/ViewModels/LeagueCenterViewModel.swift
Normal file
132
mlbTVOS/ViewModels/LeagueCenterViewModel.swift
Normal 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
|
||||
}()
|
||||
}
|
||||
108
mlbTVOS/Views/Components/LinescoreView.swift
Normal file
108
mlbTVOS/Views/Components/LinescoreView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
66
mlbTVOS/Views/Components/LiveIndicator.swift
Normal file
66
mlbTVOS/Views/Components/LiveIndicator.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
53
mlbTVOS/Views/Components/PitcherHeadshotView.swift
Normal file
53
mlbTVOS/Views/Components/PitcherHeadshotView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
67
mlbTVOS/Views/Components/ScoreOverlayView.swift
Normal file
67
mlbTVOS/Views/Components/ScoreOverlayView.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
167
mlbTVOS/Views/Components/ScoresTickerView.swift
Normal file
167
mlbTVOS/Views/Components/ScoresTickerView.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
38
mlbTVOS/Views/Components/TeamLogoView.swift
Normal file
38
mlbTVOS/Views/Components/TeamLogoView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
33
mlbTVOS/Views/ContentView.swift
Normal file
33
mlbTVOS/Views/ContentView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
340
mlbTVOS/Views/DashboardView.swift
Normal file
340
mlbTVOS/Views/DashboardView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
584
mlbTVOS/Views/FeaturedGameCard.swift
Normal file
584
mlbTVOS/Views/FeaturedGameCard.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
302
mlbTVOS/Views/GameCardView.swift
Normal file
302
mlbTVOS/Views/GameCardView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
375
mlbTVOS/Views/GameCenterView.swift
Normal file
375
mlbTVOS/Views/GameCenterView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
635
mlbTVOS/Views/GameListView.swift
Normal file
635
mlbTVOS/Views/GameListView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
665
mlbTVOS/Views/LeagueCenterView.swift
Normal file
665
mlbTVOS/Views/LeagueCenterView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
294
mlbTVOS/Views/MLBNetworkSheet.swift
Normal file
294
mlbTVOS/Views/MLBNetworkSheet.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
1257
mlbTVOS/Views/MultiStreamView.swift
Normal file
1257
mlbTVOS/Views/MultiStreamView.swift
Normal file
File diff suppressed because it is too large
Load Diff
73
mlbTVOS/Views/SettingsView.swift
Normal file
73
mlbTVOS/Views/SettingsView.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
414
mlbTVOS/Views/SingleStreamPlayerView.swift
Normal file
414
mlbTVOS/Views/SingleStreamPlayerView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
8
mlbTVOS/mlbTVOS.entitlements
Normal file
8
mlbTVOS/mlbTVOS.entitlements
Normal 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
28
mlbTVOS/mlbTVOSApp.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user