Initial commit
This commit is contained in:
108
mlbTVOS/Views/Components/LinescoreView.swift
Normal file
108
mlbTVOS/Views/Components/LinescoreView.swift
Normal file
@@ -0,0 +1,108 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LinescoreView: View {
|
||||
let linescore: StatsLinescore
|
||||
let awayCode: String
|
||||
let homeCode: String
|
||||
|
||||
private var totalInnings: Int {
|
||||
linescore.scheduledInnings ?? 9
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack(spacing: 0) {
|
||||
Text("")
|
||||
.frame(width: 70, alignment: .leading)
|
||||
|
||||
ForEach(1...totalInnings, id: \.self) { inning in
|
||||
let isCurrent = inning == linescore.currentInning
|
||||
HStack(spacing: 0) {
|
||||
if inning > 1 && inning % 3 == 1 {
|
||||
Divider()
|
||||
.frame(width: 1, height: 18)
|
||||
.background(.secondary.opacity(0.3))
|
||||
.padding(.trailing, 4)
|
||||
}
|
||||
Text("\(inning)")
|
||||
.font(.callout.weight(.semibold).monospacedDigit())
|
||||
.foregroundStyle(isCurrent ? .primary : .secondary)
|
||||
.frame(width: 44)
|
||||
}
|
||||
}
|
||||
|
||||
Divider().frame(width: 1, height: 20).padding(.horizontal, 6)
|
||||
|
||||
ForEach(["R", "H", "E"], id: \.self) { label in
|
||||
Text(label)
|
||||
.font(.callout.weight(.bold).monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 48)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.background(.ultraThinMaterial)
|
||||
|
||||
Divider()
|
||||
|
||||
teamRow(code: awayCode, innings: linescore.innings ?? [], side: .away, totals: linescore.teams?.away)
|
||||
Divider()
|
||||
teamRow(code: homeCode, innings: linescore.innings ?? [], side: .home, totals: linescore.teams?.home)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.background(.regularMaterial)
|
||||
}
|
||||
|
||||
private enum Side { case away, home }
|
||||
|
||||
@ViewBuilder
|
||||
private func teamRow(code: String, innings: [StatsInningScore], side: Side, totals: StatsLinescoreTotals?) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
Text(code)
|
||||
.font(.callout.weight(.bold))
|
||||
.foregroundStyle(TeamAssets.color(for: code))
|
||||
.frame(width: 70, alignment: .leading)
|
||||
|
||||
ForEach(1...totalInnings, id: \.self) { inning in
|
||||
let runs = inningRuns(innings: innings, inning: inning, side: side)
|
||||
let isCurrent = inning == linescore.currentInning
|
||||
HStack(spacing: 0) {
|
||||
if inning > 1 && inning % 3 == 1 {
|
||||
Divider()
|
||||
.frame(width: 1, height: 22)
|
||||
.background(.secondary.opacity(0.2))
|
||||
.padding(.trailing, 4)
|
||||
}
|
||||
Text(runs.map { "\($0)" } ?? "-")
|
||||
.font(.callout.weight(runs != nil ? .semibold : .regular).monospacedDigit())
|
||||
.foregroundStyle(runs == nil ? .tertiary : isCurrent ? .primary : .secondary)
|
||||
.frame(width: 44)
|
||||
}
|
||||
}
|
||||
|
||||
Divider().frame(width: 1, height: 24).padding(.horizontal, 6)
|
||||
|
||||
Text(totals?.runs.map { "\($0)" } ?? "-")
|
||||
.font(.callout.weight(.bold).monospacedDigit())
|
||||
.frame(width: 48)
|
||||
Text(totals?.hits.map { "\($0)" } ?? "-")
|
||||
.font(.callout.weight(.medium).monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 48)
|
||||
Text(totals?.errors.map { "\($0)" } ?? "-")
|
||||
.font(.callout.weight(.medium).monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 48)
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
private func inningRuns(innings: [StatsInningScore], inning: Int, side: Side) -> Int? {
|
||||
guard let data = innings.first(where: { $0.num == inning }) else { return nil }
|
||||
switch side {
|
||||
case .away: return data.away?.runs
|
||||
case .home: return data.home?.runs
|
||||
}
|
||||
}
|
||||
}
|
||||
66
mlbTVOS/Views/Components/LiveIndicator.swift
Normal file
66
mlbTVOS/Views/Components/LiveIndicator.swift
Normal file
@@ -0,0 +1,66 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LiveIndicator: View {
|
||||
@State private var isPulsing = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(.red)
|
||||
.frame(width: 12, height: 12)
|
||||
.scaleEffect(isPulsing ? 1.4 : 1.0)
|
||||
.opacity(isPulsing ? 0.6 : 1.0)
|
||||
.animation(
|
||||
.easeInOut(duration: 0.8).repeatForever(autoreverses: true),
|
||||
value: isPulsing
|
||||
)
|
||||
Text("LIVE")
|
||||
.font(.subheadline.weight(.black))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
.onAppear { isPulsing = true }
|
||||
}
|
||||
}
|
||||
|
||||
struct StatusBadge: View {
|
||||
let status: GameStatus
|
||||
|
||||
var body: some View {
|
||||
switch status {
|
||||
case .live(let inning):
|
||||
HStack(spacing: 8) {
|
||||
LiveIndicator()
|
||||
if let inning {
|
||||
Text(inning)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(.red.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
|
||||
case .scheduled(let time):
|
||||
Text(time)
|
||||
.font(.body.weight(.bold))
|
||||
.foregroundStyle(.green)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(.green.opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
|
||||
case .final_:
|
||||
Text("FINAL")
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(.secondary.opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
|
||||
case .unknown:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
53
mlbTVOS/Views/Components/PitcherHeadshotView.swift
Normal file
53
mlbTVOS/Views/Components/PitcherHeadshotView.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PitcherHeadshotView: View {
|
||||
let url: URL?
|
||||
var teamCode: String?
|
||||
var name: String?
|
||||
var size: CGFloat = 56
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
case .failure:
|
||||
fallbackImage
|
||||
default:
|
||||
fallbackImage
|
||||
.redacted(reason: .placeholder)
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(Circle())
|
||||
.overlay(
|
||||
Circle()
|
||||
.strokeBorder(
|
||||
teamCode.map { TeamAssets.color(for: $0) } ?? .gray,
|
||||
lineWidth: 2
|
||||
)
|
||||
)
|
||||
|
||||
if let name {
|
||||
Text(name)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var fallbackImage: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(.gray.opacity(0.3))
|
||||
Image(systemName: "person.fill")
|
||||
.font(.system(size: size * 0.4))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
67
mlbTVOS/Views/Components/ScoreOverlayView.swift
Normal file
67
mlbTVOS/Views/Components/ScoreOverlayView.swift
Normal file
@@ -0,0 +1,67 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ScoreOverlayView: View {
|
||||
let game: Game
|
||||
|
||||
var body: some View {
|
||||
// Don't show for non-game streams (e.g., MLB Network)
|
||||
if game.id == "MLBN" {
|
||||
EmptyView()
|
||||
} else {
|
||||
HStack(spacing: 10) {
|
||||
// Away
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(TeamAssets.color(for: game.awayTeam.code))
|
||||
.frame(width: 8, height: 8)
|
||||
Text(game.awayTeam.code)
|
||||
.font(.system(size: 15, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
if !game.status.isScheduled, let score = game.awayTeam.score {
|
||||
Text("\(score)")
|
||||
.font(.system(size: 17, weight: .bold, design: .rounded).monospacedDigit())
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
|
||||
Text("-")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.4))
|
||||
|
||||
// Home
|
||||
HStack(spacing: 6) {
|
||||
if !game.status.isScheduled, let score = game.homeTeam.score {
|
||||
Text("\(score)")
|
||||
.font(.system(size: 17, weight: .bold, design: .rounded).monospacedDigit())
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
Text(game.homeTeam.code)
|
||||
.font(.system(size: 15, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
Circle()
|
||||
.fill(TeamAssets.color(for: game.homeTeam.code))
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
|
||||
// Inning/status
|
||||
if game.isLive, let inning = game.currentInningDisplay {
|
||||
Text(inning)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
.padding(.leading, 4)
|
||||
} else if game.isFinal {
|
||||
Text("F")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.black.opacity(0.7))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
167
mlbTVOS/Views/Components/ScoresTickerView.swift
Normal file
167
mlbTVOS/Views/Components/ScoresTickerView.swift
Normal file
@@ -0,0 +1,167 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct ScoresTickerView: View {
|
||||
var gamesOverride: [Game]? = nil
|
||||
|
||||
@Environment(GamesViewModel.self) private var viewModel
|
||||
|
||||
private var entries: [Game] {
|
||||
gamesOverride ?? viewModel.games
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if !entries.isEmpty {
|
||||
TickerMarqueeView(text: tickerText)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(height: 22)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(.black.opacity(0.72))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
||||
}
|
||||
)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
private var tickerText: String {
|
||||
entries.map(tickerSummary(for:)).joined(separator: " | ")
|
||||
}
|
||||
|
||||
private func tickerSummary(for game: Game) -> String {
|
||||
let matchup = "\(game.awayTeam.code) \(game.awayTeam.score.map(String.init) ?? "-") - \(game.homeTeam.code) \(game.homeTeam.score.map(String.init) ?? "-")"
|
||||
|
||||
if game.isLive, let linescore = game.linescore {
|
||||
let inning = linescore.currentInningOrdinal ?? game.currentInningDisplay ?? "LIVE"
|
||||
let state = (linescore.inningState ?? linescore.inningHalf ?? "Live").uppercased()
|
||||
let outs = linescore.outs ?? 0
|
||||
return "\(matchup) \(state) \(inning.uppercased()) • \(outs) OUT\(outs == 1 ? "" : "S")"
|
||||
}
|
||||
|
||||
if game.isFinal {
|
||||
return "\(matchup) FINAL"
|
||||
}
|
||||
|
||||
if let startTime = game.startTime {
|
||||
return "\(matchup) \(startTime.uppercased())"
|
||||
}
|
||||
|
||||
return "\(matchup) \(game.status.label.uppercased())"
|
||||
}
|
||||
}
|
||||
|
||||
private struct TickerMarqueeView: UIViewRepresentable {
|
||||
let text: String
|
||||
|
||||
func makeUIView(context: Context) -> TickerMarqueeContainerView {
|
||||
let view = TickerMarqueeContainerView()
|
||||
view.setText(text)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: TickerMarqueeContainerView, context: Context) {
|
||||
uiView.setText(text)
|
||||
}
|
||||
}
|
||||
|
||||
private final class TickerMarqueeContainerView: UIView {
|
||||
private let trackView = UIView()
|
||||
private let primaryLabel = UILabel()
|
||||
private let secondaryLabel = UILabel()
|
||||
private let spacing: CGFloat = 48
|
||||
private let pointsPerSecond: CGFloat = 64
|
||||
|
||||
private var currentText = ""
|
||||
private var previousBoundsWidth: CGFloat = 0
|
||||
private var previousContentWidth: CGFloat = 0
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
clipsToBounds = true
|
||||
isUserInteractionEnabled = false
|
||||
|
||||
let baseFont = UIFont.systemFont(ofSize: 15, weight: .bold)
|
||||
let roundedFont = UIFont(descriptor: baseFont.fontDescriptor.withDesign(.rounded) ?? baseFont.fontDescriptor, size: 15)
|
||||
|
||||
[primaryLabel, secondaryLabel].forEach { label in
|
||||
label.font = roundedFont
|
||||
label.textColor = UIColor.white.withAlphaComponent(0.92)
|
||||
label.numberOfLines = 1
|
||||
label.lineBreakMode = .byClipping
|
||||
trackView.addSubview(label)
|
||||
}
|
||||
|
||||
addSubview(trackView)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func setText(_ text: String) {
|
||||
guard currentText != text else { return }
|
||||
currentText = text
|
||||
primaryLabel.text = text
|
||||
secondaryLabel.text = text
|
||||
previousContentWidth = 0
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
guard bounds.width > 0, bounds.height > 0 else { return }
|
||||
|
||||
let contentWidth = ceil(primaryLabel.sizeThatFits(CGSize(width: .greatestFiniteMagnitude, height: bounds.height)).width)
|
||||
let contentHeight = bounds.height
|
||||
|
||||
primaryLabel.frame = CGRect(x: 0, y: 0, width: contentWidth, height: contentHeight)
|
||||
secondaryLabel.frame = CGRect(x: contentWidth + spacing, y: 0, width: contentWidth, height: contentHeight)
|
||||
|
||||
let cycleWidth = contentWidth + spacing
|
||||
|
||||
if contentWidth <= bounds.width {
|
||||
trackView.layer.removeAllAnimations()
|
||||
secondaryLabel.isHidden = true
|
||||
trackView.frame = CGRect(x: 0, y: 0, width: bounds.width, height: contentHeight)
|
||||
primaryLabel.frame = CGRect(x: 0, y: 0, width: bounds.width, height: contentHeight)
|
||||
primaryLabel.textAlignment = .left
|
||||
previousBoundsWidth = bounds.width
|
||||
previousContentWidth = contentWidth
|
||||
return
|
||||
}
|
||||
|
||||
primaryLabel.textAlignment = .left
|
||||
secondaryLabel.isHidden = false
|
||||
trackView.frame = CGRect(x: 0, y: 0, width: contentWidth * 2 + spacing, height: contentHeight)
|
||||
|
||||
let shouldRestart = abs(previousBoundsWidth - bounds.width) > 0.5
|
||||
|| abs(previousContentWidth - contentWidth) > 0.5
|
||||
|| trackView.layer.animation(forKey: "tickerMarquee") == nil
|
||||
|
||||
previousBoundsWidth = bounds.width
|
||||
previousContentWidth = contentWidth
|
||||
|
||||
guard shouldRestart else { return }
|
||||
|
||||
trackView.layer.removeAllAnimations()
|
||||
trackView.transform = .identity
|
||||
|
||||
let animation = CABasicAnimation(keyPath: "transform.translation.x")
|
||||
animation.fromValue = 0
|
||||
animation.toValue = -cycleWidth
|
||||
animation.duration = Double(cycleWidth / pointsPerSecond)
|
||||
animation.repeatCount = .infinity
|
||||
animation.timingFunction = CAMediaTimingFunction(name: .linear)
|
||||
animation.isRemovedOnCompletion = false
|
||||
|
||||
trackView.layer.add(animation, forKey: "tickerMarquee")
|
||||
}
|
||||
}
|
||||
38
mlbTVOS/Views/Components/TeamLogoView.swift
Normal file
38
mlbTVOS/Views/Components/TeamLogoView.swift
Normal file
@@ -0,0 +1,38 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TeamLogoView: View {
|
||||
let team: TeamInfo
|
||||
var size: CGFloat = 64
|
||||
|
||||
var body: some View {
|
||||
AsyncImage(url: team.logoURL) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
case .failure:
|
||||
fallbackLogo
|
||||
default:
|
||||
fallbackLogo
|
||||
.redacted(reason: .placeholder)
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.overlay(
|
||||
Circle()
|
||||
.strokeBorder(.white.opacity(0.1), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var fallbackLogo: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(TeamAssets.color(for: team.code).gradient)
|
||||
Text(team.code.prefix(3))
|
||||
.font(.system(size: size * 0.3, weight: .heavy, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
33
mlbTVOS/Views/ContentView.swift
Normal file
33
mlbTVOS/Views/ContentView.swift
Normal file
@@ -0,0 +1,33 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(GamesViewModel.self) private var viewModel
|
||||
|
||||
private var multiViewLabel: String {
|
||||
let count = viewModel.activeStreams.count
|
||||
if count > 0 {
|
||||
return "Multi-View (\(count))"
|
||||
}
|
||||
return "Multi-View"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
Tab("Games", systemImage: "sportscourt.fill") {
|
||||
DashboardView()
|
||||
}
|
||||
Tab("League", systemImage: "list.bullet.rectangle.portrait.fill") {
|
||||
LeagueCenterView()
|
||||
}
|
||||
Tab(multiViewLabel, systemImage: "rectangle.split.2x2.fill") {
|
||||
MultiStreamView()
|
||||
}
|
||||
Tab("Settings", systemImage: "gearshape.fill") {
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadGames()
|
||||
}
|
||||
}
|
||||
}
|
||||
340
mlbTVOS/Views/DashboardView.swift
Normal file
340
mlbTVOS/Views/DashboardView.swift
Normal file
@@ -0,0 +1,340 @@
|
||||
import SwiftUI
|
||||
import OSLog
|
||||
|
||||
private let dashboardLogger = Logger(subsystem: "com.treyt.mlbTVOS", category: "Dashboard")
|
||||
|
||||
private func logDashboard(_ message: String) {
|
||||
dashboardLogger.debug("\(message, privacy: .public)")
|
||||
print("[Dashboard] \(message)")
|
||||
}
|
||||
|
||||
struct DashboardView: View {
|
||||
@Environment(GamesViewModel.self) private var viewModel
|
||||
@State private var selectedGame: Game?
|
||||
@State private var fullScreenBroadcast: BroadcastSelection?
|
||||
@State private var pendingFullScreenBroadcast: BroadcastSelection?
|
||||
@State private var showMLBNetworkSheet = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 50) {
|
||||
headerSection
|
||||
|
||||
if viewModel.isLoading {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView("Loading games...")
|
||||
.font(.title3)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 80)
|
||||
} else if let error = viewModel.errorMessage, viewModel.games.isEmpty {
|
||||
HStack {
|
||||
Spacer()
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.system(size: 50))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(error)
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Retry") {
|
||||
Task { await viewModel.loadGames() }
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 80)
|
||||
} else {
|
||||
// Hero featured game
|
||||
if let featured = viewModel.featuredGame {
|
||||
FeaturedGameCard(game: featured) {
|
||||
selectedGame = featured
|
||||
}
|
||||
}
|
||||
|
||||
if !viewModel.liveGames.isEmpty {
|
||||
gameShelf(title: "Live", icon: "antenna.radiowaves.left.and.right", games: viewModel.liveGames, excludeId: viewModel.featuredGame?.id)
|
||||
}
|
||||
if !viewModel.scheduledGames.isEmpty {
|
||||
gameShelf(title: "Upcoming", icon: "calendar", games: viewModel.scheduledGames, excludeId: viewModel.featuredGame?.id)
|
||||
}
|
||||
if !viewModel.finalGames.isEmpty {
|
||||
gameShelf(title: "Final", icon: "checkmark.circle", games: viewModel.finalGames, excludeId: viewModel.featuredGame?.id)
|
||||
}
|
||||
}
|
||||
|
||||
mlbNetworkCard
|
||||
|
||||
if !viewModel.activeStreams.isEmpty {
|
||||
multiViewStatus
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 60)
|
||||
.padding(.vertical, 40)
|
||||
}
|
||||
.onAppear {
|
||||
logDashboard("DashboardView appeared")
|
||||
viewModel.startAutoRefresh()
|
||||
}
|
||||
.onDisappear {
|
||||
logDashboard("DashboardView disappeared")
|
||||
viewModel.stopAutoRefresh()
|
||||
}
|
||||
.sheet(item: $selectedGame, onDismiss: presentPendingFullScreenBroadcast) { game in
|
||||
StreamOptionsSheet(game: game) { broadcast in
|
||||
logDashboard("Queued fullscreen broadcast from game sheet broadcastId=\(broadcast.broadcast.id) gameId=\(broadcast.game.id)")
|
||||
pendingFullScreenBroadcast = broadcast
|
||||
selectedGame = nil
|
||||
}
|
||||
}
|
||||
.fullScreenCover(item: $fullScreenBroadcast) { selection in
|
||||
SingleStreamPlaybackScreen(
|
||||
resolveURL: {
|
||||
logDashboard("resolveURL closure invoked broadcastId=\(selection.broadcast.id) gameId=\(selection.game.id)")
|
||||
if selection.broadcast.id == "MLBN" {
|
||||
return await viewModel.buildEventStreamURL(event: "MLBN")
|
||||
}
|
||||
let s = ActiveStream(
|
||||
id: selection.broadcast.id,
|
||||
game: selection.game,
|
||||
label: selection.broadcast.displayLabel,
|
||||
mediaId: selection.broadcast.mediaId,
|
||||
streamURLString: selection.broadcast.streamURL
|
||||
)
|
||||
return await viewModel.resolveStreamURL(for: s)
|
||||
},
|
||||
tickerGames: viewModel.games
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
.onAppear {
|
||||
logDashboard("fullScreenCover content mounted broadcastId=\(selection.broadcast.id) gameId=\(selection.game.id)")
|
||||
}
|
||||
}
|
||||
.onChange(of: fullScreenBroadcast?.id) { _, newValue in
|
||||
logDashboard("fullScreenBroadcast changed newValue=\(newValue ?? "nil")")
|
||||
}
|
||||
.sheet(isPresented: $showMLBNetworkSheet, onDismiss: presentPendingFullScreenBroadcast) {
|
||||
MLBNetworkSheet(
|
||||
onWatchFullScreen: {
|
||||
logDashboard("Queued fullscreen broadcast from MLB Network sheet")
|
||||
showMLBNetworkSheet = false
|
||||
pendingFullScreenBroadcast = mlbNetworkBroadcastSelection
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentPendingFullScreenBroadcast() {
|
||||
guard selectedGame == nil, !showMLBNetworkSheet else {
|
||||
logDashboard("Skipped pending fullscreen presentation because another sheet is still active")
|
||||
return
|
||||
}
|
||||
|
||||
guard let pendingFullScreenBroadcast else { return }
|
||||
logDashboard(
|
||||
"Presenting pending fullscreen broadcast broadcastId=\(pendingFullScreenBroadcast.broadcast.id) gameId=\(pendingFullScreenBroadcast.game.id)"
|
||||
)
|
||||
self.pendingFullScreenBroadcast = nil
|
||||
DispatchQueue.main.async {
|
||||
fullScreenBroadcast = pendingFullScreenBroadcast
|
||||
}
|
||||
}
|
||||
|
||||
private var mlbNetworkBroadcastSelection: BroadcastSelection {
|
||||
let bc = Broadcast(
|
||||
id: "MLBN",
|
||||
teamCode: "MLBN",
|
||||
name: "MLB Network",
|
||||
mediaId: "",
|
||||
streamURL: ""
|
||||
)
|
||||
let game = Game(
|
||||
id: "MLBN",
|
||||
awayTeam: TeamInfo(code: "MLBN", name: "MLB Network", score: nil),
|
||||
homeTeam: TeamInfo(code: "MLBN", name: "MLB Network", score: nil),
|
||||
status: .live(nil), gameType: nil, startTime: nil, venue: nil,
|
||||
pitchers: nil, gamePk: nil, gameDate: "",
|
||||
broadcasts: [], isBlackedOut: false
|
||||
)
|
||||
return BroadcastSelection(broadcast: bc, game: game)
|
||||
}
|
||||
|
||||
// MARK: - Game Shelf (Horizontal)
|
||||
|
||||
@ViewBuilder
|
||||
private func gameShelf(title: String, icon: String, games: [Game], excludeId: String?) -> some View {
|
||||
let filtered = games.filter { $0.id != excludeId }
|
||||
if !filtered.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Label(title, systemImage: icon)
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
ScrollView(.horizontal) {
|
||||
LazyHStack(spacing: 30) {
|
||||
ForEach(filtered) { game in
|
||||
GameCardView(game: game) {
|
||||
selectedGame = game
|
||||
}
|
||||
.frame(width: 400)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
.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()
|
||||
|
||||
HStack(spacing: 16) {
|
||||
statPill("\(viewModel.games.count)", label: "Games")
|
||||
if !viewModel.liveGames.isEmpty {
|
||||
statPill("\(viewModel.liveGames.count)", label: "Live", color: .red)
|
||||
}
|
||||
if !viewModel.activeStreams.isEmpty {
|
||||
statPill("\(viewModel.activeStreams.count)/4", label: "Streams", color: .green)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 16) {
|
||||
Button {
|
||||
Task { await viewModel.goToPreviousDay() }
|
||||
} label: {
|
||||
Label("Previous Day", systemImage: "chevron.left")
|
||||
}
|
||||
|
||||
if !viewModel.isToday {
|
||||
Button {
|
||||
Task { await viewModel.goToToday() }
|
||||
} label: {
|
||||
Label("Today", systemImage: "calendar")
|
||||
}
|
||||
.tint(.blue)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await viewModel.goToNextDay() }
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Text("Next Day")
|
||||
Image(systemName: "chevron.right")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statPill(_ value: String, label: String, color: Color = .blue) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
Text(value)
|
||||
.font(.title3.weight(.bold).monospacedDigit())
|
||||
.foregroundStyle(color)
|
||||
Text(label)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
|
||||
// MARK: - MLB Network
|
||||
|
||||
@ViewBuilder
|
||||
private var mlbNetworkCard: some View {
|
||||
let added = viewModel.activeStreams.contains(where: { $0.id == "MLBN" })
|
||||
Button {
|
||||
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)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
}
|
||||
|
||||
// MARK: - Multi-View Status
|
||||
|
||||
@ViewBuilder
|
||||
private var multiViewStatus: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Label("Multi-View", systemImage: "rectangle.split.2x2")
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
ForEach(viewModel.activeStreams) { stream in
|
||||
HStack(spacing: 8) {
|
||||
Circle().fill(.green).frame(width: 8, height: 8)
|
||||
Text(stream.label)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct BroadcastSelection: Identifiable {
|
||||
let id: String
|
||||
let broadcast: Broadcast
|
||||
let game: Game
|
||||
|
||||
init(broadcast: Broadcast, game: Game) {
|
||||
self.id = broadcast.id
|
||||
self.broadcast = broadcast
|
||||
self.game = game
|
||||
}
|
||||
}
|
||||
584
mlbTVOS/Views/FeaturedGameCard.swift
Normal file
584
mlbTVOS/Views/FeaturedGameCard.swift
Normal file
@@ -0,0 +1,584 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FeaturedGameCard: View {
|
||||
let game: Game
|
||||
let onSelect: () -> Void
|
||||
|
||||
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 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 {
|
||||
Button(action: onSelect) {
|
||||
VStack(spacing: 0) {
|
||||
HStack(alignment: .top, spacing: 28) {
|
||||
matchupColumn
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
sidePanel
|
||||
.frame(width: 760, alignment: .leading)
|
||||
}
|
||||
.padding(.horizontal, 34)
|
||||
.padding(.top, 30)
|
||||
.padding(.bottom, 24)
|
||||
|
||||
footerBar
|
||||
.padding(.horizontal, 34)
|
||||
.padding(.vertical, 16)
|
||||
.background(.white.opacity(0.04))
|
||||
}
|
||||
.background(cardBackground)
|
||||
.overlay(alignment: .top) {
|
||||
HStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(awayColor.opacity(0.92))
|
||||
Rectangle()
|
||||
.fill(homeColor.opacity(0.92))
|
||||
}
|
||||
.frame(height: 5)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 28, style: .continuous)
|
||||
.strokeBorder(borderColor, lineWidth: borderWidth)
|
||||
)
|
||||
.shadow(color: shadowColor, radius: 24, y: 10)
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var matchupColumn: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
headerBar
|
||||
|
||||
featuredTeamRow(
|
||||
team: game.awayTeam,
|
||||
color: awayColor,
|
||||
pitcherURL: game.awayPitcherHeadshotURL,
|
||||
pitcherName: awayPitcherName
|
||||
)
|
||||
|
||||
scorePanel
|
||||
|
||||
featuredTeamRow(
|
||||
team: game.homeTeam,
|
||||
color: homeColor,
|
||||
pitcherURL: game.homePitcherHeadshotURL,
|
||||
pitcherName: homePitcherName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var headerBar: some View {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text((game.gameType ?? "Featured Game").uppercased())
|
||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
.kerning(1.8)
|
||||
|
||||
if let venue = game.venue {
|
||||
Label(venue, systemImage: "mappin.and.ellipse")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 10) {
|
||||
if game.hasStreams {
|
||||
headerChip(title: "WATCH", color: .blue)
|
||||
} else if game.isBlackedOut {
|
||||
headerChip(title: "BLACKOUT", color: .red)
|
||||
}
|
||||
|
||||
statusChip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func featuredTeamRow(team: TeamInfo, color: Color, pitcherURL: URL?, pitcherName: String?) -> some View {
|
||||
HStack(spacing: 18) {
|
||||
TeamLogoView(team: team, size: 82)
|
||||
.frame(width: 88, height: 88)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 12) {
|
||||
Text(team.code)
|
||||
.font(.system(size: 34, 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: 26, 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.58))
|
||||
.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))
|
||||
.textCase(.uppercase)
|
||||
|
||||
Text(pitcherName ?? "TBD")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.86))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 22)
|
||||
.padding(.vertical, 20)
|
||||
.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: 10) {
|
||||
if let summary = scoreSummaryText {
|
||||
Text(summary)
|
||||
.font(.system(size: 74, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.monospacedDigit()
|
||||
.contentTransition(.numericText())
|
||||
} else {
|
||||
Text(game.status.label)
|
||||
.font(.system(size: 54, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Text(stateHeadline)
|
||||
.font(.system(size: 22, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(stateHeadlineColor)
|
||||
|
||||
if let detail = stateDetailText {
|
||||
Text(detail)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
.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 sidePanel: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
HStack(spacing: 14) {
|
||||
detailTile(
|
||||
title: "Venue",
|
||||
value: game.venue ?? "TBD",
|
||||
icon: "mappin.and.ellipse",
|
||||
accent: nil
|
||||
)
|
||||
|
||||
detailTile(
|
||||
title: "Pitching",
|
||||
value: game.pitchers ?? "Pitchers TBD",
|
||||
icon: "baseball",
|
||||
accent: nil
|
||||
)
|
||||
|
||||
detailTile(
|
||||
title: "Coverage",
|
||||
value: coverageSummary,
|
||||
icon: coverageIconName,
|
||||
accent: coverageAccent
|
||||
)
|
||||
}
|
||||
|
||||
if hasLinescore,
|
||||
let linescore = game.linescore {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
Text("Linescore")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(linescoreLabel)
|
||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.56))
|
||||
}
|
||||
|
||||
LinescoreView(
|
||||
linescore: linescore,
|
||||
awayCode: game.awayTeam.code,
|
||||
homeCode: game.homeTeam.code
|
||||
)
|
||||
}
|
||||
.padding(20)
|
||||
.background(panelBackground)
|
||||
} else {
|
||||
fallbackInfoPanel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func detailTile(title: String, value: String, icon: String, accent: Color?) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Label(title, systemImage: icon)
|
||||
.font(.system(size: 12, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.52))
|
||||
|
||||
Text(value)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(accent ?? .white)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.82)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 102, alignment: .topLeading)
|
||||
.padding(18)
|
||||
.background(panelBackground)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var fallbackInfoPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(hasLinescore ? "Game Flow" : "Matchup Notes")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(fallbackSummary)
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.78))
|
||||
.lineLimit(3)
|
||||
|
||||
if let venue = game.venue {
|
||||
Text(venue)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.52))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(22)
|
||||
.background(panelBackground)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var footerBar: some View {
|
||||
HStack(spacing: 14) {
|
||||
Label(footerSummary, systemImage: footerIconName)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.72))
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: game.hasStreams ? "play.fill" : "rectangle.and.text.magnifyingglass")
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
Text(game.hasStreams ? "Watch Game" : "Open Matchup")
|
||||
.font(.system(size: 15, weight: .bold))
|
||||
}
|
||||
.foregroundStyle(game.hasStreams ? .blue : .white.opacity(0.82))
|
||||
}
|
||||
}
|
||||
|
||||
@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.18))
|
||||
.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.opacity(0.96))
|
||||
.padding(.horizontal, 15)
|
||||
.padding(.vertical, 10)
|
||||
.background(.white.opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
|
||||
case .unknown:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func headerChip(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 stateHeadline: 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 stateHeadlineColor: 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 stateDetailText: String? {
|
||||
if game.status.isScheduled {
|
||||
return game.pitchers ?? game.venue
|
||||
}
|
||||
return game.venue
|
||||
}
|
||||
|
||||
private var coverageSummary: String {
|
||||
if game.isBlackedOut {
|
||||
return "Unavailable in your area"
|
||||
}
|
||||
if !game.broadcasts.isEmpty {
|
||||
let names = game.broadcasts.map(\.displayLabel)
|
||||
return names.joined(separator: " / ")
|
||||
}
|
||||
if game.status.isScheduled {
|
||||
return "Feeds closer to game time"
|
||||
}
|
||||
return "No feeds listed"
|
||||
}
|
||||
|
||||
private var coverageIconName: String {
|
||||
if game.isBlackedOut { return "eye.slash.fill" }
|
||||
if !game.broadcasts.isEmpty { return "tv.fill" }
|
||||
return "dot.radiowaves.left.and.right"
|
||||
}
|
||||
|
||||
private var coverageAccent: Color {
|
||||
if game.isBlackedOut { return .red }
|
||||
if !game.broadcasts.isEmpty { return .blue }
|
||||
return .white
|
||||
}
|
||||
|
||||
private var fallbackSummary: String {
|
||||
if let pitchers = game.pitchers, !pitchers.isEmpty {
|
||||
return pitchers
|
||||
}
|
||||
if game.isBlackedOut {
|
||||
return "This game is blacked out in your area."
|
||||
}
|
||||
if !game.broadcasts.isEmpty {
|
||||
return game.broadcasts.map(\.displayLabel).joined(separator: " / ")
|
||||
}
|
||||
return "Select the matchup to view streams and full game details."
|
||||
}
|
||||
|
||||
private var footerSummary: String {
|
||||
if game.isBlackedOut {
|
||||
return "Blackout restrictions apply"
|
||||
}
|
||||
if !game.broadcasts.isEmpty {
|
||||
let feeds = game.broadcasts.map(\.teamCode).joined(separator: " / ")
|
||||
return "Coverage: \(feeds)"
|
||||
}
|
||||
return game.venue ?? "Game details"
|
||||
}
|
||||
|
||||
private var footerIconName: String {
|
||||
if game.isBlackedOut { return "eye.slash.fill" }
|
||||
if !game.broadcasts.isEmpty { return "tv.fill" }
|
||||
return "sportscourt.fill"
|
||||
}
|
||||
|
||||
private var linescoreLabel: 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 cardBackground: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 28, style: .continuous)
|
||||
.fill(Color(red: 0.05, green: 0.07, blue: 0.11))
|
||||
|
||||
LinearGradient(
|
||||
colors: [
|
||||
awayColor.opacity(0.2),
|
||||
Color(red: 0.05, green: 0.07, blue: 0.11),
|
||||
homeColor.opacity(0.22)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
|
||||
Circle()
|
||||
.fill(awayColor.opacity(0.18))
|
||||
.frame(width: 320, height: 320)
|
||||
.blur(radius: 64)
|
||||
.offset(x: -280, y: -70)
|
||||
|
||||
Circle()
|
||||
.fill(homeColor.opacity(0.18))
|
||||
.frame(width: 360, height: 360)
|
||||
.blur(radius: 72)
|
||||
.offset(x: 320, y: 40)
|
||||
|
||||
Rectangle()
|
||||
.fill(.white.opacity(0.03))
|
||||
.frame(width: 1)
|
||||
.padding(.vertical, 28)
|
||||
.offset(x: 86)
|
||||
}
|
||||
}
|
||||
|
||||
private var borderColor: Color {
|
||||
if game.isLive {
|
||||
return .red.opacity(0.32)
|
||||
}
|
||||
if game.hasStreams {
|
||||
return .blue.opacity(0.24)
|
||||
}
|
||||
return .white.opacity(0.08)
|
||||
}
|
||||
|
||||
private var borderWidth: CGFloat {
|
||||
game.isLive || game.hasStreams ? 2 : 1
|
||||
}
|
||||
|
||||
private var shadowColor: Color {
|
||||
if game.isLive {
|
||||
return .red.opacity(0.18)
|
||||
}
|
||||
return .black.opacity(0.26)
|
||||
}
|
||||
}
|
||||
302
mlbTVOS/Views/GameCardView.swift
Normal file
302
mlbTVOS/Views/GameCardView.swift
Normal file
@@ -0,0 +1,302 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GameCardView: View {
|
||||
let game: Game
|
||||
let onSelect: () -> Void
|
||||
@Environment(GamesViewModel.self) private var viewModel
|
||||
|
||||
private var inMultiView: Bool {
|
||||
game.broadcasts.contains(where: { bc in
|
||||
viewModel.activeStreams.contains(where: { $0.id == bc.id })
|
||||
})
|
||||
}
|
||||
|
||||
private var awayColor: Color { TeamAssets.color(for: game.awayTeam.code) }
|
||||
private var homeColor: Color { TeamAssets.color(for: game.homeTeam.code) }
|
||||
|
||||
var body: some View {
|
||||
Button(action: onSelect) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
.padding(.horizontal, 22)
|
||||
.padding(.top, 18)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
teamRow(team: game.awayTeam, isWinning: isWinning(away: true))
|
||||
teamRow(team: game.homeTeam, isWinning: isWinning(away: false))
|
||||
}
|
||||
.padding(.horizontal, 22)
|
||||
|
||||
Spacer(minLength: 14)
|
||||
|
||||
footer
|
||||
.padding(.horizontal, 22)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 320, maxHeight: 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
|
||||
)
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
}
|
||||
|
||||
@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 func teamRow(team: TeamInfo, isWinning: Bool) -> some View {
|
||||
HStack(spacing: 14) {
|
||||
TeamLogoView(team: team, size: 46)
|
||||
.frame(width: 50, height: 50)
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(spacing: 10) {
|
||||
Text(team.code)
|
||||
.font(.system(size: 28, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
if let record = team.record {
|
||||
Text(record)
|
||||
.font(.system(size: 12, weight: .bold, design: .monospaced))
|
||||
.foregroundStyle(.white.opacity(0.72))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(.white.opacity(isWinning ? 0.12 : 0.07))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
Text(team.displayName)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(isWinning ? 0.88 : 0.68))
|
||||
.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 {
|
||||
guard let a = game.awayTeam.score, let h = game.homeTeam.score else { return false }
|
||||
return away ? a > h : h > a
|
||||
}
|
||||
|
||||
private var subtitleText: String {
|
||||
if game.status.isScheduled {
|
||||
return game.pitchers ?? game.venue ?? "Upcoming"
|
||||
}
|
||||
if game.isBlackedOut {
|
||||
return "Regional blackout"
|
||||
}
|
||||
if !game.broadcasts.isEmpty {
|
||||
let count = game.broadcasts.count
|
||||
return "\(count) feed\(count == 1 ? "" : "s") available"
|
||||
}
|
||||
return game.venue ?? "No feeds listed yet"
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var compactStatus: some View {
|
||||
switch game.status {
|
||||
case .live(let inning):
|
||||
HStack(spacing: 7) {
|
||||
Circle()
|
||||
.fill(.red)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(inning ?? "LIVE")
|
||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.red.opacity(0.18))
|
||||
.clipShape(Capsule())
|
||||
|
||||
case .scheduled(let time):
|
||||
Text(time)
|
||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.white.opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
|
||||
case .final_:
|
||||
Text("FINAL")
|
||||
.font(.system(size: 13, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.92))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.white.opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
|
||||
case .unknown:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
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 {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.fill(Color(red: 0.08, green: 0.09, blue: 0.12))
|
||||
|
||||
LinearGradient(
|
||||
colors: [
|
||||
awayColor.opacity(0.18),
|
||||
Color.clear,
|
||||
homeColor.opacity(0.18)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
|
||||
Circle()
|
||||
.fill(awayColor.opacity(0.18))
|
||||
.frame(width: 180)
|
||||
.blur(radius: 40)
|
||||
.offset(x: -110, y: -90)
|
||||
|
||||
Circle()
|
||||
.fill(homeColor.opacity(0.16))
|
||||
.frame(width: 200)
|
||||
.blur(radius: 44)
|
||||
.offset(x: 140, y: 120)
|
||||
}
|
||||
}
|
||||
|
||||
private var shadowColor: Color {
|
||||
if inMultiView { return .green.opacity(0.18) }
|
||||
if game.isLive { return .red.opacity(0.22) }
|
||||
return .black.opacity(0.22)
|
||||
}
|
||||
}
|
||||
375
mlbTVOS/Views/GameCenterView.swift
Normal file
375
mlbTVOS/Views/GameCenterView.swift
Normal file
@@ -0,0 +1,375 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GameCenterView: View {
|
||||
let game: Game
|
||||
|
||||
@State private var viewModel = GameCenterViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
header
|
||||
|
||||
if viewModel.isLoading && viewModel.feed == nil {
|
||||
loadingState
|
||||
} else if let feed = viewModel.feed {
|
||||
situationStrip(feed: feed)
|
||||
matchupPanel(feed: feed)
|
||||
|
||||
if !feed.decisionsSummary.isEmpty || !feed.officialSummary.isEmpty || feed.weatherSummary != nil {
|
||||
contextPanel(feed: feed)
|
||||
}
|
||||
|
||||
if !feed.awayLineup.isEmpty || !feed.homeLineup.isEmpty {
|
||||
lineupPanel(feed: feed)
|
||||
}
|
||||
|
||||
if !feed.scoringPlays.isEmpty {
|
||||
timelineSection(
|
||||
title: "Scoring Plays",
|
||||
subtitle: "Every run-scoring swing, plate appearance, and sequence.",
|
||||
plays: Array(feed.scoringPlays.suffix(6).reversed())
|
||||
)
|
||||
}
|
||||
|
||||
timelineSection(
|
||||
title: "Recent Plays",
|
||||
subtitle: "The latest plate appearances and game-state changes.",
|
||||
plays: Array(feed.recentPlays.prefix(8))
|
||||
)
|
||||
} else {
|
||||
errorState
|
||||
}
|
||||
}
|
||||
.task(id: game.id) {
|
||||
await viewModel.watch(game: game)
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Game Center")
|
||||
.font(.system(size: 28, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text("Live matchup context, lineup state, and inning-by-inning events from MLB's live feed.")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.58))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
if let gamePk = game.gamePk {
|
||||
Task { await viewModel.refresh(gamePk: gamePk) }
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
Text(refreshLabel)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(.white.opacity(0.82))
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(.white.opacity(0.08))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
.disabled(game.gamePk == nil)
|
||||
}
|
||||
.padding(22)
|
||||
.background(panelBackground)
|
||||
}
|
||||
|
||||
private func situationStrip(feed: LiveGameFeed) -> some View {
|
||||
HStack(spacing: 14) {
|
||||
situationTile(
|
||||
title: "Situation",
|
||||
value: feed.liveData.linescore?.inningDisplay ?? game.currentInningDisplay ?? game.status.label,
|
||||
accent: .red
|
||||
)
|
||||
|
||||
situationTile(
|
||||
title: "Count",
|
||||
value: feed.currentCountText ?? "No active count",
|
||||
accent: .blue
|
||||
)
|
||||
|
||||
situationTile(
|
||||
title: "Bases",
|
||||
value: feed.occupiedBases.isEmpty ? "Bases Empty" : "On \(feed.occupiedBases.joined(separator: ", "))",
|
||||
accent: .green
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func situationTile(title: String, value: String, accent: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title.uppercased())
|
||||
.font(.system(size: 12, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(accent.opacity(0.9))
|
||||
.kerning(1.2)
|
||||
|
||||
Text(value)
|
||||
.font(.system(size: 19, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.82)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(18)
|
||||
.background(panelBackground)
|
||||
}
|
||||
|
||||
private func matchupPanel(feed: LiveGameFeed) -> some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack(alignment: .top, spacing: 18) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("At Bat")
|
||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
|
||||
Text(feed.currentBatter?.displayName ?? "Awaiting matchup")
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
if let onDeck = feed.onDeckBatter?.displayName {
|
||||
Text("On deck: \(onDeck)")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
}
|
||||
|
||||
if let inHole = feed.inHoleBatter?.displayName {
|
||||
Text("In hole: \(inHole)")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.46))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 5) {
|
||||
Text("Pitching")
|
||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
|
||||
Text(feed.currentPitcher?.displayName ?? "Awaiting pitcher")
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.multilineTextAlignment(.trailing)
|
||||
|
||||
if let play = feed.currentPlay {
|
||||
Text(play.summaryText)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.58))
|
||||
.frame(maxWidth: 420, alignment: .trailing)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(22)
|
||||
.background(panelBackground)
|
||||
}
|
||||
|
||||
private func contextPanel(feed: LiveGameFeed) -> some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if let weatherSummary = feed.weatherSummary {
|
||||
contextRow(title: "Weather", values: [weatherSummary], accent: .blue)
|
||||
}
|
||||
|
||||
if !feed.decisionsSummary.isEmpty {
|
||||
contextRow(title: "Decisions", values: feed.decisionsSummary, accent: .green)
|
||||
}
|
||||
|
||||
if !feed.officialSummary.isEmpty {
|
||||
contextRow(title: "Officials", values: Array(feed.officialSummary.prefix(4)), accent: .orange)
|
||||
}
|
||||
}
|
||||
.padding(22)
|
||||
.background(panelBackground)
|
||||
}
|
||||
|
||||
private func contextRow(title: String, values: [String], accent: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(title.uppercased())
|
||||
.font(.system(size: 12, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(accent.opacity(0.9))
|
||||
.kerning(1.2)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
ForEach(values, id: \.self) { value in
|
||||
Text(value)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.82))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 9)
|
||||
.background(.white.opacity(0.08))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func lineupPanel(feed: LiveGameFeed) -> some View {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
lineupColumn(
|
||||
title: game.awayTeam.code,
|
||||
teamName: game.awayTeam.displayName,
|
||||
color: TeamAssets.color(for: game.awayTeam.code),
|
||||
players: Array(feed.awayLineup.prefix(9))
|
||||
)
|
||||
|
||||
lineupColumn(
|
||||
title: game.homeTeam.code,
|
||||
teamName: game.homeTeam.displayName,
|
||||
color: TeamAssets.color(for: game.homeTeam.code),
|
||||
players: Array(feed.homeLineup.prefix(9))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func lineupColumn(title: String, teamName: String, color: Color, players: [LiveFeedBoxscorePlayer]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 10, height: 10)
|
||||
|
||||
Text(title)
|
||||
.font(.system(size: 18, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(teamName)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(Array(players.enumerated()), id: \.element.id) { index, player in
|
||||
HStack(spacing: 10) {
|
||||
Text("\(index + 1)")
|
||||
.font(.system(size: 12, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
.frame(width: 18, alignment: .leading)
|
||||
|
||||
Text(player.person.displayName)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.88))
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(player.position?.abbreviation ?? "")
|
||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.45))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(20)
|
||||
.background(panelBackground)
|
||||
}
|
||||
|
||||
private func timelineSection(title: String, subtitle: String, plays: [LiveFeedPlay]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(subtitle)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
}
|
||||
|
||||
VStack(spacing: 12) {
|
||||
ForEach(plays) { play in
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(play.about?.halfInning?.uppercased() ?? "PLAY")
|
||||
.font(.system(size: 11, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.4))
|
||||
|
||||
if let inning = play.about?.inning {
|
||||
Text("\(inning)")
|
||||
.font(.system(size: 16, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
.frame(width: 54, alignment: .leading)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(play.summaryText)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
if let event = play.result?.event {
|
||||
Text(event)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(16)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(.white.opacity(0.05))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(22)
|
||||
.background(panelBackground)
|
||||
}
|
||||
|
||||
private var loadingState: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView("Loading game center...")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 28)
|
||||
.background(panelBackground)
|
||||
}
|
||||
|
||||
private var errorState: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Game center is unavailable.")
|
||||
.font(.system(size: 20, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(viewModel.errorMessage ?? "No live feed data was returned for this game.")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
}
|
||||
.padding(22)
|
||||
.background(panelBackground)
|
||||
}
|
||||
|
||||
private var refreshLabel: String {
|
||||
if let lastUpdated = viewModel.lastUpdated {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "h:mm:ss a"
|
||||
return formatter.string(from: lastUpdated)
|
||||
}
|
||||
return "Refresh"
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
635
mlbTVOS/Views/GameListView.swift
Normal file
635
mlbTVOS/Views/GameListView.swift
Normal file
@@ -0,0 +1,635 @@
|
||||
import SwiftUI
|
||||
|
||||
struct StreamOptionsSheet: View {
|
||||
let game: Game
|
||||
var onWatch: ((BroadcastSelection) -> Void)?
|
||||
@Environment(GamesViewModel.self) private var viewModel
|
||||
@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 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) {
|
||||
HStack(alignment: .top, spacing: 28) {
|
||||
matchupColumn
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
actionRail
|
||||
.frame(width: 520, 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)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 56)
|
||||
.padding(.vertical, 42)
|
||||
}
|
||||
.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)
|
||||
}
|
||||
|
||||
GameCenterView(game: game)
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
665
mlbTVOS/Views/LeagueCenterView.swift
Normal file
665
mlbTVOS/Views/LeagueCenterView.swift
Normal file
@@ -0,0 +1,665 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LeagueCenterView: View {
|
||||
@Environment(GamesViewModel.self) private var gamesViewModel
|
||||
@State private var viewModel = LeagueCenterViewModel()
|
||||
@State private var selectedGame: Game?
|
||||
|
||||
private let rosterColumns = [
|
||||
GridItem(.flexible(), spacing: 14),
|
||||
GridItem(.flexible(), spacing: 14),
|
||||
GridItem(.flexible(), spacing: 14),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 34) {
|
||||
header
|
||||
|
||||
if let overviewErrorMessage = viewModel.overviewErrorMessage {
|
||||
messagePanel(overviewErrorMessage, tint: .orange)
|
||||
}
|
||||
|
||||
scheduleSection
|
||||
standingsSection
|
||||
teamsSection
|
||||
|
||||
if let selectedTeam = viewModel.selectedTeam {
|
||||
teamProfileSection(team: selectedTeam)
|
||||
} else if let teamErrorMessage = viewModel.teamErrorMessage {
|
||||
messagePanel(teamErrorMessage, tint: .orange)
|
||||
}
|
||||
|
||||
if !viewModel.roster.isEmpty {
|
||||
rosterSection
|
||||
}
|
||||
|
||||
if let player = viewModel.selectedPlayer {
|
||||
playerSection(player: player)
|
||||
} else if let playerErrorMessage = viewModel.playerErrorMessage {
|
||||
messagePanel(playerErrorMessage, tint: .orange)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 56)
|
||||
.padding(.vertical, 40)
|
||||
}
|
||||
.background(screenBackground.ignoresSafeArea())
|
||||
.task {
|
||||
await viewModel.loadInitial()
|
||||
}
|
||||
.sheet(item: $selectedGame) { game in
|
||||
StreamOptionsSheet(game: game)
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .top, spacing: 24) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Around MLB")
|
||||
.font(.system(size: 42, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text("Schedules, standings, team context, roster access, and player snapshots in one control room.")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.58))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 12) {
|
||||
infoPill(title: "\(viewModel.scheduleGames.count)", label: "Games", color: .blue)
|
||||
infoPill(title: "\(viewModel.teams.count)", label: "Teams", color: .green)
|
||||
infoPill(title: "\(viewModel.standings.count)", label: "Divisions", color: .orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var scheduleSection: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Schedule")
|
||||
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(viewModel.displayDateString)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 12) {
|
||||
dateButton(title: "Previous", systemImage: "chevron.left") {
|
||||
Task { await viewModel.goToPreviousDay() }
|
||||
}
|
||||
|
||||
dateButton(title: "Today", systemImage: "calendar") {
|
||||
Task { await viewModel.goToToday() }
|
||||
}
|
||||
|
||||
dateButton(title: "Next", systemImage: "chevron.right") {
|
||||
Task { await viewModel.goToNextDay() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.scheduleGames.isEmpty && viewModel.isLoadingOverview {
|
||||
loadingPanel(title: "Loading schedule...")
|
||||
} else {
|
||||
VStack(spacing: 14) {
|
||||
ForEach(viewModel.scheduleGames.prefix(10)) { game in
|
||||
scheduleRow(game)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleRow(_ game: StatsGame) -> some View {
|
||||
let linkedGame = gamesViewModel.games.first { $0.gamePk == String(game.gamePk) }
|
||||
|
||||
return Button {
|
||||
if let linkedGame {
|
||||
selectedGame = linkedGame
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 18) {
|
||||
teamMiniColumn(team: game.teams.away)
|
||||
VStack(spacing: 6) {
|
||||
Text(scoreText(for: game))
|
||||
.font(.system(size: 28, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.monospacedDigit()
|
||||
|
||||
Text(statusText(for: game))
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(statusColor(for: game))
|
||||
}
|
||||
.frame(width: 160)
|
||||
|
||||
teamMiniColumn(team: game.teams.home, alignTrailing: true)
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 6) {
|
||||
if let venue = game.venue?.name {
|
||||
Text(venue)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.56))
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Text(linkedGame != nil ? "Open game sheet" : "Info only")
|
||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(linkedGame != nil ? .blue.opacity(0.95) : .white.opacity(0.34))
|
||||
}
|
||||
}
|
||||
.padding(22)
|
||||
.background(sectionPanel)
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
.disabled(linkedGame == nil)
|
||||
}
|
||||
|
||||
private func teamMiniColumn(team: StatsTeamGameInfo, alignTrailing: Bool = false) -> some View {
|
||||
let info = TeamInfo(
|
||||
code: team.team.abbreviation ?? "MLB",
|
||||
name: team.team.name ?? "MLB",
|
||||
score: team.score,
|
||||
teamId: team.team.id,
|
||||
record: team.leagueRecord.map { "\($0.wins)-\($0.losses)" }
|
||||
)
|
||||
|
||||
return HStack(spacing: 12) {
|
||||
if !alignTrailing {
|
||||
TeamLogoView(team: info, size: 56)
|
||||
}
|
||||
|
||||
VStack(alignment: alignTrailing ? .trailing : .leading, spacing: 6) {
|
||||
Text(info.code)
|
||||
.font(.system(size: 22, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(info.displayName)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.lineLimit(1)
|
||||
|
||||
if let record = info.record {
|
||||
Text(record)
|
||||
.font(.system(size: 13, weight: .bold, design: .monospaced))
|
||||
.foregroundStyle(.white.opacity(0.46))
|
||||
}
|
||||
}
|
||||
|
||||
if alignTrailing {
|
||||
TeamLogoView(team: info, size: 56)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: alignTrailing ? .trailing : .leading)
|
||||
}
|
||||
|
||||
private var standingsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text("Standings")
|
||||
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
if viewModel.standings.isEmpty && viewModel.isLoadingOverview {
|
||||
loadingPanel(title: "Loading standings...")
|
||||
} else {
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: 18) {
|
||||
ForEach(viewModel.standings, id: \.division?.id) { record in
|
||||
standingsCard(record)
|
||||
.frame(width: 360)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.focusSection()
|
||||
.scrollClipDisabled()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func standingsCard(_ record: StandingsDivisionRecord) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text(record.division?.name ?? "Division")
|
||||
.font(.system(size: 22, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
VStack(spacing: 10) {
|
||||
ForEach(record.teamRecords.sorted(by: standingsSort), id: \.team.id) { team in
|
||||
HStack(spacing: 10) {
|
||||
Text(team.divisionRank ?? "-")
|
||||
.font(.system(size: 12, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.52))
|
||||
.frame(width: 22)
|
||||
|
||||
Text(team.team.abbreviation ?? team.team.name ?? "MLB")
|
||||
.font(.system(size: 16, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let wins = team.wins, let losses = team.losses {
|
||||
Text("\(wins)-\(losses)")
|
||||
.font(.system(size: 15, weight: .bold, design: .monospaced))
|
||||
.foregroundStyle(.white.opacity(0.86))
|
||||
}
|
||||
|
||||
Text(team.gamesBack ?? "-")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.45))
|
||||
.frame(width: 44, alignment: .trailing)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(22)
|
||||
.background(sectionPanel)
|
||||
}
|
||||
|
||||
private var teamsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text("Teams")
|
||||
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: 16) {
|
||||
ForEach(viewModel.teams) { team in
|
||||
Button {
|
||||
Task { await viewModel.selectTeam(team.id) }
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
TeamLogoView(team: team.teamInfo, size: 72)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(team.abbreviation)
|
||||
.font(.system(size: 20, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(team.name)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.76))
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(team.recordText ?? "Season")
|
||||
.font(.system(size: 13, weight: .bold, design: .monospaced))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
.frame(width: 210, height: 220, alignment: .leading)
|
||||
.padding(18)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.fill(TeamAssets.color(for: team.abbreviation).opacity(0.16))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.stroke(.white.opacity(0.08), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.focusSection()
|
||||
.scrollClipDisabled()
|
||||
}
|
||||
}
|
||||
|
||||
private func teamProfileSection(team: TeamProfile) -> some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text("Team Profile")
|
||||
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
if viewModel.isLoadingTeam {
|
||||
loadingPanel(title: "Loading team profile...")
|
||||
} else {
|
||||
HStack(alignment: .top, spacing: 24) {
|
||||
TeamLogoView(team: team.teamInfo, size: 96)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(team.name)
|
||||
.font(.system(size: 34, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
detailChip(team.recordText ?? "Season", color: .blue)
|
||||
if let gamesBack = team.gamesBackText {
|
||||
detailChip(gamesBack, color: .orange)
|
||||
}
|
||||
if let division = team.divisionName {
|
||||
detailChip(division, color: .green)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
profileLine(label: "Venue", value: team.venueName)
|
||||
profileLine(label: "League", value: team.leagueName)
|
||||
profileLine(label: "Franchise", value: team.franchiseName)
|
||||
profileLine(label: "First Year", value: team.firstYearOfPlay)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(24)
|
||||
.background(sectionPanel)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
.focusable(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var rosterSection: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text("Roster")
|
||||
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
LazyVGrid(columns: rosterColumns, spacing: 14) {
|
||||
ForEach(viewModel.roster) { player in
|
||||
Button {
|
||||
Task { await viewModel.selectPlayer(player.id) }
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
playerHeadshot(url: player.headshotURL, size: 58)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(player.fullName)
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(2)
|
||||
|
||||
Text("\(player.positionAbbreviation ?? "P") · #\(player.jerseyNumber ?? "--")")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(16)
|
||||
.background(sectionPanel)
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func playerSection(player: PlayerProfile) -> some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text("Player Profile")
|
||||
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
if viewModel.isLoadingPlayer {
|
||||
loadingPanel(title: "Loading player profile...")
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
HStack(alignment: .top, spacing: 24) {
|
||||
playerHeadshot(url: player.headshotURL, size: 112)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
if let primaryNumber = player.primaryNumber {
|
||||
Text("#\(primaryNumber)")
|
||||
.font(.system(size: 15, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
}
|
||||
|
||||
if let position = player.primaryPosition {
|
||||
detailChip(position, color: .blue)
|
||||
}
|
||||
}
|
||||
|
||||
Text(player.fullName)
|
||||
.font(.system(size: 34, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
profileLine(label: "Age", value: player.currentAge.map(String.init))
|
||||
profileLine(label: "Bats / Throws", value: [player.bats, player.throwsHand].compactMap { $0 }.joined(separator: " / "))
|
||||
profileLine(label: "Height / Weight", value: [player.height, player.weight.map { "\($0) lbs" }].compactMap { $0 }.joined(separator: " / "))
|
||||
profileLine(label: "Born", value: player.birthPlace)
|
||||
profileLine(label: "Debut", value: player.debutDate)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if !player.statGroups.isEmpty {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
ForEach(player.statGroups) { group in
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("\(group.title) \(player.seasonLabel)")
|
||||
.font(.system(size: 18, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(group.items, id: \.label) { item in
|
||||
HStack {
|
||||
Text(item.label)
|
||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(item.value)
|
||||
.font(.system(size: 18, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(18)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(.white.opacity(0.05))
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("No regular-season MLB stats available for \(player.seasonLabel).")
|
||||
.font(.system(size: 18, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.background(sectionPanel)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
.focusable(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func playerHeadshot(url: URL, size: CGFloat) -> some View {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
default:
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(.white.opacity(0.08))
|
||||
Image(systemName: "person.fill")
|
||||
.font(.system(size: size * 0.34, weight: .bold))
|
||||
.foregroundStyle(.white.opacity(0.32))
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(Circle())
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(.white.opacity(0.08), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private func profileLine(label: String, value: String?) -> some View {
|
||||
let displayValue: String
|
||||
if let value, !value.isEmpty {
|
||||
displayValue = value
|
||||
} else {
|
||||
displayValue = "Unavailable"
|
||||
}
|
||||
|
||||
return HStack(spacing: 10) {
|
||||
Text(label)
|
||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.45))
|
||||
.frame(width: 92, alignment: .leading)
|
||||
|
||||
Text(displayValue)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.82))
|
||||
}
|
||||
}
|
||||
|
||||
private func messagePanel(_ text: String, tint: Color) -> some View {
|
||||
Text(text)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(tint.opacity(0.95))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(18)
|
||||
.background(sectionPanel)
|
||||
}
|
||||
|
||||
private func detailChip(_ title: String, color: Color) -> some View {
|
||||
Text(title)
|
||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(color.opacity(0.95))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(color.opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
private func infoPill(title: String, label: String, color: Color) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
Text(title)
|
||||
.font(.system(size: 22, weight: .black, design: .rounded))
|
||||
.foregroundStyle(color)
|
||||
.monospacedDigit()
|
||||
Text(label)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 12)
|
||||
.background(sectionPanel)
|
||||
}
|
||||
|
||||
private func dateButton(title: String, systemImage: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
Text(title)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(.white.opacity(0.84))
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(.white.opacity(0.08))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
}
|
||||
|
||||
private func loadingPanel(title: String) -> some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView(title)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.75))
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 34)
|
||||
.background(sectionPanel)
|
||||
}
|
||||
|
||||
private func scoreText(for game: StatsGame) -> String {
|
||||
if game.isScheduled {
|
||||
return game.startTime ?? "TBD"
|
||||
}
|
||||
let away = game.teams.away.score ?? 0
|
||||
let home = game.teams.home.score ?? 0
|
||||
return "\(away) - \(home)"
|
||||
}
|
||||
|
||||
private func statusText(for game: StatsGame) -> String {
|
||||
if game.isLive {
|
||||
return game.linescore?.currentInningDisplay ?? "Live"
|
||||
}
|
||||
if game.isFinal {
|
||||
return "Final"
|
||||
}
|
||||
return game.status.detailedState ?? "Scheduled"
|
||||
}
|
||||
|
||||
private func statusColor(for game: StatsGame) -> Color {
|
||||
if game.isLive {
|
||||
return .red.opacity(0.9)
|
||||
}
|
||||
if game.isFinal {
|
||||
return .white.opacity(0.72)
|
||||
}
|
||||
return .blue.opacity(0.9)
|
||||
}
|
||||
|
||||
private func standingsSort(lhs: StandingsTeamRecord, rhs: StandingsTeamRecord) -> Bool {
|
||||
Int(lhs.divisionRank ?? "99") ?? 99 < Int(rhs.divisionRank ?? "99") ?? 99
|
||||
}
|
||||
|
||||
private var sectionPanel: some View {
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.fill(.black.opacity(0.22))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
|
||||
private var screenBackground: some View {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.04, green: 0.05, blue: 0.09),
|
||||
Color(red: 0.03, green: 0.06, blue: 0.1),
|
||||
Color(red: 0.02, green: 0.03, blue: 0.06),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
|
||||
Circle()
|
||||
.fill(.blue.opacity(0.18))
|
||||
.frame(width: 520, height: 520)
|
||||
.blur(radius: 110)
|
||||
.offset(x: -360, y: -260)
|
||||
|
||||
Circle()
|
||||
.fill(.orange.opacity(0.16))
|
||||
.frame(width: 560, height: 560)
|
||||
.blur(radius: 120)
|
||||
.offset(x: 420, y: -80)
|
||||
}
|
||||
}
|
||||
}
|
||||
294
mlbTVOS/Views/MLBNetworkSheet.swift
Normal file
294
mlbTVOS/Views/MLBNetworkSheet.swift
Normal file
@@ -0,0 +1,294 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MLBNetworkSheet: View {
|
||||
var onWatchFullScreen: () -> Void
|
||||
@Environment(GamesViewModel.self) private var viewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private var added: Bool {
|
||||
viewModel.activeStreams.contains(where: { $0.id == "MLBN" })
|
||||
}
|
||||
|
||||
private var canToggleIntoMultiview: Bool {
|
||||
added || viewModel.activeStreams.count < 4
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
sheetBackground
|
||||
.ignoresSafeArea()
|
||||
|
||||
HStack(alignment: .top, spacing: 32) {
|
||||
networkOverview
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
actionColumn
|
||||
.frame(width: 360, alignment: .leading)
|
||||
}
|
||||
.padding(38)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 34, style: .continuous)
|
||||
.fill(.black.opacity(0.46))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 34, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
||||
}
|
||||
)
|
||||
.padding(.horizontal, 94)
|
||||
.padding(.vertical, 70)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var networkOverview: some View {
|
||||
VStack(alignment: .leading, spacing: 28) {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
HStack(spacing: 12) {
|
||||
statusPill(title: "LIVE CHANNEL", color: .blue)
|
||||
|
||||
if added {
|
||||
statusPill(title: "IN MULTI-VIEW", color: .green)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(alignment: .center, spacing: 22) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||
.fill(.blue.opacity(0.16))
|
||||
.frame(width: 110, height: 110)
|
||||
|
||||
Image(systemName: "tv.fill")
|
||||
.font(.system(size: 46, weight: .bold))
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [.white, .blue.opacity(0.88)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("MLB Network")
|
||||
.font(.system(size: 46, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text("Live coverage, studio hits, whip-around looks, analysis, and highlights all day.")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.72))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 14) {
|
||||
featurePill(title: "Live Look-Ins", systemImage: "dot.radiowaves.left.and.right")
|
||||
featurePill(title: "Studio Analysis", systemImage: "waveform.path.ecg.rectangle")
|
||||
featurePill(title: "Highlights", systemImage: "sparkles.tv")
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Best For")
|
||||
.font(.system(size: 16, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.48))
|
||||
.kerning(1.2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
overviewLine(
|
||||
icon: "rectangle.stack.badge.play.fill",
|
||||
title: "Background baseball TV",
|
||||
detail: "Keep a league-wide live channel running while you browse or build Multi-View."
|
||||
)
|
||||
overviewLine(
|
||||
icon: "sportscourt.fill",
|
||||
title: "Between-game coverage",
|
||||
detail: "Use it when you want updates, reactions, and cut-ins instead of one locked game feed."
|
||||
)
|
||||
overviewLine(
|
||||
icon: "square.grid.2x2.fill",
|
||||
title: "Multi-View filler stream",
|
||||
detail: "Drop it into an open tile to keep the board full when only one or two games matter."
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.background(panelBackground)
|
||||
|
||||
if !canToggleIntoMultiview {
|
||||
Label(
|
||||
"Multi-View is full. Remove a stream before adding MLB Network.",
|
||||
systemImage: "rectangle.split.2x2.fill"
|
||||
)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.orange)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 16)
|
||||
.background(panelBackground)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var actionColumn: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Actions")
|
||||
.font(.system(size: 28, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text("Launch MLB Network full screen or place it in your Multi-View lineup.")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.68))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(24)
|
||||
.background(panelBackground)
|
||||
|
||||
actionButton(
|
||||
title: "Watch Full Screen",
|
||||
subtitle: "Open the channel in the main player",
|
||||
systemImage: "play.fill",
|
||||
fill: .blue.opacity(0.18)
|
||||
) {
|
||||
onWatchFullScreen()
|
||||
}
|
||||
|
||||
actionButton(
|
||||
title: added ? "Remove From Multi-View" : "Add to Multi-View",
|
||||
subtitle: added ? "Take MLB Network out of your active grid" : "Send MLB Network into an open tile",
|
||||
systemImage: added ? "minus.circle.fill" : "plus.circle.fill",
|
||||
fill: added ? .red.opacity(0.16) : .white.opacity(0.08),
|
||||
foreground: added ? .red : .white,
|
||||
disabled: !canToggleIntoMultiview
|
||||
) {
|
||||
if added {
|
||||
viewModel.removeStream(id: "MLBN")
|
||||
} else {
|
||||
Task { await viewModel.addMLBNetwork() }
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func actionButton(
|
||||
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: 16) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(title)
|
||||
.font(.system(size: 21, weight: .bold, design: .rounded))
|
||||
|
||||
Text(subtitle)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(foreground.opacity(0.68))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.foregroundStyle(foreground)
|
||||
.frame(maxWidth: .infinity, minHeight: 88, alignment: .leading)
|
||||
.padding(.horizontal, 22)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.fill(fill)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
.disabled(disabled)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func overviewLine(icon: String, title: String, detail: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.foregroundStyle(.blue.opacity(0.9))
|
||||
.frame(width: 26)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(detail)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.66))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statusPill(title: String, color: Color) -> some View {
|
||||
Text(title)
|
||||
.font(.system(size: 13, weight: .black, design: .rounded))
|
||||
.foregroundStyle(color)
|
||||
.padding(.horizontal, 13)
|
||||
.padding(.vertical, 9)
|
||||
.background(color.opacity(0.14))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func featurePill(title: String, systemImage: String) -> some View {
|
||||
Label(title, systemImage: systemImage)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.84))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(.white.opacity(0.06))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var panelBackground: some View {
|
||||
RoundedRectangle(cornerRadius: 28, style: .continuous)
|
||||
.fill(.black.opacity(0.24))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 28, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var sheetBackground: some View {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.04, green: 0.05, blue: 0.08),
|
||||
Color(red: 0.05, green: 0.07, blue: 0.11)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
|
||||
Circle()
|
||||
.fill(.blue.opacity(0.22))
|
||||
.frame(width: 540, height: 540)
|
||||
.blur(radius: 100)
|
||||
.offset(x: -280, y: -210)
|
||||
|
||||
Circle()
|
||||
.fill(.cyan.opacity(0.14))
|
||||
.frame(width: 460, height: 460)
|
||||
.blur(radius: 100)
|
||||
.offset(x: 350, y: 180)
|
||||
}
|
||||
}
|
||||
}
|
||||
1257
mlbTVOS/Views/MultiStreamView.swift
Normal file
1257
mlbTVOS/Views/MultiStreamView.swift
Normal file
File diff suppressed because it is too large
Load Diff
73
mlbTVOS/Views/SettingsView.swift
Normal file
73
mlbTVOS/Views/SettingsView.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(GamesViewModel.self) private var viewModel
|
||||
|
||||
private let resolutions = [
|
||||
("best", "Best Quality"),
|
||||
("1080p60", "1080p 60fps"),
|
||||
("720p60", "720p 60fps"),
|
||||
("540p", "540p"),
|
||||
("adaptive", "Adaptive"),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
@Bindable var vm = viewModel
|
||||
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Server") {
|
||||
LabeledContent("URL", value: viewModel.serverBaseURL)
|
||||
}
|
||||
|
||||
Section("Default Quality") {
|
||||
ForEach(resolutions, id: \.0) { res in
|
||||
Button {
|
||||
vm.defaultResolution = res.0
|
||||
} label: {
|
||||
HStack {
|
||||
Text(res.1)
|
||||
Spacer()
|
||||
if viewModel.defaultResolution == res.0 {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Active Streams (\(viewModel.activeStreams.count)/4)") {
|
||||
if viewModel.activeStreams.isEmpty {
|
||||
Text("No active streams")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(viewModel.activeStreams) { stream in
|
||||
HStack {
|
||||
Text(stream.label)
|
||||
.fontWeight(.bold)
|
||||
Text(stream.game.displayTitle)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button(role: .destructive) {
|
||||
viewModel.removeStream(id: stream.id)
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("Clear All Streams", role: .destructive) {
|
||||
viewModel.clearAllStreams()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("About") {
|
||||
LabeledContent("Version", value: "1.0")
|
||||
LabeledContent("Server", value: "mlbserver")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
414
mlbTVOS/Views/SingleStreamPlayerView.swift
Normal file
414
mlbTVOS/Views/SingleStreamPlayerView.swift
Normal file
@@ -0,0 +1,414 @@
|
||||
import AVFoundation
|
||||
import AVKit
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
private let singleStreamLogger = Logger(subsystem: "com.treyt.mlbTVOS", category: "SingleStreamPlayer")
|
||||
|
||||
private func logSingleStream(_ message: String) {
|
||||
singleStreamLogger.debug("\(message, privacy: .public)")
|
||||
print("[SingleStream] \(message)")
|
||||
}
|
||||
|
||||
private func singleStreamDebugURLDescription(_ url: URL) -> String {
|
||||
var host = url.host ?? "unknown-host"
|
||||
if let port = url.port {
|
||||
host += ":\(port)"
|
||||
}
|
||||
|
||||
let queryKeys = URLComponents(url: url, resolvingAgainstBaseURL: false)?
|
||||
.queryItems?
|
||||
.map(\.name) ?? []
|
||||
let querySuffix = queryKeys.isEmpty ? "" : "?\(queryKeys.joined(separator: "&"))"
|
||||
|
||||
return "\(url.scheme ?? "unknown")://\(host)\(url.path)\(querySuffix)"
|
||||
}
|
||||
|
||||
private func singleStreamStatusDescription(_ status: AVPlayer.Status) -> String {
|
||||
switch status {
|
||||
case .unknown: "unknown"
|
||||
case .readyToPlay: "readyToPlay"
|
||||
case .failed: "failed"
|
||||
@unknown default: "unknown-future"
|
||||
}
|
||||
}
|
||||
|
||||
private func singleStreamItemStatusDescription(_ status: AVPlayerItem.Status) -> String {
|
||||
switch status {
|
||||
case .unknown: "unknown"
|
||||
case .readyToPlay: "readyToPlay"
|
||||
case .failed: "failed"
|
||||
@unknown default: "unknown-future"
|
||||
}
|
||||
}
|
||||
|
||||
private func singleStreamTimeControlDescription(_ status: AVPlayer.TimeControlStatus) -> String {
|
||||
switch status {
|
||||
case .paused: "paused"
|
||||
case .waitingToPlayAtSpecifiedRate: "waitingToPlayAtSpecifiedRate"
|
||||
case .playing: "playing"
|
||||
@unknown default: "unknown-future"
|
||||
}
|
||||
}
|
||||
|
||||
struct SingleStreamPlaybackScreen: View {
|
||||
let resolveURL: @Sendable () async -> URL?
|
||||
let tickerGames: [Game]
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
SingleStreamPlayerView(resolveURL: resolveURL)
|
||||
.ignoresSafeArea()
|
||||
|
||||
SingleStreamScoreStripView(games: tickerGames)
|
||||
.allowsHitTesting(false)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.bottom, 14)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.onAppear {
|
||||
logSingleStream("SingleStreamPlaybackScreen appeared tickerGames=\(tickerGames.count) tickerMode=marqueeOverlay")
|
||||
}
|
||||
.onDisappear {
|
||||
logSingleStream("SingleStreamPlaybackScreen disappeared")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SingleStreamScoreStripView: View {
|
||||
let games: [Game]
|
||||
|
||||
private var summaries: [String] {
|
||||
games.map { game in
|
||||
let matchup = "\(game.awayTeam.code) \(game.awayTeam.score.map(String.init) ?? "-") - \(game.homeTeam.code) \(game.homeTeam.score.map(String.init) ?? "-")"
|
||||
|
||||
if game.isLive, let linescore = game.linescore {
|
||||
let inning = linescore.currentInningOrdinal ?? game.currentInningDisplay ?? "LIVE"
|
||||
let state = (linescore.inningState ?? linescore.inningHalf ?? "Live").uppercased()
|
||||
let outs = linescore.outs ?? 0
|
||||
return "\(matchup) \(state) \(inning.uppercased()) • \(outs) OUT\(outs == 1 ? "" : "S")"
|
||||
}
|
||||
|
||||
if game.isFinal {
|
||||
return "\(matchup) FINAL"
|
||||
}
|
||||
|
||||
if let startTime = game.startTime {
|
||||
return "\(matchup) \(startTime.uppercased())"
|
||||
}
|
||||
|
||||
return "\(matchup) \(game.status.label.uppercased())"
|
||||
}
|
||||
}
|
||||
|
||||
private var stripText: String {
|
||||
summaries.joined(separator: " | ")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if !games.isEmpty {
|
||||
SingleStreamMarqueeView(text: stripText)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(height: 22)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(.black.opacity(0.72))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
||||
}
|
||||
)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SingleStreamMarqueeView: UIViewRepresentable {
|
||||
let text: String
|
||||
|
||||
func makeUIView(context: Context) -> SingleStreamMarqueeContainerView {
|
||||
let view = SingleStreamMarqueeContainerView()
|
||||
view.setText(text)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: SingleStreamMarqueeContainerView, context: Context) {
|
||||
uiView.setText(text)
|
||||
}
|
||||
}
|
||||
|
||||
private final class SingleStreamMarqueeContainerView: UIView {
|
||||
private let trackView = UIView()
|
||||
private let primaryLabel = UILabel()
|
||||
private let secondaryLabel = UILabel()
|
||||
private let spacing: CGFloat = 48
|
||||
private let pointsPerSecond: CGFloat = 64
|
||||
|
||||
private var currentText = ""
|
||||
private var previousBoundsWidth: CGFloat = 0
|
||||
private var previousContentWidth: CGFloat = 0
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
clipsToBounds = true
|
||||
isUserInteractionEnabled = false
|
||||
|
||||
let baseFont = UIFont.systemFont(ofSize: 15, weight: .bold)
|
||||
let roundedFont = UIFont(descriptor: baseFont.fontDescriptor.withDesign(.rounded) ?? baseFont.fontDescriptor, size: 15)
|
||||
|
||||
[primaryLabel, secondaryLabel].forEach { label in
|
||||
label.font = roundedFont
|
||||
label.textColor = UIColor.white.withAlphaComponent(0.92)
|
||||
label.numberOfLines = 1
|
||||
label.lineBreakMode = .byClipping
|
||||
trackView.addSubview(label)
|
||||
}
|
||||
|
||||
addSubview(trackView)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func setText(_ text: String) {
|
||||
guard currentText != text else { return }
|
||||
currentText = text
|
||||
primaryLabel.text = text
|
||||
secondaryLabel.text = text
|
||||
previousContentWidth = 0
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
guard bounds.width > 0, bounds.height > 0 else { return }
|
||||
|
||||
let contentWidth = ceil(primaryLabel.sizeThatFits(CGSize(width: .greatestFiniteMagnitude, height: bounds.height)).width)
|
||||
let contentHeight = bounds.height
|
||||
|
||||
primaryLabel.frame = CGRect(x: 0, y: 0, width: contentWidth, height: contentHeight)
|
||||
secondaryLabel.frame = CGRect(x: contentWidth + spacing, y: 0, width: contentWidth, height: contentHeight)
|
||||
|
||||
let cycleWidth = contentWidth + spacing
|
||||
|
||||
if contentWidth <= bounds.width {
|
||||
trackView.layer.removeAllAnimations()
|
||||
secondaryLabel.isHidden = true
|
||||
trackView.frame = CGRect(x: 0, y: 0, width: bounds.width, height: contentHeight)
|
||||
primaryLabel.frame = CGRect(x: 0, y: 0, width: bounds.width, height: contentHeight)
|
||||
primaryLabel.textAlignment = .left
|
||||
previousBoundsWidth = bounds.width
|
||||
previousContentWidth = contentWidth
|
||||
return
|
||||
}
|
||||
|
||||
primaryLabel.textAlignment = .left
|
||||
secondaryLabel.isHidden = false
|
||||
trackView.frame = CGRect(x: 0, y: 0, width: contentWidth * 2 + spacing, height: contentHeight)
|
||||
|
||||
let shouldRestart = abs(previousBoundsWidth - bounds.width) > 0.5
|
||||
|| abs(previousContentWidth - contentWidth) > 0.5
|
||||
|| trackView.layer.animation(forKey: "singleStreamMarquee") == nil
|
||||
|
||||
previousBoundsWidth = bounds.width
|
||||
previousContentWidth = contentWidth
|
||||
|
||||
guard shouldRestart else { return }
|
||||
|
||||
trackView.layer.removeAllAnimations()
|
||||
trackView.transform = .identity
|
||||
|
||||
let animation = CABasicAnimation(keyPath: "transform.translation.x")
|
||||
animation.fromValue = 0
|
||||
animation.toValue = -cycleWidth
|
||||
animation.duration = Double(cycleWidth / pointsPerSecond)
|
||||
animation.repeatCount = .infinity
|
||||
animation.timingFunction = CAMediaTimingFunction(name: .linear)
|
||||
animation.isRemovedOnCompletion = false
|
||||
|
||||
trackView.layer.add(animation, forKey: "singleStreamMarquee")
|
||||
}
|
||||
}
|
||||
|
||||
/// Full-screen player using AVPlayerViewController for PiP support on tvOS.
|
||||
struct SingleStreamPlayerView: UIViewControllerRepresentable {
|
||||
let resolveURL: @Sendable () async -> URL?
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
func makeUIViewController(context: Context) -> AVPlayerViewController {
|
||||
logSingleStream("makeUIViewController start")
|
||||
let controller = AVPlayerViewController()
|
||||
controller.allowsPictureInPicturePlayback = true
|
||||
controller.showsPlaybackControls = true
|
||||
logSingleStream("AVPlayerViewController configured without contentOverlayView ticker")
|
||||
|
||||
Task { @MainActor in
|
||||
let resolveStartedAt = Date()
|
||||
logSingleStream("Starting stream URL resolution")
|
||||
guard let url = await resolveURL() else {
|
||||
logSingleStream("resolveURL returned nil; aborting player startup")
|
||||
return
|
||||
}
|
||||
let resolveElapsedMs = Int(Date().timeIntervalSince(resolveStartedAt) * 1000)
|
||||
logSingleStream("Resolved stream URL elapsedMs=\(resolveElapsedMs) url=\(singleStreamDebugURLDescription(url))")
|
||||
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
logSingleStream("AVAudioSession configured for playback")
|
||||
} catch {
|
||||
logSingleStream("AVAudioSession configuration failed error=\(error.localizedDescription)")
|
||||
}
|
||||
|
||||
let playerItem = AVPlayerItem(url: url)
|
||||
playerItem.preferredForwardBufferDuration = 2
|
||||
let player = AVPlayer(playerItem: playerItem)
|
||||
player.automaticallyWaitsToMinimizeStalling = false
|
||||
logSingleStream("Configured player for fast start preferredForwardBufferDuration=2 automaticallyWaitsToMinimizeStalling=false")
|
||||
context.coordinator.attachDebugObservers(to: player, url: url)
|
||||
controller.player = player
|
||||
logSingleStream("AVPlayer assigned to controller; calling playImmediately(atRate: 1.0)")
|
||||
player.playImmediately(atRate: 1.0)
|
||||
}
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {}
|
||||
|
||||
static func dismantleUIViewController(_ uiViewController: AVPlayerViewController, coordinator: Coordinator) {
|
||||
logSingleStream("dismantleUIViewController start")
|
||||
coordinator.clearDebugObservers()
|
||||
uiViewController.player?.pause()
|
||||
uiViewController.player = nil
|
||||
logSingleStream("dismantleUIViewController complete")
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject {
|
||||
private var playerObservations: [NSKeyValueObservation] = []
|
||||
private var notificationTokens: [NSObjectProtocol] = []
|
||||
|
||||
func attachDebugObservers(to player: AVPlayer, url: URL) {
|
||||
clearDebugObservers()
|
||||
|
||||
logSingleStream("Attaching AVPlayer observers url=\(singleStreamDebugURLDescription(url))")
|
||||
|
||||
playerObservations.append(
|
||||
player.observe(\.status, options: [.initial, .new]) { player, _ in
|
||||
logSingleStream("Player status changed status=\(singleStreamStatusDescription(player.status)) error=\(player.error?.localizedDescription ?? "nil")")
|
||||
}
|
||||
)
|
||||
|
||||
playerObservations.append(
|
||||
player.observe(\.timeControlStatus, options: [.initial, .new]) { player, _ in
|
||||
let reason = player.reasonForWaitingToPlay?.rawValue ?? "nil"
|
||||
logSingleStream("Player timeControlStatus=\(singleStreamTimeControlDescription(player.timeControlStatus)) reasonForWaiting=\(reason)")
|
||||
}
|
||||
)
|
||||
|
||||
playerObservations.append(
|
||||
player.observe(\.reasonForWaitingToPlay, options: [.initial, .new]) { player, _ in
|
||||
logSingleStream("Player reasonForWaitingToPlay changed value=\(player.reasonForWaitingToPlay?.rawValue ?? "nil")")
|
||||
}
|
||||
)
|
||||
|
||||
guard let item = player.currentItem else {
|
||||
logSingleStream("Player currentItem missing immediately after creation")
|
||||
return
|
||||
}
|
||||
|
||||
playerObservations.append(
|
||||
item.observe(\.status, options: [.initial, .new]) { item, _ in
|
||||
logSingleStream("PlayerItem status changed status=\(singleStreamItemStatusDescription(item.status)) error=\(item.error?.localizedDescription ?? "nil")")
|
||||
}
|
||||
)
|
||||
|
||||
playerObservations.append(
|
||||
item.observe(\.isPlaybackBufferEmpty, options: [.initial, .new]) { item, _ in
|
||||
logSingleStream("PlayerItem isPlaybackBufferEmpty=\(item.isPlaybackBufferEmpty)")
|
||||
}
|
||||
)
|
||||
|
||||
playerObservations.append(
|
||||
item.observe(\.isPlaybackLikelyToKeepUp, options: [.initial, .new]) { item, _ in
|
||||
logSingleStream("PlayerItem isPlaybackLikelyToKeepUp=\(item.isPlaybackLikelyToKeepUp)")
|
||||
}
|
||||
)
|
||||
|
||||
playerObservations.append(
|
||||
item.observe(\.isPlaybackBufferFull, options: [.initial, .new]) { item, _ in
|
||||
logSingleStream("PlayerItem isPlaybackBufferFull=\(item.isPlaybackBufferFull)")
|
||||
}
|
||||
)
|
||||
|
||||
notificationTokens.append(
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemPlaybackStalled,
|
||||
object: item,
|
||||
queue: .main
|
||||
) { _ in
|
||||
logSingleStream("Notification AVPlayerItemPlaybackStalled")
|
||||
}
|
||||
)
|
||||
|
||||
notificationTokens.append(
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemFailedToPlayToEndTime,
|
||||
object: item,
|
||||
queue: .main
|
||||
) { notification in
|
||||
let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? NSError
|
||||
logSingleStream("Notification AVPlayerItemFailedToPlayToEndTime error=\(error?.localizedDescription ?? "nil")")
|
||||
}
|
||||
)
|
||||
|
||||
notificationTokens.append(
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemNewErrorLogEntry,
|
||||
object: item,
|
||||
queue: .main
|
||||
) { _ in
|
||||
let event = item.errorLog()?.events.last
|
||||
logSingleStream("Notification AVPlayerItemNewErrorLogEntry domain=\(event?.errorDomain ?? "nil") comment=\(event?.errorComment ?? "nil") statusCode=\(event?.errorStatusCode ?? 0)")
|
||||
}
|
||||
)
|
||||
|
||||
notificationTokens.append(
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemNewAccessLogEntry,
|
||||
object: item,
|
||||
queue: .main
|
||||
) { _ in
|
||||
if let event = item.accessLog()?.events.last {
|
||||
logSingleStream(
|
||||
"Notification AVPlayerItemNewAccessLogEntry indicatedBitrate=\(Int(event.indicatedBitrate)) observedBitrate=\(Int(event.observedBitrate)) segmentsDownloaded=\(event.segmentsDownloadedDuration)"
|
||||
)
|
||||
} else {
|
||||
logSingleStream("Notification AVPlayerItemNewAccessLogEntry with no access log event")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func clearDebugObservers() {
|
||||
playerObservations.removeAll()
|
||||
for token in notificationTokens {
|
||||
NotificationCenter.default.removeObserver(token)
|
||||
}
|
||||
notificationTokens.removeAll()
|
||||
}
|
||||
|
||||
deinit {
|
||||
clearDebugObservers()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user