Files
MLBApp/mlbTVOS/Views/FeedView.swift
Trey t cd605d889d Replace Feed with highlights, remove duplicate live shelf, drop Intel schedule
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>
2026-04-12 13:25:36 -05:00

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
}