Compare commits

3 Commits

Author SHA1 Message Date
Trey t
da033cf12c Fix NSFW sheet scroll on iOS/iPad, clean up audio pin
- WerkoutNSFWSheet: wrap content in ScrollView + ViewThatFits(in: .horizontal)
  so iPad's narrow sheet width falls back to VStack and content scrolls.
- Tighten padding on compact layouts (38→24).
- Revert AAC-preference in pinAudioSelection (stream is all AAC, no Dolby).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 19:30:25 -05:00
Trey t
08ad702f9d Add AudioDiagnostics heartbeat logging
Adds [AUDIO]-prefixed logs to both single-stream and multi-stream players:
1 Hz heartbeat with rate/timeControl/mute/volume/bitrate/route, plus
immediate events on rate, isMuted, volume, currentItem, media selection,
access-log, error-log, and system audio route/interruption changes.

Grep Xcode console for `[AUDIO]` or `[AUDIO SYSTEM]` to isolate.

Also reverts the AAC-preference in pinAudioSelection: the
ballgame.treytartt.com master playlist is already all mp4a.40.2 stereo,
so the Dolby-DRC theory doesn't fit. Pin simply selects the default
audible option now.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:28:15 -05:00
Trey t
85a19fdd71 Fix mid-stream audio loudness jumps
Root cause: the quality upgrade path called replaceCurrentItem mid-stream,
which re-loaded the HLS master manifest and re-picked an audio rendition,
producing a perceived loudness jump 10-30s into playback. .moviePlayback
mode amplified this by re-initializing cinematic audio processing on each
variant change.

- Start streams directly at user's desiredResolution; remove
  scheduleQualityUpgrade, qualityUpgradeTask, and the 504p->best swap.
- Switch AVAudioSession mode from .moviePlayback to .default in both
  MultiStreamView and SingleStreamPlayerView.
- Pin the HLS audio rendition by selecting the default audible
  MediaSelectionGroup option on every new AVPlayerItem, preventing
  ABR from swapping channel layouts mid-stream.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:04:39 -05:00
31 changed files with 1544 additions and 3805 deletions

View File

@@ -4,17 +4,15 @@ 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: "Today"
case .league: "Intel"
case .games: "Games"
case .league: "League"
case .multiView: "Multi-View"
case .feed: "Feed"
case .settings: "Settings"
}
}
@@ -22,9 +20,8 @@ private enum mlbIOSSection: String, CaseIterable, Identifiable {
var systemImage: String {
switch self {
case .games: "sportscourt.fill"
case .league: "chart.bar.fill"
case .league: "list.bullet.rectangle.portrait.fill"
case .multiView: "rectangle.split.2x2.fill"
case .feed: "newspaper.fill"
case .settings: "gearshape.fill"
}
}
@@ -61,18 +58,15 @@ struct mlbIOSRootView: View {
private var compactTabs: some View {
TabView {
Tab("Today", systemImage: "sportscourt.fill") {
Tab("Games", systemImage: "sportscourt.fill") {
DashboardView()
}
Tab("Intel", systemImage: "chart.bar.fill") {
Tab("League", systemImage: "list.bullet.rectangle.portrait.fill") {
LeagueCenterView()
}
Tab(multiViewTitle, systemImage: "rectangle.split.2x2.fill") {
MultiStreamView()
}
Tab("Feed", systemImage: "newspaper.fill") {
FeedView()
}
Tab("Settings", systemImage: "gearshape.fill") {
SettingsView()
}
@@ -105,8 +99,6 @@ struct mlbIOSRootView: View {
LeagueCenterView()
case .multiView:
MultiStreamView()
case .feed:
FeedView()
case .settings:
SettingsView()
}

View File

@@ -73,32 +73,6 @@
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 */
@@ -155,19 +129,6 @@
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 */
@@ -176,7 +137,6 @@
children = (
F5DA48863884BB6B4983BEE6 /* GameCenterViewModel.swift */,
9BD2DEA6BBA55E86B2555099 /* GamesViewModel.swift */,
C920FA380D9DDDED113068E3 /* FeedViewModel.swift */,
7E9E572D932D96FE2C5BD7A4 /* LeagueCenterViewModel.swift */,
);
path = ViewModels;
@@ -210,16 +170,6 @@
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 */,
@@ -277,7 +227,6 @@
isa = PBXGroup;
children = (
70E610D24BA83FB2F7490B00 /* MLBServerAPI.swift */,
7EB8E3E205222A55700CF24C /* MLBWebDataService.swift */,
6067FE4F340FC0DCCFA62C67 /* MLBStatsAPI.swift */,
C3A003EA560655E4B2200DBE /* VideoShuffle.swift */,
);
@@ -289,7 +238,6 @@
children = (
3FB99FBEDA096B95C3D2E5B4 /* ContentView.swift */,
5EA4C33F029665B80F1A6B6D /* DashboardView.swift */,
981C95E73FFDAFE2E26B06C2 /* FeedView.swift */,
102E5E8674E188A04637D3EB /* FeaturedGameCard.swift */,
E21E3115B8A9B4C798ECE828 /* GameCardView.swift */,
E80EF8B01452220FA6BCD9B2 /* GameCenterView.swift */,
@@ -444,19 +392,6 @@
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 */,
@@ -504,19 +439,6 @@
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 */,

View File

@@ -59,21 +59,4 @@ 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")
}
}

View File

@@ -305,7 +305,6 @@ actor MLBServerAPI {
struct Highlight: Codable, Sendable, Identifiable {
let id: String?
let headline: String?
let date: String?
let playbacks: [Playback]?
struct Playback: Codable, Sendable {

View File

@@ -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,division"
"\(baseURL)/standings?leagueId=103,104&season=\(season)&hydrate=team"
)
return response.records
}

View File

@@ -1,236 +0,0 @@
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?
}

View File

@@ -1,122 +0,0 @@
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
}
}

View File

@@ -16,7 +16,6 @@ final class GameCenterViewModel {
var highlights: [Highlight] = []
var winProbabilityHome: Double?
var winProbabilityAway: Double?
var winProbabilityHistory: [WinProbabilityEntry] = []
var isLoading = false
var errorMessage: String?
var lastUpdated: Date?
@@ -82,7 +81,6 @@ 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 {

View File

@@ -7,8 +7,6 @@ final class LeagueCenterViewModel {
var scheduleGames: [StatsGame] = []
var standings: [StandingsDivisionRecord] = []
var teams: [LeagueTeamSummary] = []
var leagueLeaders: [LeaderCategory] = []
var isLoadingLeaders = false
var selectedTeam: TeamProfile?
var roster: [RosterPlayerSummary] = []
@@ -23,7 +21,6 @@ final class LeagueCenterViewModel {
var playerErrorMessage: String?
private let statsAPI = MLBStatsAPI()
private let webService = MLBWebDataService()
private(set) var scheduleDate = Date()
private var seasonString: String {
@@ -59,19 +56,6 @@ 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 {

View File

@@ -1,254 +0,0 @@
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"
}
}
}

View File

@@ -1,77 +0,0 @@
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
}
}

View File

@@ -1,180 +0,0 @@
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())
}
}

View File

@@ -1,120 +0,0 @@
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
}

View File

@@ -1,3 +0,0 @@
import SwiftUI
// Placeholder highlight cards are now inline in FeedView

View File

@@ -1,80 +0,0 @@
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
}

View File

@@ -1,101 +0,0 @@
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
}
}

View File

@@ -1,52 +0,0 @@
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
}

View File

@@ -1,131 +0,0 @@
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?
}

View File

@@ -1,7 +1,5 @@
import SwiftUI
// MARK: - iOS Press Style
struct PlatformPressButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
@@ -11,42 +9,11 @@ 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(TVFocusButtonStyle())
self.buttonStyle(.card)
#else
self.buttonStyle(PlatformPressButtonStyle())
#endif

View File

@@ -19,10 +19,10 @@ struct ScoresTickerView: View {
.padding(.vertical, 14)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(DS.Colors.navFill)
.fill(.black.opacity(0.72))
.overlay {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
}
)
.accessibilityHidden(true)

View File

@@ -1,94 +0,0 @@
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
}

View File

