Feed tab: Replaced news/transaction feed with league-wide highlights and condensed game replays. FeedViewModel now fetches highlights from all games concurrently, splits into condensed games vs individual highlights. Cards show team color thumbnails with play button overlay. Today tab: Removed duplicate Live games shelf — LiveSituationBar already shows all live games at the top, so the shelf was redundant. Intel tab: Removed schedule section (already on Today tab). Updated header description and stat pills. Added division hydration to standings API call so division names display correctly instead of "Division". Focus: Added .platformFocusable() to standings cards and leaderboard cards so tvOS remote can scroll horizontally through them. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
206 lines
7.7 KiB
Swift
206 lines
7.7 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 {
|
|
// Condensed Games
|
|
if !viewModel.condensedGames.isEmpty {
|
|
highlightShelf(
|
|
title: "Condensed Games",
|
|
icon: "film.stack",
|
|
items: viewModel.condensedGames
|
|
)
|
|
}
|
|
|
|
// Latest Highlights
|
|
if !viewModel.latestHighlights.isEmpty {
|
|
highlightShelf(
|
|
title: "Latest Highlights",
|
|
icon: "play.circle.fill",
|
|
items: viewModel.latestHighlights
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.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 highlightShelf(title: String, icon: String, items: [HighlightItem]) -> some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Label(title, systemImage: icon)
|
|
#if os(tvOS)
|
|
.font(.system(size: 24, weight: .bold, design: .rounded))
|
|
#else
|
|
.font(.system(size: 18, weight: .bold, design: .rounded))
|
|
#endif
|
|
.foregroundStyle(DS.Colors.textSecondary)
|
|
|
|
ScrollView(.horizontal) {
|
|
LazyHStack(spacing: DS.Spacing.cardGap) {
|
|
ForEach(items) { item in
|
|
highlightCard(item)
|
|
.frame(width: cardWidth)
|
|
}
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
.platformFocusSection()
|
|
.scrollClipDisabled()
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func highlightCard(_ item: HighlightItem) -> some View {
|
|
Button {
|
|
playingURL = item.hlsURL ?? item.mp4URL
|
|
} label: {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
// Thumbnail area with team colors
|
|
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
|
|
)
|
|
}
|
|
|
|
// Play icon overlay
|
|
Image(systemName: "play.circle.fill")
|
|
.font(.system(size: playIconSize))
|
|
.foregroundStyle(.white.opacity(0.8))
|
|
.shadow(radius: 4)
|
|
}
|
|
.frame(height: thumbnailHeight)
|
|
.clipShape(RoundedRectangle(cornerRadius: DS.Radii.compact))
|
|
|
|
// Info
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(item.gameTitle)
|
|
.font(DS.Fonts.caption)
|
|
.foregroundStyle(DS.Colors.textTertiary)
|
|
.kerning(1)
|
|
|
|
Text(item.headline)
|
|
.font(headlineFont)
|
|
.foregroundStyle(DS.Colors.textPrimary)
|
|
.lineLimit(2)
|
|
}
|
|
.padding(.horizontal, 4)
|
|
}
|
|
.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
|
|
|
|
#if os(tvOS)
|
|
private var edgeInset: CGFloat { 60 }
|
|
private var cardWidth: CGFloat { 420 }
|
|
private var thumbnailHeight: CGFloat { 200 }
|
|
private var thumbnailLogoSize: CGFloat { 56 }
|
|
private var thumbnailLogoGap: CGFloat { 24 }
|
|
private var playIconSize: CGFloat { 44 }
|
|
private var atFontSize: CGFloat { 20 }
|
|
private var headlineFont: Font { .system(size: 18, weight: .semibold) }
|
|
#else
|
|
private var edgeInset: CGFloat { 20 }
|
|
private var cardWidth: CGFloat { 280 }
|
|
private var thumbnailHeight: CGFloat { 140 }
|
|
private var thumbnailLogoSize: CGFloat { 40 }
|
|
private var thumbnailLogoGap: CGFloat { 16 }
|
|
private var playIconSize: CGFloat { 32 }
|
|
private var atFontSize: CGFloat { 15 }
|
|
private var headlineFont: Font { .system(size: 15, weight: .semibold) }
|
|
#endif
|
|
}
|