Compare commits
13 Commits
main
...
feature/ui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
829380574c | ||
|
|
7568a3b9d3 | ||
|
|
f59df9b04d | ||
|
|
310d857c7f | ||
|
|
b3a074e362 | ||
|
|
588b42ffed | ||
|
|
870fbcb844 | ||
|
|
346557af88 | ||
|
|
65ad41840f | ||
|
|
69d84fd09b | ||
|
|
39092e5f3d | ||
|
|
cd605d889d | ||
|
|
b5daddefd3 |
@@ -4,15 +4,17 @@ private enum mlbIOSSection: String, CaseIterable, Identifiable {
|
||||
case games
|
||||
case league
|
||||
case multiView
|
||||
case feed
|
||||
case settings
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .games: "Games"
|
||||
case .league: "League"
|
||||
case .games: "Today"
|
||||
case .league: "Intel"
|
||||
case .multiView: "Multi-View"
|
||||
case .feed: "Feed"
|
||||
case .settings: "Settings"
|
||||
}
|
||||
}
|
||||
@@ -20,8 +22,9 @@ private enum mlbIOSSection: String, CaseIterable, Identifiable {
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .games: "sportscourt.fill"
|
||||
case .league: "list.bullet.rectangle.portrait.fill"
|
||||
case .league: "chart.bar.fill"
|
||||
case .multiView: "rectangle.split.2x2.fill"
|
||||
case .feed: "newspaper.fill"
|
||||
case .settings: "gearshape.fill"
|
||||
}
|
||||
}
|
||||
@@ -58,15 +61,18 @@ struct mlbIOSRootView: View {
|
||||
|
||||
private var compactTabs: some View {
|
||||
TabView {
|
||||
Tab("Games", systemImage: "sportscourt.fill") {
|
||||
Tab("Today", systemImage: "sportscourt.fill") {
|
||||
DashboardView()
|
||||
}
|
||||
Tab("League", systemImage: "list.bullet.rectangle.portrait.fill") {
|
||||
Tab("Intel", systemImage: "chart.bar.fill") {
|
||||
LeagueCenterView()
|
||||
}
|
||||
Tab(multiViewTitle, systemImage: "rectangle.split.2x2.fill") {
|
||||
MultiStreamView()
|
||||
}
|
||||
Tab("Feed", systemImage: "newspaper.fill") {
|
||||
FeedView()
|
||||
}
|
||||
Tab("Settings", systemImage: "gearshape.fill") {
|
||||
SettingsView()
|
||||
}
|
||||
@@ -99,6 +105,8 @@ struct mlbIOSRootView: View {
|
||||
LeagueCenterView()
|
||||
case .multiView:
|
||||
MultiStreamView()
|
||||
case .feed:
|
||||
FeedView()
|
||||
case .settings:
|
||||
SettingsView()
|
||||
}
|
||||
|
||||
@@ -73,6 +73,32 @@
|
||||
FAFA9B29273C4D8A54CD5916 /* FeaturedGameCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102E5E8674E188A04637D3EB /* FeaturedGameCard.swift */; };
|
||||
FD31074216AC0115B3F1C057 /* mlbTVOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45142562E644AF04F7719083 /* mlbTVOSApp.swift */; };
|
||||
FD9322BED3A2B827CB021163 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DE9E708FA75ABB9F49D2392 /* SettingsView.swift */; };
|
||||
37407BFDD8DE522452EF59A9 /* DesignSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60A41C116893411524EA91B1 /* DesignSystem.swift */; };
|
||||
E1C4B52624855C00D4248CF7 /* DesignSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60A41C116893411524EA91B1 /* DesignSystem.swift */; };
|
||||
1709C71D035868F36D786A6C /* DataPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2BCF01456B595D46DBEDFD4 /* DataPanel.swift */; };
|
||||
9F57F341DE0236C88F5D9AB4 /* DataPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2BCF01456B595D46DBEDFD4 /* DataPanel.swift */; };
|
||||
3A707B3D07159E3794810DA6 /* MiniLinescoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D48B64BA00C6B2DF270EF92 /* MiniLinescoreView.swift */; };
|
||||
394BFB3C349C866C45652736 /* MiniLinescoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D48B64BA00C6B2DF270EF92 /* MiniLinescoreView.swift */; };
|
||||
E1EE34169F3361C53FB042C2 /* DiamondView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92817781B4EB8AC773F94A1B /* DiamondView.swift */; };
|
||||
C9BAFB42FB62DB304C446C5F /* DiamondView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92817781B4EB8AC773F94A1B /* DiamondView.swift */; };
|
||||
C3245C14690AF4CE8882130E /* LiveSituationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0502ADB4033DE026238AAE01 /* LiveSituationBar.swift */; };
|
||||
492CF47C161A3F31DED29740 /* LiveSituationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0502ADB4033DE026238AAE01 /* LiveSituationBar.swift */; };
|
||||
312CC5DD0539384FDF65BEB7 /* WinProbabilityChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26F2E60D9F3315064FC7B793 /* WinProbabilityChartView.swift */; };
|
||||
4DDB697A8363736A0E0C345E /* WinProbabilityChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26F2E60D9F3315064FC7B793 /* WinProbabilityChartView.swift */; };
|
||||
F17BF3697CF0008A9417FE08 /* PitchArsenalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A86C820F4ABDD390A1E3F1FA /* PitchArsenalView.swift */; };
|
||||
026E8F398909A35C0DD41D16 /* PitchArsenalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A86C820F4ABDD390A1E3F1FA /* PitchArsenalView.swift */; };
|
||||
CB3E29DA692C18137C037F93 /* MLBWebDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EB8E3E205222A55700CF24C /* MLBWebDataService.swift */; };
|
||||
E1DA8514E6177069C04B0416 /* MLBWebDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EB8E3E205222A55700CF24C /* MLBWebDataService.swift */; };
|
||||
C7C0D9F7B6A3EBD4D9F4B973 /* FeedViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C920FA380D9DDDED113068E3 /* FeedViewModel.swift */; };
|
||||
560051D308A26379374EC9C4 /* FeedViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C920FA380D9DDDED113068E3 /* FeedViewModel.swift */; };
|
||||
5802E2BE747B7B59F00AE1E7 /* FeedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F68D38B739C81D7747CC412 /* FeedItemView.swift */; };
|
||||
9BF7942D0252B0EB87DE58B3 /* FeedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F68D38B739C81D7747CC412 /* FeedItemView.swift */; };
|
||||
C5057ECFA9FCBFFC6A8A7E15 /* FeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981C95E73FFDAFE2E26B06C2 /* FeedView.swift */; };
|
||||
6581BE8213BB231538C9EB8E /* FeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981C95E73FFDAFE2E26B06C2 /* FeedView.swift */; };
|
||||
DB34919E15ABAA1B40DF9A5B /* LeaderboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 726AEA37AC2C75C017A888C1 /* LeaderboardView.swift */; };
|
||||
94D2242FE48B5FECE15116FF /* LeaderboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 726AEA37AC2C75C017A888C1 /* LeaderboardView.swift */; };
|
||||
FA82D0AAB5FE0222ECF35105 /* CategoryPillBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5381B6E058D1194E844106D /* CategoryPillBar.swift */; };
|
||||
D3470E2BF99541CF2A3ADC2A /* CategoryPillBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5381B6E058D1194E844106D /* CategoryPillBar.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -129,6 +155,19 @@
|
||||
E80EF8B01452220FA6BCD9B2 /* GameCenterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCenterView.swift; sourceTree = "<group>"; };
|
||||
F5DA48863884BB6B4983BEE6 /* GameCenterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCenterViewModel.swift; sourceTree = "<group>"; };
|
||||
FA4AF68CDF4FC8D07194B4D0 /* SingleStreamPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleStreamPlayerView.swift; sourceTree = "<group>"; };
|
||||
60A41C116893411524EA91B1 /* DesignSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignSystem.swift; sourceTree = "<group>"; };
|
||||
C2BCF01456B595D46DBEDFD4 /* DataPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataPanel.swift; sourceTree = "<group>"; };
|
||||
1D48B64BA00C6B2DF270EF92 /* MiniLinescoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiniLinescoreView.swift; sourceTree = "<group>"; };
|
||||
92817781B4EB8AC773F94A1B /* DiamondView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiamondView.swift; sourceTree = "<group>"; };
|
||||
0502ADB4033DE026238AAE01 /* LiveSituationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveSituationBar.swift; sourceTree = "<group>"; };
|
||||
26F2E60D9F3315064FC7B793 /* WinProbabilityChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WinProbabilityChartView.swift; sourceTree = "<group>"; };
|
||||
A86C820F4ABDD390A1E3F1FA /* PitchArsenalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PitchArsenalView.swift; sourceTree = "<group>"; };
|
||||
7EB8E3E205222A55700CF24C /* MLBWebDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLBWebDataService.swift; sourceTree = "<group>"; };
|
||||
C920FA380D9DDDED113068E3 /* FeedViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedViewModel.swift; sourceTree = "<group>"; };
|
||||
9F68D38B739C81D7747CC412 /* FeedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemView.swift; sourceTree = "<group>"; };
|
||||
981C95E73FFDAFE2E26B06C2 /* FeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedView.swift; sourceTree = "<group>"; };
|
||||
726AEA37AC2C75C017A888C1 /* LeaderboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaderboardView.swift; sourceTree = "<group>"; };
|
||||
D5381B6E058D1194E844106D /* CategoryPillBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPillBar.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@@ -137,6 +176,7 @@
|
||||
children = (
|
||||
F5DA48863884BB6B4983BEE6 /* GameCenterViewModel.swift */,
|
||||
9BD2DEA6BBA55E86B2555099 /* GamesViewModel.swift */,
|
||||
C920FA380D9DDDED113068E3 /* FeedViewModel.swift */,
|
||||
7E9E572D932D96FE2C5BD7A4 /* LeagueCenterViewModel.swift */,
|
||||
);
|
||||
path = ViewModels;
|
||||
@@ -170,6 +210,16 @@
|
||||
children = (
|
||||
C3D1C29BF9DF2B8F811368BC /* AtBatTimelineView.swift */,
|
||||
327A33F2676712C2B5B4AF2F /* LinescoreView.swift */,
|
||||
D5381B6E058D1194E844106D /* CategoryPillBar.swift */,
|
||||
726AEA37AC2C75C017A888C1 /* LeaderboardView.swift */,
|
||||
9F68D38B739C81D7747CC412 /* FeedItemView.swift */,
|
||||
A86C820F4ABDD390A1E3F1FA /* PitchArsenalView.swift */,
|
||||
26F2E60D9F3315064FC7B793 /* WinProbabilityChartView.swift */,
|
||||
0502ADB4033DE026238AAE01 /* LiveSituationBar.swift */,
|
||||
92817781B4EB8AC773F94A1B /* DiamondView.swift */,
|
||||
1D48B64BA00C6B2DF270EF92 /* MiniLinescoreView.swift */,
|
||||
C2BCF01456B595D46DBEDFD4 /* DataPanel.swift */,
|
||||
60A41C116893411524EA91B1 /* DesignSystem.swift */,
|
||||
81584770090BAFBEF3ABE6FD /* LiveIndicator.swift */,
|
||||
C6E303B753C604504C2762C7 /* PitcherHeadshotView.swift */,
|
||||
7F13A88254E53AF5A4AAA2AA /* PitchSequenceView.swift */,
|
||||
@@ -227,6 +277,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
70E610D24BA83FB2F7490B00 /* MLBServerAPI.swift */,
|
||||
7EB8E3E205222A55700CF24C /* MLBWebDataService.swift */,
|
||||
6067FE4F340FC0DCCFA62C67 /* MLBStatsAPI.swift */,
|
||||
C3A003EA560655E4B2200DBE /* VideoShuffle.swift */,
|
||||
);
|
||||
@@ -238,6 +289,7 @@
|
||||
children = (
|
||||
3FB99FBEDA096B95C3D2E5B4 /* ContentView.swift */,
|
||||
5EA4C33F029665B80F1A6B6D /* DashboardView.swift */,
|
||||
981C95E73FFDAFE2E26B06C2 /* FeedView.swift */,
|
||||
102E5E8674E188A04637D3EB /* FeaturedGameCard.swift */,
|
||||
E21E3115B8A9B4C798ECE828 /* GameCardView.swift */,
|
||||
E80EF8B01452220FA6BCD9B2 /* GameCenterView.swift */,
|
||||
@@ -392,6 +444,19 @@
|
||||
8649D4F513A663D2493D91C9 /* LeagueCenterView.swift in Sources */,
|
||||
E42B27D852AB037246167C23 /* LeagueCenterViewModel.swift in Sources */,
|
||||
4CB947BC5B5ECF83EF84F7E4 /* LinescoreView.swift in Sources */,
|
||||
D3470E2BF99541CF2A3ADC2A /* CategoryPillBar.swift in Sources */,
|
||||
94D2242FE48B5FECE15116FF /* LeaderboardView.swift in Sources */,
|
||||
6581BE8213BB231538C9EB8E /* FeedView.swift in Sources */,
|
||||
9BF7942D0252B0EB87DE58B3 /* FeedItemView.swift in Sources */,
|
||||
560051D308A26379374EC9C4 /* FeedViewModel.swift in Sources */,
|
||||
E1DA8514E6177069C04B0416 /* MLBWebDataService.swift in Sources */,
|
||||
026E8F398909A35C0DD41D16 /* PitchArsenalView.swift in Sources */,
|
||||
4DDB697A8363736A0E0C345E /* WinProbabilityChartView.swift in Sources */,
|
||||
492CF47C161A3F31DED29740 /* LiveSituationBar.swift in Sources */,
|
||||
C9BAFB42FB62DB304C446C5F /* DiamondView.swift in Sources */,
|
||||
394BFB3C349C866C45652736 /* MiniLinescoreView.swift in Sources */,
|
||||
9F57F341DE0236C88F5D9AB4 /* DataPanel.swift in Sources */,
|
||||
E1C4B52624855C00D4248CF7 /* DesignSystem.swift in Sources */,
|
||||
0653C2F5CC55F4234E8950E5 /* LiveIndicator.swift in Sources */,
|
||||
D864A2844FD222A04B48F6EF /* MLBNetworkSheet.swift in Sources */,
|
||||
8801888DFD48351B1D344D45 /* MLBServerAPI.swift in Sources */,
|
||||
@@ -439,6 +504,19 @@
|
||||
07BBD0E7B7F122213708A406 /* LeagueCenterView.swift in Sources */,
|
||||
B0A9FFD5C20A80E16532CC91 /* LeagueCenterViewModel.swift in Sources */,
|
||||
051C3D14A06061D44E325FCC /* LinescoreView.swift in Sources */,
|
||||
FA82D0AAB5FE0222ECF35105 /* CategoryPillBar.swift in Sources */,
|
||||
DB34919E15ABAA1B40DF9A5B /* LeaderboardView.swift in Sources */,
|
||||
C5057ECFA9FCBFFC6A8A7E15 /* FeedView.swift in Sources */,
|
||||
5802E2BE747B7B59F00AE1E7 /* FeedItemView.swift in Sources */,
|
||||
C7C0D9F7B6A3EBD4D9F4B973 /* FeedViewModel.swift in Sources */,
|
||||
CB3E29DA692C18137C037F93 /* MLBWebDataService.swift in Sources */,
|
||||
F17BF3697CF0008A9417FE08 /* PitchArsenalView.swift in Sources */,
|
||||
312CC5DD0539384FDF65BEB7 /* WinProbabilityChartView.swift in Sources */,
|
||||
C3245C14690AF4CE8882130E /* LiveSituationBar.swift in Sources */,
|
||||
E1EE34169F3361C53FB042C2 /* DiamondView.swift in Sources */,
|
||||
3A707B3D07159E3794810DA6 /* MiniLinescoreView.swift in Sources */,
|
||||
1709C71D035868F36D786A6C /* DataPanel.swift in Sources */,
|
||||
37407BFDD8DE522452EF59A9 /* DesignSystem.swift in Sources */,
|
||||
E7FB366456557069990F550C /* LiveIndicator.swift in Sources */,
|
||||
820FB03D0E97C521BA239071 /* MLBNetworkSheet.swift in Sources */,
|
||||
1E31C6F500CEEA5284081D22 /* MLBServerAPI.swift in Sources */,
|
||||
|
||||
@@ -59,4 +59,21 @@ enum TeamAssets {
|
||||
static func logoURL(forId id: Int) -> URL {
|
||||
URL(string: "https://midfield.mlbstatic.com/v1/team/\(id)/spots/72")!
|
||||
}
|
||||
|
||||
// Venue IDs for stadium hero photos
|
||||
static let venueIds: [String: Int] = [
|
||||
"ARI": 15, "AZ": 15, "ATL": 4705, "BAL": 2, "BOS": 3,
|
||||
"CHC": 17, "CWS": 4, "CIN": 2602, "CLE": 5, "COL": 19,
|
||||
"DET": 2394, "HOU": 2392, "KC": 7, "LAA": 1,
|
||||
"LAD": 22, "MIA": 4169, "MIL": 32, "MIN": 3312,
|
||||
"NYM": 3289, "NYY": 3313, "OAK": 10, "ATH": 10,
|
||||
"PHI": 2681, "PIT": 31, "SD": 2680, "SF": 2395,
|
||||
"SEA": 680, "STL": 2889, "TB": 12, "TEX": 5325,
|
||||
"TOR": 14, "WSH": 3309,
|
||||
]
|
||||
|
||||
static func stadiumURL(for code: String) -> URL? {
|
||||
guard let id = venueIds[code.uppercased()] else { return nil }
|
||||
return URL(string: "https://midfield.mlbstatic.com/v1/venue/\(id)/spots/1200")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +305,7 @@ actor MLBServerAPI {
|
||||
struct Highlight: Codable, Sendable, Identifiable {
|
||||
let id: String?
|
||||
let headline: String?
|
||||
let date: String?
|
||||
let playbacks: [Playback]?
|
||||
|
||||
struct Playback: Codable, Sendable {
|
||||
|
||||
@@ -33,7 +33,7 @@ actor MLBStatsAPI {
|
||||
|
||||
func fetchStandingsRecords(season: String) async throws -> [StandingsDivisionRecord] {
|
||||
let response: StandingsResponse = try await fetchJSON(
|
||||
"\(baseURL)/standings?leagueId=103,104&season=\(season)&hydrate=team"
|
||||
"\(baseURL)/standings?leagueId=103,104&season=\(season)&hydrate=team,division"
|
||||
)
|
||||
return response.records
|
||||
}
|
||||
|
||||
236
mlbTVOS/Services/MLBWebDataService.swift
Normal file
236
mlbTVOS/Services/MLBWebDataService.swift
Normal file
@@ -0,0 +1,236 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
private let webDataLogger = Logger(subsystem: "com.treyt.mlbTVOS", category: "WebData")
|
||||
|
||||
private func logWebData(_ message: String) {
|
||||
webDataLogger.debug("\(message, privacy: .public)")
|
||||
print("[WebData] \(message)")
|
||||
}
|
||||
|
||||
// MARK: - Models
|
||||
|
||||
struct NewsHeadline: Identifiable, Sendable {
|
||||
let id: String
|
||||
let title: String
|
||||
let summary: String
|
||||
let timestamp: Date
|
||||
let imageURL: URL?
|
||||
}
|
||||
|
||||
struct InjuryReport: Identifiable, Sendable {
|
||||
let id: String
|
||||
let teamCode: String
|
||||
let playerName: String
|
||||
let position: String
|
||||
let status: String // "10-Day IL", "60-Day IL", "Day-to-Day"
|
||||
let date: Date
|
||||
let notes: String
|
||||
}
|
||||
|
||||
struct Transaction: Identifiable, Sendable {
|
||||
let id: String
|
||||
let teamCode: String
|
||||
let description: String
|
||||
let date: Date
|
||||
let type: String // "Trade", "DFA", "Call-Up", "Placed on IL"
|
||||
}
|
||||
|
||||
struct LeagueLeader: Identifiable, Sendable {
|
||||
let id: String
|
||||
let rank: Int
|
||||
let playerName: String
|
||||
let teamCode: String
|
||||
let value: String
|
||||
let personId: Int?
|
||||
}
|
||||
|
||||
struct LeaderCategory: Identifiable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let leaders: [LeagueLeader]
|
||||
}
|
||||
|
||||
// MARK: - Service
|
||||
|
||||
actor MLBWebDataService {
|
||||
private let statsAPI = MLBStatsAPI()
|
||||
private let session: URLSession
|
||||
|
||||
init() {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 15
|
||||
session = URLSession(configuration: config)
|
||||
}
|
||||
|
||||
// MARK: - League Leaders (via Stats API)
|
||||
|
||||
func fetchLeagueLeaders() async throws -> [LeaderCategory] {
|
||||
let categories = [
|
||||
("homeRuns", "Home Runs"),
|
||||
("battingAverage", "Batting Average"),
|
||||
("runsBattedIn", "RBI"),
|
||||
("earnedRunAverage", "ERA"),
|
||||
("strikeouts", "Strikeouts"),
|
||||
("wins", "Wins"),
|
||||
("saves", "Saves"),
|
||||
("stolenBases", "Stolen Bases"),
|
||||
("onBasePlusSlugging", "OPS"),
|
||||
("walksHitsPerInningPitched", "WHIP"),
|
||||
]
|
||||
|
||||
var results: [LeaderCategory] = []
|
||||
|
||||
for (code, name) in categories {
|
||||
let url = URL(string: "https://statsapi.mlb.com/api/v1/stats/leaders?leaderCategories=\(code)&season=\(currentSeason)&limit=5&sportId=1")!
|
||||
logWebData("fetchLeagueLeaders category=\(code)")
|
||||
|
||||
do {
|
||||
let (data, _) = try await session.data(from: url)
|
||||
let response = try JSONDecoder().decode(LeaderResponse.self, from: data)
|
||||
|
||||
if let category = response.leagueLeaders?.first {
|
||||
let leaders = (category.leaders ?? []).prefix(5).enumerated().map { index, leader in
|
||||
LeagueLeader(
|
||||
id: "\(code)-\(index)",
|
||||
rank: leader.rank ?? (index + 1),
|
||||
playerName: leader.person?.fullName ?? "Unknown",
|
||||
teamCode: leader.team?.abbreviation ?? "",
|
||||
value: leader.value ?? "",
|
||||
personId: leader.person?.id
|
||||
)
|
||||
}
|
||||
results.append(LeaderCategory(id: code, name: name, leaders: Array(leaders)))
|
||||
}
|
||||
} catch {
|
||||
logWebData("fetchLeagueLeaders FAILED category=\(code) error=\(error)")
|
||||
}
|
||||
}
|
||||
|
||||
logWebData("fetchLeagueLeaders complete categories=\(results.count)")
|
||||
return results
|
||||
}
|
||||
|
||||
// MARK: - News Headlines (from MLB.com)
|
||||
|
||||
func fetchNewsHeadlines() async -> [NewsHeadline] {
|
||||
let url = URL(string: "https://statsapi.mlb.com/api/v1/news?sportId=1")!
|
||||
logWebData("fetchNewsHeadlines")
|
||||
|
||||
do {
|
||||
let (data, _) = try await session.data(from: url)
|
||||
let response = try JSONDecoder().decode(NewsResponse.self, from: data)
|
||||
|
||||
let headlines = (response.articles ?? []).prefix(15).map { article in
|
||||
NewsHeadline(
|
||||
id: article.slug ?? UUID().uuidString,
|
||||
title: article.headline ?? "Untitled",
|
||||
summary: article.subhead ?? "",
|
||||
timestamp: ISO8601DateFormatter().date(from: article.date ?? "") ?? Date(),
|
||||
imageURL: article.image?.cuts?.first?.src.flatMap(URL.init)
|
||||
)
|
||||
}
|
||||
logWebData("fetchNewsHeadlines success count=\(headlines.count)")
|
||||
return Array(headlines)
|
||||
} catch {
|
||||
logWebData("fetchNewsHeadlines FAILED error=\(error)")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Transactions (from Stats API)
|
||||
|
||||
func fetchTransactions() async -> [Transaction] {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MM/dd/yyyy"
|
||||
let today = formatter.string(from: Date())
|
||||
let weekAgo = formatter.string(from: Calendar.current.date(byAdding: .day, value: -7, to: Date())!)
|
||||
|
||||
let url = URL(string: "https://statsapi.mlb.com/api/v1/transactions?startDate=\(weekAgo)&endDate=\(today)&sportId=1")!
|
||||
logWebData("fetchTransactions from=\(weekAgo) to=\(today)")
|
||||
|
||||
do {
|
||||
let (data, _) = try await session.data(from: url)
|
||||
let response = try JSONDecoder().decode(TransactionResponse.self, from: data)
|
||||
|
||||
let transactions = (response.transactions ?? []).prefix(30).enumerated().map { index, tx in
|
||||
Transaction(
|
||||
id: "\(tx.id ?? index)",
|
||||
teamCode: tx.team?.abbreviation ?? "",
|
||||
description: tx.description ?? "",
|
||||
date: ISO8601DateFormatter().date(from: tx.date ?? "") ?? Date(),
|
||||
type: tx.typeDesc ?? "Transaction"
|
||||
)
|
||||
}
|
||||
logWebData("fetchTransactions success count=\(transactions.count)")
|
||||
return Array(transactions)
|
||||
} catch {
|
||||
logWebData("fetchTransactions FAILED error=\(error)")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private var currentSeason: Int {
|
||||
Calendar.current.component(.year, from: Date())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - API Response Models
|
||||
|
||||
private struct LeaderResponse: Codable {
|
||||
let leagueLeaders: [LeaderCategoryResponse]?
|
||||
}
|
||||
|
||||
private struct LeaderCategoryResponse: Codable {
|
||||
let leaderCategory: String?
|
||||
let leaders: [LeaderEntry]?
|
||||
}
|
||||
|
||||
private struct LeaderEntry: Codable {
|
||||
let rank: Int?
|
||||
let value: String?
|
||||
let person: PersonRef?
|
||||
let team: TeamRef?
|
||||
}
|
||||
|
||||
private struct PersonRef: Codable {
|
||||
let id: Int?
|
||||
let fullName: String?
|
||||
}
|
||||
|
||||
private struct TeamRef: Codable {
|
||||
let id: Int?
|
||||
let abbreviation: String?
|
||||
}
|
||||
|
||||
private struct NewsResponse: Codable {
|
||||
let articles: [NewsArticle]?
|
||||
}
|
||||
|
||||
private struct NewsArticle: Codable {
|
||||
let slug: String?
|
||||
let headline: String?
|
||||
let subhead: String?
|
||||
let date: String?
|
||||
let image: NewsImage?
|
||||
}
|
||||
|
||||
private struct NewsImage: Codable {
|
||||
let cuts: [NewsImageCut]?
|
||||
}
|
||||
|
||||
private struct NewsImageCut: Codable {
|
||||
let src: String?
|
||||
}
|
||||
|
||||
private struct TransactionResponse: Codable {
|
||||
let transactions: [TransactionEntry]?
|
||||
}
|
||||
|
||||
private struct TransactionEntry: Codable {
|
||||
let id: Int?
|
||||
let description: String?
|
||||
let date: String?
|
||||
let typeDesc: String?
|
||||
let team: TeamRef?
|
||||
}
|
||||
122
mlbTVOS/ViewModels/FeedViewModel.swift
Normal file
122
mlbTVOS/ViewModels/FeedViewModel.swift
Normal file
@@ -0,0 +1,122 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
|
||||
private let feedLogger = Logger(subsystem: "com.treyt.mlbTVOS", category: "Feed")
|
||||
|
||||
private func logFeed(_ message: String) {
|
||||
feedLogger.debug("\(message, privacy: .public)")
|
||||
print("[Feed] \(message)")
|
||||
}
|
||||
|
||||
private func parseHighlightDate(_ string: String?) -> Date? {
|
||||
guard let string else { return nil }
|
||||
let iso = ISO8601DateFormatter()
|
||||
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let d = iso.date(from: string) { return d }
|
||||
iso.formatOptions = [.withInternetDateTime]
|
||||
return iso.date(from: string)
|
||||
}
|
||||
|
||||
struct HighlightItem: Identifiable, Sendable {
|
||||
let id: String
|
||||
let headline: String
|
||||
let gameTitle: String
|
||||
let awayCode: String
|
||||
let homeCode: String
|
||||
let hlsURL: URL?
|
||||
let mp4URL: URL?
|
||||
let isCondensedGame: Bool
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class FeedViewModel {
|
||||
var highlights: [HighlightItem] = []
|
||||
var isLoading = false
|
||||
|
||||
@ObservationIgnored
|
||||
private var refreshTask: Task<Void, Never>?
|
||||
|
||||
private let serverAPI = MLBServerAPI()
|
||||
|
||||
func loadHighlights(games: [Game]) async {
|
||||
isLoading = true
|
||||
logFeed("loadHighlights start gameCount=\(games.count)")
|
||||
|
||||
let gamesWithPk = games.filter { $0.gamePk != nil }
|
||||
|
||||
await withTaskGroup(of: [HighlightItem].self) { group in
|
||||
for game in gamesWithPk {
|
||||
group.addTask { [serverAPI] in
|
||||
do {
|
||||
let raw = try await serverAPI.fetchHighlights(
|
||||
gamePk: game.gamePk!,
|
||||
gameDate: game.gameDate
|
||||
)
|
||||
return raw.enumerated().compactMap { index, highlight -> HighlightItem? in
|
||||
guard let headline = highlight.headline,
|
||||
let hlsStr = highlight.hlsURL ?? highlight.mp4URL,
|
||||
let _ = URL(string: hlsStr) else { return nil }
|
||||
|
||||
let isCondensed = headline.lowercased().contains("condensed")
|
||||
|| headline.lowercased().contains("recap")
|
||||
|
||||
let timestamp = parseHighlightDate(highlight.date)
|
||||
?? Date(timeIntervalSince1970: TimeInterval(index))
|
||||
|
||||
return HighlightItem(
|
||||
id: highlight.id ?? "\(game.gamePk!)-\(index)",
|
||||
headline: headline,
|
||||
gameTitle: "\(game.awayTeam.code) @ \(game.homeTeam.code)",
|
||||
awayCode: game.awayTeam.code,
|
||||
homeCode: game.homeTeam.code,
|
||||
hlsURL: highlight.hlsURL.flatMap(URL.init),
|
||||
mp4URL: highlight.mp4URL.flatMap(URL.init),
|
||||
isCondensedGame: isCondensed,
|
||||
timestamp: timestamp
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var allHighlights: [HighlightItem] = []
|
||||
for await batch in group {
|
||||
allHighlights.append(contentsOf: batch)
|
||||
}
|
||||
// Sort all highlights by time, most recent first
|
||||
highlights = allHighlights.sorted { $0.timestamp > $1.timestamp }
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
logFeed("loadHighlights complete count=\(highlights.count)")
|
||||
}
|
||||
|
||||
var condensedGames: [HighlightItem] {
|
||||
highlights.filter(\.isCondensedGame)
|
||||
}
|
||||
|
||||
var latestHighlights: [HighlightItem] {
|
||||
highlights.filter { !$0.isCondensedGame }
|
||||
}
|
||||
|
||||
func startAutoRefresh(games: [Game]) {
|
||||
stopAutoRefresh()
|
||||
refreshTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(300))
|
||||
guard !Task.isCancelled, let self else { break }
|
||||
await self.loadHighlights(games: games)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopAutoRefresh() {
|
||||
refreshTask?.cancel()
|
||||
refreshTask = nil
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ final class GameCenterViewModel {
|
||||
var highlights: [Highlight] = []
|
||||
var winProbabilityHome: Double?
|
||||
var winProbabilityAway: Double?
|
||||
var winProbabilityHistory: [WinProbabilityEntry] = []
|
||||
var isLoading = false
|
||||
var errorMessage: String?
|
||||
var lastUpdated: Date?
|
||||
@@ -81,6 +82,7 @@ final class GameCenterViewModel {
|
||||
private func refreshWinProbability(gamePk: String) async {
|
||||
do {
|
||||
let entries = try await statsAPI.fetchWinProbability(gamePk: gamePk)
|
||||
winProbabilityHistory = entries
|
||||
if let latest = entries.last,
|
||||
let home = latest.homeTeamWinProbability,
|
||||
let away = latest.awayTeamWinProbability {
|
||||
|
||||
@@ -7,6 +7,8 @@ final class LeagueCenterViewModel {
|
||||
var scheduleGames: [StatsGame] = []
|
||||
var standings: [StandingsDivisionRecord] = []
|
||||
var teams: [LeagueTeamSummary] = []
|
||||
var leagueLeaders: [LeaderCategory] = []
|
||||
var isLoadingLeaders = false
|
||||
|
||||
var selectedTeam: TeamProfile?
|
||||
var roster: [RosterPlayerSummary] = []
|
||||
@@ -21,6 +23,7 @@ final class LeagueCenterViewModel {
|
||||
var playerErrorMessage: String?
|
||||
|
||||
private let statsAPI = MLBStatsAPI()
|
||||
private let webService = MLBWebDataService()
|
||||
private(set) var scheduleDate = Date()
|
||||
|
||||
private var seasonString: String {
|
||||
@@ -56,6 +59,19 @@ final class LeagueCenterViewModel {
|
||||
}
|
||||
|
||||
isLoadingOverview = false
|
||||
|
||||
// Load leaders in the background
|
||||
Task { await loadLeaders() }
|
||||
}
|
||||
|
||||
func loadLeaders() async {
|
||||
isLoadingLeaders = true
|
||||
do {
|
||||
leagueLeaders = try await webService.fetchLeagueLeaders()
|
||||
} catch {
|
||||
// Leaders are supplementary
|
||||
}
|
||||
isLoadingLeaders = false
|
||||
}
|
||||
|
||||
func goToPreviousDay() async {
|
||||
|
||||
254
mlbTVOS/Views/Components/CategoryPillBar.swift
Normal file
254
mlbTVOS/Views/Components/CategoryPillBar.swift
Normal file
@@ -0,0 +1,254 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CategoryPillBar: View {
|
||||
@Binding var selected: AppSection
|
||||
var streamCount: Int = 0
|
||||
var totalGames: Int = 0
|
||||
var liveGames: Int = 0
|
||||
|
||||
@Namespace private var selectionNamespace
|
||||
|
||||
private var primarySections: [AppSection] {
|
||||
AppSection.allCases.filter { $0 != .settings }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ViewThatFits {
|
||||
expandedBar
|
||||
compactBar
|
||||
}
|
||||
}
|
||||
|
||||
private var expandedBar: some View {
|
||||
HStack(spacing: 24) {
|
||||
brandLockup
|
||||
|
||||
HStack(spacing: 10) {
|
||||
ForEach(primarySections) { section in
|
||||
sectionButton(section)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 12)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
statusChip(value: "\(liveGames)", label: "Live", systemImage: "dot.radiowaves.left.and.right", tint: DS.Colors.live)
|
||||
statusChip(value: "\(totalGames)", label: "Games", systemImage: "sportscourt", tint: DS.Colors.media)
|
||||
statusChip(value: "\(streamCount)", label: "Tiles", systemImage: "rectangle.split.2x2", tint: DS.Colors.positive)
|
||||
settingsButton
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, containerPadH)
|
||||
.padding(.vertical, containerPadV)
|
||||
.background(shellBackground)
|
||||
}
|
||||
|
||||
private var compactBar: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack(spacing: 16) {
|
||||
brandLockup
|
||||
Spacer()
|
||||
settingsButton
|
||||
}
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(primarySections) { section in
|
||||
sectionButton(section)
|
||||
}
|
||||
statusChip(value: "\(liveGames)", label: "Live", systemImage: "dot.radiowaves.left.and.right", tint: DS.Colors.live)
|
||||
statusChip(value: "\(totalGames)", label: "Games", systemImage: "sportscourt", tint: DS.Colors.media)
|
||||
statusChip(value: "\(streamCount)", label: "Tiles", systemImage: "rectangle.split.2x2", tint: DS.Colors.positive)
|
||||
}
|
||||
}
|
||||
.scrollClipDisabled()
|
||||
}
|
||||
.padding(.horizontal, containerPadH)
|
||||
.padding(.vertical, containerPadV)
|
||||
.background(shellBackground)
|
||||
}
|
||||
|
||||
private var brandLockup: some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("MLB")
|
||||
.font(brandPrimaryFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
Text("CONTROL ROOM")
|
||||
.font(brandSecondaryFont)
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
.tracking(brandTracking)
|
||||
}
|
||||
}
|
||||
|
||||
private func sectionButton(_ section: AppSection) -> some View {
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.36, dampingFraction: 0.82)) {
|
||||
selected = section
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: section.systemImage)
|
||||
.font(.system(size: iconSize, weight: .bold))
|
||||
|
||||
Text(section.title)
|
||||
.font(pillFont)
|
||||
}
|
||||
.foregroundStyle(selected == section ? Color.black.opacity(0.86) : DS.Colors.textSecondary)
|
||||
.padding(.horizontal, pillPadH)
|
||||
.padding(.vertical, pillPadV)
|
||||
.background {
|
||||
if selected == section {
|
||||
Capsule()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
DS.Colors.interactive,
|
||||
DS.Colors.warning,
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.matchedGeometryEffect(id: "selected-section", in: selectionNamespace)
|
||||
} else {
|
||||
Capsule()
|
||||
.fill(DS.Colors.panelFillMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
.platformCardStyle()
|
||||
}
|
||||
|
||||
private var settingsButton: some View {
|
||||
Button {
|
||||
selected = .settings
|
||||
} label: {
|
||||
Image(systemName: "gearshape.fill")
|
||||
.font(.system(size: iconSize, weight: .bold))
|
||||
.foregroundStyle(selected == .settings ? Color.black.opacity(0.86) : DS.Colors.textSecondary)
|
||||
.padding(.horizontal, pillPadH)
|
||||
.padding(.vertical, pillPadV)
|
||||
.background {
|
||||
if selected == .settings {
|
||||
Capsule()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
DS.Colors.interactive,
|
||||
DS.Colors.warning,
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Capsule()
|
||||
.fill(DS.Colors.panelFillMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
.platformCardStyle()
|
||||
}
|
||||
|
||||
private func statusChip(value: String, label: String, systemImage: String, tint: Color) -> some View {
|
||||
HStack(spacing: 9) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: statIconSize, weight: .bold))
|
||||
.foregroundStyle(tint)
|
||||
|
||||
Text(value)
|
||||
.font(statValueFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
.monospacedDigit()
|
||||
|
||||
Text(label)
|
||||
.font(statLabelFont)
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
}
|
||||
.padding(.horizontal, statPadH)
|
||||
.padding(.vertical, statPadV)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(DS.Colors.panelFillMuted)
|
||||
.overlay {
|
||||
Capsule()
|
||||
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var shellBackground: some View {
|
||||
RoundedRectangle(cornerRadius: shellRadius, style: .continuous)
|
||||
.fill(DS.Colors.navFill)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: shellRadius, style: .continuous)
|
||||
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
|
||||
}
|
||||
.shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY)
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
private var shellRadius: CGFloat { 28 }
|
||||
private var containerPadH: CGFloat { 28 }
|
||||
private var containerPadV: CGFloat { 22 }
|
||||
private var pillPadH: CGFloat { 24 }
|
||||
private var pillPadV: CGFloat { 16 }
|
||||
private var statPadH: CGFloat { 18 }
|
||||
private var statPadV: CGFloat { 14 }
|
||||
private var pillFont: Font { .system(size: 23, weight: .bold, design: .rounded) }
|
||||
private var statValueFont: Font { .system(size: 22, weight: .black, design: .rounded) }
|
||||
private var statLabelFont: Font { .system(size: 16, weight: .bold, design: .rounded) }
|
||||
private var brandPrimaryFont: Font { .system(size: 34, weight: .black, design: .rounded) }
|
||||
private var brandSecondaryFont: Font { .system(size: 14, weight: .black, design: .rounded) }
|
||||
private var brandTracking: CGFloat { 2.6 }
|
||||
private var iconSize: CGFloat { 20 }
|
||||
private var statIconSize: CGFloat { 13 }
|
||||
#else
|
||||
private var shellRadius: CGFloat { 22 }
|
||||
private var containerPadH: CGFloat { 18 }
|
||||
private var containerPadV: CGFloat { 16 }
|
||||
private var pillPadH: CGFloat { 18 }
|
||||
private var pillPadV: CGFloat { 10 }
|
||||
private var statPadH: CGFloat { 12 }
|
||||
private var statPadV: CGFloat { 10 }
|
||||
private var pillFont: Font { .system(size: 15, weight: .bold, design: .rounded) }
|
||||
private var statValueFont: Font { .system(size: 15, weight: .black, design: .rounded) }
|
||||
private var statLabelFont: Font { .system(size: 11, weight: .bold, design: .rounded) }
|
||||
private var brandPrimaryFont: Font { .system(size: 20, weight: .black, design: .rounded) }
|
||||
private var brandSecondaryFont: Font { .system(size: 10, weight: .black, design: .rounded) }
|
||||
private var brandTracking: CGFloat { 1.8 }
|
||||
private var iconSize: CGFloat { 15 }
|
||||
private var statIconSize: CGFloat { 11 }
|
||||
#endif
|
||||
}
|
||||
|
||||
enum AppSection: String, CaseIterable, Identifiable {
|
||||
case today
|
||||
case intel
|
||||
case highlights
|
||||
case multiView
|
||||
case settings
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .today: "Dashboard"
|
||||
case .intel: "League"
|
||||
case .highlights: "Highlights"
|
||||
case .multiView: "Multi-View"
|
||||
case .settings: "Settings"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .today: "rectangle.3.group.fill"
|
||||
case .intel: "chart.xyaxis.line"
|
||||
case .highlights: "play.rectangle.on.rectangle.fill"
|
||||
case .multiView: "rectangle.split.2x2.fill"
|
||||
case .settings: "gearshape.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
77
mlbTVOS/Views/Components/DataPanel.swift
Normal file
77
mlbTVOS/Views/Components/DataPanel.swift
Normal file
@@ -0,0 +1,77 @@
|
||||
import SwiftUI
|
||||
|
||||
enum DataPanelDensity {
|
||||
case compact
|
||||
case standard
|
||||
case featured
|
||||
|
||||
var padding: CGFloat {
|
||||
switch self {
|
||||
case .compact: DS.Spacing.panelPadCompact
|
||||
case .standard: DS.Spacing.panelPadStandard
|
||||
case .featured: DS.Spacing.panelPadFeatured
|
||||
}
|
||||
}
|
||||
|
||||
var cornerRadius: CGFloat {
|
||||
switch self {
|
||||
case .compact: DS.Radii.compact
|
||||
case .standard: DS.Radii.standard
|
||||
case .featured: DS.Radii.featured
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DataPanel<Content: View>: View {
|
||||
let density: DataPanelDensity
|
||||
var teamAccentCode: String? = nil
|
||||
@ViewBuilder let content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
if let code = teamAccentCode {
|
||||
RoundedRectangle(cornerRadius: 1.5)
|
||||
.fill(TeamAssets.color(for: code))
|
||||
.frame(width: 4)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.leading, 6)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
content()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(density.padding)
|
||||
}
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: density.cornerRadius, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
DS.Colors.panelFill,
|
||||
DS.Colors.panelFillMuted,
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: density.cornerRadius, style: .continuous)
|
||||
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
|
||||
}
|
||||
.shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension DataPanel {
|
||||
init(
|
||||
_ density: DataPanelDensity = .standard,
|
||||
teamAccent: String? = nil,
|
||||
@ViewBuilder content: @escaping () -> Content
|
||||
) {
|
||||
self.density = density
|
||||
self.teamAccentCode = teamAccent
|
||||
self.content = content
|
||||
}
|
||||
}
|
||||
180
mlbTVOS/Views/Components/DesignSystem.swift
Normal file
180
mlbTVOS/Views/Components/DesignSystem.swift
Normal file
@@ -0,0 +1,180 @@
|
||||
import SwiftUI
|
||||
|
||||
enum DS {
|
||||
enum Colors {
|
||||
static let background = Color(red: 0.03, green: 0.05, blue: 0.10)
|
||||
static let backgroundElevated = Color(red: 0.06, green: 0.10, blue: 0.18)
|
||||
static let navFill = Color(red: 0.07, green: 0.10, blue: 0.18).opacity(0.94)
|
||||
static let panelFill = Color(red: 0.08, green: 0.11, blue: 0.18).opacity(0.94)
|
||||
static let panelFillMuted = Color(red: 0.10, green: 0.14, blue: 0.23).opacity(0.84)
|
||||
static let panelStroke = Color.white.opacity(0.09)
|
||||
|
||||
static let live = Color(red: 0.94, green: 0.25, blue: 0.28)
|
||||
static let positive = Color(red: 0.24, green: 0.86, blue: 0.63)
|
||||
static let warning = Color(red: 0.98, green: 0.76, blue: 0.24)
|
||||
static let interactive = Color(red: 1.00, green: 0.75, blue: 0.20)
|
||||
static let media = Color(red: 0.35, green: 0.78, blue: 0.95)
|
||||
|
||||
static let textPrimary = Color.white.opacity(0.96)
|
||||
static let textSecondary = Color.white.opacity(0.76)
|
||||
static let textTertiary = Color.white.opacity(0.50)
|
||||
static let textQuaternary = Color.white.opacity(0.24)
|
||||
|
||||
static let onDarkPrimary = textPrimary
|
||||
static let onDarkSecondary = textSecondary
|
||||
static let onDarkTertiary = textTertiary
|
||||
}
|
||||
|
||||
enum Shadows {
|
||||
static let card = Color.black.opacity(0.30)
|
||||
static let cardRadius: CGFloat = 28
|
||||
static let cardY: CGFloat = 14
|
||||
static let cardLifted = Color.black.opacity(0.44)
|
||||
static let cardLiftedRadius: CGFloat = 42
|
||||
static let cardLiftedY: CGFloat = 18
|
||||
}
|
||||
|
||||
enum Fonts {
|
||||
static let heroScore = Font.system(size: 72, weight: .black, design: .rounded).monospacedDigit()
|
||||
static let largeScore = Font.system(size: 42, weight: .black, design: .rounded).monospacedDigit()
|
||||
static let score = Font.system(size: 28, weight: .black, design: .rounded).monospacedDigit()
|
||||
static let scoreCompact = Font.system(size: 22, weight: .bold, design: .rounded).monospacedDigit()
|
||||
|
||||
static let sectionTitle = Font.system(size: 30, weight: .bold, design: .rounded)
|
||||
static let cardTitle = Font.system(size: 20, weight: .bold, design: .rounded)
|
||||
static let cardTitleCompact = Font.system(size: 17, weight: .bold, design: .rounded)
|
||||
|
||||
static let dataValue = Font.system(size: 18, weight: .bold, design: .rounded).monospacedDigit()
|
||||
static let dataValueCompact = Font.system(size: 15, weight: .semibold, design: .rounded).monospacedDigit()
|
||||
static let body = Font.system(size: 15, weight: .medium)
|
||||
static let bodySmall = Font.system(size: 13, weight: .medium)
|
||||
static let caption = Font.system(size: 11, weight: .bold, design: .rounded)
|
||||
|
||||
#if os(tvOS)
|
||||
static let tvHeroScore = Font.system(size: 94, weight: .black, design: .rounded).monospacedDigit()
|
||||
static let tvSectionTitle = Font.system(size: 40, weight: .bold, design: .rounded)
|
||||
static let tvCardTitle = Font.system(size: 28, weight: .bold, design: .rounded)
|
||||
static let tvScore = Font.system(size: 36, weight: .black, design: .rounded).monospacedDigit()
|
||||
static let tvDataValue = Font.system(size: 24, weight: .bold, design: .rounded).monospacedDigit()
|
||||
static let tvBody = Font.system(size: 22, weight: .medium)
|
||||
static let tvCaption = Font.system(size: 20, weight: .bold, design: .rounded)
|
||||
#endif
|
||||
}
|
||||
|
||||
enum Spacing {
|
||||
#if os(tvOS)
|
||||
static let panelPadCompact: CGFloat = 18
|
||||
static let panelPadStandard: CGFloat = 24
|
||||
static let panelPadFeatured: CGFloat = 32
|
||||
static let sectionGap: CGFloat = 42
|
||||
static let cardGap: CGFloat = 20
|
||||
static let itemGap: CGFloat = 12
|
||||
static let edgeInset: CGFloat = 50
|
||||
#else
|
||||
static let panelPadCompact: CGFloat = 12
|
||||
static let panelPadStandard: CGFloat = 16
|
||||
static let panelPadFeatured: CGFloat = 24
|
||||
static let sectionGap: CGFloat = 28
|
||||
static let cardGap: CGFloat = 14
|
||||
static let itemGap: CGFloat = 8
|
||||
static let edgeInset: CGFloat = 20
|
||||
#endif
|
||||
}
|
||||
|
||||
enum Radii {
|
||||
static let compact: CGFloat = 16
|
||||
static let standard: CGFloat = 24
|
||||
static let featured: CGFloat = 30
|
||||
}
|
||||
}
|
||||
|
||||
struct BroadcastBackground: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.03, green: 0.05, blue: 0.10),
|
||||
Color(red: 0.04, green: 0.08, blue: 0.16),
|
||||
Color(red: 0.02, green: 0.04, blue: 0.09),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
|
||||
// Subtle color washes — radial gradients instead of blurred circles for performance
|
||||
RadialGradient(
|
||||
colors: [Color(red: 0.00, green: 0.46, blue: 0.72).opacity(0.12), .clear],
|
||||
center: UnitPoint(x: 0.1, y: 0.15),
|
||||
startRadius: 50,
|
||||
endRadius: 500
|
||||
)
|
||||
|
||||
RadialGradient(
|
||||
colors: [DS.Colors.interactive.opacity(0.10), .clear],
|
||||
center: UnitPoint(x: 0.85, y: 0.15),
|
||||
startRadius: 50,
|
||||
endRadius: 450
|
||||
)
|
||||
|
||||
RadialGradient(
|
||||
colors: [DS.Colors.live.opacity(0.06), .clear],
|
||||
center: UnitPoint(x: 0.8, y: 0.85),
|
||||
startRadius: 50,
|
||||
endRadius: 400
|
||||
)
|
||||
|
||||
BroadcastGridOverlay()
|
||||
.opacity(0.30)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct BroadcastGridOverlay: View {
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
let size = proxy.size
|
||||
|
||||
Path { path in
|
||||
let verticalSpacing: CGFloat = 110
|
||||
let horizontalSpacing: CGFloat = 90
|
||||
|
||||
var x: CGFloat = 0
|
||||
while x <= size.width {
|
||||
path.move(to: CGPoint(x: x, y: 0))
|
||||
path.addLine(to: CGPoint(x: x, y: size.height))
|
||||
x += verticalSpacing
|
||||
}
|
||||
|
||||
var y: CGFloat = 0
|
||||
while y <= size.height {
|
||||
path.move(to: CGPoint(x: 0, y: y))
|
||||
path.addLine(to: CGPoint(x: size.width, y: y))
|
||||
y += horizontalSpacing
|
||||
}
|
||||
}
|
||||
.stroke(Color.white.opacity(0.05), lineWidth: 1)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
struct DataLabelStyle: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
#if os(tvOS)
|
||||
.font(DS.Fonts.tvCaption)
|
||||
.kerning(1.0)
|
||||
#else
|
||||
.font(DS.Fonts.caption)
|
||||
.kerning(1.5)
|
||||
#endif
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func dataLabelStyle() -> some View {
|
||||
modifier(DataLabelStyle())
|
||||
}
|
||||
}
|
||||
120
mlbTVOS/Views/Components/DiamondView.swift
Normal file
120
mlbTVOS/Views/Components/DiamondView.swift
Normal file
@@ -0,0 +1,120 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Visual baseball diamond showing base runners, count, and outs
|
||||
struct DiamondView: View {
|
||||
var onFirst: Bool = false
|
||||
var onSecond: Bool = false
|
||||
var onThird: Bool = false
|
||||
var balls: Int = 0
|
||||
var strikes: Int = 0
|
||||
var outs: Int = 0
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: diamondCountGap) {
|
||||
// Diamond
|
||||
ZStack {
|
||||
// Diamond shape outline
|
||||
diamondPath
|
||||
.stroke(DS.Colors.textQuaternary, lineWidth: 1.5)
|
||||
|
||||
// Base markers
|
||||
baseMarker(at: firstBasePos, occupied: onFirst)
|
||||
baseMarker(at: secondBasePos, occupied: onSecond)
|
||||
baseMarker(at: thirdBasePos, occupied: onThird)
|
||||
baseMarker(at: homePos, occupied: false, isHome: true)
|
||||
}
|
||||
.frame(width: diamondSize, height: diamondSize)
|
||||
|
||||
// Count + Outs
|
||||
VStack(alignment: .leading, spacing: countSpacing) {
|
||||
HStack(spacing: 3) {
|
||||
Text("B").dataLabelStyle()
|
||||
ForEach(0..<4, id: \.self) { i in
|
||||
Circle()
|
||||
.fill(i < balls ? DS.Colors.positive : DS.Colors.textQuaternary)
|
||||
.frame(width: dotSize, height: dotSize)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 3) {
|
||||
Text("S").dataLabelStyle()
|
||||
ForEach(0..<3, id: \.self) { i in
|
||||
Circle()
|
||||
.fill(i < strikes ? DS.Colors.live : DS.Colors.textQuaternary)
|
||||
.frame(width: dotSize, height: dotSize)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 3) {
|
||||
Text("O").dataLabelStyle()
|
||||
ForEach(0..<3, id: \.self) { i in
|
||||
Circle()
|
||||
.fill(i < outs ? DS.Colors.warning : DS.Colors.textQuaternary)
|
||||
.frame(width: dotSize, height: dotSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Diamond geometry
|
||||
|
||||
private var diamondPath: Path {
|
||||
let s = diamondSize
|
||||
let mid = s / 2
|
||||
let inset: CGFloat = baseSize / 2 + 2
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: mid, y: inset)) // 2nd base (top)
|
||||
path.addLine(to: CGPoint(x: s - inset, y: mid)) // 1st base (right)
|
||||
path.addLine(to: CGPoint(x: mid, y: s - inset)) // Home (bottom)
|
||||
path.addLine(to: CGPoint(x: inset, y: mid)) // 3rd base (left)
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
|
||||
private var firstBasePos: CGPoint {
|
||||
CGPoint(x: diamondSize - baseSize / 2 - 2, y: diamondSize / 2)
|
||||
}
|
||||
private var secondBasePos: CGPoint {
|
||||
CGPoint(x: diamondSize / 2, y: baseSize / 2 + 2)
|
||||
}
|
||||
private var thirdBasePos: CGPoint {
|
||||
CGPoint(x: baseSize / 2 + 2, y: diamondSize / 2)
|
||||
}
|
||||
private var homePos: CGPoint {
|
||||
CGPoint(x: diamondSize / 2, y: diamondSize - baseSize / 2 - 2)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func baseMarker(at position: CGPoint, occupied: Bool, isHome: Bool = false) -> some View {
|
||||
Group {
|
||||
if isHome {
|
||||
// Home plate: small pentagon-like shape
|
||||
Circle()
|
||||
.fill(DS.Colors.textQuaternary)
|
||||
.frame(width: baseSize * 0.7, height: baseSize * 0.7)
|
||||
} else {
|
||||
// Base: rotated square
|
||||
Rectangle()
|
||||
.fill(occupied ? DS.Colors.interactive : DS.Colors.textQuaternary)
|
||||
.frame(width: baseSize, height: baseSize)
|
||||
.rotationEffect(.degrees(45))
|
||||
}
|
||||
}
|
||||
.position(position)
|
||||
}
|
||||
|
||||
// MARK: - Platform sizing
|
||||
|
||||
#if os(tvOS)
|
||||
private var diamondSize: CGFloat { 60 }
|
||||
private var baseSize: CGFloat { 12 }
|
||||
private var dotSize: CGFloat { 9 }
|
||||
private var countSpacing: CGFloat { 5 }
|
||||
private var diamondCountGap: CGFloat { 14 }
|
||||
#else
|
||||
private var diamondSize: CGFloat { 44 }
|
||||
private var baseSize: CGFloat { 9 }
|
||||
private var dotSize: CGFloat { 7 }
|
||||
private var countSpacing: CGFloat { 3 }
|
||||
private var diamondCountGap: CGFloat { 10 }
|
||||
#endif
|
||||
}
|
||||
3
mlbTVOS/Views/Components/FeedItemView.swift
Normal file
3
mlbTVOS/Views/Components/FeedItemView.swift
Normal file
@@ -0,0 +1,3 @@
|
||||
import SwiftUI
|
||||
|
||||
// Placeholder — highlight cards are now inline in FeedView
|
||||
80
mlbTVOS/Views/Components/LeaderboardView.swift
Normal file
80
mlbTVOS/Views/Components/LeaderboardView.swift
Normal file
@@ -0,0 +1,80 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Top-5 stat leaderboard card with player headshots
|
||||
struct LeaderboardView: View {
|
||||
let category: LeaderCategory
|
||||
|
||||
var body: some View {
|
||||
DataPanel(.standard) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(category.name.uppercased())
|
||||
.dataLabelStyle()
|
||||
|
||||
ForEach(category.leaders) { leader in
|
||||
leaderRow(leader)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func leaderRow(_ leader: LeagueLeader) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Text("\(leader.rank)")
|
||||
.font(rankFont)
|
||||
.foregroundStyle(leader.rank <= 3 ? DS.Colors.textPrimary : DS.Colors.textTertiary)
|
||||
.frame(width: rankWidth, alignment: .center)
|
||||
|
||||
// Player headshot
|
||||
if let personId = leader.personId {
|
||||
AsyncImage(url: URL(string: "https://img.mlbstatic.com/mlb-photos/image/upload/w_80,q_auto:best/v1/people/\(personId)/headshot/67/current")) { phase in
|
||||
if let image = phase.image {
|
||||
image.resizable().aspectRatio(contentMode: .fill)
|
||||
} else {
|
||||
Circle().fill(DS.Colors.panelFill)
|
||||
}
|
||||
}
|
||||
.frame(width: headshotSize, height: headshotSize)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(leader.playerName)
|
||||
.font(nameFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
.lineLimit(1)
|
||||
|
||||
if !leader.teamCode.isEmpty {
|
||||
HStack(spacing: 3) {
|
||||
RoundedRectangle(cornerRadius: 1)
|
||||
.fill(TeamAssets.color(for: leader.teamCode))
|
||||
.frame(width: 2, height: 10)
|
||||
Text(leader.teamCode)
|
||||
.font(DS.Fonts.caption)
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(leader.value)
|
||||
.font(valueFont)
|
||||
.foregroundStyle(leader.rank == 1 ? DS.Colors.interactive : DS.Colors.textPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
private var rankWidth: CGFloat { 28 }
|
||||
private var headshotSize: CGFloat { 36 }
|
||||
private var rankFont: Font { .system(size: 16, weight: .bold, design: .rounded).monospacedDigit() }
|
||||
private var nameFont: Font { .system(size: 17, weight: .semibold) }
|
||||
private var valueFont: Font { DS.Fonts.tvDataValue }
|
||||
#else
|
||||
private var rankWidth: CGFloat { 22 }
|
||||
private var headshotSize: CGFloat { 28 }
|
||||
private var rankFont: Font { .system(size: 13, weight: .bold, design: .rounded).monospacedDigit() }
|
||||
private var nameFont: Font { .system(size: 14, weight: .semibold) }
|
||||
private var valueFont: Font { DS.Fonts.dataValue }
|
||||
#endif
|
||||
}
|
||||
101
mlbTVOS/Views/Components/LiveSituationBar.swift
Normal file
101
mlbTVOS/Views/Components/LiveSituationBar.swift
Normal file
@@ -0,0 +1,101 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Compact horizontal strip of all live games — scores, innings, outs at a glance
|
||||
struct LiveSituationBar: View {
|
||||
let games: [Game]
|
||||
var onTapGame: ((Game) -> Void)? = nil
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: DS.Spacing.cardGap) {
|
||||
ForEach(games) { game in
|
||||
liveTile(game)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, DS.Spacing.edgeInset)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func liveTile(_ game: Game) -> some View {
|
||||
Button {
|
||||
onTapGame?(game)
|
||||
} label: {
|
||||
DataPanel(.compact) {
|
||||
HStack(spacing: tileSpacing) {
|
||||
// Teams + scores
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
teamScoreRow(code: game.awayTeam.code, score: game.awayTeam.score)
|
||||
teamScoreRow(code: game.homeTeam.code, score: game.homeTeam.score)
|
||||
}
|
||||
|
||||
// Situation
|
||||
VStack(alignment: .trailing, spacing: 3) {
|
||||
if let inning = game.currentInningDisplay ?? game.status.liveInning {
|
||||
Text(inning)
|
||||
.font(inningFont)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
}
|
||||
|
||||
if let linescore = game.linescore {
|
||||
HStack(spacing: 2) {
|
||||
ForEach(0..<3, id: \.self) { i in
|
||||
Circle()
|
||||
.fill(i < (linescore.outs ?? 0) ? DS.Colors.warning : DS.Colors.textQuaternary)
|
||||
.frame(width: outDotSize, height: outDotSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.platformCardStyle()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func teamScoreRow(code: String, score: Int?) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
TeamLogoView(team: TeamInfo(code: code, name: "", score: nil), size: logoSize)
|
||||
|
||||
Text(code)
|
||||
.font(teamFont.weight(.bold))
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
.frame(width: codeWidth, alignment: .leading)
|
||||
|
||||
Text(score.map { "\($0)" } ?? "-")
|
||||
.font(scoreFont.weight(.black).monospacedDigit())
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
.frame(width: scoreWidth, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
private var tileSpacing: CGFloat { 20 }
|
||||
private var logoSize: CGFloat { 28 }
|
||||
private var codeWidth: CGFloat { 44 }
|
||||
private var scoreWidth: CGFloat { 30 }
|
||||
private var outDotSize: CGFloat { 8 }
|
||||
private var teamFont: Font { .system(size: 18, design: .rounded) }
|
||||
private var scoreFont: Font { .system(size: 20, design: .rounded) }
|
||||
private var inningFont: Font { .system(size: 15, weight: .semibold, design: .rounded) }
|
||||
#else
|
||||
private var tileSpacing: CGFloat { 14 }
|
||||
private var logoSize: CGFloat { 22 }
|
||||
private var codeWidth: CGFloat { 34 }
|
||||
private var scoreWidth: CGFloat { 24 }
|
||||
private var outDotSize: CGFloat { 6 }
|
||||
private var teamFont: Font { .system(size: 14, design: .rounded) }
|
||||
private var scoreFont: Font { .system(size: 16, design: .rounded) }
|
||||
private var inningFont: Font { .system(size: 12, weight: .semibold, design: .rounded) }
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - GameStatus helper
|
||||
|
||||
private extension GameStatus {
|
||||
var liveInning: String? {
|
||||
if case .live(let info) = self { return info }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
52
mlbTVOS/Views/Components/MiniLinescoreView.swift
Normal file
52
mlbTVOS/Views/Components/MiniLinescoreView.swift
Normal file
@@ -0,0 +1,52 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Compact R-H-E display for game cards
|
||||
struct MiniLinescoreView: View {
|
||||
let linescore: StatsLinescore
|
||||
let awayCode: String
|
||||
let homeCode: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
// Team abbreviations column
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(awayCode).foregroundStyle(TeamAssets.color(for: awayCode))
|
||||
Text(homeCode).foregroundStyle(TeamAssets.color(for: homeCode))
|
||||
}
|
||||
.font(statFont.weight(.bold))
|
||||
.frame(width: teamColWidth, alignment: .leading)
|
||||
|
||||
// R column
|
||||
statColumn("R", away: linescore.teams?.away?.runs, home: linescore.teams?.home?.runs, bold: true)
|
||||
// H column
|
||||
statColumn("H", away: linescore.teams?.away?.hits, home: linescore.teams?.home?.hits, bold: false)
|
||||
// E column
|
||||
statColumn("E", away: linescore.teams?.away?.errors, home: linescore.teams?.home?.errors, bold: false)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statColumn(_ label: String, away: Int?, home: Int?, bold: Bool) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
Text(label)
|
||||
.dataLabelStyle()
|
||||
Text(away.map { "\($0)" } ?? "-")
|
||||
.font(statFont.weight(bold ? .bold : .medium).monospacedDigit())
|
||||
.foregroundStyle(bold ? DS.Colors.textPrimary : DS.Colors.textSecondary)
|
||||
Text(home.map { "\($0)" } ?? "-")
|
||||
.font(statFont.weight(bold ? .bold : .medium).monospacedDigit())
|
||||
.foregroundStyle(bold ? DS.Colors.textPrimary : DS.Colors.textSecondary)
|
||||
}
|
||||
.frame(width: statColWidth)
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
private var statFont: Font { .system(size: 17, design: .rounded) }
|
||||
private var teamColWidth: CGFloat { 50 }
|
||||
private var statColWidth: CGFloat { 36 }
|
||||
#else
|
||||
private var statFont: Font { .system(size: 13, design: .rounded) }
|
||||
private var teamColWidth: CGFloat { 36 }
|
||||
private var statColWidth: CGFloat { 28 }
|
||||
#endif
|
||||
}
|
||||
131
mlbTVOS/Views/Components/PitchArsenalView.swift
Normal file
131
mlbTVOS/Views/Components/PitchArsenalView.swift
Normal file
@@ -0,0 +1,131 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Pitch type breakdown for a pitcher showing distribution and average velocity
|
||||
struct PitchArsenalView: View {
|
||||
let allPlays: [LiveFeedPlay]
|
||||
let pitcherName: String
|
||||
|
||||
private var pitchSummary: [PitchTypeSummary] {
|
||||
var grouped: [String: (count: Int, totalSpeed: Double, speedCount: Int)] = [:]
|
||||
|
||||
for play in allPlays {
|
||||
guard let events = play.playEvents else { continue }
|
||||
for event in events where event.isPitch == true {
|
||||
let type = event.pitchTypeDescription
|
||||
var entry = grouped[type, default: (0, 0, 0)]
|
||||
entry.count += 1
|
||||
if let speed = event.speedMPH {
|
||||
entry.totalSpeed += speed
|
||||
entry.speedCount += 1
|
||||
}
|
||||
grouped[type] = entry
|
||||
}
|
||||
}
|
||||
|
||||
let total = grouped.values.reduce(0) { $0 + $1.count }
|
||||
guard total > 0 else { return [] }
|
||||
|
||||
return grouped.map { type, data in
|
||||
PitchTypeSummary(
|
||||
name: type,
|
||||
count: data.count,
|
||||
percentage: Double(data.count) / Double(total) * 100,
|
||||
avgSpeed: data.speedCount > 0 ? data.totalSpeed / Double(data.speedCount) : nil
|
||||
)
|
||||
}
|
||||
.sorted { $0.count > $1.count }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let summary = pitchSummary
|
||||
if !summary.isEmpty {
|
||||
DataPanel(.standard) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("PITCH ARSENAL")
|
||||
.dataLabelStyle()
|
||||
Spacer()
|
||||
Text(pitcherName)
|
||||
.font(DS.Fonts.bodySmall)
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
}
|
||||
|
||||
ForEach(summary, id: \.name) { pitch in
|
||||
pitchRow(pitch, maxCount: summary.first?.count ?? 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func pitchRow(_ pitch: PitchTypeSummary, maxCount: Int) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Text(pitch.name)
|
||||
.font(pitchNameFont)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
.frame(width: nameWidth, alignment: .leading)
|
||||
.lineLimit(1)
|
||||
|
||||
GeometryReader { geo in
|
||||
let fraction = maxCount > 0 ? CGFloat(pitch.count) / CGFloat(maxCount) : 0
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(barColor(for: pitch.name))
|
||||
.frame(width: geo.size.width * fraction)
|
||||
}
|
||||
.frame(height: barHeight)
|
||||
|
||||
Text("\(Int(pitch.percentage))%")
|
||||
.font(DS.Fonts.dataValueCompact)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
.frame(width: pctWidth, alignment: .trailing)
|
||||
|
||||
if let speed = pitch.avgSpeed {
|
||||
Text("\(Int(speed))")
|
||||
.font(DS.Fonts.dataValueCompact)
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
.frame(width: speedWidth, alignment: .trailing)
|
||||
Text("mph")
|
||||
.dataLabelStyle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func barColor(for pitchType: String) -> Color {
|
||||
let lowered = pitchType.lowercased()
|
||||
if lowered.contains("fastball") || lowered.contains("sinker") || lowered.contains("cutter") {
|
||||
return DS.Colors.live
|
||||
}
|
||||
if lowered.contains("slider") || lowered.contains("sweep") {
|
||||
return DS.Colors.interactive
|
||||
}
|
||||
if lowered.contains("curve") || lowered.contains("knuckle") {
|
||||
return DS.Colors.media
|
||||
}
|
||||
if lowered.contains("change") || lowered.contains("split") {
|
||||
return DS.Colors.positive
|
||||
}
|
||||
return DS.Colors.warning
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
private var nameWidth: CGFloat { 140 }
|
||||
private var pctWidth: CGFloat { 44 }
|
||||
private var speedWidth: CGFloat { 36 }
|
||||
private var barHeight: CGFloat { 14 }
|
||||
private var pitchNameFont: Font { DS.Fonts.bodySmall }
|
||||
#else
|
||||
private var nameWidth: CGFloat { 100 }
|
||||
private var pctWidth: CGFloat { 36 }
|
||||
private var speedWidth: CGFloat { 30 }
|
||||
private var barHeight: CGFloat { 10 }
|
||||
private var pitchNameFont: Font { .system(size: 12, weight: .medium) }
|
||||
#endif
|
||||
}
|
||||
|
||||
private struct PitchTypeSummary {
|
||||
let name: String
|
||||
let count: Int
|
||||
let percentage: Double
|
||||
let avgSpeed: Double?
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - iOS Press Style
|
||||
|
||||
struct PlatformPressButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
@@ -9,11 +11,42 @@ struct PlatformPressButtonStyle: ButtonStyle {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - tvOS Focus Style (Light Theme)
|
||||
|
||||
#if os(tvOS)
|
||||
struct TVFocusButtonStyle: ButtonStyle {
|
||||
@Environment(\.isFocused) private var isFocused
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.98 : isFocused ? 1.035 : 1.0)
|
||||
.opacity(configuration.isPressed ? 0.85 : 1.0)
|
||||
.shadow(
|
||||
color: isFocused ? DS.Shadows.cardLifted : .clear,
|
||||
radius: isFocused ? DS.Shadows.cardLiftedRadius : 0,
|
||||
y: isFocused ? DS.Shadows.cardLiftedY : 0
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||
.strokeBorder(DS.Colors.interactive.opacity(isFocused ? 0.72 : 0), lineWidth: 3)
|
||||
)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||
.fill(DS.Colors.interactive.opacity(isFocused ? 0.08 : 0))
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: isFocused)
|
||||
.animation(.easeOut(duration: 0.12), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Platform Extensions
|
||||
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func platformCardStyle() -> some View {
|
||||
#if os(tvOS)
|
||||
self.buttonStyle(.card)
|
||||
self.buttonStyle(TVFocusButtonStyle())
|
||||
#else
|
||||
self.buttonStyle(PlatformPressButtonStyle())
|
||||
#endif
|
||||
|
||||
@@ -19,10 +19,10 @@ struct ScoresTickerView: View {
|
||||
.padding(.vertical, 14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(.black.opacity(0.72))
|
||||
.fill(DS.Colors.navFill)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
||||
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
|
||||
}
|
||||
)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
94
mlbTVOS/Views/Components/WinProbabilityChartView.swift
Normal file
94
mlbTVOS/Views/Components/WinProbabilityChartView.swift
Normal file
@@ -0,0 +1,94 @@
|
||||
import Charts
|
||||
import SwiftUI
|
||||
|
||||
/// Full-game win probability line chart using Swift Charts
|
||||
struct WinProbabilityChartView: View {
|
||||
let entries: [WinProbabilityEntry]
|
||||
let homeCode: String
|
||||
let awayCode: String
|
||||
|
||||
private var homeColor: Color { TeamAssets.color(for: homeCode) }
|
||||
private var awayColor: Color { TeamAssets.color(for: awayCode) }
|
||||
|
||||
var body: some View {
|
||||
DataPanel(.standard) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("WIN PROBABILITY")
|
||||
.dataLabelStyle()
|
||||
Spacer()
|
||||
if let latest = entries.last {
|
||||
HStack(spacing: 12) {
|
||||
probLabel(code: awayCode, value: latest.awayTeamWinProbability)
|
||||
probLabel(code: homeCode, value: latest.homeTeamWinProbability)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Chart {
|
||||
ForEach(Array(entries.enumerated()), id: \.offset) { index, entry in
|
||||
if let wp = entry.homeTeamWinProbability {
|
||||
LineMark(
|
||||
x: .value("AB", index),
|
||||
y: .value("WP", wp)
|
||||
)
|
||||
.foregroundStyle(homeColor)
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
AreaMark(
|
||||
x: .value("AB", index),
|
||||
yStart: .value("Base", 50),
|
||||
yEnd: .value("WP", wp)
|
||||
)
|
||||
.foregroundStyle(
|
||||
wp >= 50
|
||||
? homeColor.opacity(0.15)
|
||||
: awayColor.opacity(0.15)
|
||||
)
|
||||
.interpolationMethod(.catmullRom)
|
||||
}
|
||||
}
|
||||
|
||||
RuleMark(y: .value("Even", 50))
|
||||
.foregroundStyle(DS.Colors.textQuaternary)
|
||||
.lineStyle(StrokeStyle(lineWidth: 0.5, dash: [4, 4]))
|
||||
}
|
||||
.chartYScale(domain: 0...100)
|
||||
.chartYAxis {
|
||||
AxisMarks(values: [0, 25, 50, 75, 100]) { value in
|
||||
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.3))
|
||||
.foregroundStyle(DS.Colors.textQuaternary)
|
||||
AxisValueLabel {
|
||||
Text("\(value.as(Int.self) ?? 0)%")
|
||||
.font(DS.Fonts.caption)
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartXAxis(.hidden)
|
||||
.frame(height: chartHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func probLabel(code: String, value: Double?) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(TeamAssets.color(for: code))
|
||||
.frame(width: 3, height: 14)
|
||||
Text(code)
|
||||
.font(DS.Fonts.caption)
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
Text(value.map { "\(Int($0))%" } ?? "-")
|
||||
.font(DS.Fonts.dataValueCompact)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
private var chartHeight: CGFloat { 180 }
|
||||
#else
|
||||
private var chartHeight: CGFloat { 140 }
|
||||
#endif
|
||||
}
|
||||
@@ -2,32 +2,61 @@ import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(GamesViewModel.self) private var viewModel
|
||||
@State private var selectedSection: AppSection = .today
|
||||
|
||||
private var multiViewLabel: String {
|
||||
let count = viewModel.activeStreams.count
|
||||
if count > 0 {
|
||||
return "Multi-View (\(count))"
|
||||
}
|
||||
return "Multi-View"
|
||||
private var showsTicker: Bool {
|
||||
selectedSection != .multiView && !viewModel.games.isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
Tab("Games", systemImage: "sportscourt.fill") {
|
||||
ZStack {
|
||||
BroadcastBackground()
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: shellSpacing) {
|
||||
CategoryPillBar(
|
||||
selected: $selectedSection,
|
||||
streamCount: viewModel.activeStreams.count,
|
||||
totalGames: viewModel.games.count,
|
||||
liveGames: viewModel.liveGames.count
|
||||
)
|
||||
.padding(.horizontal, DS.Spacing.edgeInset)
|
||||
.padding(.top, navPadTop)
|
||||
.platformFocusSection()
|
||||
|
||||
if showsTicker {
|
||||
ScoresTickerView()
|
||||
.padding(.horizontal, DS.Spacing.edgeInset)
|
||||
}
|
||||
|
||||
Group {
|
||||
switch selectedSection {
|
||||
case .today:
|
||||
DashboardView()
|
||||
}
|
||||
Tab("League", systemImage: "list.bullet.rectangle.portrait.fill") {
|
||||
case .intel:
|
||||
LeagueCenterView()
|
||||
}
|
||||
Tab(multiViewLabel, systemImage: "rectangle.split.2x2.fill") {
|
||||
case .highlights:
|
||||
FeedView()
|
||||
case .multiView:
|
||||
MultiStreamView()
|
||||
}
|
||||
Tab("Settings", systemImage: "gearshape.fill") {
|
||||
case .settings:
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.platformFocusSection()
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadGames()
|
||||
}
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
private var navPadTop: CGFloat { 26 }
|
||||
private var shellSpacing: CGFloat { 18 }
|
||||
#else
|
||||
private var navPadTop: CGFloat { 14 }
|
||||
private var shellSpacing: CGFloat { 14 }
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -84,70 +84,93 @@ struct DashboardView: View {
|
||||
|
||||
private var shelfCardWidth: CGFloat {
|
||||
#if os(iOS)
|
||||
horizontalSizeClass == .compact ? 300 : 360
|
||||
horizontalSizeClass == .compact ? 340 : 500
|
||||
#else
|
||||
400
|
||||
540
|
||||
#endif
|
||||
}
|
||||
|
||||
private var controlRailWidth: CGFloat {
|
||||
#if os(iOS)
|
||||
horizontalSizeClass == .compact ? 0 : 300
|
||||
#else
|
||||
340
|
||||
#endif
|
||||
}
|
||||
|
||||
private var usesStackedHeroLayout: Bool {
|
||||
#if os(iOS)
|
||||
horizontalSizeClass == .compact
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
private var radarGames: [Game] {
|
||||
if !viewModel.liveGames.isEmpty {
|
||||
return Array(viewModel.liveGames.prefix(4))
|
||||
}
|
||||
if !viewModel.scheduledGames.isEmpty {
|
||||
return Array(viewModel.scheduledGames.prefix(4))
|
||||
}
|
||||
return Array(viewModel.finalGames.prefix(4))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: contentSpacing) {
|
||||
headerSection
|
||||
.platformFocusSection()
|
||||
|
||||
if viewModel.isLoading {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView("Loading games...")
|
||||
.font(.title3)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 80)
|
||||
loadingState
|
||||
} 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)
|
||||
errorState(error)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
overviewStrip
|
||||
.platformFocusSection()
|
||||
heroAndControlSection
|
||||
.platformFocusSection()
|
||||
|
||||
featuredChannelsSection
|
||||
.platformFocusSection()
|
||||
|
||||
if !viewModel.activeStreams.isEmpty {
|
||||
multiViewStatus
|
||||
if !viewModel.liveGames.isEmpty {
|
||||
gameShelf(
|
||||
title: "Live Board",
|
||||
subtitle: "Open games with inning state, records, and stream availability.",
|
||||
icon: "antenna.radiowaves.left.and.right",
|
||||
accent: DS.Colors.live,
|
||||
games: viewModel.liveGames,
|
||||
excludeId: viewModel.featuredGame?.id
|
||||
)
|
||||
}
|
||||
if !viewModel.scheduledGames.isEmpty {
|
||||
gameShelf(
|
||||
title: "Upcoming Windows",
|
||||
subtitle: "Probables, first pitch, and watch-ready cards for the rest of the slate.",
|
||||
icon: "calendar",
|
||||
accent: DS.Colors.warning,
|
||||
games: viewModel.scheduledGames,
|
||||
excludeId: viewModel.featuredGame?.id
|
||||
)
|
||||
}
|
||||
if !viewModel.finalGames.isEmpty {
|
||||
gameShelf(
|
||||
title: "Completed Games",
|
||||
subtitle: "Finished scoreboards ready for replays, box scores, and highlights.",
|
||||
icon: "checkmark.circle",
|
||||
accent: DS.Colors.positive,
|
||||
games: viewModel.finalGames,
|
||||
excludeId: viewModel.featuredGame?.id
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
.padding(.vertical, verticalPadding)
|
||||
}
|
||||
.scrollIndicators(.hidden)
|
||||
.onAppear {
|
||||
logDashboard("DashboardView appeared")
|
||||
viewModel.startAutoRefresh()
|
||||
@@ -340,19 +363,397 @@ struct DashboardView: View {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Game Shelf (Horizontal)
|
||||
private var loadingState: some View {
|
||||
VStack(spacing: 18) {
|
||||
ProgressView()
|
||||
.scaleEffect(1.3)
|
||||
Text("Loading the daily board")
|
||||
.font(sectionTitleFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
Text("Scores, streams, and matchup context are on the way.")
|
||||
.font(sectionBodyFont)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 120)
|
||||
.background(surfaceCardBackground())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func gameShelf(title: String, icon: String, games: [Game], excludeId: String?) -> some View {
|
||||
private func errorState(_ error: String) -> some View {
|
||||
VStack(spacing: 18) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 44, weight: .bold))
|
||||
.foregroundStyle(DS.Colors.warning)
|
||||
|
||||
Text(error)
|
||||
.font(sectionTitleFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
Button("Reload Board") {
|
||||
Task { await viewModel.loadGames() }
|
||||
}
|
||||
.padding(.horizontal, 22)
|
||||
.padding(.vertical, 14)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(DS.Colors.interactive)
|
||||
)
|
||||
.foregroundStyle(Color.black.opacity(0.82))
|
||||
.platformCardStyle()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 110)
|
||||
.background(surfaceCardBackground())
|
||||
}
|
||||
|
||||
private var headerSection: some View {
|
||||
ViewThatFits {
|
||||
HStack(alignment: .bottom, spacing: 28) {
|
||||
headerCopy
|
||||
Spacer(minLength: 16)
|
||||
dateNavigator
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 22) {
|
||||
headerCopy
|
||||
dateNavigator
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var headerCopy: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Daily Control Room")
|
||||
.font(headerTitleFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
Text("A broadcast-grade slate view with live radar, featured watch windows, and fast access to every stream.")
|
||||
.font(headerBodyFont)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
private var dateNavigator: some View {
|
||||
HStack(spacing: 12) {
|
||||
navigatorButton(systemImage: "chevron.left") {
|
||||
Task { await viewModel.goToPreviousDay() }
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(viewModel.isToday ? "Today" : "Archive Day")
|
||||
.font(dateLabelFont)
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
|
||||
Text(viewModel.displayDateString)
|
||||
.font(dateFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
|
||||
navigatorButton(systemImage: "chevron.right") {
|
||||
Task { await viewModel.goToNextDay() }
|
||||
}
|
||||
|
||||
if !viewModel.isToday {
|
||||
Button {
|
||||
Task { await viewModel.goToToday() }
|
||||
} label: {
|
||||
Text("Jump to Today")
|
||||
.font(todayBtnFont)
|
||||
.foregroundStyle(Color.black.opacity(0.84))
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 14)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(DS.Colors.interactive)
|
||||
)
|
||||
}
|
||||
.platformCardStyle()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 22)
|
||||
.padding(.vertical, 18)
|
||||
.background(surfaceCardBackground(radius: 26))
|
||||
}
|
||||
|
||||
private func navigatorButton(systemImage: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: dateNavIconSize, weight: .bold))
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
.frame(width: 52, height: 52)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(DS.Colors.panelFillMuted)
|
||||
)
|
||||
}
|
||||
.platformCardStyle()
|
||||
}
|
||||
|
||||
private var overviewStrip: some View {
|
||||
ViewThatFits {
|
||||
HStack(spacing: 18) {
|
||||
metricTile(value: "\(viewModel.liveGames.count)", label: "Live Games", detail: liveStatusDetail, systemImage: "dot.radiowaves.left.and.right", tint: DS.Colors.live)
|
||||
metricTile(value: "\(viewModel.scheduledGames.count)", label: "Upcoming", detail: "First pitches still ahead", systemImage: "calendar", tint: DS.Colors.warning)
|
||||
metricTile(value: "\(viewModel.finalGames.count)", label: "Final", detail: "Completed scoreboards", systemImage: "flag.checkered.2.crossed", tint: DS.Colors.positive)
|
||||
metricTile(value: "\(viewModel.activeStreams.count)", label: "Stream Tiles", detail: activeAudioDetail, systemImage: "rectangle.split.2x2", tint: DS.Colors.media)
|
||||
}
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 18) {
|
||||
metricTile(value: "\(viewModel.liveGames.count)", label: "Live Games", detail: liveStatusDetail, systemImage: "dot.radiowaves.left.and.right", tint: DS.Colors.live)
|
||||
metricTile(value: "\(viewModel.scheduledGames.count)", label: "Upcoming", detail: "First pitches still ahead", systemImage: "calendar", tint: DS.Colors.warning)
|
||||
metricTile(value: "\(viewModel.finalGames.count)", label: "Final", detail: "Completed scoreboards", systemImage: "flag.checkered.2.crossed", tint: DS.Colors.positive)
|
||||
metricTile(value: "\(viewModel.activeStreams.count)", label: "Stream Tiles", detail: activeAudioDetail, systemImage: "rectangle.split.2x2", tint: DS.Colors.media)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.scrollClipDisabled()
|
||||
}
|
||||
}
|
||||
|
||||
private var liveStatusDetail: String {
|
||||
if viewModel.liveGames.isEmpty {
|
||||
return "No live first pitch yet"
|
||||
}
|
||||
return "\(viewModel.liveGames.count) games active now"
|
||||
}
|
||||
|
||||
private var activeAudioDetail: String {
|
||||
if let activeAudio = viewModel.activeAudioStream {
|
||||
return "Audio: \(activeAudio.game.awayTeam.code) @ \(activeAudio.game.homeTeam.code)"
|
||||
}
|
||||
if isPiPActive {
|
||||
return "Picture in Picture active"
|
||||
}
|
||||
return "Quadbox ready"
|
||||
}
|
||||
|
||||
private func metricTile(value: String, label: String, detail: String, systemImage: String, tint: Color) -> some View {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 20, weight: .bold))
|
||||
.foregroundStyle(tint)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(value)
|
||||
.font(metricValueFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
.monospacedDigit()
|
||||
|
||||
Text(label)
|
||||
.font(metricLabelFont)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
|
||||
Text(detail)
|
||||
.font(metricDetailFont)
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 18)
|
||||
.background(surfaceCardBackground(radius: 26))
|
||||
}
|
||||
|
||||
private var heroAndControlSection: some View {
|
||||
Group {
|
||||
if usesStackedHeroLayout {
|
||||
VStack(alignment: .leading, spacing: 22) {
|
||||
featuredHero
|
||||
controlRail
|
||||
}
|
||||
} else {
|
||||
HStack(alignment: .top, spacing: 24) {
|
||||
featuredHero
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
controlRail
|
||||
.frame(width: controlRailWidth, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var featuredHero: some View {
|
||||
Group {
|
||||
if let featured = viewModel.featuredGame {
|
||||
FeaturedGameCard(game: featured) {
|
||||
selectedGame = featured
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("No featured matchup")
|
||||
.font(DS.Fonts.sectionTitle)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
Text("As soon as the slate populates, the best watch window appears here with scores, context, and stream access.")
|
||||
.font(DS.Fonts.body)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 320, alignment: .leading)
|
||||
.padding(32)
|
||||
.background(surfaceCardBackground(radius: 34))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var controlRail: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
liveRadarPanel
|
||||
multiViewStatus
|
||||
}
|
||||
}
|
||||
|
||||
private var liveRadarPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Live Radar")
|
||||
.font(railTitleFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
Text(radarSubtitle)
|
||||
.font(railBodyFont)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if radarGames.isEmpty {
|
||||
Text("No games loaded yet.")
|
||||
.font(railBodyFont)
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(radarGames) { game in
|
||||
radarRow(game)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.background(surfaceCardBackground())
|
||||
}
|
||||
|
||||
private var radarSubtitle: String {
|
||||
if !viewModel.liveGames.isEmpty {
|
||||
return "Fast board for the most active windows."
|
||||
}
|
||||
if !viewModel.scheduledGames.isEmpty {
|
||||
return "No live action yet. Upcoming first pitches are next."
|
||||
}
|
||||
return "Completed slate snapshots."
|
||||
}
|
||||
|
||||
private func radarRow(_ game: Game) -> some View {
|
||||
Button {
|
||||
selectedGame = game
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("\(game.awayTeam.code) @ \(game.homeTeam.code)")
|
||||
.font(radarRowTitleFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
Text(radarDetail(for: game))
|
||||
.font(radarRowBodyFont)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
VStack(alignment: .trailing, spacing: 6) {
|
||||
Text(radarStatus(for: game))
|
||||
.font(radarRowStatusFont)
|
||||
.foregroundStyle(radarTint(for: game))
|
||||
|
||||
if let score = game.scoreDisplay {
|
||||
Text(score)
|
||||
.font(radarRowScoreFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(DS.Colors.panelFillMuted)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
|
||||
}
|
||||
)
|
||||
}
|
||||
.platformCardStyle()
|
||||
}
|
||||
|
||||
private func radarDetail(for game: Game) -> String {
|
||||
if game.isLive {
|
||||
return game.venue ?? "Live board ready"
|
||||
}
|
||||
if let pitchers = game.pitchers, !pitchers.isEmpty {
|
||||
return pitchers
|
||||
}
|
||||
return game.venue ?? "Open matchup details"
|
||||
}
|
||||
|
||||
private func radarStatus(for game: Game) -> String {
|
||||
switch game.status {
|
||||
case .live(let inning):
|
||||
return inning?.uppercased() ?? "LIVE"
|
||||
case .scheduled(let time):
|
||||
return time.uppercased()
|
||||
case .final_:
|
||||
return "FINAL"
|
||||
case .unknown:
|
||||
return "PENDING"
|
||||
}
|
||||
}
|
||||
|
||||
private func radarTint(for game: Game) -> Color {
|
||||
if game.isLive { return DS.Colors.live }
|
||||
if game.isFinal { return DS.Colors.positive }
|
||||
return DS.Colors.warning
|
||||
}
|
||||
|
||||
private func gameShelf(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
icon: String,
|
||||
accent: Color,
|
||||
games: [Game],
|
||||
excludeId: String?
|
||||
) -> some View {
|
||||
let filtered = games.filter { $0.id != excludeId }
|
||||
return Group {
|
||||
if !filtered.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
HStack(alignment: .bottom, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label(title, systemImage: icon)
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(sectionTitleFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
ScrollView(.horizontal) {
|
||||
LazyHStack(spacing: 30) {
|
||||
Text(subtitle)
|
||||
.font(sectionBodyFont)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Circle()
|
||||
.fill(accent)
|
||||
.frame(width: 10, height: 10)
|
||||
}
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
LazyHStack(spacing: 24) {
|
||||
ForEach(filtered) { game in
|
||||
GameCardView(game: game) {
|
||||
selectedGame = game
|
||||
@@ -360,211 +761,229 @@ struct DashboardView: View {
|
||||
.frame(width: shelfCardWidth)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.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()
|
||||
#if os(tvOS)
|
||||
private var headerTitleFont: Font { .system(size: 50, weight: .black, design: .rounded) }
|
||||
private var headerBodyFont: Font { .system(size: 22, weight: .medium) }
|
||||
private var dateLabelFont: Font { .system(size: 14, weight: .black, design: .rounded) }
|
||||
private var dateFont: Font { .system(size: 26, weight: .bold, design: .rounded) }
|
||||
private var dateNavIconSize: CGFloat { 20 }
|
||||
private var todayBtnFont: Font { .system(size: 18, weight: .black, design: .rounded) }
|
||||
private var metricValueFont: Font { .system(size: 34, weight: .black, design: .rounded) }
|
||||
private var metricLabelFont: Font { .system(size: 19, weight: .bold, design: .rounded) }
|
||||
private var metricDetailFont: Font { .system(size: 16, weight: .semibold) }
|
||||
private var railTitleFont: Font { .system(size: 28, weight: .black, design: .rounded) }
|
||||
private var railBodyFont: Font { .system(size: 17, weight: .medium) }
|
||||
private var radarRowTitleFont: Font { .system(size: 19, weight: .black, design: .rounded) }
|
||||
private var radarRowBodyFont: Font { .system(size: 15, weight: .semibold) }
|
||||
private var radarRowStatusFont: Font { .system(size: 14, weight: .black, design: .rounded) }
|
||||
private var radarRowScoreFont: Font { .system(size: 20, weight: .black, design: .rounded).monospacedDigit() }
|
||||
private var sectionTitleFont: Font { .system(size: 30, weight: .black, design: .rounded) }
|
||||
private var sectionBodyFont: Font { .system(size: 17, weight: .medium) }
|
||||
#else
|
||||
private var headerTitleFont: Font { .system(size: 28, weight: .black, design: .rounded) }
|
||||
private var headerBodyFont: Font { .system(size: 15, weight: .medium) }
|
||||
private var dateLabelFont: Font { .system(size: 10, weight: .black, design: .rounded) }
|
||||
private var dateFont: Font { .system(size: 17, weight: .bold, design: .rounded) }
|
||||
private var dateNavIconSize: CGFloat { 15 }
|
||||
private var todayBtnFont: Font { .system(size: 13, weight: .black, design: .rounded) }
|
||||
private var metricValueFont: Font { .system(size: 22, weight: .black, design: .rounded) }
|
||||
private var metricLabelFont: Font { .system(size: 13, weight: .bold, design: .rounded) }
|
||||
private var metricDetailFont: Font { .system(size: 11, weight: .semibold) }
|
||||
private var railTitleFont: Font { .system(size: 20, weight: .black, design: .rounded) }
|
||||
private var railBodyFont: Font { .system(size: 12, weight: .medium) }
|
||||
private var radarRowTitleFont: Font { .system(size: 14, weight: .black, design: .rounded) }
|
||||
private var radarRowBodyFont: Font { .system(size: 11, weight: .semibold) }
|
||||
private var radarRowStatusFont: Font { .system(size: 10, weight: .black, design: .rounded) }
|
||||
private var radarRowScoreFont: Font { .system(size: 14, weight: .black, design: .rounded).monospacedDigit() }
|
||||
private var sectionTitleFont: Font { .system(size: 22, weight: .black, design: .rounded) }
|
||||
private var sectionBodyFont: Font { .system(size: 12, weight: .medium) }
|
||||
#endif
|
||||
|
||||
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: - Featured Channels
|
||||
|
||||
@ViewBuilder
|
||||
private var featuredChannelsSection: some View {
|
||||
ViewThatFits {
|
||||
HStack(alignment: .top, spacing: 24) {
|
||||
HStack(spacing: DS.Spacing.cardGap) {
|
||||
mlbNetworkCard
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
nsfwVideosCard
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
mlbNetworkCard
|
||||
nsfwVideosCard
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var mlbNetworkCard: some View {
|
||||
let added = viewModel.activeStreams.contains(where: { $0.id == "MLBN" })
|
||||
Button {
|
||||
return channelCard(
|
||||
title: "MLB Network",
|
||||
subtitle: "League-wide coverage, whip-around cuts, analysis, and highlights.",
|
||||
systemImage: "tv.fill",
|
||||
tint: .blue,
|
||||
status: added ? "Pinned to Multi-View" : "Open Channel"
|
||||
) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 108, alignment: .leading)
|
||||
.padding(24)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
}
|
||||
.platformCardStyle()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var nsfwVideosCard: some View {
|
||||
let added = viewModel.activeStreams.contains(where: { $0.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID })
|
||||
Button {
|
||||
return channelCard(
|
||||
title: SpecialPlaybackChannelConfig.werkoutNSFWTitle,
|
||||
subtitle: SpecialPlaybackChannelConfig.werkoutNSFWSubtitle,
|
||||
systemImage: "play.rectangle.fill",
|
||||
tint: .pink,
|
||||
status: added ? "Pinned to Multi-View" : "Private feed access"
|
||||
) {
|
||||
showWerkoutNSFWSheet = true
|
||||
} label: {
|
||||
HStack(spacing: 16) {
|
||||
Image(systemName: "play.rectangle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.pink)
|
||||
.frame(width: 56, height: 56)
|
||||
.background(.pink.opacity(0.2))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(SpecialPlaybackChannelConfig.werkoutNSFWTitle)
|
||||
.font(.title3.weight(.bold))
|
||||
Text(SpecialPlaybackChannelConfig.werkoutNSFWSubtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
private var multiViewStatus: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Multi-View Status")
|
||||
.font(railTitleFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
if added {
|
||||
Label("In Multi-View", systemImage: "checkmark.circle.fill")
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(.green)
|
||||
Text("Current grid state, active audio focus, and ready-to-open tiles.")
|
||||
.font(railBodyFont)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
|
||||
HStack(spacing: 14) {
|
||||
metricBadge(value: "\(viewModel.activeStreams.count)/4", label: "Tiles")
|
||||
metricBadge(value: viewModel.multiViewLayoutMode.title, label: "Layout")
|
||||
metricBadge(value: viewModel.activeAudioStream == nil ? "Muted" : "Live", label: "Audio")
|
||||
}
|
||||
|
||||
if viewModel.activeStreams.isEmpty {
|
||||
Text("No active tiles yet. Add any game feed to build the quadbox.")
|
||||
.font(railBodyFont)
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
} else {
|
||||
Label("Open", systemImage: "play.fill")
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(.pink)
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
|
||||
ForEach(viewModel.activeStreams) { stream in
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.fill(viewModel.activeAudioStream?.id == stream.id ? DS.Colors.interactive : DS.Colors.positive)
|
||||
.frame(width: 10, height: 10)
|
||||
Text(stream.label)
|
||||
.font(radarRowBodyFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(DS.Colors.panelFillMuted)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 108, alignment: .leading)
|
||||
.padding(24)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
.background(surfaceCardBackground())
|
||||
}
|
||||
|
||||
private func channelCard(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
systemImage: String,
|
||||
tint: Color,
|
||||
status: String,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 16) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.foregroundStyle(tint)
|
||||
.frame(width: 58, height: 58)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(tint.opacity(0.16))
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(title)
|
||||
.font(radarRowTitleFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
Text(subtitle)
|
||||
.font(radarRowBodyFont)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Text(status)
|
||||
.font(radarRowStatusFont)
|
||||
.foregroundStyle(tint)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.lineLimit(2)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||
.fill(DS.Colors.panelFillMuted)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
|
||||
}
|
||||
)
|
||||
}
|
||||
.platformCardStyle()
|
||||
}
|
||||
|
||||
// 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))
|
||||
private func metricBadge(value: String, label: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(value)
|
||||
.font(radarRowTitleFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
Text(label)
|
||||
.font(radarRowBodyFont)
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(Capsule())
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(DS.Colors.panelFillMuted)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func surfaceCardBackground(radius: CGFloat = 28) -> some View {
|
||||
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
DS.Colors.panelFill,
|
||||
DS.Colors.panelFillMuted,
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
|
||||
}
|
||||
.shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
261
mlbTVOS/Views/FeedView.swift
Normal file
261
mlbTVOS/Views/FeedView.swift
Normal file
@@ -0,0 +1,261 @@
|
||||
import AVKit
|
||||
import SwiftUI
|
||||
|
||||
struct FeedView: View {
|
||||
@Environment(GamesViewModel.self) private var gamesViewModel
|
||||
@State private var viewModel = FeedViewModel()
|
||||
@State private var playingURL: URL?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: DS.Spacing.sectionGap) {
|
||||
// Header
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("HIGHLIGHTS")
|
||||
.font(DS.Fonts.caption)
|
||||
.foregroundStyle(DS.Colors.textQuaternary)
|
||||
.kerning(3)
|
||||
Text("Across the League")
|
||||
#if os(tvOS)
|
||||
.font(DS.Fonts.tvSectionTitle)
|
||||
#else
|
||||
.font(DS.Fonts.sectionTitle)
|
||||
#endif
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
Text("Condensed games, key plays, and fresh clips from the active slate.")
|
||||
.font(DS.Fonts.body)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
|
||||
overviewChips
|
||||
|
||||
if viewModel.highlights.isEmpty && !viewModel.isLoading {
|
||||
emptyState
|
||||
} else {
|
||||
LazyVStack(spacing: DS.Spacing.cardGap) {
|
||||
ForEach(viewModel.highlights.prefix(50)) { item in
|
||||
highlightCard(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, edgeInset)
|
||||
.padding(.vertical, DS.Spacing.sectionGap)
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadHighlights(games: gamesViewModel.games)
|
||||
}
|
||||
.onChange(of: gamesViewModel.games.count) {
|
||||
Task { await viewModel.loadHighlights(games: gamesViewModel.games) }
|
||||
}
|
||||
.onAppear { viewModel.startAutoRefresh(games: gamesViewModel.games) }
|
||||
.onDisappear { viewModel.stopAutoRefresh() }
|
||||
.fullScreenCover(isPresented: Binding(
|
||||
get: { playingURL != nil },
|
||||
set: { if !$0 { playingURL = nil } }
|
||||
)) {
|
||||
if let url = playingURL {
|
||||
let player = AVPlayer(url: url)
|
||||
VideoPlayer(player: player)
|
||||
.ignoresSafeArea()
|
||||
.onAppear { player.play() }
|
||||
.onDisappear { player.pause() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var overviewChips: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
feedChip(
|
||||
title: "\(viewModel.highlights.count)",
|
||||
label: "Clips",
|
||||
tint: DS.Colors.media
|
||||
)
|
||||
feedChip(
|
||||
title: "\(viewModel.highlights.filter(\.isCondensedGame).count)",
|
||||
label: "Condensed",
|
||||
tint: DS.Colors.interactive
|
||||
)
|
||||
feedChip(
|
||||
title: "\(gamesViewModel.liveGames.count)",
|
||||
label: "Live Games",
|
||||
tint: DS.Colors.live
|
||||
)
|
||||
}
|
||||
}
|
||||
.scrollClipDisabled()
|
||||
}
|
||||
|
||||
private func feedChip(title: String, label: String, tint: Color) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Text(title)
|
||||
.font(chipValueFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
.monospacedDigit()
|
||||
|
||||
Text(label)
|
||||
.font(chipLabelFont)
|
||||
.foregroundStyle(tint)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(DS.Colors.panelFillMuted)
|
||||
.overlay {
|
||||
Capsule()
|
||||
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func highlightCard(_ item: HighlightItem) -> some View {
|
||||
Button {
|
||||
playingURL = item.hlsURL ?? item.mp4URL
|
||||
} label: {
|
||||
HStack(spacing: 16) {
|
||||
// Thumbnail
|
||||
ZStack {
|
||||
HStack(spacing: 0) {
|
||||
Rectangle().fill(TeamAssets.color(for: item.awayCode).opacity(0.3))
|
||||
Rectangle().fill(TeamAssets.color(for: item.homeCode).opacity(0.3))
|
||||
}
|
||||
|
||||
HStack(spacing: thumbnailLogoGap) {
|
||||
TeamLogoView(
|
||||
team: TeamInfo(code: item.awayCode, name: "", score: nil),
|
||||
size: thumbnailLogoSize
|
||||
)
|
||||
Text("@")
|
||||
.font(.system(size: atFontSize, weight: .bold))
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
TeamLogoView(
|
||||
team: TeamInfo(code: item.homeCode, name: "", score: nil),
|
||||
size: thumbnailLogoSize
|
||||
)
|
||||
}
|
||||
|
||||
Image(systemName: "play.circle.fill")
|
||||
.font(.system(size: playIconSize))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.shadow(radius: 4)
|
||||
|
||||
if item.isCondensedGame {
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("CONDENSED")
|
||||
.font(badgeFont)
|
||||
.foregroundStyle(.white)
|
||||
.kerning(0.5)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(DS.Colors.media)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
.frame(width: thumbnailWidth, height: thumbnailHeight)
|
||||
.clipShape(RoundedRectangle(cornerRadius: DS.Radii.compact))
|
||||
|
||||
// Info
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(item.gameTitle)
|
||||
.font(gameTagFont)
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
.kerning(0.8)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(timeAgo(item.timestamp))
|
||||
.font(gameTagFont)
|
||||
.foregroundStyle(DS.Colors.textQuaternary)
|
||||
}
|
||||
|
||||
Text(item.headline)
|
||||
.font(headlineFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(DS.Spacing.panelPadCompact)
|
||||
.background(DS.Colors.panelFill)
|
||||
.clipShape(RoundedRectangle(cornerRadius: DS.Radii.standard))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: DS.Radii.standard)
|
||||
.strokeBorder(DS.Colors.panelStroke, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.platformCardStyle()
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "play.rectangle.on.rectangle")
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(DS.Colors.textQuaternary)
|
||||
Text("No highlights available yet")
|
||||
.font(DS.Fonts.body)
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
Text("Highlights appear as games are played")
|
||||
.font(DS.Fonts.bodySmall)
|
||||
.foregroundStyle(DS.Colors.textQuaternary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 80)
|
||||
}
|
||||
|
||||
// MARK: - Platform sizing
|
||||
|
||||
private func timeAgo(_ date: Date) -> String {
|
||||
let interval = Date().timeIntervalSince(date)
|
||||
if interval < 60 { return "Just now" }
|
||||
if interval < 3600 { return "\(Int(interval / 60))m ago" }
|
||||
if interval < 86400 { return "\(Int(interval / 3600))h ago" }
|
||||
return "\(Int(interval / 86400))d ago"
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
private var edgeInset: CGFloat { 60 }
|
||||
private var thumbnailWidth: CGFloat { 300 }
|
||||
private var thumbnailHeight: CGFloat { 160 }
|
||||
private var thumbnailLogoSize: CGFloat { 48 }
|
||||
private var thumbnailLogoGap: CGFloat { 20 }
|
||||
private var playIconSize: CGFloat { 40 }
|
||||
private var atFontSize: CGFloat { 22 }
|
||||
private var headlineFont: Font { .system(size: 24, weight: .semibold) }
|
||||
private var gameTagFont: Font { .system(size: 22, weight: .bold, design: .rounded) }
|
||||
private var badgeFont: Font { .system(size: 18, weight: .bold, design: .rounded) }
|
||||
private var chipValueFont: Font { .system(size: 20, weight: .black, design: .rounded) }
|
||||
private var chipLabelFont: Font { .system(size: 14, weight: .bold, design: .rounded) }
|
||||
#else
|
||||
private var edgeInset: CGFloat { 20 }
|
||||
private var thumbnailWidth: CGFloat { 180 }
|
||||
private var thumbnailHeight: CGFloat { 100 }
|
||||
private var thumbnailLogoSize: CGFloat { 32 }
|
||||
private var thumbnailLogoGap: CGFloat { 12 }
|
||||
private var playIconSize: CGFloat { 28 }
|
||||
private var atFontSize: CGFloat { 14 }
|
||||
private var headlineFont: Font { .system(size: 15, weight: .semibold) }
|
||||
private var gameTagFont: Font { .system(size: 12, weight: .bold, design: .rounded) }
|
||||
private var badgeFont: Font { .system(size: 11, weight: .bold, design: .rounded) }
|
||||
private var chipValueFont: Font { .system(size: 14, weight: .black, design: .rounded) }
|
||||
private var chipLabelFont: Font { .system(size: 10, weight: .bold, design: .rounded) }
|
||||
#endif
|
||||
}
|
||||
@@ -3,11 +3,12 @@ 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 })
|
||||
game.broadcasts.contains(where: { broadcast in
|
||||
viewModel.activeStreams.contains(where: { $0.id == broadcast.id })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,287 +17,312 @@ struct GameCardView: View {
|
||||
|
||||
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))
|
||||
VStack(alignment: .leading, spacing: cardSpacing) {
|
||||
headerRow
|
||||
matchupBlock
|
||||
footerBlock
|
||||
}
|
||||
.padding(.horizontal, 22)
|
||||
|
||||
Spacer(minLength: 14)
|
||||
|
||||
footer
|
||||
.padding(.horizontal, 22)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 320, maxHeight: 320, alignment: .topLeading)
|
||||
.frame(maxWidth: .infinity, minHeight: cardHeight, alignment: .topLeading)
|
||||
.padding(cardPad)
|
||||
.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
|
||||
)
|
||||
.overlay(cardBorder)
|
||||
.clipShape(RoundedRectangle(cornerRadius: cardRadius, style: .continuous))
|
||||
.shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY)
|
||||
}
|
||||
.platformCardStyle()
|
||||
}
|
||||
|
||||
@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)
|
||||
private var headerRow: some View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
statusPill
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Text(subtitleText)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.82))
|
||||
.lineLimit(1)
|
||||
if inMultiView {
|
||||
chip(title: "In Multi-View", tint: DS.Colors.positive)
|
||||
}
|
||||
|
||||
Spacer(minLength: 12)
|
||||
if game.hasStreams {
|
||||
chip(title: "\(game.broadcasts.count) feed\(game.broadcasts.count == 1 ? "" : "s")", tint: DS.Colors.interactive)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compactStatus
|
||||
private var matchupBlock: some View {
|
||||
VStack(spacing: 16) {
|
||||
teamRow(team: game.awayTeam, isLeading: isWinning(away: true))
|
||||
teamRow(team: game.homeTeam, isLeading: isWinning(away: false))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func teamRow(team: TeamInfo, isWinning: Bool) -> some View {
|
||||
private var footerBlock: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
switch game.status {
|
||||
case .live:
|
||||
liveFooter
|
||||
case .final_:
|
||||
finalFooter
|
||||
case .scheduled:
|
||||
scheduledFooter
|
||||
case .unknown:
|
||||
unknownFooter
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
private func teamRow(team: TeamInfo, isLeading: Bool) -> some View {
|
||||
HStack(spacing: 14) {
|
||||
TeamLogoView(team: team, size: 46)
|
||||
.frame(width: 50, height: 50)
|
||||
TeamLogoView(team: team, size: logoSize)
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(spacing: 10) {
|
||||
Text(team.code)
|
||||
.font(.system(size: 28, weight: .black, design: .rounded))
|
||||
.font(codeFont)
|
||||
.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())
|
||||
.font(metaFont)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
Text(team.displayName)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(isWinning ? 0.88 : 0.68))
|
||||
.font(nameFont)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
|
||||
if let summary = team.standingSummary {
|
||||
Text(summary)
|
||||
.font(metaFont)
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Text(team.score.map(String.init) ?? "—")
|
||||
.font(scoreFont)
|
||||
.foregroundStyle(isLeading ? .white : DS.Colors.textSecondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var liveFooter: some View {
|
||||
if let linescore = game.linescore, !game.status.isScheduled {
|
||||
HStack(alignment: .bottom, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(game.currentInningDisplay ?? "Live")
|
||||
.font(footerTitleFont)
|
||||
.foregroundStyle(DS.Colors.live)
|
||||
|
||||
if let awayRuns = linescore.teams?.away?.runs,
|
||||
let homeRuns = linescore.teams?.home?.runs,
|
||||
let awayHits = linescore.teams?.away?.hits,
|
||||
let homeHits = linescore.teams?.home?.hits {
|
||||
HStack(spacing: 10) {
|
||||
footerMetric(label: game.awayTeam.code, value: "\(awayRuns)R \(awayHits)H")
|
||||
footerMetric(label: game.homeTeam.code, value: "\(homeRuns)R \(homeHits)H")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
DiamondView(
|
||||
balls: linescore.balls ?? 0,
|
||||
strikes: linescore.strikes ?? 0,
|
||||
outs: linescore.outs ?? 0
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 13)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(rowBackground(for: team, isWinning: isWinning))
|
||||
|
||||
MiniLinescoreView(
|
||||
linescore: linescore,
|
||||
awayCode: game.awayTeam.code,
|
||||
homeCode: game.homeTeam.code
|
||||
)
|
||||
} else {
|
||||
Text("Live update available")
|
||||
.font(footerBodyFont)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
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 finalFooter: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Final")
|
||||
.font(footerTitleFont)
|
||||
.foregroundStyle(DS.Colors.positive)
|
||||
|
||||
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))
|
||||
Text(game.scoreDisplay ?? "Game complete")
|
||||
.font(footerValueFont)
|
||||
.foregroundStyle(.white)
|
||||
|
||||
if let venue = game.venue {
|
||||
Text(venue)
|
||||
.font(footerBodyFont)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
}
|
||||
.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))
|
||||
private var scheduledFooter: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(game.startTime ?? game.status.label)
|
||||
.font(footerTitleFont)
|
||||
.foregroundStyle(DS.Colors.warning)
|
||||
|
||||
Text(game.pitchers ?? "Probable pitchers pending")
|
||||
.font(footerBodyFont)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
.lineLimit(2)
|
||||
|
||||
if let venue = game.venue {
|
||||
Text(venue)
|
||||
.font(metaFont)
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
.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")
|
||||
private var unknownFooter: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Awaiting update")
|
||||
.font(footerTitleFont)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
|
||||
if let venue = game.venue {
|
||||
Text(venue)
|
||||
.font(footerBodyFont)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
}
|
||||
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"
|
||||
private func footerMetric(label: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label)
|
||||
.font(metaFont)
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
Text(value)
|
||||
.font(footerValueFont)
|
||||
.foregroundStyle(.white)
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func footerBadge(title: String, color: Color) -> some View {
|
||||
private func chip(title: String, tint: 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())
|
||||
.font(chipFont)
|
||||
.foregroundStyle(tint)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(tint.opacity(0.12))
|
||||
.overlay {
|
||||
Capsule()
|
||||
.strokeBorder(tint.opacity(0.22), lineWidth: 1)
|
||||
}
|
||||
|
||||
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))
|
||||
private var statusPill: some View {
|
||||
switch game.status {
|
||||
case .live(let inning):
|
||||
chip(title: inning?.uppercased() ?? "LIVE", tint: DS.Colors.live)
|
||||
case .scheduled(let time):
|
||||
chip(title: time.uppercased(), tint: DS.Colors.warning)
|
||||
case .final_:
|
||||
chip(title: "FINAL", tint: DS.Colors.positive)
|
||||
case .unknown:
|
||||
chip(title: "PENDING", tint: DS.Colors.textTertiary)
|
||||
}
|
||||
}
|
||||
|
||||
private var cardBackground: some View {
|
||||
RoundedRectangle(cornerRadius: cardRadius, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
awayColor.opacity(0.18),
|
||||
Color.clear,
|
||||
homeColor.opacity(0.18)
|
||||
DS.Colors.panelFill,
|
||||
DS.Colors.panelFillMuted,
|
||||
],
|
||||
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)
|
||||
)
|
||||
.overlay(alignment: .top) {
|
||||
Rectangle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [awayColor, homeColor],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.frame(height: 5)
|
||||
.clipShape(
|
||||
RoundedRectangle(cornerRadius: cardRadius, style: .continuous)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var shadowColor: Color {
|
||||
if inMultiView { return .green.opacity(0.18) }
|
||||
if game.isLive { return .red.opacity(0.22) }
|
||||
return .black.opacity(0.22)
|
||||
private var cardBorder: some View {
|
||||
RoundedRectangle(cornerRadius: cardRadius, style: .continuous)
|
||||
.strokeBorder(borderColor, lineWidth: borderWidth)
|
||||
}
|
||||
|
||||
private func isWinning(away: Bool) -> Bool {
|
||||
guard let awayScore = game.awayTeam.score, let homeScore = game.homeTeam.score else {
|
||||
return false
|
||||
}
|
||||
return away ? awayScore > homeScore : homeScore > awayScore
|
||||
}
|
||||
|
||||
private var borderColor: Color {
|
||||
if inMultiView { return DS.Colors.positive.opacity(0.46) }
|
||||
if game.isLive { return DS.Colors.live.opacity(0.34) }
|
||||
return DS.Colors.panelStroke
|
||||
}
|
||||
|
||||
private var borderWidth: CGFloat {
|
||||
inMultiView || game.isLive ? 1.6 : 1
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
private var cardHeight: CGFloat { 270 }
|
||||
private var cardRadius: CGFloat { 28 }
|
||||
private var cardPad: CGFloat { 24 }
|
||||
private var cardSpacing: CGFloat { 18 }
|
||||
private var logoSize: CGFloat { 46 }
|
||||
private var codeFont: Font { .system(size: 24, weight: .black, design: .rounded) }
|
||||
private var nameFont: Font { .system(size: 22, weight: .bold, design: .rounded) }
|
||||
private var metaFont: Font { .system(size: 15, weight: .bold, design: .rounded) }
|
||||
private var scoreFont: Font { .system(size: 38, weight: .black, design: .rounded).monospacedDigit() }
|
||||
private var chipFont: Font { .system(size: 13, weight: .black, design: .rounded) }
|
||||
private var footerTitleFont: Font { .system(size: 18, weight: .black, design: .rounded) }
|
||||
private var footerValueFont: Font { .system(size: 18, weight: .bold, design: .rounded) }
|
||||
private var footerBodyFont: Font { .system(size: 16, weight: .semibold) }
|
||||
#else
|
||||
private var cardHeight: CGFloat { 200 }
|
||||
private var cardRadius: CGFloat { 20 }
|
||||
private var cardPad: CGFloat { 18 }
|
||||
private var cardSpacing: CGFloat { 14 }
|
||||
private var logoSize: CGFloat { 34 }
|
||||
private var codeFont: Font { .system(size: 18, weight: .black, design: .rounded) }
|
||||
private var nameFont: Font { .system(size: 16, weight: .bold, design: .rounded) }
|
||||
private var metaFont: Font { .system(size: 11, weight: .bold, design: .rounded) }
|
||||
private var scoreFont: Font { .system(size: 28, weight: .black, design: .rounded).monospacedDigit() }
|
||||
private var chipFont: Font { .system(size: 10, weight: .black, design: .rounded) }
|
||||
private var footerTitleFont: Font { .system(size: 13, weight: .black, design: .rounded) }
|
||||
private var footerValueFont: Font { .system(size: 13, weight: .bold, design: .rounded) }
|
||||
private var footerBodyFont: Font { .system(size: 12, weight: .semibold) }
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -21,7 +21,22 @@ struct GameCenterView: View {
|
||||
atBatPanel(feed: feed)
|
||||
}
|
||||
|
||||
if let wpHome = viewModel.winProbabilityHome, let wpAway = viewModel.winProbabilityAway {
|
||||
// Pitch Arsenal for current pitcher
|
||||
if let pitcherName = feed.currentPitcher?.fullName {
|
||||
PitchArsenalView(
|
||||
allPlays: feed.liveData.plays.allPlays,
|
||||
pitcherName: pitcherName
|
||||
)
|
||||
}
|
||||
|
||||
// Win Probability Chart (full game timeline)
|
||||
if !viewModel.winProbabilityHistory.isEmpty {
|
||||
WinProbabilityChartView(
|
||||
entries: viewModel.winProbabilityHistory,
|
||||
homeCode: game.homeTeam.code,
|
||||
awayCode: game.awayTeam.code
|
||||
)
|
||||
} else if let wpHome = viewModel.winProbabilityHome, let wpAway = viewModel.winProbabilityAway {
|
||||
winProbabilityPanel(home: wpHome, away: wpAway)
|
||||
}
|
||||
|
||||
|
||||
@@ -35,8 +35,30 @@ struct LeagueCenterView: View {
|
||||
}
|
||||
|
||||
scheduleSection
|
||||
.platformFocusSection()
|
||||
|
||||
#if os(tvOS)
|
||||
HStack(alignment: .top, spacing: 24) {
|
||||
standingsSection
|
||||
.frame(maxWidth: .infinity)
|
||||
.platformFocusSection()
|
||||
|
||||
if !viewModel.leagueLeaders.isEmpty {
|
||||
leadersColumnSection
|
||||
.frame(width: 420)
|
||||
.platformFocusSection()
|
||||
}
|
||||
}
|
||||
#else
|
||||
standingsSection
|
||||
|
||||
if !viewModel.leagueLeaders.isEmpty {
|
||||
leadersSection
|
||||
}
|
||||
#endif
|
||||
|
||||
teamsSection
|
||||
.platformFocusSection()
|
||||
|
||||
if let selectedTeam = viewModel.selectedTeam {
|
||||
teamProfileSection(team: selectedTeam)
|
||||
@@ -69,21 +91,21 @@ struct LeagueCenterView: View {
|
||||
private var header: some View {
|
||||
HStack(alignment: .top, spacing: 24) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Around MLB")
|
||||
Text("League Center")
|
||||
.font(.system(size: 42, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
Text("Schedules, standings, team context, roster access, and player snapshots in one control room.")
|
||||
Text("Schedule navigation, standings, league leaders, roster access, and player snapshots in one board.")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.58))
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 12) {
|
||||
infoPill(title: "\(viewModel.scheduleGames.count)", label: "Games", color: .blue)
|
||||
infoPill(title: "\(viewModel.leagueLeaders.count)", label: "Leaders", color: .blue)
|
||||
infoPill(title: "\(viewModel.teams.count)", label: "Teams", color: .green)
|
||||
infoPill(title: "\(viewModel.standings.count)", label: "Divisions", color: .orange)
|
||||
infoPill(title: "\(viewModel.scheduleGames.count)", label: "Games", color: .orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,11 +116,11 @@ struct LeagueCenterView: View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Schedule")
|
||||
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
Text(viewModel.displayDateString)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -138,36 +160,38 @@ struct LeagueCenterView: View {
|
||||
selectedGame = linkedGame
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 18) {
|
||||
HStack(spacing: 0) {
|
||||
teamMiniColumn(team: game.teams.away)
|
||||
.frame(width: scheduleTeamColWidth, alignment: .leading)
|
||||
|
||||
VStack(spacing: 6) {
|
||||
Text(scoreText(for: game))
|
||||
.font(.system(size: 28, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
.monospacedDigit()
|
||||
|
||||
Text(statusText(for: game))
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(statusColor(for: game))
|
||||
}
|
||||
.frame(width: 160)
|
||||
.frame(width: scheduleScoreColWidth)
|
||||
|
||||
teamMiniColumn(team: game.teams.home, alignTrailing: true)
|
||||
|
||||
Spacer()
|
||||
.frame(width: scheduleTeamColWidth, alignment: .trailing)
|
||||
|
||||
VStack(alignment: .trailing, spacing: 6) {
|
||||
if let venue = game.venue?.name {
|
||||
Text(venue)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.56))
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
.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))
|
||||
.foregroundStyle(linkedGame != nil ? .blue.opacity(0.95) : DS.Colors.textQuaternary)
|
||||
}
|
||||
.frame(width: scheduleVenueColWidth, alignment: .trailing)
|
||||
}
|
||||
.padding(22)
|
||||
.background(sectionPanel)
|
||||
@@ -176,6 +200,16 @@ struct LeagueCenterView: View {
|
||||
.disabled(linkedGame == nil)
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
private var scheduleTeamColWidth: CGFloat { 340 }
|
||||
private var scheduleScoreColWidth: CGFloat { 160 }
|
||||
private var scheduleVenueColWidth: CGFloat { 220 }
|
||||
#else
|
||||
private var scheduleTeamColWidth: CGFloat { 200 }
|
||||
private var scheduleScoreColWidth: CGFloat { 120 }
|
||||
private var scheduleVenueColWidth: CGFloat { 160 }
|
||||
#endif
|
||||
|
||||
private func teamMiniColumn(team: StatsTeamGameInfo, alignTrailing: Bool = false) -> some View {
|
||||
let info = TeamInfo(
|
||||
code: team.team.abbreviation ?? "MLB",
|
||||
@@ -193,17 +227,17 @@ struct LeagueCenterView: View {
|
||||
VStack(alignment: alignTrailing ? .trailing : .leading, spacing: 6) {
|
||||
Text(info.code)
|
||||
.font(.system(size: 22, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
Text(info.displayName)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
.lineLimit(1)
|
||||
|
||||
if let record = info.record {
|
||||
Text(record)
|
||||
.font(.system(size: 13, weight: .bold, design: .monospaced))
|
||||
.foregroundStyle(.white.opacity(0.46))
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,26 +245,90 @@ struct LeagueCenterView: View {
|
||||
TeamLogoView(team: info, size: 56)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: alignTrailing ? .trailing : .leading)
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
private var leadersColumnSection: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
HStack {
|
||||
Text("Leaders")
|
||||
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if viewModel.isLoadingLeaders {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
LazyVStack(spacing: DS.Spacing.cardGap) {
|
||||
ForEach(viewModel.leagueLeaders.prefix(4)) { category in
|
||||
LeaderboardView(category: category)
|
||||
.platformFocusable()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private var leadersSection: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
HStack {
|
||||
Text("League Leaders")
|
||||
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if viewModel.isLoadingLeaders {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
|
||||
ScrollView(.horizontal) {
|
||||
LazyHStack(spacing: DS.Spacing.cardGap) {
|
||||
ForEach(viewModel.leagueLeaders) { category in
|
||||
LeaderboardView(category: category)
|
||||
.frame(width: leaderCardWidth)
|
||||
.platformFocusable()
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.platformFocusSection()
|
||||
.scrollClipDisabled()
|
||||
}
|
||||
}
|
||||
|
||||
private var leaderCardWidth: CGFloat {
|
||||
#if os(tvOS)
|
||||
380
|
||||
#else
|
||||
280
|
||||
#endif
|
||||
}
|
||||
|
||||
private var standingsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text("Standings")
|
||||
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
if viewModel.standings.isEmpty && viewModel.isLoadingOverview {
|
||||
loadingPanel(title: "Loading standings...")
|
||||
} else {
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: 18) {
|
||||
LazyHStack(spacing: 18) {
|
||||
ForEach(viewModel.standings, id: \.division?.id) { record in
|
||||
standingsCard(record)
|
||||
.frame(width: 360)
|
||||
.platformFocusable()
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.platformFocusSection()
|
||||
.scrollClipDisabled()
|
||||
@@ -242,31 +340,31 @@ struct LeagueCenterView: View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text(record.division?.name ?? "Division")
|
||||
.font(.system(size: 22, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
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))
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
.frame(width: 22)
|
||||
|
||||
Text(team.team.abbreviation ?? team.team.name ?? "MLB")
|
||||
.font(.system(size: 16, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
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))
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
}
|
||||
|
||||
Text(team.gamesBack ?? "-")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.45))
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
.frame(width: 44, alignment: .trailing)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
@@ -281,7 +379,7 @@ struct LeagueCenterView: View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text("Teams")
|
||||
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: 16) {
|
||||
@@ -295,11 +393,11 @@ struct LeagueCenterView: View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(team.abbreviation)
|
||||
.font(.system(size: 20, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
Text(team.name)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.76))
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
@@ -307,7 +405,7 @@ struct LeagueCenterView: View {
|
||||
|
||||
Text(team.recordText ?? "Season")
|
||||
.font(.system(size: 13, weight: .bold, design: .monospaced))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
}
|
||||
.frame(width: 210, height: 220, alignment: .leading)
|
||||
.padding(18)
|
||||
@@ -317,7 +415,7 @@ struct LeagueCenterView: View {
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.stroke(.white.opacity(0.08), lineWidth: 1)
|
||||
.stroke(DS.Colors.panelStroke, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.platformCardStyle()
|
||||
@@ -334,7 +432,7 @@ struct LeagueCenterView: View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text("Team Profile")
|
||||
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
if viewModel.isLoadingTeam {
|
||||
loadingPanel(title: "Loading team profile...")
|
||||
@@ -345,7 +443,7 @@ struct LeagueCenterView: View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(team.name)
|
||||
.font(.system(size: 34, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
detailChip(team.recordText ?? "Season", color: .blue)
|
||||
@@ -379,7 +477,7 @@ struct LeagueCenterView: View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text("Roster")
|
||||
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
LazyVGrid(columns: rosterColumns, spacing: 14) {
|
||||
ForEach(viewModel.roster) { player in
|
||||
@@ -392,12 +490,12 @@ struct LeagueCenterView: View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(player.fullName)
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
.lineLimit(2)
|
||||
|
||||
Text("\(player.positionAbbreviation ?? "P") · #\(player.jerseyNumber ?? "--")")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -415,7 +513,7 @@ struct LeagueCenterView: View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text("Player Profile")
|
||||
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
if viewModel.isLoadingPlayer {
|
||||
loadingPanel(title: "Loading player profile...")
|
||||
@@ -429,7 +527,7 @@ struct LeagueCenterView: View {
|
||||
if let primaryNumber = player.primaryNumber {
|
||||
Text("#\(primaryNumber)")
|
||||
.font(.system(size: 15, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
}
|
||||
|
||||
if let position = player.primaryPosition {
|
||||
@@ -439,7 +537,7 @@ struct LeagueCenterView: View {
|
||||
|
||||
Text(player.fullName)
|
||||
.font(.system(size: 34, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
profileLine(label: "Age", value: player.currentAge.map(String.init))
|
||||
@@ -459,20 +557,20 @@ struct LeagueCenterView: View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("\(group.title) \(player.seasonLabel)")
|
||||
.font(.system(size: 18, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
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))
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(item.value)
|
||||
.font(.system(size: 18, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -481,14 +579,14 @@ struct LeagueCenterView: View {
|
||||
.padding(18)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(.white.opacity(0.05))
|
||||
.fill(DS.Colors.panelStroke)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("No regular-season MLB stats available for \(player.seasonLabel).")
|
||||
.font(.system(size: 18, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
@@ -511,10 +609,10 @@ struct LeagueCenterView: View {
|
||||
default:
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(.white.opacity(0.08))
|
||||
.fill(DS.Colors.panelStroke)
|
||||
Image(systemName: "person.fill")
|
||||
.font(.system(size: size * 0.34, weight: .bold))
|
||||
.foregroundStyle(.white.opacity(0.32))
|
||||
.foregroundStyle(DS.Colors.textQuaternary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -522,7 +620,7 @@ struct LeagueCenterView: View {
|
||||
.clipShape(Circle())
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(.white.opacity(0.08), lineWidth: 1)
|
||||
.stroke(DS.Colors.panelStroke, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -537,12 +635,12 @@ struct LeagueCenterView: View {
|
||||
return HStack(spacing: 10) {
|
||||
Text(label)
|
||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.45))
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
.frame(width: 92, alignment: .leading)
|
||||
|
||||
Text(displayValue)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.82))
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,7 +671,7 @@ struct LeagueCenterView: View {
|
||||
.monospacedDigit()
|
||||
Text(label)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 12)
|
||||
@@ -588,10 +686,10 @@ struct LeagueCenterView: View {
|
||||
Text(title)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(.white.opacity(0.84))
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(.white.opacity(0.08))
|
||||
.background(DS.Colors.panelStroke)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.platformCardStyle()
|
||||
@@ -602,7 +700,7 @@ struct LeagueCenterView: View {
|
||||
Spacer()
|
||||
ProgressView(title)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.75))
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 34)
|
||||
@@ -633,7 +731,7 @@ struct LeagueCenterView: View {
|
||||
return .red.opacity(0.9)
|
||||
}
|
||||
if game.isFinal {
|
||||
return .white.opacity(0.72)
|
||||
return DS.Colors.textSecondary
|
||||
}
|
||||
return .blue.opacity(0.9)
|
||||
}
|
||||
@@ -644,36 +742,24 @@ struct LeagueCenterView: View {
|
||||
|
||||
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 {
|
||||
.fill(
|
||||
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),
|
||||
DS.Colors.panelFill,
|
||||
DS.Colors.panelFillMuted,
|
||||
],
|
||||
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)
|
||||
)
|
||||
.shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
|
||||
private var screenBackground: some View {
|
||||
BroadcastBackground()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,60 +14,260 @@ struct SettingsView: View {
|
||||
var body: some View {
|
||||
@Bindable var vm = viewModel
|
||||
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Server") {
|
||||
LabeledContent("URL", value: viewModel.serverBaseURL)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 26) {
|
||||
header
|
||||
|
||||
settingsPanel(
|
||||
title: "Server",
|
||||
subtitle: "Current upstream endpoint and playback defaults."
|
||||
) {
|
||||
infoRow(label: "Base URL", value: viewModel.serverBaseURL)
|
||||
infoRow(label: "Current Quality", value: resolutionLabel(for: viewModel.defaultResolution))
|
||||
}
|
||||
|
||||
Section("Default Quality") {
|
||||
ForEach(resolutions, id: \.0) { res in
|
||||
settingsPanel(
|
||||
title: "Playback Quality",
|
||||
subtitle: "Preferred stream profile for newly opened feeds."
|
||||
) {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(resolutions, id: \.0) { resolution in
|
||||
Button {
|
||||
vm.defaultResolution = res.0
|
||||
vm.defaultResolution = resolution.0
|
||||
} label: {
|
||||
HStack {
|
||||
Text(res.1)
|
||||
HStack(spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(resolution.1)
|
||||
.font(optionTitleFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
Text(resolutionDescription(for: resolution.0))
|
||||
.font(optionBodyFont)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
if viewModel.defaultResolution == res.0 {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.blue)
|
||||
|
||||
Image(systemName: viewModel.defaultResolution == resolution.0 ? "checkmark.circle.fill" : "circle")
|
||||
.font(.system(size: indicatorSize, weight: .bold))
|
||||
.foregroundStyle(viewModel.defaultResolution == resolution.0 ? DS.Colors.interactive : DS.Colors.textQuaternary)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 16)
|
||||
.background(optionBackground(selected: viewModel.defaultResolution == resolution.0))
|
||||
}
|
||||
.platformCardStyle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Active Streams (\(viewModel.activeStreams.count)/4)") {
|
||||
settingsPanel(
|
||||
title: "Active Streams",
|
||||
subtitle: "Tile occupancy and cleanup controls for Multi-View."
|
||||
) {
|
||||
if viewModel.activeStreams.isEmpty {
|
||||
Text("No active streams")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No active streams. Add broadcasts from the dashboard to populate the grid.")
|
||||
.font(optionBodyFont)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(viewModel.activeStreams) { stream in
|
||||
HStack {
|
||||
HStack(spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(stream.label)
|
||||
.fontWeight(.bold)
|
||||
.font(optionTitleFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
Text(stream.game.displayTitle)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(optionBodyFont)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(role: .destructive) {
|
||||
viewModel.removeStream(id: stream.id)
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
Label("Remove", systemImage: "trash")
|
||||
.font(actionFont)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(DS.Colors.live.opacity(0.82))
|
||||
)
|
||||
}
|
||||
.platformCardStyle()
|
||||
}
|
||||
}
|
||||
Button("Clear All Streams", role: .destructive) {
|
||||
viewModel.clearAllStreams()
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 16)
|
||||
.background(optionBackground(selected: false))
|
||||
}
|
||||
}
|
||||
|
||||
Section("About") {
|
||||
LabeledContent("Version", value: "1.0")
|
||||
LabeledContent("Server", value: "mlbserver")
|
||||
Button(role: .destructive) {
|
||||
viewModel.clearAllStreams()
|
||||
} label: {
|
||||
Label("Clear All Streams", systemImage: "xmark.circle.fill")
|
||||
.font(actionFont)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 14)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(DS.Colors.live.opacity(0.82))
|
||||
)
|
||||
}
|
||||
.platformCardStyle()
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
|
||||
settingsPanel(
|
||||
title: "About",
|
||||
subtitle: "Build and environment context."
|
||||
) {
|
||||
infoRow(label: "Version", value: "1.0")
|
||||
infoRow(label: "Player", value: "mlbserver")
|
||||
infoRow(label: "Active Tiles", value: "\(viewModel.activeStreams.count)/4")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
.padding(.vertical, verticalPadding)
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Settings")
|
||||
.font(headerTitleFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
Text("Playback defaults, server routing, and Multi-View controls.")
|
||||
.font(headerBodyFont)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func settingsPanel<Content: View>(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
@ViewBuilder content: () -> Content
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(sectionTitleFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
Text(subtitle)
|
||||
.font(sectionBodyFont)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
}
|
||||
|
||||
content()
|
||||
}
|
||||
.padding(panelPadding)
|
||||
.background(panelBackground)
|
||||
}
|
||||
|
||||
private func infoRow(label: String, value: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
Text(label)
|
||||
.font(infoLabelFont)
|
||||
.foregroundStyle(DS.Colors.textTertiary)
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
|
||||
Text(value)
|
||||
.font(infoValueFont)
|
||||
.foregroundStyle(DS.Colors.textPrimary)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private func optionBackground(selected: Bool) -> some View {
|
||||
RoundedRectangle(cornerRadius: optionRadius, style: .continuous)
|
||||
.fill(selected ? DS.Colors.interactive.opacity(0.14) : DS.Colors.panelFillMuted)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: optionRadius, style: .continuous)
|
||||
.strokeBorder(selected ? DS.Colors.interactive.opacity(0.26) : DS.Colors.panelStroke, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolutionLabel(for key: String) -> String {
|
||||
resolutions.first(where: { $0.0 == key })?.1 ?? key
|
||||
}
|
||||
|
||||
private func resolutionDescription(for key: String) -> String {
|
||||
switch key {
|
||||
case "best":
|
||||
return "Uses the highest reliable stream available."
|
||||
case "1080p60":
|
||||
return "Prioritizes smooth full-HD playback when available."
|
||||
case "720p60":
|
||||
return "Balanced quality for lighter network conditions."
|
||||
case "540p":
|
||||
return "Lower bandwidth option for stability."
|
||||
case "adaptive":
|
||||
return "Lets the player adjust dynamically."
|
||||
default:
|
||||
return "Custom playback preference."
|
||||
}
|
||||
}
|
||||
|
||||
private var panelBackground: some View {
|
||||
RoundedRectangle(cornerRadius: panelRadius, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
DS.Colors.panelFill,
|
||||
DS.Colors.panelFillMuted,
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: panelRadius, style: .continuous)
|
||||
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
|
||||
}
|
||||
.shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY)
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
private var horizontalPadding: CGFloat { 56 }
|
||||
private var verticalPadding: CGFloat { 40 }
|
||||
private var panelPadding: CGFloat { 24 }
|
||||
private var panelRadius: CGFloat { 28 }
|
||||
private var optionRadius: CGFloat { 22 }
|
||||
private var labelWidth: CGFloat { 150 }
|
||||
private var indicatorSize: CGFloat { 22 }
|
||||
private var headerTitleFont: Font { .system(size: 44, weight: .black, design: .rounded) }
|
||||
private var headerBodyFont: Font { .system(size: 18, weight: .medium) }
|
||||
private var sectionTitleFont: Font { .system(size: 28, weight: .black, design: .rounded) }
|
||||
private var sectionBodyFont: Font { .system(size: 16, weight: .medium) }
|
||||
private var infoLabelFont: Font { .system(size: 15, weight: .black, design: .rounded) }
|
||||
private var infoValueFont: Font { .system(size: 18, weight: .bold, design: .rounded) }
|
||||
private var optionTitleFont: Font { .system(size: 20, weight: .bold, design: .rounded) }
|
||||
private var optionBodyFont: Font { .system(size: 15, weight: .medium) }
|
||||
private var actionFont: Font { .system(size: 14, weight: .black, design: .rounded) }
|
||||
#else
|
||||
private var horizontalPadding: CGFloat { 20 }
|
||||
private var verticalPadding: CGFloat { 24 }
|
||||
private var panelPadding: CGFloat { 18 }
|
||||
private var panelRadius: CGFloat { 22 }
|
||||
private var optionRadius: CGFloat { 18 }
|
||||
private var labelWidth: CGFloat { 100 }
|
||||
private var indicatorSize: CGFloat { 18 }
|
||||
private var headerTitleFont: Font { .system(size: 28, weight: .black, design: .rounded) }
|
||||
private var headerBodyFont: Font { .system(size: 14, weight: .medium) }
|
||||
private var sectionTitleFont: Font { .system(size: 20, weight: .black, design: .rounded) }
|
||||
private var sectionBodyFont: Font { .system(size: 13, weight: .medium) }
|
||||
private var infoLabelFont: Font { .system(size: 12, weight: .black, design: .rounded) }
|
||||
private var infoValueFont: Font { .system(size: 14, weight: .bold, design: .rounded) }
|
||||
private var optionTitleFont: Font { .system(size: 16, weight: .bold, design: .rounded) }
|
||||
private var optionBodyFont: Font { .system(size: 12, weight: .medium) }
|
||||
private var actionFont: Font { .system(size: 12, weight: .black, design: .rounded) }
|
||||
#endif
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user