@@ -2,61 +2,32 @@ import SwiftUI
struct ContentView: View {
@Environment(GamesViewModel.self) private var viewModel
@State private var selectedSection: AppSection = .today
private var showsTicker: Bool {
selectedSection != .multiView && !viewModel.games.isEmpty
private var multiViewLabel: String {
let count = viewModel.activeStreams.count
if count > 0 {
return "Multi-View (\(count))"
}
return "Multi-View"
}
var body: some View {
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()
case .intel:
LeagueCenterView()
case .highlights:
FeedView()
case .multiView:
MultiStreamView()
case .settings:
SettingsView()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.platformFocusSection()
TabView {
Tab("Games", systemImage: "sportscourt.fill") {
DashboardView()
}
Tab("League", systemImage: "list.bullet.rectangle.portrait.fill") {
LeagueCenterView()
}
Tab(multiViewLabel, systemImage: "rectangle.split.2x2.fill") {
MultiStreamView()
}
Tab("Settings", systemImage: "gearshape.fill") {
SettingsView()
}
}
.task {
await viewModel.loadGames()
}
}
#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
}

View File

@@ -84,93 +84,70 @@ struct DashboardView: View {
private var shelfCardWidth: CGFloat {
#if os(iOS)
horizontalSizeClass == .compact ? 340 : 500
horizontalSizeClass == .compact ? 300 : 360
#else
540
400
#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 {
loadingState
HStack {
Spacer()
ProgressView("Loading games...")
.font(.title3)
Spacer()
}
.padding(.top, 80)
} else if let error = viewModel.errorMessage, viewModel.games.isEmpty {
errorState(error)
HStack {
Spacer()
VStack(spacing: 20) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 50))
.foregroundStyle(.secondary)
Text(error)
.font(.title3)
.foregroundStyle(.secondary)
Button("Retry") {
Task { await viewModel.loadGames() }
}
}
Spacer()
}
.padding(.top, 80)
} else {
overviewStrip
.platformFocusSection()
heroAndControlSection
.platformFocusSection()
featuredChannelsSection
.platformFocusSection()
// Hero featured game
if let featured = viewModel.featuredGame {
FeaturedGameCard(game: featured) {
selectedGame = featured
}
}
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
)
gameShelf(title: "Live", icon: "antenna.radiowaves.left.and.right", 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
)
gameShelf(title: "Upcoming", icon: "calendar", 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
)
gameShelf(title: "Final", icon: "checkmark.circle", games: viewModel.finalGames, excludeId: viewModel.featuredGame?.id)
}
}
featuredChannelsSection
if !viewModel.activeStreams.isEmpty {
multiViewStatus
}
}
.padding(.horizontal, horizontalPadding)
.padding(.vertical, verticalPadding)
}
.scrollIndicators(.hidden)
.onAppear {
logDashboard("DashboardView appeared")
viewModel.startAutoRefresh()
@@ -363,627 +340,231 @@ struct DashboardView: View {
)
}
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())
}
// MARK: - Game Shelf (Horizontal)
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)
@ViewBuilder
private func gameShelf(title: String, icon: String, games: [Game], excludeId: String?) -> some View {
let filtered = games.filter { $0.id != excludeId }
if !filtered.isEmpty {
VStack(alignment: .leading, spacing: 20) {
Label(title, systemImage: icon)
.font(.title3.weight(.bold))
.foregroundStyle(.secondary)
Text(error)
.font(sectionTitleFont)
.foregroundStyle(DS.Colors.textPrimary)
Button("Reload Board") {
Task { await viewModel.loadGames() }
ScrollView(.horizontal) {
LazyHStack(spacing: 30) {
ForEach(filtered) { game in
GameCardView(game: game) {
selectedGame = game
}
.frame(width: shelfCardWidth)
}
}
.padding(.vertical, 12)
}
.scrollClipDisabled()
}
.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())
}
// MARK: - Header
@ViewBuilder
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(spacing: 24) {
HStack(alignment: .bottom) {
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)
Text("MLB")
.font(.headline.weight(.black))
.foregroundStyle(.secondary)
.kerning(4)
Text(viewModel.displayDateString)
.font(.system(size: 40, weight: .bold))
.contentTransition(.numericText())
}
Spacer()
HStack(spacing: 16) {
statPill("\(viewModel.games.count)", label: "Games")
if !viewModel.liveGames.isEmpty {
statPill("\(viewModel.liveGames.count)", label: "Live", color: .red)
}
if !viewModel.activeStreams.isEmpty {
statPill("\(viewModel.activeStreams.count)/4", label: "Streams", color: .green)
}
}
}
HStack(spacing: 16) {
Button {
Task { await viewModel.goToPreviousDay() }
} label: {
Label("Previous Day", systemImage: "chevron.left")
}
if !viewModel.isToday {
Button {
Task { await viewModel.goToToday() }
} label: {
Label("Today", systemImage: "calendar")
}
.tint(.blue)
}
Button {
Task { await viewModel.goToNextDay() }
} label: {
HStack(spacing: 6) {
Text("Next Day")
Image(systemName: "chevron.right")
}
}
Spacer()
}
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: 18) {
HStack(alignment: .bottom, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Label(title, systemImage: icon)
.font(sectionTitleFont)
.foregroundStyle(DS.Colors.textPrimary)
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
}
.frame(width: shelfCardWidth)
}
}
.padding(.vertical, 8)
}
.scrollClipDisabled()
}
}
@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))
}
#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
// MARK: - Featured Channels
@ViewBuilder
private var featuredChannelsSection: some View {
HStack(spacing: DS.Spacing.cardGap) {
mlbNetworkCard
.frame(maxWidth: .infinity)
nsfwVideosCard
.frame(maxWidth: .infinity)
ViewThatFits {
HStack(alignment: .top, spacing: 24) {
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" })
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"
) {
Button {
showMLBNetworkSheet = true
}
}
private var nsfwVideosCard: some View {
let added = viewModel.activeStreams.contains(where: { $0.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID })
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
}
}
private var multiViewStatus: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Multi-View Status")
.font(railTitleFont)
.foregroundStyle(DS.Colors.textPrimary)
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 {
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)
}
)
}
}
}
}
.padding(24)
.background(surfaceCardBackground())
}
private func channelCard(
title: String,
subtitle: String,
systemImage: String,
tint: Color,
status: String,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
} label: {
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))
)
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: 5) {
Text(title)
.font(radarRowTitleFont)
.foregroundStyle(DS.Colors.textPrimary)
Text(subtitle)
.font(radarRowBodyFont)
.foregroundStyle(DS.Colors.textSecondary)
.lineLimit(2)
VStack(alignment: .leading, spacing: 4) {
Text("MLB Network")
.font(.title3.weight(.bold))
Text("Live coverage, analysis & highlights")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
Spacer()
Text(status)
.font(radarRowStatusFont)
.foregroundStyle(tint)
.multilineTextAlignment(.trailing)
.lineLimit(2)
if added {
Label("In Multi-View", systemImage: "checkmark.circle.fill")
.font(.subheadline.weight(.bold))
.foregroundStyle(.green)
}
}
.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)
}
)
.frame(maxWidth: .infinity, minHeight: 108, alignment: .leading)
.padding(24)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 20))
}
.platformCardStyle()
}
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, 12)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(DS.Colors.panelFillMuted)
.overlay {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
@ViewBuilder
private var nsfwVideosCard: some View {
let added = viewModel.activeStreams.contains(where: { $0.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID })
Button {
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()
if added {
Label("In Multi-View", systemImage: "checkmark.circle.fill")
.font(.subheadline.weight(.bold))
.foregroundStyle(.green)
} else {
Label("Open", systemImage: "play.fill")
.font(.subheadline.weight(.bold))
.foregroundStyle(.pink)
}
}
.frame(maxWidth: .infinity, minHeight: 108, alignment: .leading)
.padding(24)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 20))
}
.platformCardStyle()
}
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)
// MARK: - Multi-View Status
@ViewBuilder
private var multiViewStatus: some View {
VStack(alignment: .leading, spacing: 14) {
Label("Multi-View", systemImage: "rectangle.split.2x2")
.font(.title3.weight(.bold))
.foregroundStyle(.secondary)
HStack(spacing: 12) {
ForEach(viewModel.activeStreams) { stream in
HStack(spacing: 8) {
Circle().fill(.green).frame(width: 8, height: 8)
Text(stream.label)
.font(.subheadline.weight(.semibold))
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(.regularMaterial)
.clipShape(Capsule())
}
}
.shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY)
}
}
}
@@ -1042,22 +623,24 @@ struct WerkoutNSFWSheet: View {
sheetBackground
.ignoresSafeArea()
ViewThatFits {
HStack(alignment: .top, spacing: 32) {
overviewColumn
.frame(maxWidth: .infinity, alignment: .leading)
ScrollView(.vertical, showsIndicators: false) {
ViewThatFits(in: .horizontal) {
HStack(alignment: .top, spacing: 32) {
overviewColumn
.frame(maxWidth: .infinity, alignment: .leading)
actionColumn
.frame(width: 360, alignment: .leading)
}
actionColumn
.frame(width: 360, alignment: .leading)
}
VStack(alignment: .leading, spacing: 24) {
overviewColumn
actionColumn
.frame(maxWidth: .infinity, alignment: .leading)
VStack(alignment: .leading, spacing: 24) {
overviewColumn
actionColumn
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.padding(38)
.padding(usesStackedLayout ? 24 : 38)
.background(
RoundedRectangle(cornerRadius: 34, style: .continuous)
.fill(.black.opacity(0.46))

File diff suppressed because it is too large Load Diff

View File

@@ -1,261 +0,0 @@
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
}

View File

@@ -3,12 +3,11 @@ 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: { broadcast in
viewModel.activeStreams.contains(where: { $0.id == broadcast.id })
game.broadcasts.contains(where: { bc in
viewModel.activeStreams.contains(where: { $0.id == bc.id })
})
}
@@ -17,312 +16,287 @@ struct GameCardView: View {
var body: some View {
Button(action: onSelect) {
VStack(alignment: .leading, spacing: cardSpacing) {
headerRow
matchupBlock
footerBlock
VStack(alignment: .leading, spacing: 0) {
header
.padding(.horizontal, 22)
.padding(.top, 18)
.padding(.bottom, 16)
VStack(spacing: 12) {
teamRow(team: game.awayTeam, isWinning: isWinning(away: true))
teamRow(team: game.homeTeam, isWinning: isWinning(away: false))
}
.padding(.horizontal, 22)
Spacer(minLength: 14)
footer
.padding(.horizontal, 22)
.padding(.vertical, 16)
}
.frame(maxWidth: .infinity, minHeight: cardHeight, alignment: .topLeading)
.padding(cardPad)
.frame(maxWidth: .infinity, minHeight: 320, maxHeight: 320, alignment: .topLeading)
.background(cardBackground)
.overlay(cardBorder)
.clipShape(RoundedRectangle(cornerRadius: cardRadius, style: .continuous))
.shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY)
.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
)
}
.platformCardStyle()
}
private var headerRow: some View {
HStack(alignment: .center, spacing: 12) {
statusPill
Spacer(minLength: 8)
@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)
if inMultiView {
chip(title: "In Multi-View", tint: DS.Colors.positive)
Text(subtitleText)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.white.opacity(0.82))
.lineLimit(1)
}
if game.hasStreams {
chip(title: "\(game.broadcasts.count) feed\(game.broadcasts.count == 1 ? "" : "s")", tint: DS.Colors.interactive)
}
}
}
Spacer(minLength: 12)
private var matchupBlock: some View {
VStack(spacing: 16) {
teamRow(team: game.awayTeam, isLeading: isWinning(away: true))
teamRow(team: game.homeTeam, isLeading: isWinning(away: false))
compactStatus
}
}
@ViewBuilder
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 {
private func teamRow(team: TeamInfo, isWinning: Bool) -> some View {
HStack(spacing: 14) {
TeamLogoView(team: team, size: logoSize)
TeamLogoView(team: team, size: 46)
.frame(width: 50, height: 50)
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 10) {
Text(team.code)
.font(codeFont)
.font(.system(size: 28, weight: .black, design: .rounded))
.foregroundStyle(.white)
if let record = team.record {
Text(record)
.font(metaFont)
.foregroundStyle(DS.Colors.textSecondary)
.font(.system(size: 12, weight: .bold, design: .monospaced))
.foregroundStyle(.white.opacity(0.72))
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(.white.opacity(isWinning ? 0.12 : 0.07))
.clipShape(Capsule())
}
}
Text(team.displayName)
.font(nameFont)
.foregroundStyle(DS.Colors.textSecondary)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white.opacity(isWinning ? 0.88 : 0.68))
.lineLimit(1)
if let summary = team.standingSummary {
Text(summary)
.font(metaFont)
.foregroundStyle(DS.Colors.textTertiary)
.lineLimit(1)
}
.minimumScaleFactor(0.75)
}
Spacer(minLength: 8)
Spacer(minLength: 12)
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)
DiamondView(
balls: linescore.balls ?? 0,
strikes: linescore.strikes ?? 0,
outs: linescore.outs ?? 0
)
}
MiniLinescoreView(
linescore: linescore,
awayCode: game.awayTeam.code,
homeCode: game.homeTeam.code
)
} else {
Text("Live update available")
.font(footerBodyFont)
.foregroundStyle(DS.Colors.textSecondary)
}
}
private var finalFooter: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Final")
.font(footerTitleFont)
.foregroundStyle(DS.Colors.positive)
Text(game.scoreDisplay ?? "Game complete")
.font(footerValueFont)
.foregroundStyle(.white)
if let venue = game.venue {
Text(venue)
.font(footerBodyFont)
.foregroundStyle(DS.Colors.textSecondary)
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())
}
}
}
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)
}
.padding(.horizontal, 14)
.padding(.vertical, 13)
.background {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(rowBackground(for: team, isWinning: isWinning))
}
}
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)
}
}
}
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()
}
}
private func chip(title: String, tint: Color) -> some View {
Text(title)
.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)
}
)
}
@ViewBuilder
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: [
DS.Colors.panelFill,
DS.Colors.panelFillMuted,
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.overlay(alignment: .top) {
Rectangle()
.fill(
LinearGradient(
colors: [awayColor, homeColor],
startPoint: .leading,
endPoint: .trailing
)
)
.frame(height: 5)
.clipShape(
RoundedRectangle(cornerRadius: cardRadius, style: .continuous)
)
}
}
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
guard let a = game.awayTeam.score, let h = game.homeTeam.score else { return false }
return away ? a > h : h > a
}
private var subtitleText: String {
if game.status.isScheduled {
return game.pitchers ?? game.venue ?? "Upcoming"
}
return away ? awayScore > homeScore : homeScore > awayScore
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"
}
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
@ViewBuilder
private var compactStatus: some View {
switch game.status {
case .live(let inning):
HStack(spacing: 7) {
Circle()
.fill(.red)
.frame(width: 8, height: 8)
Text(inning ?? "LIVE")
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.red.opacity(0.18))
.clipShape(Capsule())
case .scheduled(let time):
Text(time)
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.white.opacity(0.12))
.clipShape(Capsule())
case .final_:
Text("FINAL")
.font(.system(size: 13, weight: .black, design: .rounded))
.foregroundStyle(.white.opacity(0.92))
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.white.opacity(0.12))
.clipShape(Capsule())
case .unknown:
EmptyView()
}
}
private var borderWidth: CGFloat {
inMultiView || game.isLive ? 1.6 : 1
@ViewBuilder
private var footer: some View {
HStack(spacing: 12) {
Label(footerText, systemImage: footerIconName)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white.opacity(0.66))
.lineLimit(1)
Spacer(minLength: 12)
if inMultiView {
footerBadge(title: "In Multi-View", color: .green)
} else if game.isBlackedOut {
footerBadge(title: "Blacked Out", color: .red)
} else if game.hasStreams {
footerBadge(title: "Watch", color: .blue)
}
}
.overlay(alignment: .top) {
Rectangle()
.fill(.white.opacity(0.08))
.frame(height: 1)
}
}
#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
private var footerText: String {
if game.status.isScheduled {
return game.venue ?? (game.pitchers ?? "First pitch later today")
}
if game.isBlackedOut {
return "This game is unavailable in your area"
}
if let pitchers = game.pitchers, !pitchers.isEmpty {
return pitchers
}
if !game.broadcasts.isEmpty {
return game.broadcasts.map(\.teamCode).joined(separator: "")
}
return game.venue ?? "Tap for details"
}
private var footerIconName: String {
if game.isBlackedOut { return "eye.slash.fill" }
if game.hasStreams { return "tv.fill" }
if game.status.isScheduled { return "mappin.and.ellipse" }
return "sportscourt.fill"
}
@ViewBuilder
private func footerBadge(title: String, color: Color) -> some View {
Text(title)
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(color)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(color.opacity(0.12))
.clipShape(Capsule())
}
private func rowBackground(for team: TeamInfo, isWinning: Bool) -> some ShapeStyle {
let color = TeamAssets.color(for: team.code)
return LinearGradient(
colors: [
color.opacity(isWinning ? 0.22 : 0.12),
.white.opacity(isWinning ? 0.07 : 0.03)
],
startPoint: .leading,
endPoint: .trailing
)
}
@ViewBuilder
private var cardBackground: some View {
ZStack {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(Color(red: 0.08, green: 0.09, blue: 0.12))
LinearGradient(
colors: [
awayColor.opacity(0.18),
Color.clear,
homeColor.opacity(0.18)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Circle()
.fill(awayColor.opacity(0.18))
.frame(width: 180)
.blur(radius: 40)
.offset(x: -110, y: -90)
Circle()
.fill(homeColor.opacity(0.16))
.frame(width: 200)
.blur(radius: 44)
.offset(x: 140, y: 120)
}
}
private var shadowColor: Color {
if inMultiView { return .green.opacity(0.18) }
if game.isLive { return .red.opacity(0.22) }
return .black.opacity(0.22)
}
}

View File

@@ -21,22 +21,7 @@ struct GameCenterView: View {
atBatPanel(feed: feed)
}
// 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 {
if let wpHome = viewModel.winProbabilityHome, let wpAway = viewModel.winProbabilityAway {
winProbabilityPanel(home: wpHome, away: wpAway)
}

View File

@@ -35,30 +35,8 @@ 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)
@@ -91,21 +69,21 @@ struct LeagueCenterView: View {
private var header: some View {
HStack(alignment: .top, spacing: 24) {
VStack(alignment: .leading, spacing: 8) {
Text("League Center")
Text("Around MLB")
.font(.system(size: 42, weight: .bold, design: .rounded))
.foregroundStyle(DS.Colors.textPrimary)
.foregroundStyle(.white)
Text("Schedule navigation, standings, league leaders, roster access, and player snapshots in one board.")
Text("Schedules, standings, team context, roster access, and player snapshots in one control room.")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(DS.Colors.textTertiary)
.foregroundStyle(.white.opacity(0.58))
}
Spacer()
HStack(spacing: 12) {
infoPill(title: "\(viewModel.leagueLeaders.count)", label: "Leaders", color: .blue)
infoPill(title: "\(viewModel.scheduleGames.count)", label: "Games", color: .blue)
infoPill(title: "\(viewModel.teams.count)", label: "Teams", color: .green)
infoPill(title: "\(viewModel.scheduleGames.count)", label: "Games", color: .orange)
infoPill(title: "\(viewModel.standings.count)", label: "Divisions", color: .orange)
}
}
}
@@ -116,11 +94,11 @@ struct LeagueCenterView: View {
VStack(alignment: .leading, spacing: 6) {
Text("Schedule")
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(DS.Colors.textPrimary)
.foregroundStyle(.white)
Text(viewModel.displayDateString)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(DS.Colors.textTertiary)
.foregroundStyle(.white.opacity(0.55))
}
Spacer()
@@ -160,38 +138,36 @@ struct LeagueCenterView: View {
selectedGame = linkedGame
}
} label: {
HStack(spacing: 0) {
HStack(spacing: 18) {
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(DS.Colors.textPrimary)
.foregroundStyle(.white)
.monospacedDigit()
Text(statusText(for: game))
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(statusColor(for: game))
}
.frame(width: scheduleScoreColWidth)
.frame(width: 160)
teamMiniColumn(team: game.teams.home, alignTrailing: true)
.frame(width: scheduleTeamColWidth, alignment: .trailing)
Spacer()
VStack(alignment: .trailing, spacing: 6) {
if let venue = game.venue?.name {
Text(venue)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(DS.Colors.textTertiary)
.foregroundStyle(.white.opacity(0.56))
.lineLimit(1)
}
Text(linkedGame != nil ? "Open game sheet" : "Info only")
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(linkedGame != nil ? .blue.opacity(0.95) : DS.Colors.textQuaternary)
.foregroundStyle(linkedGame != nil ? .blue.opacity(0.95) : .white.opacity(0.34))
}
.frame(width: scheduleVenueColWidth, alignment: .trailing)
}
.padding(22)
.background(sectionPanel)
@@ -200,16 +176,6 @@ 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",
@@ -227,17 +193,17 @@ struct LeagueCenterView: View {
VStack(alignment: alignTrailing ? .trailing : .leading, spacing: 6) {
Text(info.code)
.font(.system(size: 22, weight: .black, design: .rounded))
.foregroundStyle(DS.Colors.textPrimary)
.foregroundStyle(.white)
Text(info.displayName)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(DS.Colors.textSecondary)
.foregroundStyle(.white.opacity(0.7))
.lineLimit(1)
if let record = info.record {
Text(record)
.font(.system(size: 13, weight: .bold, design: .monospaced))
.foregroundStyle(DS.Colors.textTertiary)
.foregroundStyle(.white.opacity(0.46))
}
}
@@ -245,90 +211,26 @@ struct LeagueCenterView: View {
TeamLogoView(team: info, size: 56)
}
}
}
#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
.frame(maxWidth: .infinity, alignment: alignTrailing ? .trailing : .leading)
}
private var standingsSection: some View {
VStack(alignment: .leading, spacing: 18) {
Text("Standings")
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(DS.Colors.textPrimary)
.foregroundStyle(.white)
if viewModel.standings.isEmpty && viewModel.isLoadingOverview {
loadingPanel(title: "Loading standings...")
} else {
ScrollView(.horizontal) {
LazyHStack(spacing: 18) {
HStack(spacing: 18) {
ForEach(viewModel.standings, id: \.division?.id) { record in
standingsCard(record)
.frame(width: 360)
.platformFocusable()
}
}
.padding(.vertical, 8)
.padding(.vertical, 4)
}
.platformFocusSection()
.scrollClipDisabled()
@@ -340,31 +242,31 @@ struct LeagueCenterView: View {
VStack(alignment: .leading, spacing: 14) {
Text(record.division?.name ?? "Division")
.font(.system(size: 22, weight: .bold, design: .rounded))
.foregroundStyle(DS.Colors.textPrimary)
.foregroundStyle(.white)
VStack(spacing: 10) {
ForEach(record.teamRecords.sorted(by: standingsSort), id: \.team.id) { team in
HStack(spacing: 10) {
Text(team.divisionRank ?? "-")
.font(.system(size: 12, weight: .black, design: .rounded))
.foregroundStyle(DS.Colors.textTertiary)
.foregroundStyle(.white.opacity(0.52))
.frame(width: 22)
Text(team.team.abbreviation ?? team.team.name ?? "MLB")
.font(.system(size: 16, weight: .bold, design: .rounded))
.foregroundStyle(DS.Colors.textPrimary)
.foregroundStyle(.white)
Spacer()
if let wins = team.wins, let losses = team.losses {
Text("\(wins)-\(losses)")
.font(.system(size: 15, weight: .bold, design: .monospaced))
.foregroundStyle(DS.Colors.textPrimary)
.foregroundStyle(.white.opacity(0.86))
}
Text(team.gamesBack ?? "-")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(DS.Colors.textTertiary)
.foregroundStyle(.white.opacity(0.45))
.frame(width: 44, alignment: .trailing)
}
.padding(.vertical, 4)
@@ -379,7 +281,7 @@ struct LeagueCenterView: View {
VStack(alignment: .leading, spacing: 18) {
Text("Teams")
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(DS.Colors.textPrimary)
.foregroundStyle(.white)
ScrollView(.horizontal) {
HStack(spacing: 16) {
@@ -393,11 +295,11 @@ struct LeagueCenterView: View {
VStack(alignment: .leading, spacing: 6) {
Text(team.abbreviation)
.font(.system(size: 20, weight: .black, design: .rounded))
.foregroundStyle(DS.Colors.textPrimary)
.foregroundStyle(.white)
Text(team.name)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(DS.Colors.textSecondary)
.foregroundStyle(.white.opacity(0.76))
.lineLimit(2)
}
@@ -405,7 +307,7 @@ struct LeagueCenterView: View {
Text(team.recordText ?? "Season")
.font(.system(size: 13, weight: .bold, design: .monospaced))
.foregroundStyle(DS.Colors.textTertiary)
.foregroundStyle(.white.opacity(0.5))
}
.frame(width: 210, height: 220, alignment: .leading)
.padding(18)
@@ -415,7 +317,7 @@ struct LeagueCenterView: View {
)
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.stroke(DS.Colors.panelStroke, lineWidth: 0.5)
.stroke(.white.opacity(0.08), lineWidth: 1)
)
}
.platformCardStyle()
@@ -432,7 +334,7 @@ struct LeagueCenterView: View {
VStack(alignment: .leading, spacing: 18) {
Text("Team Profile")
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(DS.Colors.textPrimary)
.foregroundStyle(.white)
if viewModel.isLoadingTeam {
loadingPanel(title: "Loading team profile...")
@@ -443,7 +345,7 @@ struct LeagueCenterView: View {
VStack(alignment: .leading, spacing: 12) {
Text(team.name)
.font(.system(size: 34, weight: .bold, design: .rounded))
.foregroundStyle(DS.Colors.textPrimary)
.foregroundStyle(.white)
HStack(spacing: 10) {
detailChip(team.recordText ?? "Season", color: .blue)
@@ -477,7 +379,7 @@ struct LeagueCenterView: View {
VStack(alignment: .leading, spacing: 18) {
Text("Roster")
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(DS.Colors.textPrimary)
.foregroundStyle(.white)
LazyVGrid(columns: rosterColumns, spacing: 14) {
ForEach(viewModel.roster) { player in
@@ -490,12 +392,12 @@ struct LeagueCenterView: View {
VStack(alignment: .leading, spacing: 6) {
Text(player.fullName)
.font(.system(size: 16, weight: .bold))
.foregroundStyle(DS.Colors.textPrimary)
.foregroundStyle(.white)
.lineLimit(2)
Text("\(player.positionAbbreviation ?? "P") · #\(player.jerseyNumber ?? "--")")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(DS.Colors.textTertiary)
.foregroundStyle(.white.opacity(0.5))
}
Spacer()
@@ -513,7 +415,7 @@ struct LeagueCenterView: View {
VStack(alignment: .leading, spacing: 18) {
Text("Player Profile")
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(DS.Colors.textPrimary)
.foregroundStyle(.white)
if viewModel.isLoadingPlayer {
loadingPanel(title: "Loading player profile...")
@@ -527,7 +429,7 @@ struct LeagueCenterView: View {
if let primaryNumber = player.primaryNumber {
Text("#\(primaryNumber)")
.font(.system(size: 15, weight: .black, design: .rounded))
.foregroundStyle(DS.Colors.textSecondary)
.foregroundStyle(.white.opacity(0.7))
}
if let position = player.primaryPosition {
@@ -537,7 +439,7 @@ struct LeagueCenterView: View {
Text(player.fullName)
.font(.system(size: 34, weight: .bold, design: .rounded))
.foregroundStyle(DS.Colors.textPrimary)
.foregroundStyle(.white)
VStack(alignment: .leading, spacing: 8) {
profileLine(label: "Age", value: player.currentAge.map(String.init))
@@ -557,20 +459,20 @@ struct LeagueCenterView: View {
VStack(alignment: .leading, spacing: 14) {
Text("\(group.title) \(player.seasonLabel)")
.font(.system(size: 18, weight: .bold, design: .rounded))
.foregroundStyle(DS.Colors.textPrimary)
.foregroundStyle(.white)
VStack(alignment: .leading, spacing: 10) {
ForEach(group.items, id: \.label) { item in
HStack {
Text(item.label)
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(DS.Colors.textTertiary)
.foregroundStyle(.white.opacity(0.5))
Spacer()
Text(item.value)
.font(.system(size: 18, weight: .black, design: .rounded))
.foregroundStyle(DS.Colors.textPrimary)
.foregroundStyle(.white)
}
}
}
@@ -579,14 +481,14 @@ struct LeagueCenterView: View {
.padding(18)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(DS.Colors.panelStroke)
.fill(.white.opacity(0.05))
)
}
}
} else {
Text("No regular-season MLB stats available for \(player.seasonLabel).")
.font(.system(size: 18, weight: .semibold, design: .rounded))
.foregroundStyle(DS.Colors.textSecondary)
.foregroundStyle(.white.opacity(0.7))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 4)
}
@@ -609,10 +511,10 @@ struct LeagueCenterView: View {
default:
ZStack {
Circle()
.fill(DS.Colors.panelStroke)
.fill(.white.opacity(0.08))
Image(systemName: "person.fill")
.font(.system(size: size * 0.34, weight: .bold))
.foregroundStyle(DS.Colors.textQuaternary)
.foregroundStyle(.white.opacity(0.32))
}
}
}
@@ -620,7 +522,7 @@ struct LeagueCenterView: View {
.clipShape(Circle())
.overlay(
Circle()
.stroke(DS.Colors.panelStroke, lineWidth: 0.5)
.stroke(.white.opacity(0.08), lineWidth: 1)
)
}
@@ -635,12 +537,12 @@ struct LeagueCenterView: View {
return HStack(spacing: 10) {
Text(label)
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(DS.Colors.textTertiary)
.foregroundStyle(.white.opacity(0.45))
.frame(width: 92, alignment: .leading)
Text(displayValue)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(DS.Colors.textPrimary)
.foregroundStyle(.white.opacity(0.82))
}
}
@@ -671,7 +573,7 @@ struct LeagueCenterView: View {
.monospacedDigit()
Text(label)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(DS.Colors.textTertiary)
.foregroundStyle(.white.opacity(0.5))
}
.padding(.horizontal, 18)
.padding(.vertical, 12)
@@ -686,10 +588,10 @@ struct LeagueCenterView: View {
Text(title)
.font(.system(size: 14, weight: .semibold))
}
.foregroundStyle(DS.Colors.textPrimary)
.foregroundStyle(.white.opacity(0.84))
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(DS.Colors.panelStroke)
.background(.white.opacity(0.08))
.clipShape(Capsule())
}
.platformCardStyle()
@@ -700,7 +602,7 @@ struct LeagueCenterView: View {
Spacer()
ProgressView(title)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(DS.Colors.textSecondary)
.foregroundStyle(.white.opacity(0.75))
Spacer()
}
.padding(.vertical, 34)
@@ -731,7 +633,7 @@ struct LeagueCenterView: View {
return .red.opacity(0.9)
}
if game.isFinal {
return DS.Colors.textSecondary
return .white.opacity(0.72)
}
return .blue.opacity(0.9)
}
@@ -742,24 +644,36 @@ struct LeagueCenterView: View {
private var sectionPanel: some View {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(
LinearGradient(
colors: [
DS.Colors.panelFill,
DS.Colors.panelFillMuted,
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY)
.fill(.black.opacity(0.22))
.overlay {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
}
}
private var screenBackground: some View {
BroadcastBackground()
ZStack {
LinearGradient(
colors: [
Color(red: 0.04, green: 0.05, blue: 0.09),
Color(red: 0.03, green: 0.06, blue: 0.1),
Color(red: 0.02, green: 0.03, blue: 0.06),
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Circle()
.fill(.blue.opacity(0.18))
.frame(width: 520, height: 520)
.blur(radius: 110)
.offset(x: -360, y: -260)
Circle()
.fill(.orange.opacity(0.16))
.frame(width: 560, height: 560)
.blur(radius: 120)
.offset(x: 420, y: -80)
}
}
}

View File

@@ -352,11 +352,11 @@ private struct MultiStreamTile: View {
@State private var player: AVPlayer?
@State private var hasError = false
@State private var startupPlaybackTask: Task<Void, Never>?
@State private var qualityUpgradeTask: Task<Void, Never>?
@State private var werkoutMonitorTask: Task<Void, Never>?
@State private var clipTimeLimitObserver: Any?
@State private var isAdvancingClip = false
@StateObject private var playbackDiagnostics = MultiStreamPlaybackDiagnostics()
@State private var audioDiagnostics: AudioDiagnostics?
private static let maxClipDuration: Double = 15.0
private static var audioSessionConfigured = false
@@ -446,8 +446,6 @@ private struct MultiStreamTile: View {
logMultiView("tile disappeared id=\(stream.id) label=\(stream.label)")
startupPlaybackTask?.cancel()
startupPlaybackTask = nil
qualityUpgradeTask?.cancel()
qualityUpgradeTask = nil
werkoutMonitorTask?.cancel()
werkoutMonitorTask = nil
if let player {
@@ -456,6 +454,8 @@ private struct MultiStreamTile: View {
}
player = nil
playbackDiagnostics.clear(streamID: stream.id, reason: "tile disappeared")
audioDiagnostics?.detach()
audioDiagnostics = nil
}
#if os(tvOS)
.focusEffectDisabled()
@@ -536,11 +536,8 @@ private struct MultiStreamTile: View {
.clipShape(Capsule())
}
private var multiViewStartupResolution: String { "504p" }
private var multiViewUpgradeTargetResolution: String? {
let desiredResolution = viewModel.defaultResolution
return desiredResolution == multiViewStartupResolution ? nil : desiredResolution
private var multiViewStartupResolution: String {
viewModel.defaultResolution
}
private func startStream() async {
@@ -556,18 +553,18 @@ private struct MultiStreamTile: View {
onPlaybackEnded: playbackEndedHandler(for: player)
)
scheduleStartupPlaybackRecovery(for: player)
scheduleQualityUpgrade(for: player)
installClipTimeLimit(on: player)
attachAudioDiagnostics(to: player)
logMultiView("startStream reused inline player id=\(stream.id) muted=\(player.isMuted)")
return
}
if !Self.audioSessionConfigured {
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
try AVAudioSession.sharedInstance().setActive(true)
Self.audioSessionConfigured = true
logMultiView("startStream audio session configured id=\(stream.id)")
logMultiView("startStream audio session configured id=\(stream.id) mode=default")
} catch {
logMultiView("startStream audio session failed id=\(stream.id) error=\(error.localizedDescription)")
}
@@ -583,8 +580,8 @@ private struct MultiStreamTile: View {
onPlaybackEnded: playbackEndedHandler(for: existingPlayer)
)
scheduleStartupPlaybackRecovery(for: existingPlayer)
scheduleQualityUpgrade(for: existingPlayer)
installClipTimeLimit(on: existingPlayer)
attachAudioDiagnostics(to: existingPlayer)
logMultiView("startStream reused shared player id=\(stream.id) muted=\(existingPlayer.isMuted)")
return
}
@@ -620,7 +617,7 @@ private struct MultiStreamTile: View {
onPlaybackEnded: playbackEndedHandler(for: avPlayer)
)
scheduleStartupPlaybackRecovery(for: avPlayer)
scheduleQualityUpgrade(for: avPlayer)
attachAudioDiagnostics(to: avPlayer)
logMultiView("startStream attached player id=\(stream.id) muted=\(avPlayer.isMuted) startupResolution=\(multiViewStartupResolution) fastStart=true calling playImmediately(atRate: 1.0)")
avPlayer.playImmediately(atRate: 1.0)
installClipTimeLimit(on: avPlayer)
@@ -633,19 +630,35 @@ private struct MultiStreamTile: View {
)
let item = makePlayerItem(url: url, headers: headers)
return AVPlayer(playerItem: item)
let player = AVPlayer(playerItem: item)
player.appliesMediaSelectionCriteriaAutomatically = false
logMultiView("startStream configured AVPlayer id=\(stream.id) appliesMediaSelectionCriteriaAutomatically=false")
return player
}
private func makePlayerItem(url: URL, headers: [String: String]) -> AVPlayerItem {
let item: AVPlayerItem
if headers.isEmpty {
return AVPlayerItem(url: url)
item = AVPlayerItem(url: url)
} else {
let assetOptions: [String: Any] = [
"AVURLAssetHTTPHeaderFieldsKey": headers,
]
let asset = AVURLAsset(url: url, options: assetOptions)
item = AVPlayerItem(asset: asset)
}
item.allowedAudioSpatializationFormats = []
logMultiView("startStream configured player item id=\(stream.id) allowedAudioSpatializationFormats=[]")
pinAudioSelection(on: item)
return item
}
let assetOptions: [String: Any] = [
"AVURLAssetHTTPHeaderFieldsKey": headers,
]
let asset = AVURLAsset(url: url, options: assetOptions)
return AVPlayerItem(asset: asset)
/// Pin the HLS audio rendition so ABR can't swap channel layouts mid-stream.
private func pinAudioSelection(on item: AVPlayerItem) {
let streamID = stream.id
Task { @MainActor in
await enforcePinnedMultiStreamAudioSelection(on: item, streamID: streamID)
}
}
private func scheduleStartupPlaybackRecovery(for player: AVPlayer) {
@@ -686,79 +699,17 @@ private struct MultiStreamTile: View {
}
}
private func scheduleQualityUpgrade(for player: AVPlayer) {
qualityUpgradeTask?.cancel()
guard stream.overrideURL == nil else {
logMultiView("qualityUpgrade skip id=\(stream.id) reason=override-url")
return
}
guard let targetResolution = multiViewUpgradeTargetResolution else {
logMultiView("qualityUpgrade skip id=\(stream.id) reason=target-already-\(multiViewStartupResolution)")
return
}
let streamID = stream.id
let label = stream.label
qualityUpgradeTask = Task { @MainActor in
let checkDelays: [Double] = [2.0, 4.0, 7.0, 15.0, 30.0]
for delay in checkDelays {
try? await Task.sleep(for: .seconds(delay))
guard !Task.isCancelled else { return }
guard let currentPlayer = self.player, currentPlayer === player else {
logMultiView("qualityUpgrade abort id=\(streamID) label=\(label) reason=player-changed")
return
}
let itemStatus = multiViewItemStatusDescription(player.currentItem?.status ?? .unknown)
let likelyToKeepUp = player.currentItem?.isPlaybackLikelyToKeepUp ?? false
let bufferEmpty = player.currentItem?.isPlaybackBufferEmpty ?? false
let indicatedBitrate = player.currentItem?.accessLog()?.events.last?.indicatedBitrate ?? 0
let stable = (itemStatus == "readyToPlay" || likelyToKeepUp) && !bufferEmpty
logMultiView(
"qualityUpgrade check id=\(streamID) delay=\(delay)s targetResolution=\(targetResolution) stable=\(stable) rate=\(player.rate) indicatedBitrate=\(Int(indicatedBitrate))"
)
guard stable else { continue }
guard let upgradedURL = await viewModel.resolveStreamURL(
for: stream,
resolutionOverride: targetResolution,
preserveServerResolutionWhenBest: false
) else {
logMultiView("qualityUpgrade failed id=\(streamID) targetResolution=\(targetResolution) reason=resolve-nil")
return
}
if let currentURL = currentStreamURL(for: player), currentURL == upgradedURL {
logMultiView("qualityUpgrade skip id=\(streamID) reason=same-url targetResolution=\(targetResolution)")
return
}
logMultiView("qualityUpgrade begin id=\(streamID) targetResolution=\(targetResolution) url=\(upgradedURL.absoluteString)")
let upgradedItem = AVPlayerItem(url: upgradedURL)
upgradedItem.preferredForwardBufferDuration = 4
player.replaceCurrentItem(with: upgradedItem)
player.automaticallyWaitsToMinimizeStalling = false
playbackDiagnostics.attach(to: player, streamID: streamID, label: label)
viewModel.attachPlayer(player, to: streamID)
scheduleStartupPlaybackRecovery(for: player)
logMultiView("qualityUpgrade replay id=\(streamID) targetResolution=\(targetResolution)")
player.playImmediately(atRate: 1.0)
return
}
logMultiView("qualityUpgrade timeout id=\(streamID) targetResolution=\(targetResolution)")
}
}
private func currentStreamURL(for player: AVPlayer) -> URL? {
(player.currentItem?.asset as? AVURLAsset)?.url
}
private func attachAudioDiagnostics(to player: AVPlayer) {
if audioDiagnostics == nil {
audioDiagnostics = AudioDiagnostics(tag: "multi:\(stream.label)")
}
audioDiagnostics?.attach(to: player)
}
private func installClipTimeLimit(on player: AVPlayer) {
removeClipTimeLimit(from: player)
@@ -867,6 +818,45 @@ private struct MultiStreamTile: View {
}
}
@MainActor
private func enforcePinnedMultiStreamAudioSelection(on item: AVPlayerItem, streamID: String) async {
let asset = item.asset
guard let group = try? await asset.loadMediaSelectionGroup(for: .audible),
let option = preferredMultiStreamAudioOption(in: group) else { return }
let current = item.currentMediaSelection.selectedMediaOption(in: group)
if current != option {
item.select(option, in: group)
}
logMultiView(
"pinAudioSelection id=\(streamID) selected=\(option.displayName) current=\(current?.displayName ?? "nil") options=\(group.options.count)"
)
}
private func preferredMultiStreamAudioOption(in group: AVMediaSelectionGroup) -> AVMediaSelectionOption? {
let defaultOption = group.defaultOption
return group.options.max { lhs, rhs in
multiStreamAudioPreferenceScore(for: lhs, defaultOption: defaultOption) < multiStreamAudioPreferenceScore(for: rhs, defaultOption: defaultOption)
} ?? defaultOption ?? group.options.first
}
private func multiStreamAudioPreferenceScore(for option: AVMediaSelectionOption, defaultOption: AVMediaSelectionOption?) -> Int {
let name = option.displayName.lowercased()
var score = 0
if option == defaultOption { score += 40 }
if name.contains("stereo") || name.contains("2.0") || name.contains("main") { score += 30 }
if name.contains("english") || name.contains("eng") { score += 20 }
if name.contains("surround") || name.contains("5.1") || name.contains("atmos") { score -= 30 }
if name.contains("spanish") || name.contains("sap") || name.contains("descriptive") || name.contains("alternate") {
score -= 25
}
if option.hasMediaCharacteristic(.describesVideoForAccessibility) {
score -= 40
}
return score
}
private struct MultiStreamPlayerLayerView: UIViewRepresentable {
let player: AVPlayer
let streamID: String
@@ -1481,3 +1471,245 @@ private func nextMultiViewFocusID(
.streamID
}
#endif
// MARK: - AudioDiagnostics
//
// Audio-specific diagnostic logging. Attach one `AudioDiagnostics` per AVPlayer
// you want to track. Emits a `[AUDIO]`-prefixed 1 Hz heartbeat with rate,
// mute, time, bitrate, and route info plus immediate logs on route changes,
// interruptions, access-log events, and media-selection changes.
//
// Grep Xcode console with `[AUDIO]` to isolate these lines.
@MainActor
final class AudioDiagnostics {
private let tag: String
private weak var player: AVPlayer?
private var heartbeatTask: Task<Void, Never>?
private var observations: [NSKeyValueObservation] = []
private var tokens: [NSObjectProtocol] = []
private static var processWideInstalled = false
init(tag: String) {
self.tag = tag
AudioDiagnostics.installProcessWideObservers()
}
deinit {
heartbeatTask?.cancel()
}
func attach(to player: AVPlayer) {
detach()
self.player = player
log("attach rate=\(player.rate) isMuted=\(player.isMuted) volume=\(player.volume)")
observations.append(
player.observe(\.rate, options: [.new]) { [weak self] p, _ in
Task { @MainActor in self?.log("rate-change rate=\(p.rate) tc=\(p.timeControlStatus.rawValue)") }
}
)
observations.append(
player.observe(\.isMuted, options: [.new]) { [weak self] p, _ in
Task { @MainActor in self?.log("isMuted-change value=\(p.isMuted)") }
}
)
observations.append(
player.observe(\.volume, options: [.new]) { [weak self] p, _ in
Task { @MainActor in self?.log("volume-change value=\(p.volume)") }
}
)
if let item = player.currentItem {
attachItemObservers(item)
}
observations.append(
player.observe(\.currentItem, options: [.new]) { [weak self] p, _ in
Task { @MainActor in
guard let self else { return }
self.log("currentItem-change newItem=\(p.currentItem != nil)")
if let item = p.currentItem {
self.attachItemObservers(item)
}
}
}
)
startHeartbeat()
}
func detach() {
heartbeatTask?.cancel()
heartbeatTask = nil
observations.removeAll()
for t in tokens {
NotificationCenter.default.removeObserver(t)
}
tokens.removeAll()
player = nil
}
private func attachItemObservers(_ item: AVPlayerItem) {
tokens.append(
NotificationCenter.default.addObserver(
forName: .AVPlayerItemNewAccessLogEntry,
object: item,
queue: .main
) { [weak self, weak item] _ in
guard let self, let event = item?.accessLog()?.events.last else { return }
Task { @MainActor in
self.log(
"accessLog indicated=\(Int(event.indicatedBitrate)) observed=\(Int(event.observedBitrate)) switches=\(event.numberOfMediaRequests) stalls=\(event.numberOfStalls) avgVideo=\(Int(event.averageVideoBitrate)) avgAudio=\(Int(event.averageAudioBitrate))"
)
}
}
)
tokens.append(
NotificationCenter.default.addObserver(
forName: .AVPlayerItemNewErrorLogEntry,
object: item,
queue: .main
) { [weak self, weak item] _ in
guard let self, let event = item?.errorLog()?.events.last else { return }
Task { @MainActor in
self.log("errorLog domain=\(event.errorDomain) statusCode=\(event.errorStatusCode) comment=\(event.errorComment ?? "nil")")
}
}
)
tokens.append(
NotificationCenter.default.addObserver(
forName: AVPlayerItem.mediaSelectionDidChangeNotification,
object: item,
queue: .main
) { [weak self, weak item] _ in
guard let self, let item else { return }
Task { @MainActor in
await enforcePinnedMultiStreamAudioSelection(on: item, streamID: self.tag)
let asset = item.asset
guard let group = try? await asset.loadMediaSelectionGroup(for: .audible),
let selected = item.currentMediaSelection.selectedMediaOption(in: group) else {
self.log("mediaSelection-change selected=nil")
return
}
let codec = (selected.mediaSubTypes as [NSNumber]).map { audioDiagFourCC($0.uint32Value) }.joined(separator: ",")
self.log("mediaSelection-change audio=\"\(selected.displayName)\" codec=\(codec)")
}
}
)
}
private func startHeartbeat() {
heartbeatTask?.cancel()
heartbeatTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
self?.emitHeartbeat()
try? await Task.sleep(for: .seconds(1))
}
}
}
private func emitHeartbeat() {
guard let player, let item = player.currentItem else { return }
let currentTime = CMTimeGetSeconds(player.currentTime())
let event = item.accessLog()?.events.last
let indicated = event.map { Int($0.indicatedBitrate) } ?? 0
let observed = event.map { Int($0.observedBitrate) } ?? 0
log(
"hb t=\(String(format: "%.1f", currentTime))s rate=\(player.rate) tc=\(player.timeControlStatus.rawValue) muted=\(player.isMuted) vol=\(String(format: "%.2f", player.volume)) indicated=\(indicated) observed=\(observed) ltku=\(item.isPlaybackLikelyToKeepUp) route=\(AudioDiagnostics.currentRouteDescription())"
)
}
private func log(_ message: String) {
let ts = AudioDiagnostics.timestamp()
print("[AUDIO \(tag) \(ts)] \(message)")
}
// MARK: Process-wide
private static func installProcessWideObservers() {
guard !processWideInstalled else { return }
processWideInstalled = true
let session = AVAudioSession.sharedInstance()
print("[AUDIO SYSTEM \(timestamp())] initial category=\(session.category.rawValue) mode=\(session.mode.rawValue) sampleRate=\(session.sampleRate) route=\(currentRouteDescription())")
NotificationCenter.default.addObserver(
forName: AVAudioSession.routeChangeNotification,
object: nil,
queue: .main
) { notification in
let reasonValue = notification.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt ?? 0
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue).map(audioDiagReasonDescription) ?? "unknown(\(reasonValue))"
let prev = (notification.userInfo?[AVAudioSessionRouteChangePreviousRouteKey] as? AVAudioSessionRouteDescription).map(routeDescription) ?? "nil"
print("[AUDIO SYSTEM \(timestamp())] routeChange reason=\(reason) previous=\(prev) current=\(currentRouteDescription())")
}
NotificationCenter.default.addObserver(
forName: AVAudioSession.interruptionNotification,
object: nil,
queue: .main
) { notification in
let typeValue = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt ?? 0
let type = AVAudioSession.InterruptionType(rawValue: typeValue).map(audioDiagInterruptionDescription) ?? "unknown(\(typeValue))"
print("[AUDIO SYSTEM \(timestamp())] interruption type=\(type)")
}
}
nonisolated static func currentRouteDescription() -> String {
routeDescription(AVAudioSession.sharedInstance().currentRoute)
}
nonisolated static func routeDescription(_ route: AVAudioSessionRouteDescription) -> String {
let outs = route.outputs.map { "\($0.portType.rawValue):\($0.portName)" }.joined(separator: ",")
return outs.isEmpty ? "none" : outs
}
nonisolated static func timestamp() -> String {
audioDiagTSFormatter.string(from: Date())
}
}
private let audioDiagTSFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "HH:mm:ss.SSS"
return f
}()
private func audioDiagFourCC(_ raw: UInt32) -> String {
let bytes: [UInt8] = [
UInt8((raw >> 24) & 0xFF),
UInt8((raw >> 16) & 0xFF),
UInt8((raw >> 8) & 0xFF),
UInt8(raw & 0xFF),
]
let chars = bytes.map { b -> Character in
let scalar = UnicodeScalar(b)
return (0x20...0x7E).contains(b) ? Character(scalar) : "?"
}
return String(chars)
}
private func audioDiagReasonDescription(_ reason: AVAudioSession.RouteChangeReason) -> String {
switch reason {
case .unknown: "unknown"
case .newDeviceAvailable: "newDeviceAvailable"
case .oldDeviceUnavailable: "oldDeviceUnavailable"
case .categoryChange: "categoryChange"
case .override: "override"
case .wakeFromSleep: "wakeFromSleep"
case .noSuitableRouteForCategory: "noSuitableRouteForCategory"
case .routeConfigurationChange: "routeConfigurationChange"
@unknown default: "unknown-future"
}
}
private func audioDiagInterruptionDescription(_ type: AVAudioSession.InterruptionType) -> String {
switch type {
case .began: "began"
case .ended: "ended"
@unknown default: "unknown-future"
}
}

View File

@@ -14,260 +14,60 @@ struct SettingsView: View {
var body: some View {
@Bindable var vm = viewModel
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))
NavigationStack {
Form {
Section("Server") {
LabeledContent("URL", value: viewModel.serverBaseURL)
}
settingsPanel(
title: "Playback Quality",
subtitle: "Preferred stream profile for newly opened feeds."
) {
VStack(spacing: 12) {
ForEach(resolutions, id: \.0) { resolution in
Button {
vm.defaultResolution = resolution.0
} label: {
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()
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()
}
}
}
settingsPanel(
title: "Active Streams",
subtitle: "Tile occupancy and cleanup controls for Multi-View."
) {
if viewModel.activeStreams.isEmpty {
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(spacing: 14) {
VStack(alignment: .leading, spacing: 5) {
Text(stream.label)
.font(optionTitleFont)
.foregroundStyle(DS.Colors.textPrimary)
Text(stream.game.displayTitle)
.font(optionBodyFont)
.foregroundStyle(DS.Colors.textSecondary)
}
Spacer()
Button(role: .destructive) {
viewModel.removeStream(id: stream.id)
} label: {
Label("Remove", systemImage: "trash")
.font(actionFont)
.foregroundStyle(.white)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
Capsule()
.fill(DS.Colors.live.opacity(0.82))
)
}
.platformCardStyle()
}
.padding(.horizontal, 18)
.padding(.vertical, 16)
.background(optionBackground(selected: false))
}
}
Button(role: .destructive) {
viewModel.clearAllStreams()
Section("Default Quality") {
ForEach(resolutions, id: \.0) { res in
Button {
vm.defaultResolution = res.0
} 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))
)
HStack {
Text(res.1)
Spacer()
if viewModel.defaultResolution == res.0 {
Image(systemName: "checkmark")
.foregroundStyle(.blue)
}
}
}
.platformCardStyle()
}
}
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")
Section("Active Streams (\(viewModel.activeStreams.count)/4)") {
if viewModel.activeStreams.isEmpty {
Text("No active streams")
.foregroundStyle(.secondary)
} else {
ForEach(viewModel.activeStreams) { stream in
HStack {
Text(stream.label)
.fontWeight(.bold)
Text(stream.game.displayTitle)
.foregroundStyle(.secondary)
Spacer()
Button(role: .destructive) {
viewModel.removeStream(id: stream.id)
} label: {
Image(systemName: "trash")
}
}
}
Button("Clear All Streams", role: .destructive) {
viewModel.clearAllStreams()
}
}
}
Section("About") {
LabeledContent("Version", value: "1.0")
LabeledContent("Server", value: "mlbserver")
}
}
.padding(.horizontal, horizontalPadding)
.padding(.vertical, verticalPadding)
.navigationTitle("Settings")
}
}
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
}

View File

@@ -57,22 +57,70 @@ private func singleStreamTimeControlDescription(_ status: AVPlayer.TimeControlSt
}
private func makeSingleStreamPlayerItem(from source: SingleStreamPlaybackSource) -> AVPlayerItem {
let item: AVPlayerItem
if source.httpHeaders.isEmpty {
let item = AVPlayerItem(url: source.url)
item.preferredForwardBufferDuration = 8
return item
item = AVPlayerItem(url: source.url)
} else {
let assetOptions: [String: Any] = [
"AVURLAssetHTTPHeaderFieldsKey": source.httpHeaders,
]
let asset = AVURLAsset(url: source.url, options: assetOptions)
item = AVPlayerItem(asset: asset)
logSingleStream(
"Configured authenticated AVURLAsset headerKeys=\(singleStreamHeaderKeysDescription(source.httpHeaders))"
)
}
item.preferredForwardBufferDuration = 8
item.allowedAudioSpatializationFormats = []
logSingleStream("Configured player item preferredForwardBufferDuration=8 allowedAudioSpatializationFormats=[]")
pinSingleStreamAudioSelection(on: item)
return item
}
/// Pin the HLS audio rendition so ABR can't swap channel layouts mid-stream.
private func pinSingleStreamAudioSelection(on item: AVPlayerItem) {
Task { @MainActor in
await enforcePinnedSingleStreamAudioSelection(on: item)
}
}
@MainActor
private func enforcePinnedSingleStreamAudioSelection(on item: AVPlayerItem) async {
let asset = item.asset
guard let group = try? await asset.loadMediaSelectionGroup(for: .audible),
let option = preferredSingleStreamAudioOption(in: group) else { return }
let current = item.currentMediaSelection.selectedMediaOption(in: group)
if current != option {
item.select(option, in: group)
}
logSingleStream(
"pinAudioSelection selected=\(option.displayName) current=\(current?.displayName ?? "nil") options=\(group.options.count)"
)
}
private func preferredSingleStreamAudioOption(in group: AVMediaSelectionGroup) -> AVMediaSelectionOption? {
let defaultOption = group.defaultOption
return group.options.max { lhs, rhs in
audioPreferenceScore(for: lhs, defaultOption: defaultOption) < audioPreferenceScore(for: rhs, defaultOption: defaultOption)
} ?? defaultOption ?? group.options.first
}
private func audioPreferenceScore(for option: AVMediaSelectionOption, defaultOption: AVMediaSelectionOption?) -> Int {
let name = option.displayName.lowercased()
var score = 0
if option == defaultOption { score += 40 }
if name.contains("stereo") || name.contains("2.0") || name.contains("main") { score += 30 }
if name.contains("english") || name.contains("eng") { score += 20 }
if name.contains("surround") || name.contains("5.1") || name.contains("atmos") { score -= 30 }
if name.contains("spanish") || name.contains("sap") || name.contains("descriptive") || name.contains("alternate") {
score -= 25
}
if option.hasMediaCharacteristic(.describesVideoForAccessibility) {
score -= 40
}
let assetOptions: [String: Any] = [
"AVURLAssetHTTPHeaderFieldsKey": source.httpHeaders,
]
let asset = AVURLAsset(url: source.url, options: assetOptions)
let item = AVPlayerItem(asset: asset)
item.preferredForwardBufferDuration = 8
logSingleStream(
"Configured authenticated AVURLAsset headerKeys=\(singleStreamHeaderKeysDescription(source.httpHeaders))"
)
return item
return score
}
struct SingleStreamPlaybackScreen: View {
@@ -544,20 +592,27 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
)
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
try AVAudioSession.sharedInstance().setActive(true)
logSingleStream("AVAudioSession configured for playback")
logSingleStream("AVAudioSession configured for playback mode=default")
} catch {
logSingleStream("AVAudioSession configuration failed error=\(error.localizedDescription)")
}
let playerItem = makeSingleStreamPlayerItem(from: source)
let player = AVPlayer(playerItem: playerItem)
player.appliesMediaSelectionCriteriaAutomatically = false
player.automaticallyWaitsToMinimizeStalling = true
player.isMuted = source.forceMuteAudio
logSingleStream("Configured player for quality ramp preferredForwardBufferDuration=8 automaticallyWaitsToMinimizeStalling=true")
logSingleStream(
"Configured player for quality ramp preferredForwardBufferDuration=8 automaticallyWaitsToMinimizeStalling=true appliesMediaSelectionCriteriaAutomatically=false"
)
context.coordinator.attachDebugObservers(to: player, url: url, resolveNextSource: resolveNextSource)
controller.player = player
if context.coordinator.audioDiagnostics == nil {
context.coordinator.audioDiagnostics = AudioDiagnostics(tag: "single")
}
context.coordinator.audioDiagnostics?.attach(to: player)
logSingleStream("AVPlayer assigned to controller; calling playImmediately(atRate: 1.0)")
player.playImmediately(atRate: 1.0)
context.coordinator.installClipTimeLimit(on: player, resolveNextSource: resolveNextSource)
@@ -589,6 +644,10 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
logSingleStream("dismantleUIViewController — PiP active, observers cleared but keeping player")
return
}
Task { @MainActor in
coordinator.audioDiagnostics?.detach()
coordinator.audioDiagnostics = nil
}
uiViewController.player?.pause()
uiViewController.player = nil
logSingleStream("dismantleUIViewController complete")
@@ -600,6 +659,7 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
private var startupRecoveryTask: Task<Void, Never>?
private var qualityMonitorTask: Task<Void, Never>?
private var clipTimeLimitObserver: Any?
var audioDiagnostics: AudioDiagnostics?
private static let maxClipDuration: Double = 15.0
var onTogglePitchInfo: (() -> Void)?
var onToggleGameCenter: (() -> Void)?
@@ -791,6 +851,19 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
}
}
)
notificationTokens.append(
NotificationCenter.default.addObserver(
forName: AVPlayerItem.mediaSelectionDidChangeNotification,
object: item,
queue: .main
) { _ in
logSingleStream("Notification mediaSelectionDidChange")
Task { @MainActor in
await enforcePinnedSingleStreamAudioSelection(on: item)
}
}
)
}
func scheduleStartupRecovery(for player: AVPlayer) {