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>
195 lines
7.5 KiB
Swift
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
|
|
}
|