import SwiftUI struct StreamOptionsSheet: View { let game: Game var onWatch: ((BroadcastSelection) -> Void)? @Environment(GamesViewModel.self) private var viewModel @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.dismiss) private var dismiss private var awayColor: Color { TeamAssets.color(for: game.awayTeam.code) } private var homeColor: Color { TeamAssets.color(for: game.homeTeam.code) } private var hasLinescore: Bool { !game.status.isScheduled && (game.linescore?.hasData ?? false) } private var canAddMoreStreams: Bool { viewModel.activeStreams.count < 4 } private var usesStackedLayout: Bool { #if os(iOS) horizontalSizeClass == .compact #else false #endif } private var horizontalPadding: CGFloat { usesStackedLayout ? 20 : 56 } private var verticalPadding: CGFloat { usesStackedLayout ? 24 : 42 } private var awayPitcherName: String? { guard let pitchers = game.pitchers else { return nil } let parts = pitchers.components(separatedBy: " vs ") return parts.first } private var homePitcherName: String? { guard let pitchers = game.pitchers else { return nil } let parts = pitchers.components(separatedBy: " vs ") return parts.count > 1 ? parts.last : nil } var body: some View { ScrollView { VStack(spacing: 28) { ViewThatFits { HStack(alignment: .top, spacing: 28) { matchupColumn .frame(maxWidth: .infinity, alignment: .leading) actionRail .frame(width: 520, alignment: .leading) } VStack(alignment: .leading, spacing: 24) { matchupColumn actionRail .frame(maxWidth: .infinity, alignment: .leading) } } if !canAddMoreStreams { Label( "Multi-view is full. Remove a stream in Multi-View or Settings before adding another.", systemImage: "rectangle.split.2x2.fill" ) .font(.system(size: 16, weight: .semibold)) .foregroundStyle(.orange) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 22) .padding(.vertical, 18) .background(panelBackground) } GameCenterView(game: game) } .padding(.horizontal, horizontalPadding) .padding(.vertical, verticalPadding) } .background(sheetBackground.ignoresSafeArea()) } @ViewBuilder private var matchupColumn: some View { VStack(alignment: .leading, spacing: 20) { headerBar featuredTeamRow( team: game.awayTeam, color: awayColor, pitcherURL: game.awayPitcherHeadshotURL, pitcherName: awayPitcherName ) scorePanel featuredTeamRow( team: game.homeTeam, color: homeColor, pitcherURL: game.homePitcherHeadshotURL, pitcherName: homePitcherName ) if hasLinescore, let linescore = game.linescore { VStack(alignment: .leading, spacing: 14) { HStack { Text("Linescore") .font(.system(size: 18, weight: .bold)) .foregroundStyle(.white) Spacer() Text(linescoreStatusText) .font(.system(size: 13, weight: .bold, design: .rounded)) .foregroundStyle(.white.opacity(0.58)) } LinescoreView( linescore: linescore, awayCode: game.awayTeam.code, homeCode: game.homeTeam.code ) } .padding(22) .background(panelBackground) } } } @ViewBuilder private var headerBar: some View { HStack(alignment: .top, spacing: 16) { VStack(alignment: .leading, spacing: 8) { Text((game.gameType ?? "Game Center").uppercased()) .font(.system(size: 13, weight: .bold, design: .rounded)) .foregroundStyle(.white.opacity(0.58)) .kerning(1.8) if let venue = game.venue { Label(venue, systemImage: "mappin.and.ellipse") .font(.system(size: 15, weight: .medium)) .foregroundStyle(.white.opacity(0.82)) } } Spacer() HStack(spacing: 10) { if game.isBlackedOut { chip(title: "BLACKOUT", color: .red) } else if !game.broadcasts.isEmpty { chip(title: "\(game.broadcasts.count) FEED\(game.broadcasts.count == 1 ? "" : "S")", color: .blue) } statusChip } } } @ViewBuilder private func featuredTeamRow(team: TeamInfo, color: Color, pitcherURL: URL?, pitcherName: String?) -> some View { HStack(spacing: 18) { TeamLogoView(team: team, size: 72) .frame(width: 78, height: 78) VStack(alignment: .leading, spacing: 7) { HStack(spacing: 10) { Text(team.code) .font(.system(size: 30, weight: .black, design: .rounded)) .foregroundStyle(.white) if let record = team.record { Text(record) .font(.system(size: 13, weight: .bold, design: .monospaced)) .foregroundStyle(.white.opacity(0.72)) .padding(.horizontal, 11) .padding(.vertical, 6) .background(.white.opacity(0.08)) .clipShape(Capsule()) } } Text(team.displayName) .font(.system(size: 28, weight: .bold)) .foregroundStyle(.white.opacity(0.96)) .lineLimit(1) .minimumScaleFactor(0.82) if let standing = team.standingSummary { Text(standing) .font(.system(size: 15, weight: .medium)) .foregroundStyle(.white.opacity(0.56)) .lineLimit(1) } } Spacer(minLength: 14) if pitcherURL != nil || pitcherName != nil { HStack(spacing: 12) { if pitcherURL != nil { PitcherHeadshotView( url: pitcherURL, teamCode: team.code, name: nil, size: 46 ) } VStack(alignment: .trailing, spacing: 4) { Text("Probable") .font(.system(size: 12, weight: .bold, design: .rounded)) .foregroundStyle(.white.opacity(0.42)) Text(pitcherName ?? "TBD") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(.white.opacity(0.84)) .lineLimit(1) } } } } .padding(.horizontal, 22) .padding(.vertical, 18) .background { RoundedRectangle(cornerRadius: 24, style: .continuous) .fill(teamPanelBackground(color: color)) } .overlay { RoundedRectangle(cornerRadius: 24, style: .continuous) .strokeBorder(.white.opacity(0.08), lineWidth: 1) } } @ViewBuilder private var scorePanel: some View { VStack(spacing: 12) { if let summary = scoreSummaryText { Text(summary) .font(.system(size: 72, weight: .black, design: .rounded)) .foregroundStyle(.white) .monospacedDigit() .contentTransition(.numericText()) } else { Text(game.status.label) .font(.system(size: 52, weight: .black, design: .rounded)) .foregroundStyle(.white) .multilineTextAlignment(.center) } Text(statusHeadline) .font(.system(size: 22, weight: .semibold, design: .rounded)) .foregroundStyle(statusHeadlineColor) if let detail = centerDetailText { Text(detail) .font(.system(size: 16, weight: .medium)) .foregroundStyle(.white.opacity(0.62)) .lineLimit(2) .multilineTextAlignment(.center) } } .frame(maxWidth: .infinity) .padding(.horizontal, 24) .padding(.vertical, 26) .background { RoundedRectangle(cornerRadius: 26, style: .continuous) .fill(.white.opacity(0.06)) } .overlay { RoundedRectangle(cornerRadius: 26, style: .continuous) .strokeBorder(.white.opacity(0.08), lineWidth: 1) } } @ViewBuilder private var actionRail: some View { VStack(alignment: .leading, spacing: 18) { actionRailHeader if !game.broadcasts.isEmpty { ForEach(game.broadcasts) { broadcast in broadcastCard(broadcast) } } else if game.isBlackedOut { blackoutCard } else if game.status.isScheduled { scheduledStateCard } else { fallbackActionsCard } } } @ViewBuilder private var actionRailHeader: some View { VStack(alignment: .leading, spacing: 10) { Text("Watch Options") .font(.system(size: 28, weight: .bold, design: .rounded)) .foregroundStyle(.white) Text(actionRailSubtitle) .font(.system(size: 16, weight: .medium)) .foregroundStyle(.white.opacity(0.68)) .lineLimit(2) } .padding(22) .background(panelBackground) } @ViewBuilder private func broadcastCard(_ broadcast: Broadcast) -> some View { let alreadyAdded = viewModel.activeStreams.contains(where: { $0.id == broadcast.id }) VStack(alignment: .leading, spacing: 16) { HStack(spacing: 12) { Circle() .fill(TeamAssets.color(for: broadcast.teamCode)) .frame(width: 10, height: 10) Text(broadcast.teamCode) .font(.system(size: 13, weight: .black, design: .rounded)) .foregroundStyle(.white.opacity(0.78)) .padding(.horizontal, 10) .padding(.vertical, 6) .background(.white.opacity(0.08)) .clipShape(Capsule()) Text(broadcast.name) .font(.system(size: 18, weight: .semibold)) .foregroundStyle(.white) .lineLimit(1) Spacer() if alreadyAdded { chip(title: "ADDED", color: .green) } } VStack(spacing: 14) { railActionButton( title: "Watch Full Screen", subtitle: "Open \(broadcast.name) in the main player", systemImage: "play.fill", fill: .blue.opacity(0.18) ) { let selection = BroadcastSelection(broadcast: broadcast, game: game) if let onWatch { onWatch(selection) } else { dismiss() } } railActionButton( title: alreadyAdded ? "Already Added to Multi-View" : "Add to Multi-View", subtitle: alreadyAdded ? "This feed is already active in the grid" : "Send \(broadcast.name) into an open tile", systemImage: alreadyAdded ? "checkmark.circle.fill" : "plus.circle.fill", fill: alreadyAdded ? .green.opacity(0.14) : .white.opacity(0.08), foreground: alreadyAdded ? .green : .white, disabled: !canAddMoreStreams || alreadyAdded ) { viewModel.addStream(broadcast: broadcast, game: game) dismiss() } } } .padding(22) .background(panelBackground) } @ViewBuilder private var blackoutCard: some View { VStack(alignment: .leading, spacing: 14) { Image(systemName: "eye.slash.fill") .font(.system(size: 36, weight: .bold)) .foregroundStyle(.red) Text("Blacked Out") .font(.system(size: 28, weight: .bold, design: .rounded)) .foregroundStyle(.white) Text("This game is unavailable in your area. The matchup data is still live, but watch controls are disabled.") .font(.system(size: 17, weight: .medium)) .foregroundStyle(.white.opacity(0.72)) .lineLimit(3) } .frame(maxWidth: .infinity, alignment: .leading) .padding(24) .background( RoundedRectangle(cornerRadius: 24, style: .continuous) .fill(.red.opacity(0.12)) ) .overlay { RoundedRectangle(cornerRadius: 24, style: .continuous) .strokeBorder(.red.opacity(0.24), lineWidth: 1) } } @ViewBuilder private var scheduledStateCard: some View { VStack(alignment: .leading, spacing: 14) { Label("Feeds Not Posted Yet", systemImage: "calendar") .font(.system(size: 16, weight: .bold)) .foregroundStyle(.white) Text("Streams usually appear closer to first pitch. Check back around game time.") .font(.system(size: 17, weight: .medium)) .foregroundStyle(.white.opacity(0.72)) .lineLimit(3) if let venue = game.venue { Text(venue) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(.white.opacity(0.5)) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(24) .background(panelBackground) } @ViewBuilder private var fallbackActionsCard: some View { VStack(alignment: .leading, spacing: 16) { Text("No Feeds Listed") .font(.system(size: 24, weight: .bold, design: .rounded)) .foregroundStyle(.white) Text("You can still try building a stream from one of the team feeds.") .font(.system(size: 16, weight: .medium)) .foregroundStyle(.white.opacity(0.68)) VStack(spacing: 14) { fallbackButton(teamCode: game.awayTeam.code) fallbackButton(teamCode: game.homeTeam.code) } } .padding(22) .background(panelBackground) } @ViewBuilder private func fallbackButton(teamCode: String) -> some View { railActionButton( title: "Try \(teamCode) Feed", subtitle: "Build a fallback stream for \(teamCode)", systemImage: "play.circle.fill", fill: .white.opacity(0.08), disabled: !canAddMoreStreams ) { viewModel.addStreamByTeam(teamCode: teamCode, game: game) dismiss() } } @ViewBuilder private func railActionButton( title: String, subtitle: String, systemImage: String, fill: Color, foreground: Color = .white, disabled: Bool = false, action: @escaping () -> Void ) -> some View { Button(action: action) { HStack(alignment: .center, spacing: 14) { Image(systemName: systemImage) .font(.system(size: 20, weight: .bold)) .frame(width: 28) VStack(alignment: .leading, spacing: 4) { Text(title) .font(.system(size: 18, weight: .bold, design: .rounded)) .foregroundStyle(foreground) .fixedSize(horizontal: false, vertical: true) Text(subtitle) .font(.system(size: 13, weight: .medium)) .foregroundStyle(foreground.opacity(0.64)) .fixedSize(horizontal: false, vertical: true) } Spacer(minLength: 0) } .frame(maxWidth: .infinity, minHeight: 84, alignment: .leading) .padding(.horizontal, 20) .background( RoundedRectangle(cornerRadius: 20, style: .continuous) .fill(fill) ) } .platformCardStyle() .disabled(disabled) } @ViewBuilder private var statusChip: some View { switch game.status { case .live(let inning): HStack(spacing: 8) { Circle() .fill(.red) .frame(width: 8, height: 8) Text(inning ?? "LIVE") .font(.system(size: 15, weight: .black, design: .rounded)) .foregroundStyle(.white) } .padding(.horizontal, 15) .padding(.vertical, 10) .background(.red.opacity(0.16)) .clipShape(Capsule()) case .scheduled(let time): Text(time) .font(.system(size: 15, weight: .black, design: .rounded)) .foregroundStyle(.white) .padding(.horizontal, 15) .padding(.vertical, 10) .background(.white.opacity(0.1)) .clipShape(Capsule()) case .final_: Text("FINAL") .font(.system(size: 15, weight: .black, design: .rounded)) .foregroundStyle(.white) .padding(.horizontal, 15) .padding(.vertical, 10) .background(.white.opacity(0.12)) .clipShape(Capsule()) case .unknown: EmptyView() } } @ViewBuilder private func chip(title: String, color: Color) -> some View { Text(title) .font(.system(size: 13, weight: .bold, design: .rounded)) .foregroundStyle(color) .padding(.horizontal, 12) .padding(.vertical, 9) .background(color.opacity(0.12)) .clipShape(Capsule()) } private var scoreSummaryText: String? { guard let away = game.awayTeam.score, let home = game.homeTeam.score else { return nil } return "\(away) - \(home)" } private var statusHeadline: String { switch game.status { case .scheduled: return "First Pitch" case .live(let inning): return inning ?? "Live" case .final_: return "Final" case .unknown: return "Game Day" } } private var statusHeadlineColor: Color { switch game.status { case .live: return .red.opacity(0.92) case .scheduled: return .blue.opacity(0.92) case .final_: return .white.opacity(0.82) case .unknown: return .white.opacity(0.72) } } private var centerDetailText: String? { if game.status.isScheduled { return game.pitchers ?? game.venue } return game.pitchers ?? game.venue } private var actionRailSubtitle: String { if game.isBlackedOut { return "Watch controls are unavailable for this matchup." } if !game.broadcasts.isEmpty { return "Pick a feed to watch full screen or send it into Multi-View." } if game.status.isScheduled { return "Streams normally appear closer to game time." } return "Try a team feed fallback for this matchup." } private var linescoreStatusText: String { if let inning = game.currentInningDisplay, !inning.isEmpty { return inning.uppercased() } if game.isFinal { return "FINAL" } return "GAME" } private func teamPanelBackground(color: Color) -> some ShapeStyle { LinearGradient( colors: [ color.opacity(0.22), .white.opacity(0.05) ], startPoint: .leading, endPoint: .trailing ) } @ViewBuilder private var panelBackground: some View { RoundedRectangle(cornerRadius: 24, style: .continuous) .fill(.black.opacity(0.22)) .overlay { RoundedRectangle(cornerRadius: 24, style: .continuous) .strokeBorder(.white.opacity(0.08), lineWidth: 1) } } @ViewBuilder private var sheetBackground: some View { ZStack { LinearGradient( colors: [ Color(red: 0.05, green: 0.06, blue: 0.1), Color(red: 0.08, green: 0.06, blue: 0.09) ], startPoint: .topLeading, endPoint: .bottomTrailing ) Circle() .fill(awayColor.opacity(0.16)) .frame(width: 520, height: 520) .blur(radius: 90) .offset(x: -360, y: -220) Circle() .fill(homeColor.opacity(0.18)) .frame(width: 520, height: 520) .blur(radius: 92) .offset(x: 420, y: 120) } } }