Phase 1 - Focus & Typography: New TVFocusButtonStyle with 1.04x scale +
white glow border on focus, 0.97x press. Enforced 22px minimum text on
tvOS across DesignSystem (tvCaption 22px, tvBody 24px, tvDataValue 24px).
DataLabelStyle uses tvOS caption with reduced kerning.
Phase 2 - Today Tab: FeaturedGameCard redesigned as full-bleed hero with
away team left, home team right, 96pt score centered, DiamondView for
live count/outs. Removed side panel, replaced with single subtitle row.
GameCardView redesigned as horizontal score-bug style (~120px tall vs
320px) with team color accent bar, stacked logos, centered score, inline
mini linescore. Both show record + streak on every card.
Phase 3 - Intel Tab: Side-by-side layout on tvOS with standings
horizontal scroll on left (60%) and leaders vertical column on right
(40%). Both visible without scrolling past each other. iOS keeps the
stacked layout.
Phase 4 - Feed: Cards now horizontal with thumbnail left (300px tvOS),
info right. Added timestamp ("2h ago") to every card. All text meets
22px minimum on tvOS. Condensed game badge uses larger font.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
207 lines
8.0 KiB
Swift
207 lines
8.0 KiB
Swift
import AVKit
|
|
import SwiftUI
|
|
|
|
struct FeedView: View {
|
|
@Environment(GamesViewModel.self) private var gamesViewModel
|
|
@State private var viewModel = FeedViewModel()
|
|
@State private var playingURL: URL?
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: DS.Spacing.sectionGap) {
|
|
// Header
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("HIGHLIGHTS")
|
|
.font(DS.Fonts.caption)
|
|
.foregroundStyle(DS.Colors.textQuaternary)
|
|
.kerning(3)
|
|
Text("Across the League")
|
|
#if os(tvOS)
|
|
.font(DS.Fonts.tvSectionTitle)
|
|
#else
|
|
.font(DS.Fonts.sectionTitle)
|
|
#endif
|
|
.foregroundStyle(DS.Colors.textPrimary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if viewModel.isLoading {
|
|
ProgressView()
|
|
}
|
|
}
|
|
|
|
if viewModel.highlights.isEmpty && !viewModel.isLoading {
|
|
emptyState
|
|
} else {
|
|
LazyVStack(spacing: DS.Spacing.cardGap) {
|
|
ForEach(viewModel.highlights) { item in
|
|
highlightCard(item)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, edgeInset)
|
|
.padding(.vertical, DS.Spacing.sectionGap)
|
|
}
|
|
.task {
|
|
await viewModel.loadHighlights(games: gamesViewModel.games)
|
|
}
|
|
.onChange(of: gamesViewModel.games.count) {
|
|
Task { await viewModel.loadHighlights(games: gamesViewModel.games) }
|
|
}
|
|
.onAppear { viewModel.startAutoRefresh(games: gamesViewModel.games) }
|
|
.onDisappear { viewModel.stopAutoRefresh() }
|
|
.fullScreenCover(isPresented: Binding(
|
|
get: { playingURL != nil },
|
|
set: { if !$0 { playingURL = nil } }
|
|
)) {
|
|
if let url = playingURL {
|
|
let player = AVPlayer(url: url)
|
|
VideoPlayer(player: player)
|
|
.ignoresSafeArea()
|
|
.onAppear { player.play() }
|
|
.onDisappear { player.pause() }
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func highlightCard(_ item: HighlightItem) -> some View {
|
|
Button {
|
|
playingURL = item.hlsURL ?? item.mp4URL
|
|
} label: {
|
|
HStack(spacing: 16) {
|
|
// Thumbnail
|
|
ZStack {
|
|
HStack(spacing: 0) {
|
|
Rectangle().fill(TeamAssets.color(for: item.awayCode).opacity(0.3))
|
|
Rectangle().fill(TeamAssets.color(for: item.homeCode).opacity(0.3))
|
|
}
|
|
|
|
HStack(spacing: thumbnailLogoGap) {
|
|
TeamLogoView(
|
|
team: TeamInfo(code: item.awayCode, name: "", score: nil),
|
|
size: thumbnailLogoSize
|
|
)
|
|
Text("@")
|
|
.font(.system(size: atFontSize, weight: .bold))
|
|
.foregroundStyle(DS.Colors.textTertiary)
|
|
TeamLogoView(
|
|
team: TeamInfo(code: item.homeCode, name: "", score: nil),
|
|
size: thumbnailLogoSize
|
|
)
|
|
}
|
|
|
|
Image(systemName: "play.circle.fill")
|
|
.font(.system(size: playIconSize))
|
|
.foregroundStyle(.white.opacity(0.7))
|
|
.shadow(radius: 4)
|
|
|
|
if item.isCondensedGame {
|
|
VStack {
|
|
HStack {
|
|
Spacer()
|
|
Text("CONDENSED")
|
|
.font(badgeFont)
|
|
.foregroundStyle(.white)
|
|
.kerning(0.5)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(DS.Colors.media)
|
|
.clipShape(Capsule())
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(8)
|
|
}
|
|
}
|
|
.frame(width: thumbnailWidth, height: thumbnailHeight)
|
|
.clipShape(RoundedRectangle(cornerRadius: DS.Radii.compact))
|
|
|
|
// Info
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack {
|
|
Text(item.gameTitle)
|
|
.font(gameTagFont)
|
|
.foregroundStyle(DS.Colors.textTertiary)
|
|
.kerning(0.8)
|
|
|
|
Spacer()
|
|
|
|
Text(timeAgo(item.timestamp))
|
|
.font(gameTagFont)
|
|
.foregroundStyle(DS.Colors.textQuaternary)
|
|
}
|
|
|
|
Text(item.headline)
|
|
.font(headlineFont)
|
|
.foregroundStyle(DS.Colors.textPrimary)
|
|
.lineLimit(2)
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(DS.Spacing.panelPadCompact)
|
|
.background(DS.Colors.panelFill)
|
|
.clipShape(RoundedRectangle(cornerRadius: DS.Radii.standard))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: DS.Radii.standard)
|
|
.strokeBorder(DS.Colors.panelStroke, lineWidth: 0.5)
|
|
)
|
|
}
|
|
.platformCardStyle()
|
|
}
|
|
|
|
private var emptyState: some View {
|
|
VStack(spacing: 16) {
|
|
Image(systemName: "play.rectangle.on.rectangle")
|
|
.font(.system(size: 44))
|
|
.foregroundStyle(DS.Colors.textQuaternary)
|
|
Text("No highlights available yet")
|
|
.font(DS.Fonts.body)
|
|
.foregroundStyle(DS.Colors.textTertiary)
|
|
Text("Highlights appear as games are played")
|
|
.font(DS.Fonts.bodySmall)
|
|
.foregroundStyle(DS.Colors.textQuaternary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.top, 80)
|
|
}
|
|
|
|
// MARK: - Platform sizing
|
|
|
|
private func timeAgo(_ date: Date) -> String {
|
|
let interval = Date().timeIntervalSince(date)
|
|
if interval < 60 { return "Just now" }
|
|
if interval < 3600 { return "\(Int(interval / 60))m ago" }
|
|
if interval < 86400 { return "\(Int(interval / 3600))h ago" }
|
|
return "\(Int(interval / 86400))d ago"
|
|
}
|
|
|
|
#if os(tvOS)
|
|
private var edgeInset: CGFloat { 60 }
|
|
private var thumbnailWidth: CGFloat { 300 }
|
|
private var thumbnailHeight: CGFloat { 160 }
|
|
private var thumbnailLogoSize: CGFloat { 48 }
|
|
private var thumbnailLogoGap: CGFloat { 20 }
|
|
private var playIconSize: CGFloat { 40 }
|
|
private var atFontSize: CGFloat { 22 }
|
|
private var headlineFont: Font { .system(size: 24, weight: .semibold) }
|
|
private var gameTagFont: Font { .system(size: 22, weight: .bold, design: .rounded) }
|
|
private var badgeFont: Font { .system(size: 18, weight: .bold, design: .rounded) }
|
|
#else
|
|
private var edgeInset: CGFloat { 20 }
|
|
private var thumbnailWidth: CGFloat { 180 }
|
|
private var thumbnailHeight: CGFloat { 100 }
|
|
private var thumbnailLogoSize: CGFloat { 32 }
|
|
private var thumbnailLogoGap: CGFloat { 12 }
|
|
private var playIconSize: CGFloat { 28 }
|
|
private var atFontSize: CGFloat { 14 }
|
|
private var headlineFont: Font { .system(size: 15, weight: .semibold) }
|
|
private var gameTagFont: Font { .system(size: 12, weight: .bold, design: .rounded) }
|
|
private var badgeFont: Font { .system(size: 11, weight: .bold, design: .rounded) }
|
|
#endif
|
|
}
|