Compare commits

13 Commits

Author SHA1 Message Date
Trey t
829380574c Move Quick Channels to full-width row below hero card
Quick Channels (MLB Network + Werkout NSFW) were cramped in the right
sidebar with text wrapping ("Networ k"). Moved them out of controlRail
into the main content flow as a full-width HStack. Each card takes
50% width with proper text display. Positioned between the hero section
and game shelves.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:15:09 -05:00
Trey t
7568a3b9d3 Fix tvOS focus navigation: add focusSection to all major UI sections
Focus couldn't move from DashboardView controls up to the
CategoryPillBar, or between sections within screens. Added
.platformFocusSection() to:

- ContentView: pill bar and content area as separate focus sections
- DashboardView: header, overview strip, hero+control section
- LeagueCenterView: schedule, standings, leaders, teams sections

This lets the tvOS Focus Engine navigate vertically between all
sections with d-pad up/down.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:05:17 -05:00
Trey t
f59df9b04d Fix hero card content clipping: flex height, simpler team rows, smaller title
Title was clipping off the left edge ("ston Astros" instead of "Houston
Astros"). Root cause was too much content in a fixed-height card.

Fixed by: using minHeight instead of fixed height so the card expands
to fit content, simplified team rows to just logo + code + record +
score (removed full team name and standings summary since the title
already shows them), reduced title from 44pt to 36pt, added .clipped()
to hero image, added .fixedSize(horizontal: false, vertical: true).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:01:58 -05:00
Trey t
310d857c7f Fix horizontal overflow: shrink control rail, hero padding, title font
Everything was clipping off the left edge because the hero card + Live
Radar sidebar + padding exceeded screen width. Reduced control rail from
420px to 340px, hero internal padding from 48px to 40px, detail panel
from 360px to 300px, title font from 52pt to 44pt, hero height from
560px to 500px.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:52:45 -05:00
Trey t
b3a074e362 Fix hero card overflow: increase height, padding, stronger gradient
Hero card was 470px tall with 36px padding — content was clipping off
the left edge and bottom. Increased to 560px tall with 48px horizontal
padding. Added minimumScaleFactor(0.7) to title text so long matchup
names shrink instead of clip. Strengthened the left gradient overlay
(0.92→0.75→0.4→0.15) so text is readable over the pitcher action photo.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:49:12 -05:00
Trey t
588b42ffed Fix tvOS memory crash: cap highlights to 50, replace blurs with gradients
The app was crashing from memory pressure on tvOS. Three causes fixed:

1. Feed was rendering all 418 highlights at once — capped to 50 items.

2. FeaturedGameCard had 3 blur effects (radius 80-120) on large circles
   for team color glow — replaced with a single LinearGradient. Same
   visual effect, fraction of the GPU memory.

3. BroadcastBackground had 3 blurred circles (radius 120-140, 680-900px)
   rendering on every screen — replaced with RadialGradients which are
   composited by the GPU natively without offscreen render passes.

Also fixed iOS build: replaced tvOS-only font refs (tvSectionTitle,
tvBody) with cross-platform equivalents in DashboardView fallback state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:44:25 -05:00
Trey t
870fbcb844 Fix hero image: use pitcher action photos instead of broken stadium URLs
Stadium venue URLs return 404. Switched to pitcher action hero photos
from img.mlbstatic.com which return real high-res player photos — much
more impactful than stadiums anyway (matches the cast photo aesthetic
from the reference). Falls back to prominent team logos with rich
team color gradients instead of washed-out gray circles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:22:35 -05:00
Trey t
346557af88 Fix hero to match reference: white surface, image right, outlined CTA
Completely rebuilt FeaturedGameCard to match the Dribbble vtv reference.
White/cream background surface instead of dark card. Stadium image sits
on the right side and fades into white via left-to-right gradient. Dark
text on light background. "Watch Now" as outlined orange pill (not
filled). Plus icon for add-to-multiview.

Title uses split weight: thin "Houston Astros vs" + bold orange "Seattle
Mariners" — mimicking the "modern family" typography split.

Cleaned up DashboardView header: removed bulky MLB/date/stat pills
section. Replaced with compact inline date nav: chevron left, date text,
chevron right, "Today" link, game count + live count on the right. One
line instead of a full section.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:17:20 -05:00
Trey t
65ad41840f Complete visual overhaul: warm light theme, stadium hero, inline pill nav
Inspired by vtv/Dribbble streaming concept. Every dark surface replaced
with warm off-white (#F5F3F0) and white cards with soft shadows.

Design System: Warm off-white background, white card fills, subtle drop
shadows, dark text hierarchy, warm orange accent (#F27326) replacing
blue as primary interactive color. Added onDark color variants for hero
overlays. Shadow system with card/lifted states.

Navigation: Replaced TabView with inline CategoryPillBar — horizontal
orange pills (Today | Intel | Highlights | Multi-View | Settings).
Single scrolling view, no system chrome. Multi-View as icon button
with stream count badge. Settings as gear icon.

Stadium Hero: Full-bleed stadium photos from MLB CDN
(mlbstatic.com/v1/venue/{id}/spots/1200) as featured game background.
Left gradient overlay for text readability. Live games show score +
inning + DiamondView count/outs. Scheduled games show probable pitchers
with headshots + records. Final games show final score. Warm orange
"Watch Now" CTA pill. Added venue ID mapping for all 30 stadiums to
TeamAssets.

Game Cards: White cards with team color top bar, horizontal team rows,
dark text, soft shadows. Record + streak on every card.

Intel Tab: All dark panels replaced with white cards + shadows.
Replaced dark gradient screen background with flat warm off-white.
58 hardcoded .white.opacity() values replaced with DS.Colors tokens.

Feed Tab: Already used DS.Colors — inherits light theme automatically.

Focus: tvOS focus style uses warm orange border highlight + lifted
shadow instead of white glow on dark.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:56:26 -05:00
Trey t
69d84fd09b Cinematic UI overhaul: focus states, full-bleed hero, score bug cards, grid Intel
Phase 1 - Focus & Typography: New TVFocusButtonStyle with 1.04x scale +
white glow border on focus, 0.97x press. Enforced 22px minimum text on
tvOS across DesignSystem (tvCaption 22px, tvBody 24px, tvDataValue 24px).
DataLabelStyle uses tvOS caption with reduced kerning.

Phase 2 - Today Tab: FeaturedGameCard redesigned as full-bleed hero with
away team left, home team right, 96pt score centered, DiamondView for
live count/outs. Removed side panel, replaced with single subtitle row.
GameCardView redesigned as horizontal score-bug style (~120px tall vs
320px) with team color accent bar, stacked logos, centered score, inline
mini linescore. Both show record + streak on every card.

Phase 3 - Intel Tab: Side-by-side layout on tvOS with standings
horizontal scroll on left (60%) and leaders vertical column on right
(40%). Both visible without scrolling past each other. iOS keeps the
stacked layout.

Phase 4 - Feed: Cards now horizontal with thumbnail left (300px tvOS),
info right. Added timestamp ("2h ago") to every card. All text meets
22px minimum on tvOS. Condensed game badge uses larger font.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:57:17 -05:00
Trey t
39092e5f3d Restore Live shelf on Today, flatten Feed to time-ordered highlights
Today tab: Removed LiveSituationBar, restored the full Live game shelf
below the featured Astros card where it belongs.

Feed tab: Changed from two grouped shelves (condensed / highlights) to a
single horizontal scroll with ALL highlights ordered by timestamp (most
recent first). Added condensed game badge overlay on thumbnails. Added
date field to Highlight model for time-based ordering.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:39:41 -05:00
Trey t
cd605d889d Replace Feed with highlights, remove duplicate live shelf, drop Intel schedule
Feed tab: Replaced news/transaction feed with league-wide highlights and
condensed game replays. FeedViewModel now fetches highlights from all
games concurrently, splits into condensed games vs individual highlights.
Cards show team color thumbnails with play button overlay.

Today tab: Removed duplicate Live games shelf — LiveSituationBar already
shows all live games at the top, so the shelf was redundant.

Intel tab: Removed schedule section (already on Today tab). Updated
header description and stat pills. Added division hydration to standings
API call so division names display correctly instead of "Division".

Focus: Added .platformFocusable() to standings cards and leaderboard
cards so tvOS remote can scroll horizontally through them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:25:36 -05:00
Trey t
b5daddefd3 Add UI redesign: design system, dashboard density, game intelligence, feed tab, league leaders
Phase 1 - Design System: DesignSystem.swift (typography, colors, spacing
constants) and DataPanel.swift (reusable panel container with 3 densities
and optional team accent bar).

Phase 2 - Dashboard Density: LiveSituationBar (compact strip of all live
games with scores/innings/outs), MiniLinescoreView (R-H-E footer for game
cards), DiamondView (visual baseball diamond with runners and count).
Dashboard shows live situation bar when games are active. Game cards now
display mini linescore for live/final games.

Phase 3 - Game Center Intelligence: WinProbabilityChartView (full-game
line chart using Swift Charts with area fills), PitchArsenalView (pitch
type distribution with velocity bars). GameCenterViewModel now stores
full WP history array instead of just latest values.

Phase 4 - Feed Tab: MLBWebDataService (fetches league leaders from Stats
API, news headlines, transactions), FeedViewModel, FeedView with
reverse-chronological feed items. FeedItemView with colored edge bars
by category. Added 5th "Feed" tab to both tvOS and iOS.

Phase 5 - Intel Tab: LeaderboardView (top-5 stat cards with headshots),
integrated into LeagueCenterView. Renamed tabs: Games->Today,
League->Intel. LeagueCenterViewModel now fetches league leaders.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:12:09 -05:00
29 changed files with 3696 additions and 1128 deletions

View File

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

View File

@@ -73,6 +73,32 @@
FAFA9B29273C4D8A54CD5916 /* FeaturedGameCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102E5E8674E188A04637D3EB /* FeaturedGameCard.swift */; }; FAFA9B29273C4D8A54CD5916 /* FeaturedGameCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102E5E8674E188A04637D3EB /* FeaturedGameCard.swift */; };
FD31074216AC0115B3F1C057 /* mlbTVOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45142562E644AF04F7719083 /* mlbTVOSApp.swift */; }; FD31074216AC0115B3F1C057 /* mlbTVOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45142562E644AF04F7719083 /* mlbTVOSApp.swift */; };
FD9322BED3A2B827CB021163 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DE9E708FA75ABB9F49D2392 /* SettingsView.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 */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -129,6 +155,19 @@
E80EF8B01452220FA6BCD9B2 /* GameCenterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCenterView.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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 */ /* End PBXFileReference section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
@@ -137,6 +176,7 @@
children = ( children = (
F5DA48863884BB6B4983BEE6 /* GameCenterViewModel.swift */, F5DA48863884BB6B4983BEE6 /* GameCenterViewModel.swift */,
9BD2DEA6BBA55E86B2555099 /* GamesViewModel.swift */, 9BD2DEA6BBA55E86B2555099 /* GamesViewModel.swift */,
C920FA380D9DDDED113068E3 /* FeedViewModel.swift */,
7E9E572D932D96FE2C5BD7A4 /* LeagueCenterViewModel.swift */, 7E9E572D932D96FE2C5BD7A4 /* LeagueCenterViewModel.swift */,
); );
path = ViewModels; path = ViewModels;
@@ -170,6 +210,16 @@
children = ( children = (
C3D1C29BF9DF2B8F811368BC /* AtBatTimelineView.swift */, C3D1C29BF9DF2B8F811368BC /* AtBatTimelineView.swift */,
327A33F2676712C2B5B4AF2F /* LinescoreView.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 */, 81584770090BAFBEF3ABE6FD /* LiveIndicator.swift */,
C6E303B753C604504C2762C7 /* PitcherHeadshotView.swift */, C6E303B753C604504C2762C7 /* PitcherHeadshotView.swift */,
7F13A88254E53AF5A4AAA2AA /* PitchSequenceView.swift */, 7F13A88254E53AF5A4AAA2AA /* PitchSequenceView.swift */,
@@ -227,6 +277,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
70E610D24BA83FB2F7490B00 /* MLBServerAPI.swift */, 70E610D24BA83FB2F7490B00 /* MLBServerAPI.swift */,
7EB8E3E205222A55700CF24C /* MLBWebDataService.swift */,
6067FE4F340FC0DCCFA62C67 /* MLBStatsAPI.swift */, 6067FE4F340FC0DCCFA62C67 /* MLBStatsAPI.swift */,
C3A003EA560655E4B2200DBE /* VideoShuffle.swift */, C3A003EA560655E4B2200DBE /* VideoShuffle.swift */,
); );
@@ -238,6 +289,7 @@
children = ( children = (
3FB99FBEDA096B95C3D2E5B4 /* ContentView.swift */, 3FB99FBEDA096B95C3D2E5B4 /* ContentView.swift */,
5EA4C33F029665B80F1A6B6D /* DashboardView.swift */, 5EA4C33F029665B80F1A6B6D /* DashboardView.swift */,
981C95E73FFDAFE2E26B06C2 /* FeedView.swift */,
102E5E8674E188A04637D3EB /* FeaturedGameCard.swift */, 102E5E8674E188A04637D3EB /* FeaturedGameCard.swift */,
E21E3115B8A9B4C798ECE828 /* GameCardView.swift */, E21E3115B8A9B4C798ECE828 /* GameCardView.swift */,
E80EF8B01452220FA6BCD9B2 /* GameCenterView.swift */, E80EF8B01452220FA6BCD9B2 /* GameCenterView.swift */,
@@ -392,6 +444,19 @@
8649D4F513A663D2493D91C9 /* LeagueCenterView.swift in Sources */, 8649D4F513A663D2493D91C9 /* LeagueCenterView.swift in Sources */,
E42B27D852AB037246167C23 /* LeagueCenterViewModel.swift in Sources */, E42B27D852AB037246167C23 /* LeagueCenterViewModel.swift in Sources */,
4CB947BC5B5ECF83EF84F7E4 /* LinescoreView.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 */, 0653C2F5CC55F4234E8950E5 /* LiveIndicator.swift in Sources */,
D864A2844FD222A04B48F6EF /* MLBNetworkSheet.swift in Sources */, D864A2844FD222A04B48F6EF /* MLBNetworkSheet.swift in Sources */,
8801888DFD48351B1D344D45 /* MLBServerAPI.swift in Sources */, 8801888DFD48351B1D344D45 /* MLBServerAPI.swift in Sources */,
@@ -439,6 +504,19 @@
07BBD0E7B7F122213708A406 /* LeagueCenterView.swift in Sources */, 07BBD0E7B7F122213708A406 /* LeagueCenterView.swift in Sources */,
B0A9FFD5C20A80E16532CC91 /* LeagueCenterViewModel.swift in Sources */, B0A9FFD5C20A80E16532CC91 /* LeagueCenterViewModel.swift in Sources */,
051C3D14A06061D44E325FCC /* LinescoreView.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 */, E7FB366456557069990F550C /* LiveIndicator.swift in Sources */,
820FB03D0E97C521BA239071 /* MLBNetworkSheet.swift in Sources */, 820FB03D0E97C521BA239071 /* MLBNetworkSheet.swift in Sources */,
1E31C6F500CEEA5284081D22 /* MLBServerAPI.swift in Sources */, 1E31C6F500CEEA5284081D22 /* MLBServerAPI.swift in Sources */,

View File

@@ -59,4 +59,21 @@ enum TeamAssets {
static func logoURL(forId id: Int) -> URL { static func logoURL(forId id: Int) -> URL {
URL(string: "https://midfield.mlbstatic.com/v1/team/\(id)/spots/72")! 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,6 +305,7 @@ actor MLBServerAPI {
struct Highlight: Codable, Sendable, Identifiable { struct Highlight: Codable, Sendable, Identifiable {
let id: String? let id: String?
let headline: String? let headline: String?
let date: String?
let playbacks: [Playback]? let playbacks: [Playback]?
struct Playback: Codable, Sendable { struct Playback: Codable, Sendable {

View File

@@ -33,7 +33,7 @@ actor MLBStatsAPI {
func fetchStandingsRecords(season: String) async throws -> [StandingsDivisionRecord] { func fetchStandingsRecords(season: String) async throws -> [StandingsDivisionRecord] {
let response: StandingsResponse = try await fetchJSON( let response: StandingsResponse = try await fetchJSON(
"\(baseURL)/standings?leagueId=103,104&season=\(season)&hydrate=team" "\(baseURL)/standings?leagueId=103,104&season=\(season)&hydrate=team,division"
) )
return response.records return response.records
} }

View File

@@ -0,0 +1,236 @@
import Foundation
import OSLog
private let webDataLogger = Logger(subsystem: "com.treyt.mlbTVOS", category: "WebData")
private func logWebData(_ message: String) {
webDataLogger.debug("\(message, privacy: .public)")
print("[WebData] \(message)")
}
// MARK: - Models
struct NewsHeadline: Identifiable, Sendable {
let id: String
let title: String
let summary: String
let timestamp: Date
let imageURL: URL?
}
struct InjuryReport: Identifiable, Sendable {
let id: String
let teamCode: String
let playerName: String
let position: String
let status: String // "10-Day IL", "60-Day IL", "Day-to-Day"
let date: Date
let notes: String
}
struct Transaction: Identifiable, Sendable {
let id: String
let teamCode: String
let description: String
let date: Date
let type: String // "Trade", "DFA", "Call-Up", "Placed on IL"
}
struct LeagueLeader: Identifiable, Sendable {
let id: String
let rank: Int
let playerName: String
let teamCode: String
let value: String
let personId: Int?
}
struct LeaderCategory: Identifiable, Sendable {
let id: String
let name: String
let leaders: [LeagueLeader]
}
// MARK: - Service
actor MLBWebDataService {
private let statsAPI = MLBStatsAPI()
private let session: URLSession
init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 15
session = URLSession(configuration: config)
}
// MARK: - League Leaders (via Stats API)
func fetchLeagueLeaders() async throws -> [LeaderCategory] {
let categories = [
("homeRuns", "Home Runs"),
("battingAverage", "Batting Average"),
("runsBattedIn", "RBI"),
("earnedRunAverage", "ERA"),
("strikeouts", "Strikeouts"),
("wins", "Wins"),
("saves", "Saves"),
("stolenBases", "Stolen Bases"),
("onBasePlusSlugging", "OPS"),
("walksHitsPerInningPitched", "WHIP"),
]
var results: [LeaderCategory] = []
for (code, name) in categories {
let url = URL(string: "https://statsapi.mlb.com/api/v1/stats/leaders?leaderCategories=\(code)&season=\(currentSeason)&limit=5&sportId=1")!
logWebData("fetchLeagueLeaders category=\(code)")
do {
let (data, _) = try await session.data(from: url)
let response = try JSONDecoder().decode(LeaderResponse.self, from: data)
if let category = response.leagueLeaders?.first {
let leaders = (category.leaders ?? []).prefix(5).enumerated().map { index, leader in
LeagueLeader(
id: "\(code)-\(index)",
rank: leader.rank ?? (index + 1),
playerName: leader.person?.fullName ?? "Unknown",
teamCode: leader.team?.abbreviation ?? "",
value: leader.value ?? "",
personId: leader.person?.id
)
}
results.append(LeaderCategory(id: code, name: name, leaders: Array(leaders)))
}
} catch {
logWebData("fetchLeagueLeaders FAILED category=\(code) error=\(error)")
}
}
logWebData("fetchLeagueLeaders complete categories=\(results.count)")
return results
}
// MARK: - News Headlines (from MLB.com)
func fetchNewsHeadlines() async -> [NewsHeadline] {
let url = URL(string: "https://statsapi.mlb.com/api/v1/news?sportId=1")!
logWebData("fetchNewsHeadlines")
do {
let (data, _) = try await session.data(from: url)
let response = try JSONDecoder().decode(NewsResponse.self, from: data)
let headlines = (response.articles ?? []).prefix(15).map { article in
NewsHeadline(
id: article.slug ?? UUID().uuidString,
title: article.headline ?? "Untitled",
summary: article.subhead ?? "",
timestamp: ISO8601DateFormatter().date(from: article.date ?? "") ?? Date(),
imageURL: article.image?.cuts?.first?.src.flatMap(URL.init)
)
}
logWebData("fetchNewsHeadlines success count=\(headlines.count)")
return Array(headlines)
} catch {
logWebData("fetchNewsHeadlines FAILED error=\(error)")
return []
}
}
// MARK: - Transactions (from Stats API)
func fetchTransactions() async -> [Transaction] {
let formatter = DateFormatter()
formatter.dateFormat = "MM/dd/yyyy"
let today = formatter.string(from: Date())
let weekAgo = formatter.string(from: Calendar.current.date(byAdding: .day, value: -7, to: Date())!)
let url = URL(string: "https://statsapi.mlb.com/api/v1/transactions?startDate=\(weekAgo)&endDate=\(today)&sportId=1")!
logWebData("fetchTransactions from=\(weekAgo) to=\(today)")
do {
let (data, _) = try await session.data(from: url)
let response = try JSONDecoder().decode(TransactionResponse.self, from: data)
let transactions = (response.transactions ?? []).prefix(30).enumerated().map { index, tx in
Transaction(
id: "\(tx.id ?? index)",
teamCode: tx.team?.abbreviation ?? "",
description: tx.description ?? "",
date: ISO8601DateFormatter().date(from: tx.date ?? "") ?? Date(),
type: tx.typeDesc ?? "Transaction"
)
}
logWebData("fetchTransactions success count=\(transactions.count)")
return Array(transactions)
} catch {
logWebData("fetchTransactions FAILED error=\(error)")
return []
}
}
private var currentSeason: Int {
Calendar.current.component(.year, from: Date())
}
}
// MARK: - API Response Models
private struct LeaderResponse: Codable {
let leagueLeaders: [LeaderCategoryResponse]?
}
private struct LeaderCategoryResponse: Codable {
let leaderCategory: String?
let leaders: [LeaderEntry]?
}
private struct LeaderEntry: Codable {
let rank: Int?
let value: String?
let person: PersonRef?
let team: TeamRef?
}
private struct PersonRef: Codable {
let id: Int?
let fullName: String?
}
private struct TeamRef: Codable {
let id: Int?
let abbreviation: String?
}
private struct NewsResponse: Codable {
let articles: [NewsArticle]?
}
private struct NewsArticle: Codable {
let slug: String?
let headline: String?
let subhead: String?
let date: String?
let image: NewsImage?
}
private struct NewsImage: Codable {
let cuts: [NewsImageCut]?
}
private struct NewsImageCut: Codable {
let src: String?
}
private struct TransactionResponse: Codable {
let transactions: [TransactionEntry]?
}
private struct TransactionEntry: Codable {
let id: Int?
let description: String?
let date: String?
let typeDesc: String?
let team: TeamRef?
}

View File

@@ -0,0 +1,122 @@
import Foundation
import Observation
import OSLog
private let feedLogger = Logger(subsystem: "com.treyt.mlbTVOS", category: "Feed")
private func logFeed(_ message: String) {
feedLogger.debug("\(message, privacy: .public)")
print("[Feed] \(message)")
}
private func parseHighlightDate(_ string: String?) -> Date? {
guard let string else { return nil }
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let d = iso.date(from: string) { return d }
iso.formatOptions = [.withInternetDateTime]
return iso.date(from: string)
}
struct HighlightItem: Identifiable, Sendable {
let id: String
let headline: String
let gameTitle: String
let awayCode: String
let homeCode: String
let hlsURL: URL?
let mp4URL: URL?
let isCondensedGame: Bool
let timestamp: Date
}
@Observable
@MainActor
final class FeedViewModel {
var highlights: [HighlightItem] = []
var isLoading = false
@ObservationIgnored
private var refreshTask: Task<Void, Never>?
private let serverAPI = MLBServerAPI()
func loadHighlights(games: [Game]) async {
isLoading = true
logFeed("loadHighlights start gameCount=\(games.count)")
let gamesWithPk = games.filter { $0.gamePk != nil }
await withTaskGroup(of: [HighlightItem].self) { group in
for game in gamesWithPk {
group.addTask { [serverAPI] in
do {
let raw = try await serverAPI.fetchHighlights(
gamePk: game.gamePk!,
gameDate: game.gameDate
)
return raw.enumerated().compactMap { index, highlight -> HighlightItem? in
guard let headline = highlight.headline,
let hlsStr = highlight.hlsURL ?? highlight.mp4URL,
let _ = URL(string: hlsStr) else { return nil }
let isCondensed = headline.lowercased().contains("condensed")
|| headline.lowercased().contains("recap")
let timestamp = parseHighlightDate(highlight.date)
?? Date(timeIntervalSince1970: TimeInterval(index))
return HighlightItem(
id: highlight.id ?? "\(game.gamePk!)-\(index)",
headline: headline,
gameTitle: "\(game.awayTeam.code) @ \(game.homeTeam.code)",
awayCode: game.awayTeam.code,
homeCode: game.homeTeam.code,
hlsURL: highlight.hlsURL.flatMap(URL.init),
mp4URL: highlight.mp4URL.flatMap(URL.init),
isCondensedGame: isCondensed,
timestamp: timestamp
)
}
} catch {
return []
}
}
}
var allHighlights: [HighlightItem] = []
for await batch in group {
allHighlights.append(contentsOf: batch)
}
// Sort all highlights by time, most recent first
highlights = allHighlights.sorted { $0.timestamp > $1.timestamp }
}
isLoading = false
logFeed("loadHighlights complete count=\(highlights.count)")
}
var condensedGames: [HighlightItem] {
highlights.filter(\.isCondensedGame)
}
var latestHighlights: [HighlightItem] {
highlights.filter { !$0.isCondensedGame }
}
func startAutoRefresh(games: [Game]) {
stopAutoRefresh()
refreshTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(300))
guard !Task.isCancelled, let self else { break }
await self.loadHighlights(games: games)
}
}
}
func stopAutoRefresh() {
refreshTask?.cancel()
refreshTask = nil
}
}

View File

@@ -16,6 +16,7 @@ final class GameCenterViewModel {
var highlights: [Highlight] = [] var highlights: [Highlight] = []
var winProbabilityHome: Double? var winProbabilityHome: Double?
var winProbabilityAway: Double? var winProbabilityAway: Double?
var winProbabilityHistory: [WinProbabilityEntry] = []
var isLoading = false var isLoading = false
var errorMessage: String? var errorMessage: String?
var lastUpdated: Date? var lastUpdated: Date?
@@ -81,6 +82,7 @@ final class GameCenterViewModel {
private func refreshWinProbability(gamePk: String) async { private func refreshWinProbability(gamePk: String) async {
do { do {
let entries = try await statsAPI.fetchWinProbability(gamePk: gamePk) let entries = try await statsAPI.fetchWinProbability(gamePk: gamePk)
winProbabilityHistory = entries
if let latest = entries.last, if let latest = entries.last,
let home = latest.homeTeamWinProbability, let home = latest.homeTeamWinProbability,
let away = latest.awayTeamWinProbability { let away = latest.awayTeamWinProbability {

View File

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

View File

@@ -0,0 +1,254 @@
import SwiftUI
struct CategoryPillBar: View {
@Binding var selected: AppSection
var streamCount: Int = 0
var totalGames: Int = 0
var liveGames: Int = 0
@Namespace private var selectionNamespace
private var primarySections: [AppSection] {
AppSection.allCases.filter { $0 != .settings }
}
var body: some View {
ViewThatFits {
expandedBar
compactBar
}
}
private var expandedBar: some View {
HStack(spacing: 24) {
brandLockup
HStack(spacing: 10) {
ForEach(primarySections) { section in
sectionButton(section)
}
}
Spacer(minLength: 12)
HStack(spacing: 10) {
statusChip(value: "\(liveGames)", label: "Live", systemImage: "dot.radiowaves.left.and.right", tint: DS.Colors.live)
statusChip(value: "\(totalGames)", label: "Games", systemImage: "sportscourt", tint: DS.Colors.media)
statusChip(value: "\(streamCount)", label: "Tiles", systemImage: "rectangle.split.2x2", tint: DS.Colors.positive)
settingsButton
}
}
.padding(.horizontal, containerPadH)
.padding(.vertical, containerPadV)
.background(shellBackground)
}
private var compactBar: some View {
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 16) {
brandLockup
Spacer()
settingsButton
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(primarySections) { section in
sectionButton(section)
}
statusChip(value: "\(liveGames)", label: "Live", systemImage: "dot.radiowaves.left.and.right", tint: DS.Colors.live)
statusChip(value: "\(totalGames)", label: "Games", systemImage: "sportscourt", tint: DS.Colors.media)
statusChip(value: "\(streamCount)", label: "Tiles", systemImage: "rectangle.split.2x2", tint: DS.Colors.positive)
}
}
.scrollClipDisabled()
}
.padding(.horizontal, containerPadH)
.padding(.vertical, containerPadV)
.background(shellBackground)
}
private var brandLockup: some View {
VStack(alignment: .leading, spacing: 3) {
Text("MLB")
.font(brandPrimaryFont)
.foregroundStyle(DS.Colors.textPrimary)
Text("CONTROL ROOM")
.font(brandSecondaryFont)
.foregroundStyle(DS.Colors.textTertiary)
.tracking(brandTracking)
}
}
private func sectionButton(_ section: AppSection) -> some View {
Button {
withAnimation(.spring(response: 0.36, dampingFraction: 0.82)) {
selected = section
}
} label: {
HStack(spacing: 10) {
Image(systemName: section.systemImage)
.font(.system(size: iconSize, weight: .bold))
Text(section.title)
.font(pillFont)
}
.foregroundStyle(selected == section ? Color.black.opacity(0.86) : DS.Colors.textSecondary)
.padding(.horizontal, pillPadH)
.padding(.vertical, pillPadV)
.background {
if selected == section {
Capsule()
.fill(
LinearGradient(
colors: [
DS.Colors.interactive,
DS.Colors.warning,
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.matchedGeometryEffect(id: "selected-section", in: selectionNamespace)
} else {
Capsule()
.fill(DS.Colors.panelFillMuted)
}
}
}
.platformCardStyle()
}
private var settingsButton: some View {
Button {
selected = .settings
} label: {
Image(systemName: "gearshape.fill")
.font(.system(size: iconSize, weight: .bold))
.foregroundStyle(selected == .settings ? Color.black.opacity(0.86) : DS.Colors.textSecondary)
.padding(.horizontal, pillPadH)
.padding(.vertical, pillPadV)
.background {
if selected == .settings {
Capsule()
.fill(
LinearGradient(
colors: [
DS.Colors.interactive,
DS.Colors.warning,
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
} else {
Capsule()
.fill(DS.Colors.panelFillMuted)
}
}
}
.platformCardStyle()
}
private func statusChip(value: String, label: String, systemImage: String, tint: Color) -> some View {
HStack(spacing: 9) {
Image(systemName: systemImage)
.font(.system(size: statIconSize, weight: .bold))
.foregroundStyle(tint)
Text(value)
.font(statValueFont)
.foregroundStyle(DS.Colors.textPrimary)
.monospacedDigit()
Text(label)
.font(statLabelFont)
.foregroundStyle(DS.Colors.textTertiary)
}
.padding(.horizontal, statPadH)
.padding(.vertical, statPadV)
.background(
Capsule()
.fill(DS.Colors.panelFillMuted)
.overlay {
Capsule()
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
}
)
}
private var shellBackground: some View {
RoundedRectangle(cornerRadius: shellRadius, style: .continuous)
.fill(DS.Colors.navFill)
.overlay {
RoundedRectangle(cornerRadius: shellRadius, style: .continuous)
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
}
.shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY)
}
#if os(tvOS)
private var shellRadius: CGFloat { 28 }
private var containerPadH: CGFloat { 28 }
private var containerPadV: CGFloat { 22 }
private var pillPadH: CGFloat { 24 }
private var pillPadV: CGFloat { 16 }
private var statPadH: CGFloat { 18 }
private var statPadV: CGFloat { 14 }
private var pillFont: Font { .system(size: 23, weight: .bold, design: .rounded) }
private var statValueFont: Font { .system(size: 22, weight: .black, design: .rounded) }
private var statLabelFont: Font { .system(size: 16, weight: .bold, design: .rounded) }
private var brandPrimaryFont: Font { .system(size: 34, weight: .black, design: .rounded) }
private var brandSecondaryFont: Font { .system(size: 14, weight: .black, design: .rounded) }
private var brandTracking: CGFloat { 2.6 }
private var iconSize: CGFloat { 20 }
private var statIconSize: CGFloat { 13 }
#else
private var shellRadius: CGFloat { 22 }
private var containerPadH: CGFloat { 18 }
private var containerPadV: CGFloat { 16 }
private var pillPadH: CGFloat { 18 }
private var pillPadV: CGFloat { 10 }
private var statPadH: CGFloat { 12 }
private var statPadV: CGFloat { 10 }
private var pillFont: Font { .system(size: 15, weight: .bold, design: .rounded) }
private var statValueFont: Font { .system(size: 15, weight: .black, design: .rounded) }
private var statLabelFont: Font { .system(size: 11, weight: .bold, design: .rounded) }
private var brandPrimaryFont: Font { .system(size: 20, weight: .black, design: .rounded) }
private var brandSecondaryFont: Font { .system(size: 10, weight: .black, design: .rounded) }
private var brandTracking: CGFloat { 1.8 }
private var iconSize: CGFloat { 15 }
private var statIconSize: CGFloat { 11 }
#endif
}
enum AppSection: String, CaseIterable, Identifiable {
case today
case intel
case highlights
case multiView
case settings
var id: String { rawValue }
var title: String {
switch self {
case .today: "Dashboard"
case .intel: "League"
case .highlights: "Highlights"
case .multiView: "Multi-View"
case .settings: "Settings"
}
}
var systemImage: String {
switch self {
case .today: "rectangle.3.group.fill"
case .intel: "chart.xyaxis.line"
case .highlights: "play.rectangle.on.rectangle.fill"
case .multiView: "rectangle.split.2x2.fill"
case .settings: "gearshape.fill"
}
}
}

View File

@@ -0,0 +1,77 @@
import SwiftUI
enum DataPanelDensity {
case compact
case standard
case featured
var padding: CGFloat {
switch self {
case .compact: DS.Spacing.panelPadCompact
case .standard: DS.Spacing.panelPadStandard
case .featured: DS.Spacing.panelPadFeatured
}
}
var cornerRadius: CGFloat {
switch self {
case .compact: DS.Radii.compact
case .standard: DS.Radii.standard
case .featured: DS.Radii.featured
}
}
}
struct DataPanel<Content: View>: View {
let density: DataPanelDensity
var teamAccentCode: String? = nil
@ViewBuilder let content: () -> Content
var body: some View {
HStack(spacing: 0) {
if let code = teamAccentCode {
RoundedRectangle(cornerRadius: 1.5)
.fill(TeamAssets.color(for: code))
.frame(width: 4)
.padding(.vertical, 8)
.padding(.leading, 6)
}
VStack(alignment: .leading, spacing: 0) {
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(density.padding)
}
.background {
RoundedRectangle(cornerRadius: density.cornerRadius, style: .continuous)
.fill(
LinearGradient(
colors: [
DS.Colors.panelFill,
DS.Colors.panelFillMuted,
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.overlay {
RoundedRectangle(cornerRadius: density.cornerRadius, style: .continuous)
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
}
.shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY)
}
}
}
extension DataPanel {
init(
_ density: DataPanelDensity = .standard,
teamAccent: String? = nil,
@ViewBuilder content: @escaping () -> Content
) {
self.density = density
self.teamAccentCode = teamAccent
self.content = content
}
}

View File

@@ -0,0 +1,180 @@
import SwiftUI
enum DS {
enum Colors {
static let background = Color(red: 0.03, green: 0.05, blue: 0.10)
static let backgroundElevated = Color(red: 0.06, green: 0.10, blue: 0.18)
static let navFill = Color(red: 0.07, green: 0.10, blue: 0.18).opacity(0.94)
static let panelFill = Color(red: 0.08, green: 0.11, blue: 0.18).opacity(0.94)
static let panelFillMuted = Color(red: 0.10, green: 0.14, blue: 0.23).opacity(0.84)
static let panelStroke = Color.white.opacity(0.09)
static let live = Color(red: 0.94, green: 0.25, blue: 0.28)
static let positive = Color(red: 0.24, green: 0.86, blue: 0.63)
static let warning = Color(red: 0.98, green: 0.76, blue: 0.24)
static let interactive = Color(red: 1.00, green: 0.75, blue: 0.20)
static let media = Color(red: 0.35, green: 0.78, blue: 0.95)
static let textPrimary = Color.white.opacity(0.96)
static let textSecondary = Color.white.opacity(0.76)
static let textTertiary = Color.white.opacity(0.50)
static let textQuaternary = Color.white.opacity(0.24)
static let onDarkPrimary = textPrimary
static let onDarkSecondary = textSecondary
static let onDarkTertiary = textTertiary
}
enum Shadows {
static let card = Color.black.opacity(0.30)
static let cardRadius: CGFloat = 28
static let cardY: CGFloat = 14
static let cardLifted = Color.black.opacity(0.44)
static let cardLiftedRadius: CGFloat = 42
static let cardLiftedY: CGFloat = 18
}
enum Fonts {
static let heroScore = Font.system(size: 72, weight: .black, design: .rounded).monospacedDigit()
static let largeScore = Font.system(size: 42, weight: .black, design: .rounded).monospacedDigit()
static let score = Font.system(size: 28, weight: .black, design: .rounded).monospacedDigit()
static let scoreCompact = Font.system(size: 22, weight: .bold, design: .rounded).monospacedDigit()
static let sectionTitle = Font.system(size: 30, weight: .bold, design: .rounded)
static let cardTitle = Font.system(size: 20, weight: .bold, design: .rounded)
static let cardTitleCompact = Font.system(size: 17, weight: .bold, design: .rounded)
static let dataValue = Font.system(size: 18, weight: .bold, design: .rounded).monospacedDigit()
static let dataValueCompact = Font.system(size: 15, weight: .semibold, design: .rounded).monospacedDigit()
static let body = Font.system(size: 15, weight: .medium)
static let bodySmall = Font.system(size: 13, weight: .medium)
static let caption = Font.system(size: 11, weight: .bold, design: .rounded)
#if os(tvOS)
static let tvHeroScore = Font.system(size: 94, weight: .black, design: .rounded).monospacedDigit()
static let tvSectionTitle = Font.system(size: 40, weight: .bold, design: .rounded)
static let tvCardTitle = Font.system(size: 28, weight: .bold, design: .rounded)
static let tvScore = Font.system(size: 36, weight: .black, design: .rounded).monospacedDigit()
static let tvDataValue = Font.system(size: 24, weight: .bold, design: .rounded).monospacedDigit()
static let tvBody = Font.system(size: 22, weight: .medium)
static let tvCaption = Font.system(size: 20, weight: .bold, design: .rounded)
#endif
}
enum Spacing {
#if os(tvOS)
static let panelPadCompact: CGFloat = 18
static let panelPadStandard: CGFloat = 24
static let panelPadFeatured: CGFloat = 32
static let sectionGap: CGFloat = 42
static let cardGap: CGFloat = 20
static let itemGap: CGFloat = 12
static let edgeInset: CGFloat = 50
#else
static let panelPadCompact: CGFloat = 12
static let panelPadStandard: CGFloat = 16
static let panelPadFeatured: CGFloat = 24
static let sectionGap: CGFloat = 28
static let cardGap: CGFloat = 14
static let itemGap: CGFloat = 8
static let edgeInset: CGFloat = 20
#endif
}
enum Radii {
static let compact: CGFloat = 16
static let standard: CGFloat = 24
static let featured: CGFloat = 30
}
}
struct BroadcastBackground: View {
var body: some View {
ZStack {
LinearGradient(
colors: [
Color(red: 0.03, green: 0.05, blue: 0.10),
Color(red: 0.04, green: 0.08, blue: 0.16),
Color(red: 0.02, green: 0.04, blue: 0.09),
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
// Subtle color washes radial gradients instead of blurred circles for performance
RadialGradient(
colors: [Color(red: 0.00, green: 0.46, blue: 0.72).opacity(0.12), .clear],
center: UnitPoint(x: 0.1, y: 0.15),
startRadius: 50,
endRadius: 500
)
RadialGradient(
colors: [DS.Colors.interactive.opacity(0.10), .clear],
center: UnitPoint(x: 0.85, y: 0.15),
startRadius: 50,
endRadius: 450
)
RadialGradient(
colors: [DS.Colors.live.opacity(0.06), .clear],
center: UnitPoint(x: 0.8, y: 0.85),
startRadius: 50,
endRadius: 400
)
BroadcastGridOverlay()
.opacity(0.30)
}
}
}
private struct BroadcastGridOverlay: View {
var body: some View {
GeometryReader { proxy in
let size = proxy.size
Path { path in
let verticalSpacing: CGFloat = 110
let horizontalSpacing: CGFloat = 90
var x: CGFloat = 0
while x <= size.width {
path.move(to: CGPoint(x: x, y: 0))
path.addLine(to: CGPoint(x: x, y: size.height))
x += verticalSpacing
}
var y: CGFloat = 0
while y <= size.height {
path.move(to: CGPoint(x: 0, y: y))
path.addLine(to: CGPoint(x: size.width, y: y))
y += horizontalSpacing
}
}
.stroke(Color.white.opacity(0.05), lineWidth: 1)
}
.allowsHitTesting(false)
}
}
struct DataLabelStyle: ViewModifier {
func body(content: Content) -> some View {
content
#if os(tvOS)
.font(DS.Fonts.tvCaption)
.kerning(1.0)
#else
.font(DS.Fonts.caption)
.kerning(1.5)
#endif
.foregroundStyle(DS.Colors.textTertiary)
.textCase(.uppercase)
}
}
extension View {
func dataLabelStyle() -> some View {
modifier(DataLabelStyle())
}
}

View File

@@ -0,0 +1,120 @@
import SwiftUI
/// Visual baseball diamond showing base runners, count, and outs
struct DiamondView: View {
var onFirst: Bool = false
var onSecond: Bool = false
var onThird: Bool = false
var balls: Int = 0
var strikes: Int = 0
var outs: Int = 0
var body: some View {
HStack(spacing: diamondCountGap) {
// Diamond
ZStack {
// Diamond shape outline
diamondPath
.stroke(DS.Colors.textQuaternary, lineWidth: 1.5)
// Base markers
baseMarker(at: firstBasePos, occupied: onFirst)
baseMarker(at: secondBasePos, occupied: onSecond)
baseMarker(at: thirdBasePos, occupied: onThird)
baseMarker(at: homePos, occupied: false, isHome: true)
}
.frame(width: diamondSize, height: diamondSize)
// Count + Outs
VStack(alignment: .leading, spacing: countSpacing) {
HStack(spacing: 3) {
Text("B").dataLabelStyle()
ForEach(0..<4, id: \.self) { i in
Circle()
.fill(i < balls ? DS.Colors.positive : DS.Colors.textQuaternary)
.frame(width: dotSize, height: dotSize)
}
}
HStack(spacing: 3) {
Text("S").dataLabelStyle()
ForEach(0..<3, id: \.self) { i in
Circle()
.fill(i < strikes ? DS.Colors.live : DS.Colors.textQuaternary)
.frame(width: dotSize, height: dotSize)
}
}
HStack(spacing: 3) {
Text("O").dataLabelStyle()
ForEach(0..<3, id: \.self) { i in
Circle()
.fill(i < outs ? DS.Colors.warning : DS.Colors.textQuaternary)
.frame(width: dotSize, height: dotSize)
}
}
}
}
}
// MARK: - Diamond geometry
private var diamondPath: Path {
let s = diamondSize
let mid = s / 2
let inset: CGFloat = baseSize / 2 + 2
var path = Path()
path.move(to: CGPoint(x: mid, y: inset)) // 2nd base (top)
path.addLine(to: CGPoint(x: s - inset, y: mid)) // 1st base (right)
path.addLine(to: CGPoint(x: mid, y: s - inset)) // Home (bottom)
path.addLine(to: CGPoint(x: inset, y: mid)) // 3rd base (left)
path.closeSubpath()
return path
}
private var firstBasePos: CGPoint {
CGPoint(x: diamondSize - baseSize / 2 - 2, y: diamondSize / 2)
}
private var secondBasePos: CGPoint {
CGPoint(x: diamondSize / 2, y: baseSize / 2 + 2)
}
private var thirdBasePos: CGPoint {
CGPoint(x: baseSize / 2 + 2, y: diamondSize / 2)
}
private var homePos: CGPoint {
CGPoint(x: diamondSize / 2, y: diamondSize - baseSize / 2 - 2)
}
@ViewBuilder
private func baseMarker(at position: CGPoint, occupied: Bool, isHome: Bool = false) -> some View {
Group {
if isHome {
// Home plate: small pentagon-like shape
Circle()
.fill(DS.Colors.textQuaternary)
.frame(width: baseSize * 0.7, height: baseSize * 0.7)
} else {
// Base: rotated square
Rectangle()
.fill(occupied ? DS.Colors.interactive : DS.Colors.textQuaternary)
.frame(width: baseSize, height: baseSize)
.rotationEffect(.degrees(45))
}
}
.position(position)
}
// MARK: - Platform sizing
#if os(tvOS)
private var diamondSize: CGFloat { 60 }
private var baseSize: CGFloat { 12 }
private var dotSize: CGFloat { 9 }
private var countSpacing: CGFloat { 5 }
private var diamondCountGap: CGFloat { 14 }
#else
private var diamondSize: CGFloat { 44 }
private var baseSize: CGFloat { 9 }
private var dotSize: CGFloat { 7 }
private var countSpacing: CGFloat { 3 }
private var diamondCountGap: CGFloat { 10 }
#endif
}

View File

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

View File

@@ -0,0 +1,80 @@
import SwiftUI
/// Top-5 stat leaderboard card with player headshots
struct LeaderboardView: View {
let category: LeaderCategory
var body: some View {
DataPanel(.standard) {
VStack(alignment: .leading, spacing: 10) {
Text(category.name.uppercased())
.dataLabelStyle()
ForEach(category.leaders) { leader in
leaderRow(leader)
}
}
}
}
@ViewBuilder
private func leaderRow(_ leader: LeagueLeader) -> some View {
HStack(spacing: 10) {
Text("\(leader.rank)")
.font(rankFont)
.foregroundStyle(leader.rank <= 3 ? DS.Colors.textPrimary : DS.Colors.textTertiary)
.frame(width: rankWidth, alignment: .center)
// Player headshot
if let personId = leader.personId {
AsyncImage(url: URL(string: "https://img.mlbstatic.com/mlb-photos/image/upload/w_80,q_auto:best/v1/people/\(personId)/headshot/67/current")) { phase in
if let image = phase.image {
image.resizable().aspectRatio(contentMode: .fill)
} else {
Circle().fill(DS.Colors.panelFill)
}
}
.frame(width: headshotSize, height: headshotSize)
.clipShape(Circle())
}
VStack(alignment: .leading, spacing: 1) {
Text(leader.playerName)
.font(nameFont)
.foregroundStyle(DS.Colors.textPrimary)
.lineLimit(1)
if !leader.teamCode.isEmpty {
HStack(spacing: 3) {
RoundedRectangle(cornerRadius: 1)
.fill(TeamAssets.color(for: leader.teamCode))
.frame(width: 2, height: 10)
Text(leader.teamCode)
.font(DS.Fonts.caption)
.foregroundStyle(DS.Colors.textTertiary)
}
}
}
Spacer()
Text(leader.value)
.font(valueFont)
.foregroundStyle(leader.rank == 1 ? DS.Colors.interactive : DS.Colors.textPrimary)
}
}
#if os(tvOS)
private var rankWidth: CGFloat { 28 }
private var headshotSize: CGFloat { 36 }
private var rankFont: Font { .system(size: 16, weight: .bold, design: .rounded).monospacedDigit() }
private var nameFont: Font { .system(size: 17, weight: .semibold) }
private var valueFont: Font { DS.Fonts.tvDataValue }
#else
private var rankWidth: CGFloat { 22 }
private var headshotSize: CGFloat { 28 }
private var rankFont: Font { .system(size: 13, weight: .bold, design: .rounded).monospacedDigit() }
private var nameFont: Font { .system(size: 14, weight: .semibold) }
private var valueFont: Font { DS.Fonts.dataValue }
#endif
}

View File

@@ -0,0 +1,101 @@
import SwiftUI
/// Compact horizontal strip of all live games scores, innings, outs at a glance
struct LiveSituationBar: View {
let games: [Game]
var onTapGame: ((Game) -> Void)? = nil
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: DS.Spacing.cardGap) {
ForEach(games) { game in
liveTile(game)
}
}
.padding(.horizontal, DS.Spacing.edgeInset)
}
}
@ViewBuilder
private func liveTile(_ game: Game) -> some View {
Button {
onTapGame?(game)
} label: {
DataPanel(.compact) {
HStack(spacing: tileSpacing) {
// Teams + scores
VStack(alignment: .leading, spacing: 3) {
teamScoreRow(code: game.awayTeam.code, score: game.awayTeam.score)
teamScoreRow(code: game.homeTeam.code, score: game.homeTeam.score)
}
// Situation
VStack(alignment: .trailing, spacing: 3) {
if let inning = game.currentInningDisplay ?? game.status.liveInning {
Text(inning)
.font(inningFont)
.foregroundStyle(DS.Colors.textSecondary)
}
if let linescore = game.linescore {
HStack(spacing: 2) {
ForEach(0..<3, id: \.self) { i in
Circle()
.fill(i < (linescore.outs ?? 0) ? DS.Colors.warning : DS.Colors.textQuaternary)
.frame(width: outDotSize, height: outDotSize)
}
}
}
}
}
}
}
.platformCardStyle()
}
@ViewBuilder
private func teamScoreRow(code: String, score: Int?) -> some View {
HStack(spacing: 6) {
TeamLogoView(team: TeamInfo(code: code, name: "", score: nil), size: logoSize)
Text(code)
.font(teamFont.weight(.bold))
.foregroundStyle(DS.Colors.textPrimary)
.frame(width: codeWidth, alignment: .leading)
Text(score.map { "\($0)" } ?? "-")
.font(scoreFont.weight(.black).monospacedDigit())
.foregroundStyle(DS.Colors.textPrimary)
.frame(width: scoreWidth, alignment: .trailing)
}
}
#if os(tvOS)
private var tileSpacing: CGFloat { 20 }
private var logoSize: CGFloat { 28 }
private var codeWidth: CGFloat { 44 }
private var scoreWidth: CGFloat { 30 }
private var outDotSize: CGFloat { 8 }
private var teamFont: Font { .system(size: 18, design: .rounded) }
private var scoreFont: Font { .system(size: 20, design: .rounded) }
private var inningFont: Font { .system(size: 15, weight: .semibold, design: .rounded) }
#else
private var tileSpacing: CGFloat { 14 }
private var logoSize: CGFloat { 22 }
private var codeWidth: CGFloat { 34 }
private var scoreWidth: CGFloat { 24 }
private var outDotSize: CGFloat { 6 }
private var teamFont: Font { .system(size: 14, design: .rounded) }
private var scoreFont: Font { .system(size: 16, design: .rounded) }
private var inningFont: Font { .system(size: 12, weight: .semibold, design: .rounded) }
#endif
}
// MARK: - GameStatus helper
private extension GameStatus {
var liveInning: String? {
if case .live(let info) = self { return info }
return nil
}
}

View File

@@ -0,0 +1,52 @@
import SwiftUI
/// Compact R-H-E display for game cards
struct MiniLinescoreView: View {
let linescore: StatsLinescore
let awayCode: String
let homeCode: String
var body: some View {
HStack(spacing: 0) {
// Team abbreviations column
VStack(alignment: .leading, spacing: 2) {
Text(awayCode).foregroundStyle(TeamAssets.color(for: awayCode))
Text(homeCode).foregroundStyle(TeamAssets.color(for: homeCode))
}
.font(statFont.weight(.bold))
.frame(width: teamColWidth, alignment: .leading)
// R column
statColumn("R", away: linescore.teams?.away?.runs, home: linescore.teams?.home?.runs, bold: true)
// H column
statColumn("H", away: linescore.teams?.away?.hits, home: linescore.teams?.home?.hits, bold: false)
// E column
statColumn("E", away: linescore.teams?.away?.errors, home: linescore.teams?.home?.errors, bold: false)
}
}
@ViewBuilder
private func statColumn(_ label: String, away: Int?, home: Int?, bold: Bool) -> some View {
VStack(spacing: 2) {
Text(label)
.dataLabelStyle()
Text(away.map { "\($0)" } ?? "-")
.font(statFont.weight(bold ? .bold : .medium).monospacedDigit())
.foregroundStyle(bold ? DS.Colors.textPrimary : DS.Colors.textSecondary)
Text(home.map { "\($0)" } ?? "-")
.font(statFont.weight(bold ? .bold : .medium).monospacedDigit())
.foregroundStyle(bold ? DS.Colors.textPrimary : DS.Colors.textSecondary)
}
.frame(width: statColWidth)
}
#if os(tvOS)
private var statFont: Font { .system(size: 17, design: .rounded) }
private var teamColWidth: CGFloat { 50 }
private var statColWidth: CGFloat { 36 }
#else
private var statFont: Font { .system(size: 13, design: .rounded) }
private var teamColWidth: CGFloat { 36 }
private var statColWidth: CGFloat { 28 }
#endif
}

View File

@@ -0,0 +1,131 @@
import SwiftUI
/// Pitch type breakdown for a pitcher showing distribution and average velocity
struct PitchArsenalView: View {
let allPlays: [LiveFeedPlay]
let pitcherName: String
private var pitchSummary: [PitchTypeSummary] {
var grouped: [String: (count: Int, totalSpeed: Double, speedCount: Int)] = [:]
for play in allPlays {
guard let events = play.playEvents else { continue }
for event in events where event.isPitch == true {
let type = event.pitchTypeDescription
var entry = grouped[type, default: (0, 0, 0)]
entry.count += 1
if let speed = event.speedMPH {
entry.totalSpeed += speed
entry.speedCount += 1
}
grouped[type] = entry
}
}
let total = grouped.values.reduce(0) { $0 + $1.count }
guard total > 0 else { return [] }
return grouped.map { type, data in
PitchTypeSummary(
name: type,
count: data.count,
percentage: Double(data.count) / Double(total) * 100,
avgSpeed: data.speedCount > 0 ? data.totalSpeed / Double(data.speedCount) : nil
)
}
.sorted { $0.count > $1.count }
}
var body: some View {
let summary = pitchSummary
if !summary.isEmpty {
DataPanel(.standard) {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("PITCH ARSENAL")
.dataLabelStyle()
Spacer()
Text(pitcherName)
.font(DS.Fonts.bodySmall)
.foregroundStyle(DS.Colors.textTertiary)
}
ForEach(summary, id: \.name) { pitch in
pitchRow(pitch, maxCount: summary.first?.count ?? 1)
}
}
}
}
}
@ViewBuilder
private func pitchRow(_ pitch: PitchTypeSummary, maxCount: Int) -> some View {
HStack(spacing: 10) {
Text(pitch.name)
.font(pitchNameFont)
.foregroundStyle(DS.Colors.textSecondary)
.frame(width: nameWidth, alignment: .leading)
.lineLimit(1)
GeometryReader { geo in
let fraction = maxCount > 0 ? CGFloat(pitch.count) / CGFloat(maxCount) : 0
RoundedRectangle(cornerRadius: 3)
.fill(barColor(for: pitch.name))
.frame(width: geo.size.width * fraction)
}
.frame(height: barHeight)
Text("\(Int(pitch.percentage))%")
.font(DS.Fonts.dataValueCompact)
.foregroundStyle(DS.Colors.textPrimary)
.frame(width: pctWidth, alignment: .trailing)
if let speed = pitch.avgSpeed {
Text("\(Int(speed))")
.font(DS.Fonts.dataValueCompact)
.foregroundStyle(DS.Colors.textTertiary)
.frame(width: speedWidth, alignment: .trailing)
Text("mph")
.dataLabelStyle()
}
}
}
private func barColor(for pitchType: String) -> Color {
let lowered = pitchType.lowercased()
if lowered.contains("fastball") || lowered.contains("sinker") || lowered.contains("cutter") {
return DS.Colors.live
}
if lowered.contains("slider") || lowered.contains("sweep") {
return DS.Colors.interactive
}
if lowered.contains("curve") || lowered.contains("knuckle") {
return DS.Colors.media
}
if lowered.contains("change") || lowered.contains("split") {
return DS.Colors.positive
}
return DS.Colors.warning
}
#if os(tvOS)
private var nameWidth: CGFloat { 140 }
private var pctWidth: CGFloat { 44 }
private var speedWidth: CGFloat { 36 }
private var barHeight: CGFloat { 14 }
private var pitchNameFont: Font { DS.Fonts.bodySmall }
#else
private var nameWidth: CGFloat { 100 }
private var pctWidth: CGFloat { 36 }
private var speedWidth: CGFloat { 30 }
private var barHeight: CGFloat { 10 }
private var pitchNameFont: Font { .system(size: 12, weight: .medium) }
#endif
}
private struct PitchTypeSummary {
let name: String
let count: Int
let percentage: Double
let avgSpeed: Double?
}

View File

@@ -1,5 +1,7 @@
import SwiftUI import SwiftUI
// MARK: - iOS Press Style
struct PlatformPressButtonStyle: ButtonStyle { struct PlatformPressButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View { func makeBody(configuration: Configuration) -> some View {
configuration.label configuration.label
@@ -9,11 +11,42 @@ struct PlatformPressButtonStyle: ButtonStyle {
} }
} }
// MARK: - tvOS Focus Style (Light Theme)
#if os(tvOS)
struct TVFocusButtonStyle: ButtonStyle {
@Environment(\.isFocused) private var isFocused
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.98 : isFocused ? 1.035 : 1.0)
.opacity(configuration.isPressed ? 0.85 : 1.0)
.shadow(
color: isFocused ? DS.Shadows.cardLifted : .clear,
radius: isFocused ? DS.Shadows.cardLiftedRadius : 0,
y: isFocused ? DS.Shadows.cardLiftedY : 0
)
.overlay(
RoundedRectangle(cornerRadius: 26, style: .continuous)
.strokeBorder(DS.Colors.interactive.opacity(isFocused ? 0.72 : 0), lineWidth: 3)
)
.overlay {
RoundedRectangle(cornerRadius: 26, style: .continuous)
.fill(DS.Colors.interactive.opacity(isFocused ? 0.08 : 0))
}
.animation(.easeInOut(duration: 0.2), value: isFocused)
.animation(.easeOut(duration: 0.12), value: configuration.isPressed)
}
}
#endif
// MARK: - Platform Extensions
extension View { extension View {
@ViewBuilder @ViewBuilder
func platformCardStyle() -> some View { func platformCardStyle() -> some View {
#if os(tvOS) #if os(tvOS)
self.buttonStyle(.card) self.buttonStyle(TVFocusButtonStyle())
#else #else
self.buttonStyle(PlatformPressButtonStyle()) self.buttonStyle(PlatformPressButtonStyle())
#endif #endif

View File

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

View File

@@ -0,0 +1,94 @@
import Charts
import SwiftUI
/// Full-game win probability line chart using Swift Charts
struct WinProbabilityChartView: View {
let entries: [WinProbabilityEntry]
let homeCode: String
let awayCode: String
private var homeColor: Color { TeamAssets.color(for: homeCode) }
private var awayColor: Color { TeamAssets.color(for: awayCode) }
var body: some View {
DataPanel(.standard) {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("WIN PROBABILITY")
.dataLabelStyle()
Spacer()
if let latest = entries.last {
HStack(spacing: 12) {
probLabel(code: awayCode, value: latest.awayTeamWinProbability)
probLabel(code: homeCode, value: latest.homeTeamWinProbability)
}
}
}
Chart {
ForEach(Array(entries.enumerated()), id: \.offset) { index, entry in
if let wp = entry.homeTeamWinProbability {
LineMark(
x: .value("AB", index),
y: .value("WP", wp)
)
.foregroundStyle(homeColor)
.interpolationMethod(.catmullRom)
AreaMark(
x: .value("AB", index),
yStart: .value("Base", 50),
yEnd: .value("WP", wp)
)
.foregroundStyle(
wp >= 50
? homeColor.opacity(0.15)
: awayColor.opacity(0.15)
)
.interpolationMethod(.catmullRom)
}
}
RuleMark(y: .value("Even", 50))
.foregroundStyle(DS.Colors.textQuaternary)
.lineStyle(StrokeStyle(lineWidth: 0.5, dash: [4, 4]))
}
.chartYScale(domain: 0...100)
.chartYAxis {
AxisMarks(values: [0, 25, 50, 75, 100]) { value in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.3))
.foregroundStyle(DS.Colors.textQuaternary)
AxisValueLabel {
Text("\(value.as(Int.self) ?? 0)%")
.font(DS.Fonts.caption)
.foregroundStyle(DS.Colors.textTertiary)
}
}
}
.chartXAxis(.hidden)
.frame(height: chartHeight)
}
}
}
@ViewBuilder
private func probLabel(code: String, value: Double?) -> some View {
HStack(spacing: 4) {
RoundedRectangle(cornerRadius: 2)
.fill(TeamAssets.color(for: code))
.frame(width: 3, height: 14)
Text(code)
.font(DS.Fonts.caption)
.foregroundStyle(DS.Colors.textTertiary)
Text(value.map { "\(Int($0))%" } ?? "-")
.font(DS.Fonts.dataValueCompact)
.foregroundStyle(DS.Colors.textPrimary)
}
}
#if os(tvOS)
private var chartHeight: CGFloat { 180 }
#else
private var chartHeight: CGFloat { 140 }
#endif
}

View File

@@ -2,32 +2,61 @@ import SwiftUI
struct ContentView: View { struct ContentView: View {
@Environment(GamesViewModel.self) private var viewModel @Environment(GamesViewModel.self) private var viewModel
@State private var selectedSection: AppSection = .today
private var multiViewLabel: String { private var showsTicker: Bool {
let count = viewModel.activeStreams.count selectedSection != .multiView && !viewModel.games.isEmpty
if count > 0 {
return "Multi-View (\(count))"
}
return "Multi-View"
} }
var body: some View { var body: some View {
TabView { ZStack {
Tab("Games", systemImage: "sportscourt.fill") { BroadcastBackground()
DashboardView() .ignoresSafeArea()
}
Tab("League", systemImage: "list.bullet.rectangle.portrait.fill") { VStack(spacing: shellSpacing) {
LeagueCenterView() CategoryPillBar(
} selected: $selectedSection,
Tab(multiViewLabel, systemImage: "rectangle.split.2x2.fill") { streamCount: viewModel.activeStreams.count,
MultiStreamView() totalGames: viewModel.games.count,
} liveGames: viewModel.liveGames.count
Tab("Settings", systemImage: "gearshape.fill") { )
SettingsView() .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()
} }
} }
.task { .task {
await viewModel.loadGames() 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,70 +84,93 @@ struct DashboardView: View {
private var shelfCardWidth: CGFloat { private var shelfCardWidth: CGFloat {
#if os(iOS) #if os(iOS)
horizontalSizeClass == .compact ? 300 : 360 horizontalSizeClass == .compact ? 340 : 500
#else #else
400 540
#endif #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 { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: contentSpacing) { VStack(alignment: .leading, spacing: contentSpacing) {
headerSection headerSection
.platformFocusSection()
if viewModel.isLoading { if viewModel.isLoading {
HStack { loadingState
Spacer()
ProgressView("Loading games...")
.font(.title3)
Spacer()
}
.padding(.top, 80)
} else if let error = viewModel.errorMessage, viewModel.games.isEmpty { } else if let error = viewModel.errorMessage, viewModel.games.isEmpty {
HStack { errorState(error)
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 { } else {
// Hero featured game overviewStrip
if let featured = viewModel.featuredGame { .platformFocusSection()
FeaturedGameCard(game: featured) { heroAndControlSection
selectedGame = featured .platformFocusSection()
}
} featuredChannelsSection
.platformFocusSection()
if !viewModel.liveGames.isEmpty { if !viewModel.liveGames.isEmpty {
gameShelf(title: "Live", icon: "antenna.radiowaves.left.and.right", games: viewModel.liveGames, excludeId: viewModel.featuredGame?.id) gameShelf(
title: "Live Board",
subtitle: "Open games with inning state, records, and stream availability.",
icon: "antenna.radiowaves.left.and.right",
accent: DS.Colors.live,
games: viewModel.liveGames,
excludeId: viewModel.featuredGame?.id
)
} }
if !viewModel.scheduledGames.isEmpty { if !viewModel.scheduledGames.isEmpty {
gameShelf(title: "Upcoming", icon: "calendar", games: viewModel.scheduledGames, excludeId: viewModel.featuredGame?.id) gameShelf(
title: "Upcoming Windows",
subtitle: "Probables, first pitch, and watch-ready cards for the rest of the slate.",
icon: "calendar",
accent: DS.Colors.warning,
games: viewModel.scheduledGames,
excludeId: viewModel.featuredGame?.id
)
} }
if !viewModel.finalGames.isEmpty { if !viewModel.finalGames.isEmpty {
gameShelf(title: "Final", icon: "checkmark.circle", games: viewModel.finalGames, excludeId: viewModel.featuredGame?.id) 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
)
} }
} }
featuredChannelsSection
if !viewModel.activeStreams.isEmpty {
multiViewStatus
}
} }
.padding(.horizontal, horizontalPadding) .padding(.horizontal, horizontalPadding)
.padding(.vertical, verticalPadding) .padding(.vertical, verticalPadding)
} }
.scrollIndicators(.hidden)
.onAppear { .onAppear {
logDashboard("DashboardView appeared") logDashboard("DashboardView appeared")
viewModel.startAutoRefresh() viewModel.startAutoRefresh()
@@ -340,231 +363,627 @@ struct DashboardView: View {
) )
} }
// MARK: - Game Shelf (Horizontal) private var loadingState: some View {
VStack(spacing: 18) {
@ViewBuilder ProgressView()
private func gameShelf(title: String, icon: String, games: [Game], excludeId: String?) -> some View { .scaleEffect(1.3)
let filtered = games.filter { $0.id != excludeId } Text("Loading the daily board")
if !filtered.isEmpty { .font(sectionTitleFont)
VStack(alignment: .leading, spacing: 20) { .foregroundStyle(DS.Colors.textPrimary)
Label(title, systemImage: icon) Text("Scores, streams, and matchup context are on the way.")
.font(.title3.weight(.bold)) .font(sectionBodyFont)
.foregroundStyle(.secondary) .foregroundStyle(DS.Colors.textSecondary)
ScrollView(.horizontal) {
LazyHStack(spacing: 30) {
ForEach(filtered) { game in
GameCardView(game: game) {
selectedGame = game
}
.frame(width: shelfCardWidth)
}
}
.padding(.vertical, 12)
}
.scrollClipDisabled()
}
} }
.frame(maxWidth: .infinity)
.padding(.vertical, 120)
.background(surfaceCardBackground())
} }
// MARK: - Header private func errorState(_ error: String) -> some View {
VStack(spacing: 18) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 44, weight: .bold))
.foregroundStyle(DS.Colors.warning)
Text(error)
.font(sectionTitleFont)
.foregroundStyle(DS.Colors.textPrimary)
Button("Reload Board") {
Task { await viewModel.loadGames() }
}
.padding(.horizontal, 22)
.padding(.vertical, 14)
.background(
Capsule()
.fill(DS.Colors.interactive)
)
.foregroundStyle(Color.black.opacity(0.82))
.platformCardStyle()
}
.frame(maxWidth: .infinity)
.padding(.vertical, 110)
.background(surfaceCardBackground())
}
@ViewBuilder
private var headerSection: some View { private var headerSection: some View {
VStack(spacing: 24) {
HStack(alignment: .bottom) {
VStack(alignment: .leading, spacing: 6) {
Text("MLB")
.font(.headline.weight(.black))
.foregroundStyle(.secondary)
.kerning(4)
Text(viewModel.displayDateString)
.font(.system(size: 40, weight: .bold))
.contentTransition(.numericText())
}
Spacer()
HStack(spacing: 16) {
statPill("\(viewModel.games.count)", label: "Games")
if !viewModel.liveGames.isEmpty {
statPill("\(viewModel.liveGames.count)", label: "Live", color: .red)
}
if !viewModel.activeStreams.isEmpty {
statPill("\(viewModel.activeStreams.count)/4", label: "Streams", color: .green)
}
}
}
HStack(spacing: 16) {
Button {
Task { await viewModel.goToPreviousDay() }
} label: {
Label("Previous Day", systemImage: "chevron.left")
}
if !viewModel.isToday {
Button {
Task { await viewModel.goToToday() }
} label: {
Label("Today", systemImage: "calendar")
}
.tint(.blue)
}
Button {
Task { await viewModel.goToNextDay() }
} label: {
HStack(spacing: 6) {
Text("Next Day")
Image(systemName: "chevron.right")
}
}
Spacer()
}
}
}
@ViewBuilder
private func statPill(_ value: String, label: String, color: Color = .blue) -> some View {
VStack(spacing: 2) {
Text(value)
.font(.title3.weight(.bold).monospacedDigit())
.foregroundStyle(color)
Text(label)
.font(.subheadline.weight(.medium))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
// MARK: - Featured Channels
@ViewBuilder
private var featuredChannelsSection: some View {
ViewThatFits { ViewThatFits {
HStack(alignment: .top, spacing: 24) { HStack(alignment: .bottom, spacing: 28) {
mlbNetworkCard headerCopy
.frame(maxWidth: .infinity) Spacer(minLength: 16)
dateNavigator
nsfwVideosCard
.frame(maxWidth: .infinity)
} }
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 22) {
mlbNetworkCard headerCopy
nsfwVideosCard dateNavigator
} }
} }
} }
@ViewBuilder private var headerCopy: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Daily Control Room")
.font(headerTitleFont)
.foregroundStyle(DS.Colors.textPrimary)
Text("A broadcast-grade slate view with live radar, featured watch windows, and fast access to every stream.")
.font(headerBodyFont)
.foregroundStyle(DS.Colors.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
}
private var dateNavigator: some View {
HStack(spacing: 12) {
navigatorButton(systemImage: "chevron.left") {
Task { await viewModel.goToPreviousDay() }
}
VStack(alignment: .leading, spacing: 4) {
Text(viewModel.isToday ? "Today" : "Archive Day")
.font(dateLabelFont)
.foregroundStyle(DS.Colors.textTertiary)
Text(viewModel.displayDateString)
.font(dateFont)
.foregroundStyle(DS.Colors.textPrimary)
.contentTransition(.numericText())
}
navigatorButton(systemImage: "chevron.right") {
Task { await viewModel.goToNextDay() }
}
if !viewModel.isToday {
Button {
Task { await viewModel.goToToday() }
} label: {
Text("Jump to Today")
.font(todayBtnFont)
.foregroundStyle(Color.black.opacity(0.84))
.padding(.horizontal, 18)
.padding(.vertical, 14)
.background(
Capsule()
.fill(DS.Colors.interactive)
)
}
.platformCardStyle()
}
}
.padding(.horizontal, 22)
.padding(.vertical, 18)
.background(surfaceCardBackground(radius: 26))
}
private func navigatorButton(systemImage: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: systemImage)
.font(.system(size: dateNavIconSize, weight: .bold))
.foregroundStyle(DS.Colors.textPrimary)
.frame(width: 52, height: 52)
.background(
Circle()
.fill(DS.Colors.panelFillMuted)
)
}
.platformCardStyle()
}
private var overviewStrip: some View {
ViewThatFits {
HStack(spacing: 18) {
metricTile(value: "\(viewModel.liveGames.count)", label: "Live Games", detail: liveStatusDetail, systemImage: "dot.radiowaves.left.and.right", tint: DS.Colors.live)
metricTile(value: "\(viewModel.scheduledGames.count)", label: "Upcoming", detail: "First pitches still ahead", systemImage: "calendar", tint: DS.Colors.warning)
metricTile(value: "\(viewModel.finalGames.count)", label: "Final", detail: "Completed scoreboards", systemImage: "flag.checkered.2.crossed", tint: DS.Colors.positive)
metricTile(value: "\(viewModel.activeStreams.count)", label: "Stream Tiles", detail: activeAudioDetail, systemImage: "rectangle.split.2x2", tint: DS.Colors.media)
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 18) {
metricTile(value: "\(viewModel.liveGames.count)", label: "Live Games", detail: liveStatusDetail, systemImage: "dot.radiowaves.left.and.right", tint: DS.Colors.live)
metricTile(value: "\(viewModel.scheduledGames.count)", label: "Upcoming", detail: "First pitches still ahead", systemImage: "calendar", tint: DS.Colors.warning)
metricTile(value: "\(viewModel.finalGames.count)", label: "Final", detail: "Completed scoreboards", systemImage: "flag.checkered.2.crossed", tint: DS.Colors.positive)
metricTile(value: "\(viewModel.activeStreams.count)", label: "Stream Tiles", detail: activeAudioDetail, systemImage: "rectangle.split.2x2", tint: DS.Colors.media)
}
.padding(.vertical, 4)
}
.scrollClipDisabled()
}
}
private var liveStatusDetail: String {
if viewModel.liveGames.isEmpty {
return "No live first pitch yet"
}
return "\(viewModel.liveGames.count) games active now"
}
private var activeAudioDetail: String {
if let activeAudio = viewModel.activeAudioStream {
return "Audio: \(activeAudio.game.awayTeam.code) @ \(activeAudio.game.homeTeam.code)"
}
if isPiPActive {
return "Picture in Picture active"
}
return "Quadbox ready"
}
private func metricTile(value: String, label: String, detail: String, systemImage: String, tint: Color) -> some View {
HStack(alignment: .top, spacing: 14) {
Image(systemName: systemImage)
.font(.system(size: 20, weight: .bold))
.foregroundStyle(tint)
.frame(width: 28, height: 28)
VStack(alignment: .leading, spacing: 6) {
Text(value)
.font(metricValueFont)
.foregroundStyle(DS.Colors.textPrimary)
.monospacedDigit()
Text(label)
.font(metricLabelFont)
.foregroundStyle(DS.Colors.textSecondary)
Text(detail)
.font(metricDetailFont)
.foregroundStyle(DS.Colors.textTertiary)
.lineLimit(2)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 20)
.padding(.vertical, 18)
.background(surfaceCardBackground(radius: 26))
}
private var heroAndControlSection: some View {
Group {
if usesStackedHeroLayout {
VStack(alignment: .leading, spacing: 22) {
featuredHero
controlRail
}
} else {
HStack(alignment: .top, spacing: 24) {
featuredHero
.frame(maxWidth: .infinity, alignment: .leading)
controlRail
.frame(width: controlRailWidth, alignment: .leading)
}
}
}
}
private var featuredHero: some View {
Group {
if let featured = viewModel.featuredGame {
FeaturedGameCard(game: featured) {
selectedGame = featured
}
} else {
VStack(alignment: .leading, spacing: 16) {
Text("No featured matchup")
.font(DS.Fonts.sectionTitle)
.foregroundStyle(DS.Colors.textPrimary)
Text("As soon as the slate populates, the best watch window appears here with scores, context, and stream access.")
.font(DS.Fonts.body)
.foregroundStyle(DS.Colors.textSecondary)
}
.frame(maxWidth: .infinity, minHeight: 320, alignment: .leading)
.padding(32)
.background(surfaceCardBackground(radius: 34))
}
}
}
private var controlRail: some View {
VStack(alignment: .leading, spacing: 18) {
liveRadarPanel
multiViewStatus
}
}
private var liveRadarPanel: some View {
VStack(alignment: .leading, spacing: 18) {
HStack {
VStack(alignment: .leading, spacing: 6) {
Text("Live Radar")
.font(railTitleFont)
.foregroundStyle(DS.Colors.textPrimary)
Text(radarSubtitle)
.font(railBodyFont)
.foregroundStyle(DS.Colors.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
}
if radarGames.isEmpty {
Text("No games loaded yet.")
.font(railBodyFont)
.foregroundStyle(DS.Colors.textTertiary)
} else {
VStack(spacing: 12) {
ForEach(radarGames) { game in
radarRow(game)
}
}
}
}
.padding(24)
.background(surfaceCardBackground())
}
private var radarSubtitle: String {
if !viewModel.liveGames.isEmpty {
return "Fast board for the most active windows."
}
if !viewModel.scheduledGames.isEmpty {
return "No live action yet. Upcoming first pitches are next."
}
return "Completed slate snapshots."
}
private func radarRow(_ game: Game) -> some View {
Button {
selectedGame = game
} label: {
HStack(spacing: 14) {
VStack(alignment: .leading, spacing: 6) {
Text("\(game.awayTeam.code) @ \(game.homeTeam.code)")
.font(radarRowTitleFont)
.foregroundStyle(DS.Colors.textPrimary)
Text(radarDetail(for: game))
.font(radarRowBodyFont)
.foregroundStyle(DS.Colors.textSecondary)
.lineLimit(2)
}
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 6) {
Text(radarStatus(for: game))
.font(radarRowStatusFont)
.foregroundStyle(radarTint(for: game))
if let score = game.scoreDisplay {
Text(score)
.font(radarRowScoreFont)
.foregroundStyle(DS.Colors.textPrimary)
.monospacedDigit()
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(DS.Colors.panelFillMuted)
.overlay {
RoundedRectangle(cornerRadius: 20, style: .continuous)
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
}
)
}
.platformCardStyle()
}
private func radarDetail(for game: Game) -> String {
if game.isLive {
return game.venue ?? "Live board ready"
}
if let pitchers = game.pitchers, !pitchers.isEmpty {
return pitchers
}
return game.venue ?? "Open matchup details"
}
private func radarStatus(for game: Game) -> String {
switch game.status {
case .live(let inning):
return inning?.uppercased() ?? "LIVE"
case .scheduled(let time):
return time.uppercased()
case .final_:
return "FINAL"
case .unknown:
return "PENDING"
}
}
private func radarTint(for game: Game) -> Color {
if game.isLive { return DS.Colors.live }
if game.isFinal { return DS.Colors.positive }
return DS.Colors.warning
}
private func gameShelf(
title: String,
subtitle: String,
icon: String,
accent: Color,
games: [Game],
excludeId: String?
) -> some View {
let filtered = games.filter { $0.id != excludeId }
return Group {
if !filtered.isEmpty {
VStack(alignment: .leading, spacing: 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()
}
}
}
}
#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
private var featuredChannelsSection: some View {
HStack(spacing: DS.Spacing.cardGap) {
mlbNetworkCard
.frame(maxWidth: .infinity)
nsfwVideosCard
.frame(maxWidth: .infinity)
}
}
private var mlbNetworkCard: some View { private var mlbNetworkCard: some View {
let added = viewModel.activeStreams.contains(where: { $0.id == "MLBN" }) let added = viewModel.activeStreams.contains(where: { $0.id == "MLBN" })
Button { return channelCard(
title: "MLB Network",
subtitle: "League-wide coverage, whip-around cuts, analysis, and highlights.",
systemImage: "tv.fill",
tint: .blue,
status: added ? "Pinned to Multi-View" : "Open Channel"
) {
showMLBNetworkSheet = true showMLBNetworkSheet = true
} label: {
HStack(spacing: 16) {
Image(systemName: "tv.fill")
.font(.title2)
.foregroundStyle(.blue)
.frame(width: 56, height: 56)
.background(.blue.opacity(0.2))
.clipShape(RoundedRectangle(cornerRadius: 14))
VStack(alignment: .leading, spacing: 4) {
Text("MLB Network")
.font(.title3.weight(.bold))
Text("Live coverage, analysis & highlights")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
if added {
Label("In Multi-View", systemImage: "checkmark.circle.fill")
.font(.subheadline.weight(.bold))
.foregroundStyle(.green)
}
}
.frame(maxWidth: .infinity, minHeight: 108, alignment: .leading)
.padding(24)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 20))
} }
.platformCardStyle()
} }
@ViewBuilder
private var nsfwVideosCard: some View { private var nsfwVideosCard: some View {
let added = viewModel.activeStreams.contains(where: { $0.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID }) let added = viewModel.activeStreams.contains(where: { $0.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID })
Button { return channelCard(
title: SpecialPlaybackChannelConfig.werkoutNSFWTitle,
subtitle: SpecialPlaybackChannelConfig.werkoutNSFWSubtitle,
systemImage: "play.rectangle.fill",
tint: .pink,
status: added ? "Pinned to Multi-View" : "Private feed access"
) {
showWerkoutNSFWSheet = true 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) { private var multiViewStatus: some View {
Text(SpecialPlaybackChannelConfig.werkoutNSFWTitle) VStack(alignment: .leading, spacing: 14) {
.font(.title3.weight(.bold)) Text("Multi-View Status")
Text(SpecialPlaybackChannelConfig.werkoutNSFWSubtitle) .font(railTitleFont)
.font(.subheadline) .foregroundStyle(DS.Colors.textPrimary)
.foregroundStyle(.secondary)
}
Spacer() Text("Current grid state, active audio focus, and ready-to-open tiles.")
.font(railBodyFont)
.foregroundStyle(DS.Colors.textSecondary)
if added { HStack(spacing: 14) {
Label("In Multi-View", systemImage: "checkmark.circle.fill") metricBadge(value: "\(viewModel.activeStreams.count)/4", label: "Tiles")
.font(.subheadline.weight(.bold)) metricBadge(value: viewModel.multiViewLayoutMode.title, label: "Layout")
.foregroundStyle(.green) metricBadge(value: viewModel.activeAudioStream == nil ? "Muted" : "Live", label: "Audio")
} else { }
Label("Open", systemImage: "play.fill")
.font(.subheadline.weight(.bold)) if viewModel.activeStreams.isEmpty {
.foregroundStyle(.pink) 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)
}
)
}
} }
} }
.frame(maxWidth: .infinity, minHeight: 108, alignment: .leading) }
.padding(24) .padding(24)
.background(.regularMaterial) .background(surfaceCardBackground())
.clipShape(RoundedRectangle(cornerRadius: 20)) }
private func channelCard(
title: String,
subtitle: String,
systemImage: String,
tint: Color,
status: String,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
HStack(spacing: 16) {
Image(systemName: systemImage)
.font(.system(size: 22, weight: .bold))
.foregroundStyle(tint)
.frame(width: 58, height: 58)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(tint.opacity(0.16))
)
VStack(alignment: .leading, spacing: 5) {
Text(title)
.font(radarRowTitleFont)
.foregroundStyle(DS.Colors.textPrimary)
Text(subtitle)
.font(radarRowBodyFont)
.foregroundStyle(DS.Colors.textSecondary)
.lineLimit(2)
}
Spacer(minLength: 8)
Text(status)
.font(radarRowStatusFont)
.foregroundStyle(tint)
.multilineTextAlignment(.trailing)
.lineLimit(2)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.fill(DS.Colors.panelFillMuted)
.overlay {
RoundedRectangle(cornerRadius: 22, style: .continuous)
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
}
)
} }
.platformCardStyle() .platformCardStyle()
} }
// MARK: - Multi-View Status private func metricBadge(value: String, label: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
@ViewBuilder Text(value)
private var multiViewStatus: some View { .font(radarRowTitleFont)
VStack(alignment: .leading, spacing: 14) { .foregroundStyle(DS.Colors.textPrimary)
Label("Multi-View", systemImage: "rectangle.split.2x2") Text(label)
.font(.title3.weight(.bold)) .font(radarRowBodyFont)
.foregroundStyle(.secondary) .foregroundStyle(DS.Colors.textTertiary)
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())
}
}
} }
.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)
}
)
}
private func surfaceCardBackground(radius: CGFloat = 28) -> some View {
RoundedRectangle(cornerRadius: radius, style: .continuous)
.fill(
LinearGradient(
colors: [
DS.Colors.panelFill,
DS.Colors.panelFillMuted,
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.overlay {
RoundedRectangle(cornerRadius: radius, style: .continuous)
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
}
.shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY)
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,261 @@
import AVKit
import SwiftUI
struct FeedView: View {
@Environment(GamesViewModel.self) private var gamesViewModel
@State private var viewModel = FeedViewModel()
@State private var playingURL: URL?
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: DS.Spacing.sectionGap) {
// Header
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("HIGHLIGHTS")
.font(DS.Fonts.caption)
.foregroundStyle(DS.Colors.textQuaternary)
.kerning(3)
Text("Across the League")
#if os(tvOS)
.font(DS.Fonts.tvSectionTitle)
#else
.font(DS.Fonts.sectionTitle)
#endif
.foregroundStyle(DS.Colors.textPrimary)
Text("Condensed games, key plays, and fresh clips from the active slate.")
.font(DS.Fonts.body)
.foregroundStyle(DS.Colors.textSecondary)
}
Spacer()
if viewModel.isLoading {
ProgressView()
}
}
overviewChips
if viewModel.highlights.isEmpty && !viewModel.isLoading {
emptyState
} else {
LazyVStack(spacing: DS.Spacing.cardGap) {
ForEach(viewModel.highlights.prefix(50)) { item in
highlightCard(item)
}
}
}
}
.padding(.horizontal, edgeInset)
.padding(.vertical, DS.Spacing.sectionGap)
}
.task {
await viewModel.loadHighlights(games: gamesViewModel.games)
}
.onChange(of: gamesViewModel.games.count) {
Task { await viewModel.loadHighlights(games: gamesViewModel.games) }
}
.onAppear { viewModel.startAutoRefresh(games: gamesViewModel.games) }
.onDisappear { viewModel.stopAutoRefresh() }
.fullScreenCover(isPresented: Binding(
get: { playingURL != nil },
set: { if !$0 { playingURL = nil } }
)) {
if let url = playingURL {
let player = AVPlayer(url: url)
VideoPlayer(player: player)
.ignoresSafeArea()
.onAppear { player.play() }
.onDisappear { player.pause() }
}
}
}
private var overviewChips: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
feedChip(
title: "\(viewModel.highlights.count)",
label: "Clips",
tint: DS.Colors.media
)
feedChip(
title: "\(viewModel.highlights.filter(\.isCondensedGame).count)",
label: "Condensed",
tint: DS.Colors.interactive
)
feedChip(
title: "\(gamesViewModel.liveGames.count)",
label: "Live Games",
tint: DS.Colors.live
)
}
}
.scrollClipDisabled()
}
private func feedChip(title: String, label: String, tint: Color) -> some View {
HStack(spacing: 10) {
Text(title)
.font(chipValueFont)
.foregroundStyle(DS.Colors.textPrimary)
.monospacedDigit()
Text(label)
.font(chipLabelFont)
.foregroundStyle(tint)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(
Capsule()
.fill(DS.Colors.panelFillMuted)
.overlay {
Capsule()
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
}
)
}
@ViewBuilder
private func highlightCard(_ item: HighlightItem) -> some View {
Button {
playingURL = item.hlsURL ?? item.mp4URL
} label: {
HStack(spacing: 16) {
// Thumbnail
ZStack {
HStack(spacing: 0) {
Rectangle().fill(TeamAssets.color(for: item.awayCode).opacity(0.3))
Rectangle().fill(TeamAssets.color(for: item.homeCode).opacity(0.3))
}
HStack(spacing: thumbnailLogoGap) {
TeamLogoView(
team: TeamInfo(code: item.awayCode, name: "", score: nil),
size: thumbnailLogoSize
)
Text("@")
.font(.system(size: atFontSize, weight: .bold))
.foregroundStyle(DS.Colors.textTertiary)
TeamLogoView(
team: TeamInfo(code: item.homeCode, name: "", score: nil),
size: thumbnailLogoSize
)
}
Image(systemName: "play.circle.fill")
.font(.system(size: playIconSize))
.foregroundStyle(.white.opacity(0.7))
.shadow(radius: 4)
if item.isCondensedGame {
VStack {
HStack {
Spacer()
Text("CONDENSED")
.font(badgeFont)
.foregroundStyle(.white)
.kerning(0.5)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(DS.Colors.media)
.clipShape(Capsule())
}
Spacer()
}
.padding(8)
}
}
.frame(width: thumbnailWidth, height: thumbnailHeight)
.clipShape(RoundedRectangle(cornerRadius: DS.Radii.compact))
// Info
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(item.gameTitle)
.font(gameTagFont)
.foregroundStyle(DS.Colors.textTertiary)
.kerning(0.8)
Spacer()
Text(timeAgo(item.timestamp))
.font(gameTagFont)
.foregroundStyle(DS.Colors.textQuaternary)
}
Text(item.headline)
.font(headlineFont)
.foregroundStyle(DS.Colors.textPrimary)
.lineLimit(2)
}
Spacer(minLength: 0)
}
.padding(DS.Spacing.panelPadCompact)
.background(DS.Colors.panelFill)
.clipShape(RoundedRectangle(cornerRadius: DS.Radii.standard))
.overlay(
RoundedRectangle(cornerRadius: DS.Radii.standard)
.strokeBorder(DS.Colors.panelStroke, lineWidth: 0.5)
)
}
.platformCardStyle()
}
private var emptyState: some View {
VStack(spacing: 16) {
Image(systemName: "play.rectangle.on.rectangle")
.font(.system(size: 44))
.foregroundStyle(DS.Colors.textQuaternary)
Text("No highlights available yet")
.font(DS.Fonts.body)
.foregroundStyle(DS.Colors.textTertiary)
Text("Highlights appear as games are played")
.font(DS.Fonts.bodySmall)
.foregroundStyle(DS.Colors.textQuaternary)
}
.frame(maxWidth: .infinity)
.padding(.top, 80)
}
// MARK: - Platform sizing
private func timeAgo(_ date: Date) -> String {
let interval = Date().timeIntervalSince(date)
if interval < 60 { return "Just now" }
if interval < 3600 { return "\(Int(interval / 60))m ago" }
if interval < 86400 { return "\(Int(interval / 3600))h ago" }
return "\(Int(interval / 86400))d ago"
}
#if os(tvOS)
private var edgeInset: CGFloat { 60 }
private var thumbnailWidth: CGFloat { 300 }
private var thumbnailHeight: CGFloat { 160 }
private var thumbnailLogoSize: CGFloat { 48 }
private var thumbnailLogoGap: CGFloat { 20 }
private var playIconSize: CGFloat { 40 }
private var atFontSize: CGFloat { 22 }
private var headlineFont: Font { .system(size: 24, weight: .semibold) }
private var gameTagFont: Font { .system(size: 22, weight: .bold, design: .rounded) }
private var badgeFont: Font { .system(size: 18, weight: .bold, design: .rounded) }
private var chipValueFont: Font { .system(size: 20, weight: .black, design: .rounded) }
private var chipLabelFont: Font { .system(size: 14, weight: .bold, design: .rounded) }
#else
private var edgeInset: CGFloat { 20 }
private var thumbnailWidth: CGFloat { 180 }
private var thumbnailHeight: CGFloat { 100 }
private var thumbnailLogoSize: CGFloat { 32 }
private var thumbnailLogoGap: CGFloat { 12 }
private var playIconSize: CGFloat { 28 }
private var atFontSize: CGFloat { 14 }
private var headlineFont: Font { .system(size: 15, weight: .semibold) }
private var gameTagFont: Font { .system(size: 12, weight: .bold, design: .rounded) }
private var badgeFont: Font { .system(size: 11, weight: .bold, design: .rounded) }
private var chipValueFont: Font { .system(size: 14, weight: .black, design: .rounded) }
private var chipLabelFont: Font { .system(size: 10, weight: .bold, design: .rounded) }
#endif
}

