Files
MLBApp/mlbTVOS/Views/FeedView.swift
Trey t 39092e5f3d Restore Live shelf on Today, flatten Feed to time-ordered highlights
Today tab: Removed LiveSituationBar, restored the full Live game shelf
below the featured Astros card where it belongs.

Feed tab: Changed from two grouped shelves (condensed / highlights) to a
single horizontal scroll with ALL highlights ordered by timestamp (most
recent first). Added condensed game badge overlay on thumbnails. Added
date field to Highlight model for time-based ordering.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:39:41 -05:00

195 lines
7.5 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 {
// All highlights in one horizontal scroll, ordered by time
ScrollView(.horizontal) {
LazyHStack(spacing: DS.Spacing.cardGap) {
ForEach(viewModel.highlights) { item in
highlightCard(item)
.frame(width: cardWidth)
}
}
.padding(.vertical, 8)
}
.platformFocusSection()
.scrollClipDisabled()
}
}
.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: {
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)
// Condensed/Recap badge
if item.isCondensedGame {
VStack {
HStack {
Spacer()
Text("CONDENSED")
.font(DS.Fonts.caption)
.foregroundStyle(.white)
.kerning(0.8)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(DS.Colors.media)
.clipShape(Capsule())
}
Spacer()
}
.padding(8)
}
}
.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
}