Compare commits

...

10 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
14 changed files with 2298 additions and 1218 deletions

View File

@@ -97,6 +97,8 @@
6581BE8213BB231538C9EB8E /* 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 */; }; DB34919E15ABAA1B40DF9A5B /* LeaderboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 726AEA37AC2C75C017A888C1 /* LeaderboardView.swift */; };
94D2242FE48B5FECE15116FF /* 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 */
@@ -165,6 +167,7 @@
9F68D38B739C81D7747CC412 /* FeedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemView.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>"; }; 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>"; }; 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 */
@@ -207,6 +210,7 @@
children = ( children = (
C3D1C29BF9DF2B8F811368BC /* AtBatTimelineView.swift */, C3D1C29BF9DF2B8F811368BC /* AtBatTimelineView.swift */,
327A33F2676712C2B5B4AF2F /* LinescoreView.swift */, 327A33F2676712C2B5B4AF2F /* LinescoreView.swift */,
D5381B6E058D1194E844106D /* CategoryPillBar.swift */,
726AEA37AC2C75C017A888C1 /* LeaderboardView.swift */, 726AEA37AC2C75C017A888C1 /* LeaderboardView.swift */,
9F68D38B739C81D7747CC412 /* FeedItemView.swift */, 9F68D38B739C81D7747CC412 /* FeedItemView.swift */,
A86C820F4ABDD390A1E3F1FA /* PitchArsenalView.swift */, A86C820F4ABDD390A1E3F1FA /* PitchArsenalView.swift */,
@@ -440,6 +444,7 @@
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 */, 94D2242FE48B5FECE15116FF /* LeaderboardView.swift in Sources */,
6581BE8213BB231538C9EB8E /* FeedView.swift in Sources */, 6581BE8213BB231538C9EB8E /* FeedView.swift in Sources */,
9BF7942D0252B0EB87DE58B3 /* FeedItemView.swift in Sources */, 9BF7942D0252B0EB87DE58B3 /* FeedItemView.swift in Sources */,
@@ -499,6 +504,7 @@
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 */, DB34919E15ABAA1B40DF9A5B /* LeaderboardView.swift in Sources */,
C5057ECFA9FCBFFC6A8A7E15 /* FeedView.swift in Sources */, C5057ECFA9FCBFFC6A8A7E15 /* FeedView.swift in Sources */,
5802E2BE747B7B59F00AE1E7 /* FeedItemView.swift in Sources */, 5802E2BE747B7B59F00AE1E7 /* FeedItemView.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

@@ -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

@@ -32,9 +32,9 @@ struct DataPanel<Content: View>: View {
if let code = teamAccentCode { if let code = teamAccentCode {
RoundedRectangle(cornerRadius: 1.5) RoundedRectangle(cornerRadius: 1.5)
.fill(TeamAssets.color(for: code)) .fill(TeamAssets.color(for: code))
.frame(width: 3) .frame(width: 4)
.padding(.vertical, 6) .padding(.vertical, 8)
.padding(.leading, 4) .padding(.leading, 6)
} }
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
@@ -43,19 +43,27 @@ struct DataPanel<Content: View>: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(density.padding) .padding(density.padding)
} }
.background( .background {
RoundedRectangle(cornerRadius: density.cornerRadius) RoundedRectangle(cornerRadius: density.cornerRadius, style: .continuous)
.fill(DS.Colors.panelFill) .fill(
LinearGradient(
colors: [
DS.Colors.panelFill,
DS.Colors.panelFillMuted,
],
startPoint: .topLeading,
endPoint: .bottomTrailing
) )
.overlay(
RoundedRectangle(cornerRadius: density.cornerRadius)
.strokeBorder(DS.Colors.panelStroke, lineWidth: 0.5)
) )
.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)
}
} }
} }
// MARK: - Convenience initializers
extension DataPanel { extension DataPanel {
init( init(
_ density: DataPanelDensity = .standard, _ density: DataPanelDensity = .standard,

View File

@@ -1,28 +1,38 @@
import SwiftUI import SwiftUI
// MARK: - The Dugout Design System
enum DS { enum DS {
// MARK: - Colors
enum Colors { enum Colors {
static let background = Color(red: 0.02, green: 0.03, blue: 0.05) static let background = Color(red: 0.03, green: 0.05, blue: 0.10)
static let panelFill = Color.white.opacity(0.04) static let backgroundElevated = Color(red: 0.06, green: 0.10, blue: 0.18)
static let panelStroke = Color.white.opacity(0.06) 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.95, green: 0.22, blue: 0.22) static let live = Color(red: 0.94, green: 0.25, blue: 0.28)
static let positive = Color(red: 0.20, green: 0.78, blue: 0.35) static let positive = Color(red: 0.24, green: 0.86, blue: 0.63)
static let warning = Color(red: 0.95, green: 0.55, blue: 0.15) static let warning = Color(red: 0.98, green: 0.76, blue: 0.24)
static let interactive = Color(red: 0.25, green: 0.52, blue: 0.95) static let interactive = Color(red: 1.00, green: 0.75, blue: 0.20)
static let media = Color(red: 0.55, green: 0.35, blue: 0.85) static let media = Color(red: 0.35, green: 0.78, blue: 0.95)
static let textPrimary = Color.white static let textPrimary = Color.white.opacity(0.96)
static let textSecondary = Color.white.opacity(0.7) static let textSecondary = Color.white.opacity(0.76)
static let textTertiary = Color.white.opacity(0.45) static let textTertiary = Color.white.opacity(0.50)
static let textQuaternary = Color.white.opacity(0.2) static let textQuaternary = Color.white.opacity(0.24)
static let onDarkPrimary = textPrimary
static let onDarkSecondary = textSecondary
static let onDarkTertiary = textTertiary
} }
// MARK: - Typography 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 { enum Fonts {
static let heroScore = Font.system(size: 72, weight: .black, design: .rounded).monospacedDigit() static let heroScore = Font.system(size: 72, weight: .black, design: .rounded).monospacedDigit()
@@ -30,7 +40,7 @@ enum DS {
static let score = Font.system(size: 28, 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 scoreCompact = Font.system(size: 22, weight: .bold, design: .rounded).monospacedDigit()
static let sectionTitle = Font.system(size: 28, weight: .bold, design: .rounded) static let sectionTitle = Font.system(size: 30, weight: .bold, design: .rounded)
static let cardTitle = Font.system(size: 20, 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 cardTitleCompact = Font.system(size: 17, weight: .bold, design: .rounded)
@@ -40,25 +50,23 @@ enum DS {
static let bodySmall = Font.system(size: 13, weight: .medium) static let bodySmall = Font.system(size: 13, weight: .medium)
static let caption = Font.system(size: 11, weight: .bold, design: .rounded) static let caption = Font.system(size: 11, weight: .bold, design: .rounded)
// tvOS scaled variants
#if os(tvOS) #if os(tvOS)
static let tvSectionTitle = Font.system(size: 36, weight: .bold, design: .rounded) static let tvHeroScore = Font.system(size: 94, weight: .black, design: .rounded).monospacedDigit()
static let tvCardTitle = Font.system(size: 26, weight: .bold, design: .rounded) static let tvSectionTitle = Font.system(size: 40, weight: .bold, design: .rounded)
static let tvScore = Font.system(size: 34, weight: .black, design: .rounded).monospacedDigit() static let tvCardTitle = Font.system(size: 28, weight: .bold, design: .rounded)
static let tvDataValue = Font.system(size: 22, weight: .bold, design: .rounded).monospacedDigit() static let tvScore = Font.system(size: 36, weight: .black, design: .rounded).monospacedDigit()
static let tvBody = Font.system(size: 20, weight: .medium) static let tvDataValue = Font.system(size: 24, weight: .bold, design: .rounded).monospacedDigit()
static let tvCaption = Font.system(size: 15, weight: .bold, design: .rounded) static let tvBody = Font.system(size: 22, weight: .medium)
static let tvCaption = Font.system(size: 20, weight: .bold, design: .rounded)
#endif #endif
} }
// MARK: - Spacing
enum Spacing { enum Spacing {
#if os(tvOS) #if os(tvOS)
static let panelPadCompact: CGFloat = 18 static let panelPadCompact: CGFloat = 18
static let panelPadStandard: CGFloat = 24 static let panelPadStandard: CGFloat = 24
static let panelPadFeatured: CGFloat = 32 static let panelPadFeatured: CGFloat = 32
static let sectionGap: CGFloat = 40 static let sectionGap: CGFloat = 42
static let cardGap: CGFloat = 20 static let cardGap: CGFloat = 20
static let itemGap: CGFloat = 12 static let itemGap: CGFloat = 12
static let edgeInset: CGFloat = 50 static let edgeInset: CGFloat = 50
@@ -73,24 +81,95 @@ enum DS {
#endif #endif
} }
// MARK: - Radii
enum Radii { enum Radii {
static let compact: CGFloat = 14 static let compact: CGFloat = 16
static let standard: CGFloat = 18 static let standard: CGFloat = 24
static let featured: CGFloat = 22 static let featured: CGFloat = 30
} }
} }
// MARK: - Data Label Style 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 { struct DataLabelStyle: ViewModifier {
func body(content: Content) -> some View { func body(content: Content) -> some View {
content content
#if os(tvOS)
.font(DS.Fonts.tvCaption)
.kerning(1.0)
#else
.font(DS.Fonts.caption) .font(DS.Fonts.caption)
.foregroundStyle(DS.Colors.textQuaternary)
.textCase(.uppercase)
.kerning(1.5) .kerning(1.5)
#endif
.foregroundStyle(DS.Colors.textTertiary)
.textCase(.uppercase)
} }
} }

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

@@ -2,35 +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("Today", systemImage: "sportscourt.fill") { BroadcastBackground()
.ignoresSafeArea()
VStack(spacing: shellSpacing) {
CategoryPillBar(
selected: $selectedSection,
streamCount: viewModel.activeStreams.count,
totalGames: viewModel.games.count,
liveGames: viewModel.liveGames.count
)
.padding(.horizontal, DS.Spacing.edgeInset)
.padding(.top, navPadTop)
.platformFocusSection()
if showsTicker {
ScoresTickerView()
.padding(.horizontal, DS.Spacing.edgeInset)
}
Group {
switch selectedSection {
case .today:
DashboardView() DashboardView()
} case .intel:
Tab("Intel", systemImage: "chart.bar.fill") {
LeagueCenterView() LeagueCenterView()
} case .highlights:
Tab(multiViewLabel, systemImage: "rectangle.split.2x2.fill") {
MultiStreamView()
}
Tab("Feed", systemImage: "newspaper.fill") {
FeedView() FeedView()
} case .multiView:
Tab("Settings", systemImage: "gearshape.fill") { MultiStreamView()
case .settings:
SettingsView() 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()
}
}
if !viewModel.liveGames.isEmpty {
gameShelf(title: "Live", icon: "antenna.radiowaves.left.and.right", games: viewModel.liveGames, excludeId: viewModel.featuredGame?.id)
}
if !viewModel.scheduledGames.isEmpty {
gameShelf(title: "Upcoming", icon: "calendar", games: viewModel.scheduledGames, excludeId: viewModel.featuredGame?.id)
}
if !viewModel.finalGames.isEmpty {
gameShelf(title: "Final", icon: "checkmark.circle", games: viewModel.finalGames, excludeId: viewModel.featuredGame?.id)
}
}
featuredChannelsSection featuredChannelsSection
.platformFocusSection()
if !viewModel.activeStreams.isEmpty { if !viewModel.liveGames.isEmpty {
multiViewStatus gameShelf(
title: "Live Board",
subtitle: "Open games with inning state, records, and stream availability.",
icon: "antenna.radiowaves.left.and.right",
accent: DS.Colors.live,
games: viewModel.liveGames,
excludeId: viewModel.featuredGame?.id
)
}
if !viewModel.scheduledGames.isEmpty {
gameShelf(
title: "Upcoming Windows",
subtitle: "Probables, first pitch, and watch-ready cards for the rest of the slate.",
icon: "calendar",
accent: DS.Colors.warning,
games: viewModel.scheduledGames,
excludeId: viewModel.featuredGame?.id
)
}
if !viewModel.finalGames.isEmpty {
gameShelf(
title: "Completed Games",
subtitle: "Finished scoreboards ready for replays, box scores, and highlights.",
icon: "checkmark.circle",
accent: DS.Colors.positive,
games: viewModel.finalGames,
excludeId: viewModel.featuredGame?.id
)
}
} }
} }
.padding(.horizontal, horizontalPadding) .padding(.horizontal, horizontalPadding)
.padding(.vertical, verticalPadding) .padding(.vertical, verticalPadding)
} }
.scrollIndicators(.hidden)
.onAppear { .onAppear {
logDashboard("DashboardView appeared") logDashboard("DashboardView appeared")
viewModel.startAutoRefresh() viewModel.startAutoRefresh()
@@ -340,19 +363,397 @@ struct DashboardView: View {
) )
} }
// MARK: - Game Shelf (Horizontal) private var loadingState: some View {
VStack(spacing: 18) {
ProgressView()
.scaleEffect(1.3)
Text("Loading the daily board")
.font(sectionTitleFont)
.foregroundStyle(DS.Colors.textPrimary)
Text("Scores, streams, and matchup context are on the way.")
.font(sectionBodyFont)
.foregroundStyle(DS.Colors.textSecondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 120)
.background(surfaceCardBackground())
}
@ViewBuilder private func errorState(_ error: String) -> some View {
private func gameShelf(title: String, icon: String, games: [Game], excludeId: String?) -> some View { VStack(spacing: 18) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 44, weight: .bold))
.foregroundStyle(DS.Colors.warning)
Text(error)
.font(sectionTitleFont)
.foregroundStyle(DS.Colors.textPrimary)
Button("Reload Board") {
Task { await viewModel.loadGames() }
}
.padding(.horizontal, 22)
.padding(.vertical, 14)
.background(
Capsule()
.fill(DS.Colors.interactive)
)
.foregroundStyle(Color.black.opacity(0.82))
.platformCardStyle()
}
.frame(maxWidth: .infinity)
.padding(.vertical, 110)
.background(surfaceCardBackground())
}
private var headerSection: some View {
ViewThatFits {
HStack(alignment: .bottom, spacing: 28) {
headerCopy
Spacer(minLength: 16)
dateNavigator
}
VStack(alignment: .leading, spacing: 22) {
headerCopy
dateNavigator
}
}
}
private var headerCopy: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Daily Control Room")
.font(headerTitleFont)
.foregroundStyle(DS.Colors.textPrimary)
Text("A broadcast-grade slate view with live radar, featured watch windows, and fast access to every stream.")
.font(headerBodyFont)
.foregroundStyle(DS.Colors.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
}
private var dateNavigator: some View {
HStack(spacing: 12) {
navigatorButton(systemImage: "chevron.left") {
Task { await viewModel.goToPreviousDay() }
}
VStack(alignment: .leading, spacing: 4) {
Text(viewModel.isToday ? "Today" : "Archive Day")
.font(dateLabelFont)
.foregroundStyle(DS.Colors.textTertiary)
Text(viewModel.displayDateString)
.font(dateFont)
.foregroundStyle(DS.Colors.textPrimary)
.contentTransition(.numericText())
}
navigatorButton(systemImage: "chevron.right") {
Task { await viewModel.goToNextDay() }
}
if !viewModel.isToday {
Button {
Task { await viewModel.goToToday() }
} label: {
Text("Jump to Today")
.font(todayBtnFont)
.foregroundStyle(Color.black.opacity(0.84))
.padding(.horizontal, 18)
.padding(.vertical, 14)
.background(
Capsule()
.fill(DS.Colors.interactive)
)
}
.platformCardStyle()
}
}
.padding(.horizontal, 22)
.padding(.vertical, 18)
.background(surfaceCardBackground(radius: 26))
}
private func navigatorButton(systemImage: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: systemImage)
.font(.system(size: dateNavIconSize, weight: .bold))
.foregroundStyle(DS.Colors.textPrimary)
.frame(width: 52, height: 52)
.background(
Circle()
.fill(DS.Colors.panelFillMuted)
)
}
.platformCardStyle()
}
private var overviewStrip: some View {
ViewThatFits {
HStack(spacing: 18) {
metricTile(value: "\(viewModel.liveGames.count)", label: "Live Games", detail: liveStatusDetail, systemImage: "dot.radiowaves.left.and.right", tint: DS.Colors.live)
metricTile(value: "\(viewModel.scheduledGames.count)", label: "Upcoming", detail: "First pitches still ahead", systemImage: "calendar", tint: DS.Colors.warning)
metricTile(value: "\(viewModel.finalGames.count)", label: "Final", detail: "Completed scoreboards", systemImage: "flag.checkered.2.crossed", tint: DS.Colors.positive)
metricTile(value: "\(viewModel.activeStreams.count)", label: "Stream Tiles", detail: activeAudioDetail, systemImage: "rectangle.split.2x2", tint: DS.Colors.media)
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 18) {
metricTile(value: "\(viewModel.liveGames.count)", label: "Live Games", detail: liveStatusDetail, systemImage: "dot.radiowaves.left.and.right", tint: DS.Colors.live)
metricTile(value: "\(viewModel.scheduledGames.count)", label: "Upcoming", detail: "First pitches still ahead", systemImage: "calendar", tint: DS.Colors.warning)
metricTile(value: "\(viewModel.finalGames.count)", label: "Final", detail: "Completed scoreboards", systemImage: "flag.checkered.2.crossed", tint: DS.Colors.positive)
metricTile(value: "\(viewModel.activeStreams.count)", label: "Stream Tiles", detail: activeAudioDetail, systemImage: "rectangle.split.2x2", tint: DS.Colors.media)
}
.padding(.vertical, 4)
}
.scrollClipDisabled()
}
}
private var liveStatusDetail: String {
if viewModel.liveGames.isEmpty {
return "No live first pitch yet"
}
return "\(viewModel.liveGames.count) games active now"
}
private var activeAudioDetail: String {
if let activeAudio = viewModel.activeAudioStream {
return "Audio: \(activeAudio.game.awayTeam.code) @ \(activeAudio.game.homeTeam.code)"
}
if isPiPActive {
return "Picture in Picture active"
}
return "Quadbox ready"
}
private func metricTile(value: String, label: String, detail: String, systemImage: String, tint: Color) -> some View {
HStack(alignment: .top, spacing: 14) {
Image(systemName: systemImage)
.font(.system(size: 20, weight: .bold))
.foregroundStyle(tint)
.frame(width: 28, height: 28)
VStack(alignment: .leading, spacing: 6) {
Text(value)
.font(metricValueFont)
.foregroundStyle(DS.Colors.textPrimary)
.monospacedDigit()
Text(label)
.font(metricLabelFont)
.foregroundStyle(DS.Colors.textSecondary)
Text(detail)
.font(metricDetailFont)
.foregroundStyle(DS.Colors.textTertiary)
.lineLimit(2)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 20)
.padding(.vertical, 18)
.background(surfaceCardBackground(radius: 26))
}
private var heroAndControlSection: some View {
Group {
if usesStackedHeroLayout {
VStack(alignment: .leading, spacing: 22) {
featuredHero
controlRail
}
} else {
HStack(alignment: .top, spacing: 24) {
featuredHero
.frame(maxWidth: .infinity, alignment: .leading)
controlRail
.frame(width: controlRailWidth, alignment: .leading)
}
}
}
}
private var featuredHero: some View {
Group {
if let featured = viewModel.featuredGame {
FeaturedGameCard(game: featured) {
selectedGame = featured
}
} else {
VStack(alignment: .leading, spacing: 16) {
Text("No featured matchup")
.font(DS.Fonts.sectionTitle)
.foregroundStyle(DS.Colors.textPrimary)
Text("As soon as the slate populates, the best watch window appears here with scores, context, and stream access.")
.font(DS.Fonts.body)
.foregroundStyle(DS.Colors.textSecondary)
}
.frame(maxWidth: .infinity, minHeight: 320, alignment: .leading)
.padding(32)
.background(surfaceCardBackground(radius: 34))
}
}
}
private var controlRail: some View {
VStack(alignment: .leading, spacing: 18) {
liveRadarPanel
multiViewStatus
}
}
private var liveRadarPanel: some View {
VStack(alignment: .leading, spacing: 18) {
HStack {
VStack(alignment: .leading, spacing: 6) {
Text("Live Radar")
.font(railTitleFont)
.foregroundStyle(DS.Colors.textPrimary)
Text(radarSubtitle)
.font(railBodyFont)
.foregroundStyle(DS.Colors.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
}
if radarGames.isEmpty {
Text("No games loaded yet.")
.font(railBodyFont)
.foregroundStyle(DS.Colors.textTertiary)
} else {
VStack(spacing: 12) {
ForEach(radarGames) { game in
radarRow(game)
}
}
}
}
.padding(24)
.background(surfaceCardBackground())
}
private var radarSubtitle: String {
if !viewModel.liveGames.isEmpty {
return "Fast board for the most active windows."
}
if !viewModel.scheduledGames.isEmpty {
return "No live action yet. Upcoming first pitches are next."
}
return "Completed slate snapshots."
}
private func radarRow(_ game: Game) -> some View {
Button {
selectedGame = game
} label: {
HStack(spacing: 14) {
VStack(alignment: .leading, spacing: 6) {
Text("\(game.awayTeam.code) @ \(game.homeTeam.code)")
.font(radarRowTitleFont)
.foregroundStyle(DS.Colors.textPrimary)
Text(radarDetail(for: game))
.font(radarRowBodyFont)
.foregroundStyle(DS.Colors.textSecondary)
.lineLimit(2)
}
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 6) {
Text(radarStatus(for: game))
.font(radarRowStatusFont)
.foregroundStyle(radarTint(for: game))
if let score = game.scoreDisplay {
Text(score)
.font(radarRowScoreFont)
.foregroundStyle(DS.Colors.textPrimary)
.monospacedDigit()
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(DS.Colors.panelFillMuted)
.overlay {
RoundedRectangle(cornerRadius: 20, style: .continuous)
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
}
)
}
.platformCardStyle()
}
private func radarDetail(for game: Game) -> String {
if game.isLive {
return game.venue ?? "Live board ready"
}
if let pitchers = game.pitchers, !pitchers.isEmpty {
return pitchers
}
return game.venue ?? "Open matchup details"
}
private func radarStatus(for game: Game) -> String {
switch game.status {
case .live(let inning):
return inning?.uppercased() ?? "LIVE"
case .scheduled(let time):
return time.uppercased()
case .final_:
return "FINAL"
case .unknown:
return "PENDING"
}
}
private func radarTint(for game: Game) -> Color {
if game.isLive { return DS.Colors.live }
if game.isFinal { return DS.Colors.positive }
return DS.Colors.warning
}
private func gameShelf(
title: String,
subtitle: String,
icon: String,
accent: Color,
games: [Game],
excludeId: String?
) -> some View {
let filtered = games.filter { $0.id != excludeId } let filtered = games.filter { $0.id != excludeId }
return Group {
if !filtered.isEmpty { if !filtered.isEmpty {
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 18) {
HStack(alignment: .bottom, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Label(title, systemImage: icon) Label(title, systemImage: icon)
.font(.title3.weight(.bold)) .font(sectionTitleFont)
.foregroundStyle(.secondary) .foregroundStyle(DS.Colors.textPrimary)
ScrollView(.horizontal) { Text(subtitle)
LazyHStack(spacing: 30) { .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 ForEach(filtered) { game in
GameCardView(game: game) { GameCardView(game: game) {
selectedGame = game selectedGame = game
@@ -360,214 +761,229 @@ struct DashboardView: View {
.frame(width: shelfCardWidth) .frame(width: shelfCardWidth)
} }
} }
.padding(.vertical, 12) .padding(.vertical, 8)
} }
.scrollClipDisabled() .scrollClipDisabled()
} }
} }
} }
// MARK: - Header
@ViewBuilder
private var headerSection: some View {
VStack(spacing: 24) {
HStack(alignment: .bottom) {
VStack(alignment: .leading, spacing: 6) {
Text("MLB")
.font(.headline.weight(.black))
.foregroundStyle(.secondary)
.kerning(4)
Text(viewModel.displayDateString)
.font(.system(size: 40, weight: .bold))
.contentTransition(.numericText())
} }
Spacer() #if os(tvOS)
private var headerTitleFont: Font { .system(size: 50, weight: .black, design: .rounded) }
private var headerBodyFont: Font { .system(size: 22, weight: .medium) }
private var dateLabelFont: Font { .system(size: 14, weight: .black, design: .rounded) }
private var dateFont: Font { .system(size: 26, weight: .bold, design: .rounded) }
private var dateNavIconSize: CGFloat { 20 }
private var todayBtnFont: Font { .system(size: 18, weight: .black, design: .rounded) }
private var metricValueFont: Font { .system(size: 34, weight: .black, design: .rounded) }
private var metricLabelFont: Font { .system(size: 19, weight: .bold, design: .rounded) }
private var metricDetailFont: Font { .system(size: 16, weight: .semibold) }
private var railTitleFont: Font { .system(size: 28, weight: .black, design: .rounded) }
private var railBodyFont: Font { .system(size: 17, weight: .medium) }
private var radarRowTitleFont: Font { .system(size: 19, weight: .black, design: .rounded) }
private var radarRowBodyFont: Font { .system(size: 15, weight: .semibold) }
private var radarRowStatusFont: Font { .system(size: 14, weight: .black, design: .rounded) }
private var radarRowScoreFont: Font { .system(size: 20, weight: .black, design: .rounded).monospacedDigit() }
private var sectionTitleFont: Font { .system(size: 30, weight: .black, design: .rounded) }
private var sectionBodyFont: Font { .system(size: 17, weight: .medium) }
#else
private var headerTitleFont: Font { .system(size: 28, weight: .black, design: .rounded) }
private var headerBodyFont: Font { .system(size: 15, weight: .medium) }
private var dateLabelFont: Font { .system(size: 10, weight: .black, design: .rounded) }
private var dateFont: Font { .system(size: 17, weight: .bold, design: .rounded) }
private var dateNavIconSize: CGFloat { 15 }
private var todayBtnFont: Font { .system(size: 13, weight: .black, design: .rounded) }
private var metricValueFont: Font { .system(size: 22, weight: .black, design: .rounded) }
private var metricLabelFont: Font { .system(size: 13, weight: .bold, design: .rounded) }
private var metricDetailFont: Font { .system(size: 11, weight: .semibold) }
private var railTitleFont: Font { .system(size: 20, weight: .black, design: .rounded) }
private var railBodyFont: Font { .system(size: 12, weight: .medium) }
private var radarRowTitleFont: Font { .system(size: 14, weight: .black, design: .rounded) }
private var radarRowBodyFont: Font { .system(size: 11, weight: .semibold) }
private var radarRowStatusFont: Font { .system(size: 10, weight: .black, design: .rounded) }
private var radarRowScoreFont: Font { .system(size: 14, weight: .black, design: .rounded).monospacedDigit() }
private var sectionTitleFont: Font { .system(size: 22, weight: .black, design: .rounded) }
private var sectionBodyFont: Font { .system(size: 12, weight: .medium) }
#endif
HStack(spacing: 16) {
statPill("\(viewModel.games.count)", label: "Games")
if !viewModel.liveGames.isEmpty {
statPill("\(viewModel.liveGames.count)", label: "Live", color: .red)
}
if !viewModel.activeStreams.isEmpty {
statPill("\(viewModel.activeStreams.count)/4", label: "Streams", color: .green)
}
}
}
HStack(spacing: 16) {
Button {
Task { await viewModel.goToPreviousDay() }
} label: {
Label("Previous Day", systemImage: "chevron.left")
}
if !viewModel.isToday {
Button {
Task { await viewModel.goToToday() }
} label: {
Label("Today", systemImage: "calendar")
}
.tint(.blue)
}
Button {
Task { await viewModel.goToNextDay() }
} label: {
HStack(spacing: 6) {
Text("Next Day")
Image(systemName: "chevron.right")
}
}
Spacer()
}
}
}
@ViewBuilder
private func statPill(_ value: String, label: String, color: Color = DS.Colors.interactive) -> some View {
VStack(spacing: 2) {
Text(value)
.font(DS.Fonts.dataValue)
.foregroundStyle(color)
Text(label)
.dataLabelStyle()
}
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(DS.Colors.panelFill)
.overlay(
RoundedRectangle(cornerRadius: DS.Radii.compact)
.strokeBorder(DS.Colors.panelStroke, lineWidth: 0.5)
)
.clipShape(RoundedRectangle(cornerRadius: DS.Radii.compact))
}
// MARK: - Featured Channels
@ViewBuilder
private var featuredChannelsSection: some View { private var featuredChannelsSection: some View {
ViewThatFits { HStack(spacing: DS.Spacing.cardGap) {
HStack(alignment: .top, spacing: 24) {
mlbNetworkCard mlbNetworkCard
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
nsfwVideosCard nsfwVideosCard
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
VStack(alignment: .leading, spacing: 16) {
mlbNetworkCard
nsfwVideosCard
}
}
} }
@ViewBuilder
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) {
Text(SpecialPlaybackChannelConfig.werkoutNSFWTitle)
.font(.title3.weight(.bold))
Text(SpecialPlaybackChannelConfig.werkoutNSFWSubtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
} }
Spacer() private var multiViewStatus: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Multi-View Status")
.font(railTitleFont)
.foregroundStyle(DS.Colors.textPrimary)
if added { Text("Current grid state, active audio focus, and ready-to-open tiles.")
Label("In Multi-View", systemImage: "checkmark.circle.fill") .font(railBodyFont)
.font(.subheadline.weight(.bold)) .foregroundStyle(DS.Colors.textSecondary)
.foregroundStyle(.green)
HStack(spacing: 14) {
metricBadge(value: "\(viewModel.activeStreams.count)/4", label: "Tiles")
metricBadge(value: viewModel.multiViewLayoutMode.title, label: "Layout")
metricBadge(value: viewModel.activeAudioStream == nil ? "Muted" : "Live", label: "Audio")
}
if viewModel.activeStreams.isEmpty {
Text("No active tiles yet. Add any game feed to build the quadbox.")
.font(railBodyFont)
.foregroundStyle(DS.Colors.textTertiary)
} else { } else {
Label("Open", systemImage: "play.fill") LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
.font(.subheadline.weight(.bold)) ForEach(viewModel.activeStreams) { stream in
.foregroundStyle(.pink) 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))
} }
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14) .padding(.horizontal, 14)
.padding(.vertical, 8) .padding(.vertical, 12)
.background(.regularMaterial) .background(
.clipShape(Capsule()) 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

@@ -23,6 +23,9 @@ struct FeedView: View {
.font(DS.Fonts.sectionTitle) .font(DS.Fonts.sectionTitle)
#endif #endif
.foregroundStyle(DS.Colors.textPrimary) .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() Spacer()
@@ -32,21 +35,16 @@ struct FeedView: View {
} }
} }
overviewChips
if viewModel.highlights.isEmpty && !viewModel.isLoading { if viewModel.highlights.isEmpty && !viewModel.isLoading {
emptyState emptyState
} else { } else {
// All highlights in one horizontal scroll, ordered by time LazyVStack(spacing: DS.Spacing.cardGap) {
ScrollView(.horizontal) { ForEach(viewModel.highlights.prefix(50)) { item in
LazyHStack(spacing: DS.Spacing.cardGap) {
ForEach(viewModel.highlights) { item in
highlightCard(item) highlightCard(item)
.frame(width: cardWidth)
} }
} }
.padding(.vertical, 8)
}
.platformFocusSection()
.scrollClipDisabled()
} }
} }
.padding(.horizontal, edgeInset) .padding(.horizontal, edgeInset)
@@ -74,13 +72,59 @@ struct FeedView: View {
} }
} }
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 @ViewBuilder
private func highlightCard(_ item: HighlightItem) -> some View { private func highlightCard(_ item: HighlightItem) -> some View {
Button { Button {
playingURL = item.hlsURL ?? item.mp4URL playingURL = item.hlsURL ?? item.mp4URL
} label: { } label: {
VStack(alignment: .leading, spacing: 10) { HStack(spacing: 16) {
// Thumbnail area with team colors // Thumbnail
ZStack { ZStack {
HStack(spacing: 0) { HStack(spacing: 0) {
Rectangle().fill(TeamAssets.color(for: item.awayCode).opacity(0.3)) Rectangle().fill(TeamAssets.color(for: item.awayCode).opacity(0.3))
@@ -101,21 +145,19 @@ struct FeedView: View {
) )
} }
// Play icon overlay
Image(systemName: "play.circle.fill") Image(systemName: "play.circle.fill")
.font(.system(size: playIconSize)) .font(.system(size: playIconSize))
.foregroundStyle(.white.opacity(0.8)) .foregroundStyle(.white.opacity(0.7))
.shadow(radius: 4) .shadow(radius: 4)
// Condensed/Recap badge
if item.isCondensedGame { if item.isCondensedGame {
VStack { VStack {
HStack { HStack {
Spacer() Spacer()
Text("CONDENSED") Text("CONDENSED")
.font(DS.Fonts.caption) .font(badgeFont)
.foregroundStyle(.white) .foregroundStyle(.white)
.kerning(0.8) .kerning(0.5)
.padding(.horizontal, 8) .padding(.horizontal, 8)
.padding(.vertical, 4) .padding(.vertical, 4)
.background(DS.Colors.media) .background(DS.Colors.media)
@@ -126,22 +168,31 @@ struct FeedView: View {
.padding(8) .padding(8)
} }
} }
.frame(height: thumbnailHeight) .frame(width: thumbnailWidth, height: thumbnailHeight)
.clipShape(RoundedRectangle(cornerRadius: DS.Radii.compact)) .clipShape(RoundedRectangle(cornerRadius: DS.Radii.compact))
// Info // Info
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 6) {
HStack {
Text(item.gameTitle) Text(item.gameTitle)
.font(DS.Fonts.caption) .font(gameTagFont)
.foregroundStyle(DS.Colors.textTertiary) .foregroundStyle(DS.Colors.textTertiary)
.kerning(1) .kerning(0.8)
Spacer()
Text(timeAgo(item.timestamp))
.font(gameTagFont)
.foregroundStyle(DS.Colors.textQuaternary)
}
Text(item.headline) Text(item.headline)
.font(headlineFont) .font(headlineFont)
.foregroundStyle(DS.Colors.textPrimary) .foregroundStyle(DS.Colors.textPrimary)
.lineLimit(2) .lineLimit(2)
} }
.padding(.horizontal, 4)
Spacer(minLength: 0)
} }
.padding(DS.Spacing.panelPadCompact) .padding(DS.Spacing.panelPadCompact)
.background(DS.Colors.panelFill) .background(DS.Colors.panelFill)
@@ -172,23 +223,39 @@ struct FeedView: View {
// MARK: - Platform sizing // 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) #if os(tvOS)
private var edgeInset: CGFloat { 60 } private var edgeInset: CGFloat { 60 }
private var cardWidth: CGFloat { 420 } private var thumbnailWidth: CGFloat { 300 }
private var thumbnailHeight: CGFloat { 200 } private var thumbnailHeight: CGFloat { 160 }
private var thumbnailLogoSize: CGFloat { 56 } private var thumbnailLogoSize: CGFloat { 48 }
private var thumbnailLogoGap: CGFloat { 24 } private var thumbnailLogoGap: CGFloat { 20 }
private var playIconSize: CGFloat { 44 } private var playIconSize: CGFloat { 40 }
private var atFontSize: CGFloat { 20 } private var atFontSize: CGFloat { 22 }
private var headlineFont: Font { .system(size: 18, weight: .semibold) } 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 #else
private var edgeInset: CGFloat { 20 } private var edgeInset: CGFloat { 20 }
private var cardWidth: CGFloat { 280 } private var thumbnailWidth: CGFloat { 180 }
private var thumbnailHeight: CGFloat { 140 } private var thumbnailHeight: CGFloat { 100 }
private var thumbnailLogoSize: CGFloat { 40 } private var thumbnailLogoSize: CGFloat { 32 }
private var thumbnailLogoGap: CGFloat { 16 } private var thumbnailLogoGap: CGFloat { 12 }
private var playIconSize: CGFloat { 32 } private var playIconSize: CGFloat { 28 }
private var atFontSize: CGFloat { 15 } private var atFontSize: CGFloat { 14 }
private var headlineFont: Font { .system(size: 15, weight: .semibold) } 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 #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,298 +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) }
.frame(maxWidth: .infinity, minHeight: cardHeight, alignment: .topLeading)
VStack(spacing: 12) { .padding(cardPad)
teamRow(team: game.awayTeam, isWinning: isWinning(away: true)) .background(cardBackground)
teamRow(team: game.homeTeam, isWinning: isWinning(away: false)) .overlay(cardBorder)
.clipShape(RoundedRectangle(cornerRadius: cardRadius, style: .continuous))
.shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY)
}
.platformCardStyle()
} }
.padding(.horizontal, 22)
// Mini linescore for live/final games private var headerRow: some View {
HStack(alignment: .center, spacing: 12) {
statusPill
Spacer(minLength: 8)
if inMultiView {
chip(title: "In Multi-View", tint: DS.Colors.positive)
}
if game.hasStreams {
chip(title: "\(game.broadcasts.count) feed\(game.broadcasts.count == 1 ? "" : "s")", tint: DS.Colors.interactive)
}
}
}
private var matchupBlock: some View {
VStack(spacing: 16) {
teamRow(team: game.awayTeam, isLeading: isWinning(away: true))
teamRow(team: game.homeTeam, isLeading: isWinning(away: false))
}
}
@ViewBuilder
private var footerBlock: some View {
VStack(alignment: .leading, spacing: 14) {
switch game.status {
case .live:
liveFooter
case .final_:
finalFooter
case .scheduled:
scheduledFooter
case .unknown:
unknownFooter
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 2)
}
private func teamRow(team: TeamInfo, isLeading: Bool) -> some View {
HStack(spacing: 14) {
TeamLogoView(team: team, size: logoSize)
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 10) {
Text(team.code)
.font(codeFont)
.foregroundStyle(.white)
if let record = team.record {
Text(record)
.font(metaFont)
.foregroundStyle(DS.Colors.textSecondary)
}
}
Text(team.displayName)
.font(nameFont)
.foregroundStyle(DS.Colors.textSecondary)
.lineLimit(1)
if let summary = team.standingSummary {
Text(summary)
.font(metaFont)
.foregroundStyle(DS.Colors.textTertiary)
.lineLimit(1)
}
}
Spacer(minLength: 8)
Text(team.score.map(String.init) ?? "")
.font(scoreFont)
.foregroundStyle(isLeading ? .white : DS.Colors.textSecondary)
.monospacedDigit()
}
}
@ViewBuilder
private var liveFooter: some View {
if let linescore = game.linescore, !game.status.isScheduled { 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( MiniLinescoreView(
linescore: linescore, linescore: linescore,
awayCode: game.awayTeam.code, awayCode: game.awayTeam.code,
homeCode: game.homeTeam.code homeCode: game.homeTeam.code
) )
.padding(.horizontal, 22) } else {
.padding(.top, 10) Text("Live update available")
} .font(footerBodyFont)
.foregroundStyle(DS.Colors.textSecondary)
Spacer(minLength: 10)
footer
.padding(.horizontal, 22)
.padding(.vertical, 14)
}
.frame(maxWidth: .infinity, minHeight: 320, alignment: .topLeading)
.background(cardBackground)
.overlay(alignment: .top) {
HStack(spacing: 0) {
Rectangle()
.fill(awayColor.opacity(0.95))
Rectangle()
.fill(homeColor.opacity(0.95))
}
.frame(height: 4)
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
}
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.strokeBorder(
inMultiView ? .green.opacity(0.4) :
game.isLive ? .red.opacity(0.4) :
.white.opacity(0.08),
lineWidth: inMultiView || game.isLive ? 2 : 1
)
)
.shadow(
color: shadowColor,
radius: 18,
y: 8
)
}
.platformCardStyle()
}
@ViewBuilder
private var header: some View {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 6) {
Text((game.gameType ?? "Matchup").uppercased())
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.58))
.kerning(1.2)
.lineLimit(1)
Text(subtitleText)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.white.opacity(0.82))
.lineLimit(1)
}
Spacer(minLength: 12)
compactStatus
} }
} }
@ViewBuilder private var finalFooter: some View {
private func teamRow(team: TeamInfo, isWinning: Bool) -> some View { VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 14) { Text("Final")
TeamLogoView(team: team, size: 46) .font(footerTitleFont)
.frame(width: 50, height: 50) .foregroundStyle(DS.Colors.positive)
VStack(alignment: .leading, spacing: 5) { Text(game.scoreDisplay ?? "Game complete")
HStack(spacing: 10) { .font(footerValueFont)
Text(team.code)
.font(.system(size: 28, weight: .black, design: .rounded))
.foregroundStyle(.white) .foregroundStyle(.white)
if let record = team.record { if let venue = game.venue {
Text(record) Text(venue)
.font(.system(size: 12, weight: .bold, design: .monospaced)) .font(footerBodyFont)
.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) private var scheduledFooter: some View {
.font(.system(size: 15, weight: .semibold)) VStack(alignment: .leading, spacing: 10) {
.foregroundStyle(.white.opacity(isWinning ? 0.88 : 0.68)) 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) .lineLimit(1)
.minimumScaleFactor(0.75)
} }
Spacer(minLength: 12)
if !game.status.isScheduled, let score = team.score {
Text("\(score)")
.font(.system(size: 42, weight: .black, design: .rounded))
.foregroundStyle(isWinning ? .white : .white.opacity(0.72))
.contentTransition(.numericText())
}
}
.padding(.horizontal, 14)
.padding(.vertical, 13)
.background {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(rowBackground(for: team, isWinning: isWinning))
} }
} }
private func isWinning(away: Bool) -> Bool { private var unknownFooter: some View {
guard let a = game.awayTeam.score, let h = game.homeTeam.score else { return false } VStack(alignment: .leading, spacing: 10) {
return away ? a > h : h > a Text("Awaiting update")
.font(footerTitleFont)
.foregroundStyle(DS.Colors.textSecondary)
if let venue = game.venue {
Text(venue)
.font(footerBodyFont)
.foregroundStyle(DS.Colors.textSecondary)
}
}
} }
private var subtitleText: String { private func footerMetric(label: String, value: String) -> some View {
if game.status.isScheduled { VStack(alignment: .leading, spacing: 4) {
return game.pitchers ?? game.venue ?? "Upcoming" Text(label)
.font(metaFont)
.foregroundStyle(DS.Colors.textTertiary)
Text(value)
.font(footerValueFont)
.foregroundStyle(.white)
.monospacedDigit()
} }
if game.isBlackedOut {
return "Regional blackout"
} }
if !game.broadcasts.isEmpty {
let count = game.broadcasts.count private func chip(title: String, tint: Color) -> some View {
return "\(count) feed\(count == 1 ? "" : "s") available" 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)
} }
return game.venue ?? "No feeds listed yet" )
} }
@ViewBuilder @ViewBuilder
private var compactStatus: some View { private var statusPill: some View {
switch game.status { switch game.status {
case .live(let inning): case .live(let inning):
HStack(spacing: 7) { chip(title: inning?.uppercased() ?? "LIVE", tint: DS.Colors.live)
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): case .scheduled(let time):
Text(time) chip(title: time.uppercased(), tint: DS.Colors.warning)
.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_: case .final_:
Text("FINAL") chip(title: "FINAL", tint: DS.Colors.positive)
.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: case .unknown:
EmptyView() chip(title: "PENDING", tint: DS.Colors.textTertiary)
} }
} }
@ViewBuilder
private var footer: some View {
HStack(spacing: 12) {
Label(footerText, systemImage: footerIconName)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white.opacity(0.66))
.lineLimit(1)
Spacer(minLength: 12)
if inMultiView {
footerBadge(title: "In Multi-View", color: .green)
} else if game.isBlackedOut {
footerBadge(title: "Blacked Out", color: .red)
} else if game.hasStreams {
footerBadge(title: "Watch", color: .blue)
}
}
.overlay(alignment: .top) {
Rectangle()
.fill(.white.opacity(0.08))
.frame(height: 1)
}
}
private var footerText: String {
if game.status.isScheduled {
return game.venue ?? (game.pitchers ?? "First pitch later today")
}
if game.isBlackedOut {
return "This game is unavailable in your area"
}
if let pitchers = game.pitchers, !pitchers.isEmpty {
return pitchers
}
if !game.broadcasts.isEmpty {
return game.broadcasts.map(\.teamCode).joined(separator: "")
}
return game.venue ?? "Tap for details"
}
private var footerIconName: String {
if game.isBlackedOut { return "eye.slash.fill" }
if game.hasStreams { return "tv.fill" }
if game.status.isScheduled { return "mappin.and.ellipse" }
return "sportscourt.fill"
}
@ViewBuilder
private func footerBadge(title: String, color: Color) -> some View {
Text(title)
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(color)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(color.opacity(0.12))
.clipShape(Capsule())
}
private func rowBackground(for team: TeamInfo, isWinning: Bool) -> some ShapeStyle {
let color = TeamAssets.color(for: team.code)
return LinearGradient(
colors: [
color.opacity(isWinning ? 0.22 : 0.12),
.white.opacity(isWinning ? 0.07 : 0.03)
],
startPoint: .leading,
endPoint: .trailing
)
}
@ViewBuilder
private var cardBackground: some View { private var cardBackground: some View {
ZStack { RoundedRectangle(cornerRadius: cardRadius, style: .continuous)
RoundedRectangle(cornerRadius: 24, style: .continuous) .fill(
.fill(Color(red: 0.08, green: 0.09, blue: 0.12))
LinearGradient( LinearGradient(
colors: [ colors: [
awayColor.opacity(0.18), DS.Colors.panelFill,
Color.clear, DS.Colors.panelFillMuted,
homeColor.opacity(0.18)
], ],
startPoint: .topLeading, startPoint: .topLeading,
endPoint: .bottomTrailing endPoint: .bottomTrailing
) )
)
Circle() .overlay(alignment: .top) {
.fill(awayColor.opacity(0.18)) Rectangle()
.frame(width: 180) .fill(
.blur(radius: 40) LinearGradient(
.offset(x: -110, y: -90) colors: [awayColor, homeColor],
startPoint: .leading,
Circle() endPoint: .trailing
.fill(homeColor.opacity(0.16)) )
.frame(width: 200) )
.blur(radius: 44) .frame(height: 5)
.offset(x: 140, y: 120) .clipShape(
RoundedRectangle(cornerRadius: cardRadius, style: .continuous)
)
} }
} }
private var shadowColor: Color { private var cardBorder: some View {
if inMultiView { return .green.opacity(0.18) } RoundedRectangle(cornerRadius: cardRadius, style: .continuous)
if game.isLive { return .red.opacity(0.22) } .strokeBorder(borderColor, lineWidth: borderWidth)
return .black.opacity(0.22)
} }
private func isWinning(away: Bool) -> Bool {
guard let awayScore = game.awayTeam.score, let homeScore = game.homeTeam.score else {
return false
}
return away ? awayScore > homeScore : homeScore > awayScore
}
private var borderColor: Color {
if inMultiView { return DS.Colors.positive.opacity(0.46) }
if game.isLive { return DS.Colors.live.opacity(0.34) }
return DS.Colors.panelStroke
}
private var borderWidth: CGFloat {
inMultiView || game.isLive ? 1.6 : 1
}
#if os(tvOS)
private var cardHeight: CGFloat { 270 }
private var cardRadius: CGFloat { 28 }
private var cardPad: CGFloat { 24 }
private var cardSpacing: CGFloat { 18 }
private var logoSize: CGFloat { 46 }
private var codeFont: Font { .system(size: 24, weight: .black, design: .rounded) }
private var nameFont: Font { .system(size: 22, weight: .bold, design: .rounded) }
private var metaFont: Font { .system(size: 15, weight: .bold, design: .rounded) }
private var scoreFont: Font { .system(size: 38, weight: .black, design: .rounded).monospacedDigit() }
private var chipFont: Font { .system(size: 13, weight: .black, design: .rounded) }
private var footerTitleFont: Font { .system(size: 18, weight: .black, design: .rounded) }
private var footerValueFont: Font { .system(size: 18, weight: .bold, design: .rounded) }
private var footerBodyFont: Font { .system(size: 16, weight: .semibold) }
#else
private var cardHeight: CGFloat { 200 }
private var cardRadius: CGFloat { 20 }
private var cardPad: CGFloat { 18 }
private var cardSpacing: CGFloat { 14 }
private var logoSize: CGFloat { 34 }
private var codeFont: Font { .system(size: 18, weight: .black, design: .rounded) }
private var nameFont: Font { .system(size: 16, weight: .bold, design: .rounded) }
private var metaFont: Font { .system(size: 11, weight: .bold, design: .rounded) }
private var scoreFont: Font { .system(size: 28, weight: .black, design: .rounded).monospacedDigit() }
private var chipFont: Font { .system(size: 10, weight: .black, design: .rounded) }
private var footerTitleFont: Font { .system(size: 13, weight: .black, design: .rounded) }
private var footerValueFont: Font { .system(size: 13, weight: .bold, design: .rounded) }
private var footerBodyFont: Font { .system(size: 12, weight: .semibold) }
#endif
} }

View File

@@ -34,14 +34,31 @@ struct LeagueCenterView: View {
messagePanel(overviewErrorMessage, tint: .orange) messagePanel(overviewErrorMessage, tint: .orange)
} }
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
// League Leaders
if !viewModel.leagueLeaders.isEmpty { if !viewModel.leagueLeaders.isEmpty {
leadersSection leadersSection
} }
#endif
teamsSection teamsSection
.platformFocusSection()
if let selectedTeam = viewModel.selectedTeam { if let selectedTeam = viewModel.selectedTeam {
teamProfileSection(team: selectedTeam) teamProfileSection(team: selectedTeam)
@@ -74,13 +91,13 @@ 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("Standings, league leaders, 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()
@@ -88,7 +105,7 @@ struct LeagueCenterView: View {
HStack(spacing: 12) { HStack(spacing: 12) {
infoPill(title: "\(viewModel.leagueLeaders.count)", label: "Leaders", 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)
} }
} }
} }
@@ -99,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()
@@ -150,7 +167,7 @@ struct LeagueCenterView: View {
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))
@@ -166,13 +183,13 @@ struct LeagueCenterView: View {
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) .frame(width: scheduleVenueColWidth, alignment: .trailing)
} }
@@ -210,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)
} }
} }
@@ -230,12 +247,39 @@ struct LeagueCenterView: View {
} }
} }
#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 { private var leadersSection: some View {
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
HStack { HStack {
Text("League Leaders") Text("League Leaders")
.font(.system(size: 30, weight: .bold, design: .rounded)) .font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(.white) .foregroundStyle(DS.Colors.textPrimary)
Spacer() Spacer()
@@ -271,7 +315,7 @@ struct LeagueCenterView: 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...")
@@ -296,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)
@@ -335,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) {
@@ -349,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)
} }
@@ -361,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)
@@ -371,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()
@@ -388,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...")
@@ -399,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)
@@ -433,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
@@ -446,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()
@@ -469,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...")
@@ -483,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 {
@@ -493,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))
@@ -513,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)
} }
} }
} }
@@ -535,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)
} }
@@ -565,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)
} }
} }
} }
@@ -576,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)
) )
} }
@@ -591,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)
} }
} }
@@ -627,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)
@@ -642,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()
@@ -656,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)
@@ -687,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)
} }
@@ -698,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(
.overlay {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
}
}
private var screenBackground: some View {
ZStack {
LinearGradient( LinearGradient(
colors: [ colors: [
Color(red: 0.04, green: 0.05, blue: 0.09), DS.Colors.panelFill,
Color(red: 0.03, green: 0.06, blue: 0.1), DS.Colors.panelFillMuted,
Color(red: 0.02, green: 0.03, blue: 0.06),
], ],
startPoint: .topLeading, startPoint: .topLeading,
endPoint: .bottomTrailing endPoint: .bottomTrailing
) )
)
Circle() .shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY)
.fill(.blue.opacity(0.18)) .overlay {
.frame(width: 520, height: 520) RoundedRectangle(cornerRadius: 24, style: .continuous)
.blur(radius: 110) .strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
.offset(x: -360, y: -260)
Circle()
.fill(.orange.opacity(0.16))
.frame(width: 560, height: 560)
.blur(radius: 120)
.offset(x: 420, y: -80)
} }
} }
private var screenBackground: some View {
BroadcastBackground()
}
} }

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