View File

@@ -3,11 +3,12 @@ import SwiftUI
struct GameCardView: View { struct GameCardView: View {
let game: Game let game: Game
let onSelect: () -> Void let onSelect: () -> Void
@Environment(GamesViewModel.self) private var viewModel @Environment(GamesViewModel.self) private var viewModel
private var inMultiView: Bool { private var inMultiView: Bool {
game.broadcasts.contains(where: { bc in game.broadcasts.contains(where: { broadcast in
viewModel.activeStreams.contains(where: { $0.id == bc.id }) viewModel.activeStreams.contains(where: { $0.id == broadcast.id })
}) })
} }
@@ -16,287 +17,312 @@ struct GameCardView: View {
var body: some View { var body: some View {
Button(action: onSelect) { Button(action: onSelect) {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: cardSpacing) {
header headerRow
.padding(.horizontal, 22) matchupBlock
.padding(.top, 18) footerBlock
.padding(.bottom, 16)
VStack(spacing: 12) {
teamRow(team: game.awayTeam, isWinning: isWinning(away: true))
teamRow(team: game.homeTeam, isWinning: isWinning(away: false))
}
.padding(.horizontal, 22)
Spacer(minLength: 14)
footer
.padding(.horizontal, 22)
.padding(.vertical, 16)
} }
.frame(maxWidth: .infinity, minHeight: 320, maxHeight: 320, alignment: .topLeading) .frame(maxWidth: .infinity, minHeight: cardHeight, alignment: .topLeading)
.padding(cardPad)
.background(cardBackground) .background(cardBackground)
.overlay(alignment: .top) { .overlay(cardBorder)
HStack(spacing: 0) { .clipShape(RoundedRectangle(cornerRadius: cardRadius, style: .continuous))
Rectangle() .shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY)
.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() .platformCardStyle()
} }
@ViewBuilder private var headerRow: some View {
private var header: some View { HStack(alignment: .center, spacing: 12) {
HStack(alignment: .top, spacing: 12) { statusPill
VStack(alignment: .leading, spacing: 6) { Spacer(minLength: 8)
Text((game.gameType ?? "Matchup").uppercased())
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.58))
.kerning(1.2)
.lineLimit(1)
Text(subtitleText) if inMultiView {
.font(.system(size: 14, weight: .medium)) chip(title: "In Multi-View", tint: DS.Colors.positive)
.foregroundStyle(.white.opacity(0.82))
.lineLimit(1)
} }
Spacer(minLength: 12) if game.hasStreams {
chip(title: "\(game.broadcasts.count) feed\(game.broadcasts.count == 1 ? "" : "s")", tint: DS.Colors.interactive)
}
}
}
compactStatus private var matchupBlock: some View {
VStack(spacing: 16) {
teamRow(team: game.awayTeam, isLeading: isWinning(away: true))
teamRow(team: game.homeTeam, isLeading: isWinning(away: false))
} }
} }
@ViewBuilder @ViewBuilder
private func teamRow(team: TeamInfo, isWinning: Bool) -> some View { private var footerBlock: some View {
VStack(alignment: .leading, spacing: 14) {
switch game.status {
case .live:
liveFooter
case .final_:
finalFooter
case .scheduled:
scheduledFooter
case .unknown:
unknownFooter
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 2)
}
private func teamRow(team: TeamInfo, isLeading: Bool) -> some View {
HStack(spacing: 14) { HStack(spacing: 14) {
TeamLogoView(team: team, size: 46) TeamLogoView(team: team, size: logoSize)
.frame(width: 50, height: 50)
VStack(alignment: .leading, spacing: 5) { VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 10) { HStack(spacing: 10) {
Text(team.code) Text(team.code)
.font(.system(size: 28, weight: .black, design: .rounded)) .font(codeFont)
.foregroundStyle(.white) .foregroundStyle(.white)
if let record = team.record { if let record = team.record {
Text(record) Text(record)
.font(.system(size: 12, weight: .bold, design: .monospaced)) .font(metaFont)
.foregroundStyle(.white.opacity(0.72)) .foregroundStyle(DS.Colors.textSecondary)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(.white.opacity(isWinning ? 0.12 : 0.07))
.clipShape(Capsule())
} }
} }
Text(team.displayName) Text(team.displayName)
.font(.system(size: 15, weight: .semibold)) .font(nameFont)
.foregroundStyle(.white.opacity(isWinning ? 0.88 : 0.68)) .foregroundStyle(DS.Colors.textSecondary)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.75)
if let summary = team.standingSummary {
Text(summary)
.font(metaFont)
.foregroundStyle(DS.Colors.textTertiary)
.lineLimit(1)
}
} }
Spacer(minLength: 12) Spacer(minLength: 8)
if !game.status.isScheduled, let score = team.score { Text(team.score.map(String.init) ?? "")
Text("\(score)") .font(scoreFont)
.font(.system(size: 42, weight: .black, design: .rounded)) .foregroundStyle(isLeading ? .white : DS.Colors.textSecondary)
.foregroundStyle(isWinning ? .white : .white.opacity(0.72)) .monospacedDigit()
.contentTransition(.numericText()) }
}
@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)
} }
} }
.padding(.horizontal, 14) }
.padding(.vertical, 13)
.background { private var scheduledFooter: some View {
RoundedRectangle(cornerRadius: 18, style: .continuous) VStack(alignment: .leading, spacing: 10) {
.fill(rowBackground(for: team, isWinning: isWinning)) 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)
}
} }
} }
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 { private func isWinning(away: Bool) -> Bool {
guard let a = game.awayTeam.score, let h = game.homeTeam.score else { return false } guard let awayScore = game.awayTeam.score, let homeScore = game.homeTeam.score else {
return away ? a > h : h > a return false
}
return away ? awayScore > homeScore : homeScore > awayScore
} }
private var subtitleText: String { private var borderColor: Color {
if game.status.isScheduled { if inMultiView { return DS.Colors.positive.opacity(0.46) }
return game.pitchers ?? game.venue ?? "Upcoming" if game.isLive { return DS.Colors.live.opacity(0.34) }
} return DS.Colors.panelStroke
if game.isBlackedOut {
return "Regional blackout"
}
if !game.broadcasts.isEmpty {
let count = game.broadcasts.count
return "\(count) feed\(count == 1 ? "" : "s") available"
}
return game.venue ?? "No feeds listed yet"
} }
@ViewBuilder private var borderWidth: CGFloat {
private var compactStatus: some View { inMultiView || game.isLive ? 1.6 : 1
switch game.status {
case .live(let inning):
HStack(spacing: 7) {
Circle()
.fill(.red)
.frame(width: 8, height: 8)
Text(inning ?? "LIVE")
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.red.opacity(0.18))
.clipShape(Capsule())
case .scheduled(let time):
Text(time)
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.white.opacity(0.12))
.clipShape(Capsule())
case .final_:
Text("FINAL")
.font(.system(size: 13, weight: .black, design: .rounded))
.foregroundStyle(.white.opacity(0.92))
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.white.opacity(0.12))
.clipShape(Capsule())
case .unknown:
EmptyView()
}
} }
@ViewBuilder #if os(tvOS)
private var footer: some View { private var cardHeight: CGFloat { 270 }
HStack(spacing: 12) { private var cardRadius: CGFloat { 28 }
Label(footerText, systemImage: footerIconName) private var cardPad: CGFloat { 24 }
.font(.system(size: 13, weight: .semibold)) private var cardSpacing: CGFloat { 18 }
.foregroundStyle(.white.opacity(0.66)) private var logoSize: CGFloat { 46 }
.lineLimit(1) private var codeFont: Font { .system(size: 24, weight: .black, design: .rounded) }
private var nameFont: Font { .system(size: 22, weight: .bold, design: .rounded) }
Spacer(minLength: 12) private var metaFont: Font { .system(size: 15, weight: .bold, design: .rounded) }
private var scoreFont: Font { .system(size: 38, weight: .black, design: .rounded).monospacedDigit() }
if inMultiView { private var chipFont: Font { .system(size: 13, weight: .black, design: .rounded) }
footerBadge(title: "In Multi-View", color: .green) private var footerTitleFont: Font { .system(size: 18, weight: .black, design: .rounded) }
} else if game.isBlackedOut { private var footerValueFont: Font { .system(size: 18, weight: .bold, design: .rounded) }
footerBadge(title: "Blacked Out", color: .red) private var footerBodyFont: Font { .system(size: 16, weight: .semibold) }
} else if game.hasStreams { #else
footerBadge(title: "Watch", color: .blue) private var cardHeight: CGFloat { 200 }
} private var cardRadius: CGFloat { 20 }
} private var cardPad: CGFloat { 18 }
.overlay(alignment: .top) { private var cardSpacing: CGFloat { 14 }
Rectangle() private var logoSize: CGFloat { 34 }
.fill(.white.opacity(0.08)) private var codeFont: Font { .system(size: 18, weight: .black, design: .rounded) }
.frame(height: 1) 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 footerText: String { private var footerTitleFont: Font { .system(size: 13, weight: .black, design: .rounded) }
if game.status.isScheduled { private var footerValueFont: Font { .system(size: 13, weight: .bold, design: .rounded) }
return game.venue ?? (game.pitchers ?? "First pitch later today") private var footerBodyFont: Font { .system(size: 12, weight: .semibold) }
} #endif
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,7 +21,22 @@ struct GameCenterView: View {
atBatPanel(feed: feed) atBatPanel(feed: feed)
} }
if let wpHome = viewModel.winProbabilityHome, let wpAway = viewModel.winProbabilityAway { // Pitch Arsenal for current pitcher
if let pitcherName = feed.currentPitcher?.fullName {
PitchArsenalView(
allPlays: feed.liveData.plays.allPlays,
pitcherName: pitcherName
)
}
// Win Probability Chart (full game timeline)
if !viewModel.winProbabilityHistory.isEmpty {
WinProbabilityChartView(
entries: viewModel.winProbabilityHistory,
homeCode: game.homeTeam.code,
awayCode: game.awayTeam.code
)
} else if let wpHome = viewModel.winProbabilityHome, let wpAway = viewModel.winProbabilityAway {
winProbabilityPanel(home: wpHome, away: wpAway) winProbabilityPanel(home: wpHome, away: wpAway)
} }

View File

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

@@ -14,60 +14,260 @@ struct SettingsView: View {
var body: some View { var body: some View {
@Bindable var vm = viewModel @Bindable var vm = viewModel
NavigationStack { ScrollView {
Form { VStack(alignment: .leading, spacing: 26) {
Section("Server") { header
LabeledContent("URL", value: viewModel.serverBaseURL)
settingsPanel(
title: "Server",
subtitle: "Current upstream endpoint and playback defaults."
) {
infoRow(label: "Base URL", value: viewModel.serverBaseURL)
infoRow(label: "Current Quality", value: resolutionLabel(for: viewModel.defaultResolution))
} }
Section("Default Quality") { settingsPanel(
ForEach(resolutions, id: \.0) { res in title: "Playback Quality",
Button { subtitle: "Preferred stream profile for newly opened feeds."
vm.defaultResolution = res.0 ) {
} label: { VStack(spacing: 12) {
HStack { ForEach(resolutions, id: \.0) { resolution in
Text(res.1) Button {
Spacer() vm.defaultResolution = resolution.0
if viewModel.defaultResolution == res.0 { } label: {
Image(systemName: "checkmark") HStack(spacing: 14) {
.foregroundStyle(.blue) 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()
} }
} }
} }
Section("Active Streams (\(viewModel.activeStreams.count)/4)") { settingsPanel(
title: "Active Streams",
subtitle: "Tile occupancy and cleanup controls for Multi-View."
) {
if viewModel.activeStreams.isEmpty { if viewModel.activeStreams.isEmpty {
Text("No active streams") Text("No active streams. Add broadcasts from the dashboard to populate the grid.")
.foregroundStyle(.secondary) .font(optionBodyFont)
.foregroundStyle(DS.Colors.textSecondary)
} else { } else {
ForEach(viewModel.activeStreams) { stream in VStack(spacing: 12) {
HStack { ForEach(viewModel.activeStreams) { stream in
Text(stream.label) HStack(spacing: 14) {
.fontWeight(.bold) VStack(alignment: .leading, spacing: 5) {
Text(stream.game.displayTitle) Text(stream.label)
.foregroundStyle(.secondary) .font(optionTitleFont)
Spacer() .foregroundStyle(DS.Colors.textPrimary)
Button(role: .destructive) {
viewModel.removeStream(id: stream.id) Text(stream.game.displayTitle)
} label: { .font(optionBodyFont)
Image(systemName: "trash") .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("Clear All Streams", role: .destructive) {
Button(role: .destructive) {
viewModel.clearAllStreams() viewModel.clearAllStreams()
} label: {
Label("Clear All Streams", systemImage: "xmark.circle.fill")
.font(actionFont)
.foregroundStyle(.white)
.padding(.horizontal, 18)
.padding(.vertical, 14)
.background(
Capsule()
.fill(DS.Colors.live.opacity(0.82))
)
} }
.platformCardStyle()
} }
} }
Section("About") { settingsPanel(
LabeledContent("Version", value: "1.0") title: "About",
LabeledContent("Server", value: "mlbserver") 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")
} }
} }
.navigationTitle("Settings") .padding(.horizontal, horizontalPadding)
.padding(.vertical, verticalPadding)
} }
} }
private var header: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Settings")
.font(headerTitleFont)
.foregroundStyle(DS.Colors.textPrimary)
Text("Playback defaults, server routing, and Multi-View controls.")
.font(headerBodyFont)
.foregroundStyle(DS.Colors.textSecondary)
}
}
private func settingsPanel<Content: View>(
title: String,
subtitle: String,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(sectionTitleFont)
.foregroundStyle(DS.Colors.textPrimary)
Text(subtitle)
.font(sectionBodyFont)
.foregroundStyle(DS.Colors.textSecondary)
}
content()
}
.padding(panelPadding)
.background(panelBackground)
}
private func infoRow(label: String, value: String) -> some View {
HStack(alignment: .top, spacing: 16) {
Text(label)
.font(infoLabelFont)
.foregroundStyle(DS.Colors.textTertiary)
.frame(width: labelWidth, alignment: .leading)
Text(value)
.font(infoValueFont)
.foregroundStyle(DS.Colors.textPrimary)
Spacer(minLength: 0)
}
}
private func optionBackground(selected: Bool) -> some View {
RoundedRectangle(cornerRadius: optionRadius, style: .continuous)
.fill(selected ? DS.Colors.interactive.opacity(0.14) : DS.Colors.panelFillMuted)
.overlay {
RoundedRectangle(cornerRadius: optionRadius, style: .continuous)
.strokeBorder(selected ? DS.Colors.interactive.opacity(0.26) : DS.Colors.panelStroke, lineWidth: 1)
}
}
private func resolutionLabel(for key: String) -> String {
resolutions.first(where: { $0.0 == key })?.1 ?? key
}
private func resolutionDescription(for key: String) -> String {
switch key {
case "best":
return "Uses the highest reliable stream available."
case "1080p60":
return "Prioritizes smooth full-HD playback when available."
case "720p60":
return "Balanced quality for lighter network conditions."
case "540p":
return "Lower bandwidth option for stability."
case "adaptive":
return "Lets the player adjust dynamically."
default:
return "Custom playback preference."
}
}
private var panelBackground: some View {
RoundedRectangle(cornerRadius: panelRadius, style: .continuous)
.fill(
LinearGradient(
colors: [
DS.Colors.panelFill,
DS.Colors.panelFillMuted,
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.overlay {
RoundedRectangle(cornerRadius: panelRadius, style: .continuous)
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
}
.shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY)
}
#if os(tvOS)
private var horizontalPadding: CGFloat { 56 }
private var verticalPadding: CGFloat { 40 }
private var panelPadding: CGFloat { 24 }
private var panelRadius: CGFloat { 28 }
private var optionRadius: CGFloat { 22 }
private var labelWidth: CGFloat { 150 }
private var indicatorSize: CGFloat { 22 }
private var headerTitleFont: Font { .system(size: 44, weight: .black, design: .rounded) }
private var headerBodyFont: Font { .system(size: 18, weight: .medium) }
private var sectionTitleFont: Font { .system(size: 28, weight: .black, design: .rounded) }
private var sectionBodyFont: Font { .system(size: 16, weight: .medium) }
private var infoLabelFont: Font { .system(size: 15, weight: .black, design: .rounded) }
private var infoValueFont: Font { .system(size: 18, weight: .bold, design: .rounded) }
private var optionTitleFont: Font { .system(size: 20, weight: .bold, design: .rounded) }
private var optionBodyFont: Font { .system(size: 15, weight: .medium) }
private var actionFont: Font { .system(size: 14, weight: .black, design: .rounded) }
#else
private var horizontalPadding: CGFloat { 20 }
private var verticalPadding: CGFloat { 24 }
private var panelPadding: CGFloat { 18 }
private var panelRadius: CGFloat { 22 }
private var optionRadius: CGFloat { 18 }
private var labelWidth: CGFloat { 100 }
private var indicatorSize: CGFloat { 18 }
private var headerTitleFont: Font { .system(size: 28, weight: .black, design: .rounded) }
private var headerBodyFont: Font { .system(size: 14, weight: .medium) }
private var sectionTitleFont: Font { .system(size: 20, weight: .black, design: .rounded) }
private var sectionBodyFont: Font { .system(size: 13, weight: .medium) }
private var infoLabelFont: Font { .system(size: 12, weight: .black, design: .rounded) }
private var infoValueFont: Font { .system(size: 14, weight: .bold, design: .rounded) }
private var optionTitleFont: Font { .system(size: 16, weight: .bold, design: .rounded) }
private var optionBodyFont: Font { .system(size: 12, weight: .medium) }
private var actionFont: Font { .system(size: 12, weight: .black, design: .rounded) }
#endif
} }