The app was crashing from memory pressure on tvOS. Three causes fixed: 1. Feed was rendering all 418 highlights at once — capped to 50 items. 2. FeaturedGameCard had 3 blur effects (radius 80-120) on large circles for team color glow — replaced with a single LinearGradient. Same visual effect, fraction of the GPU memory. 3. BroadcastBackground had 3 blurred circles (radius 120-140, 680-900px) rendering on every screen — replaced with RadialGradients which are composited by the GPU natively without offscreen render passes. Also fixed iOS build: replaced tvOS-only font refs (tvSectionTitle, tvBody) with cross-platform equivalents in DashboardView fallback state. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
262 lines
10 KiB
Swift
262 lines
10 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)
|
|
Text("Condensed games, key plays, and fresh clips from the active slate.")
|
|
.font(DS.Fonts.body)
|
|
.foregroundStyle(DS.Colors.textSecondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if viewModel.isLoading {
|
|
ProgressView()
|
|
}
|
|
}
|
|
|
|
overviewChips
|
|
|
|
if viewModel.highlights.isEmpty && !viewModel.isLoading {
|
|
emptyState
|
|
} else {
|
|
LazyVStack(spacing: DS.Spacing.cardGap) {
|
|
ForEach(viewModel.highlights.prefix(50)) { 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() }
|
|
}
|
|
}
|
|
}
|
|
|
|
private var overviewChips: some View {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 12) {
|
|
feedChip(
|
|
title: "\(viewModel.highlights.count)",
|
|
label: "Clips",
|
|
tint: DS.Colors.media
|
|
)
|
|
feedChip(
|
|
title: "\(viewModel.highlights.filter(\.isCondensedGame).count)",
|
|
label: "Condensed",
|
|
tint: DS.Colors.interactive
|
|
)
|
|
feedChip(
|
|
title: "\(gamesViewModel.liveGames.count)",
|
|
label: "Live Games",
|
|
tint: DS.Colors.live
|
|
)
|
|
}
|
|
}
|
|
.scrollClipDisabled()
|
|
}
|
|
|
|
private func feedChip(title: String, label: String, tint: Color) -> some View {
|
|
HStack(spacing: 10) {
|
|
Text(title)
|
|
.font(chipValueFont)
|
|
.foregroundStyle(DS.Colors.textPrimary)
|
|
.monospacedDigit()
|
|
|
|
Text(label)
|
|
.font(chipLabelFont)
|
|
.foregroundStyle(tint)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
.background(
|
|
Capsule()
|
|
.fill(DS.Colors.panelFillMuted)
|
|
.overlay {
|
|
Capsule()
|
|
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
|
|
}
|
|
)
|
|
}
|
|
|
|
@ViewBuilder
|
|
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) }
|
|
private var chipValueFont: Font { .system(size: 20, weight: .black, design: .rounded) }
|
|
private var chipLabelFont: Font { .system(size: 14, 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) }
|
|
private var chipValueFont: Font { .system(size: 14, weight: .black, design: .rounded) }
|
|
private var chipLabelFont: Font { .system(size: 10, weight: .bold, design: .rounded) }
|
|
#endif
|
|
}
|