From cd605d889d6da3b5675aacb16a61e76390136fc8 Mon Sep 17 00:00:00 2001 From: Trey t Date: Sun, 12 Apr 2026 13:25:36 -0500 Subject: [PATCH] Replace Feed with highlights, remove duplicate live shelf, drop Intel schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- mlbTVOS/Services/MLBStatsAPI.swift | 2 +- mlbTVOS/ViewModels/FeedViewModel.swift | 109 ++++++------ mlbTVOS/Views/Components/FeedItemView.swift | 111 +----------- mlbTVOS/Views/DashboardView.swift | 3 - mlbTVOS/Views/FeedView.swift | 182 +++++++++++++++++--- mlbTVOS/Views/LeagueCenterView.swift | 33 ++-- 6 files changed, 244 insertions(+), 196 deletions(-) diff --git a/mlbTVOS/Services/MLBStatsAPI.swift b/mlbTVOS/Services/MLBStatsAPI.swift index e780eb7..919c04f 100644 --- a/mlbTVOS/Services/MLBStatsAPI.swift +++ b/mlbTVOS/Services/MLBStatsAPI.swift @@ -33,7 +33,7 @@ actor MLBStatsAPI { func fetchStandingsRecords(season: String) async throws -> [StandingsDivisionRecord] { let response: StandingsResponse = try await fetchJSON( - "\(baseURL)/standings?leagueId=103,104&season=\(season)&hydrate=team" + "\(baseURL)/standings?leagueId=103,104&season=\(season)&hydrate=team,division" ) return response.records } diff --git a/mlbTVOS/ViewModels/FeedViewModel.swift b/mlbTVOS/ViewModels/FeedViewModel.swift index 880fefb..c465b05 100644 --- a/mlbTVOS/ViewModels/FeedViewModel.swift +++ b/mlbTVOS/ViewModels/FeedViewModel.swift @@ -9,81 +9,94 @@ private func logFeed(_ message: String) { print("[Feed] \(message)") } -enum FeedItemType: Sendable { - case news - case transaction - case scoring -} - -struct FeedItem: Identifiable, Sendable { +struct HighlightItem: Identifiable, Sendable { let id: String - let type: FeedItemType - let title: String - let subtitle: String - let teamCode: String? - let timestamp: Date + let headline: String + let gameTitle: String + let awayCode: String + let homeCode: String + let hlsURL: URL? + let mp4URL: URL? + let isCondensedGame: Bool } @Observable @MainActor final class FeedViewModel { - var items: [FeedItem] = [] + var highlights: [HighlightItem] = [] var isLoading = false @ObservationIgnored private var refreshTask: Task? - private let webService = MLBWebDataService() + private let serverAPI = MLBServerAPI() - func loadFeed() async { + func loadHighlights(games: [Game]) async { isLoading = true - logFeed("loadFeed start") + logFeed("loadHighlights start gameCount=\(games.count)") - async let newsTask = webService.fetchNewsHeadlines() - async let transactionsTask = webService.fetchTransactions() + let gamesWithPk = games.filter { $0.gamePk != nil } - let news = await newsTask - let transactions = await transactionsTask + // Fetch highlights for all games concurrently + await withTaskGroup(of: [HighlightItem].self) { group in + for game in gamesWithPk { + group.addTask { [serverAPI] in + do { + let raw = try await serverAPI.fetchHighlights( + gamePk: game.gamePk!, + gameDate: game.gameDate + ) + return raw.compactMap { highlight -> HighlightItem? in + guard let headline = highlight.headline, + let hlsStr = highlight.hlsURL ?? highlight.mp4URL, + let _ = URL(string: hlsStr) else { return nil } - var allItems: [FeedItem] = [] + let isCondensed = headline.lowercased().contains("condensed") + || headline.lowercased().contains("recap") - // News - for headline in news { - allItems.append(FeedItem( - id: "news-\(headline.id)", - type: .news, - title: headline.title, - subtitle: headline.summary, - teamCode: nil, - timestamp: headline.timestamp - )) + return HighlightItem( + id: highlight.id ?? UUID().uuidString, + headline: headline, + gameTitle: "\(game.awayTeam.code) @ \(game.homeTeam.code)", + awayCode: game.awayTeam.code, + homeCode: game.homeTeam.code, + hlsURL: highlight.hlsURL.flatMap(URL.init), + mp4URL: highlight.mp4URL.flatMap(URL.init), + isCondensedGame: isCondensed + ) + } + } catch { + return [] + } + } + } + + var allHighlights: [HighlightItem] = [] + for await batch in group { + allHighlights.append(contentsOf: batch) + } + highlights = allHighlights } - // Transactions - for tx in transactions { - allItems.append(FeedItem( - id: "tx-\(tx.id)", - type: .transaction, - title: tx.description, - subtitle: tx.type, - teamCode: tx.teamCode.isEmpty ? nil : tx.teamCode, - timestamp: tx.date - )) - } - - // Sort reverse chronological - items = allItems.sorted { $0.timestamp > $1.timestamp } isLoading = false - logFeed("loadFeed complete items=\(items.count)") + logFeed("loadHighlights complete count=\(highlights.count)") } - func startAutoRefresh() { + var condensedGames: [HighlightItem] { + highlights.filter(\.isCondensedGame) + } + + var latestHighlights: [HighlightItem] { + highlights.filter { !$0.isCondensedGame } + } + + func startAutoRefresh(games: [Game]) { stopAutoRefresh() refreshTask = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(for: .seconds(300)) guard !Task.isCancelled, let self else { break } - await self.loadFeed() + await self.loadHighlights(games: games) } } } diff --git a/mlbTVOS/Views/Components/FeedItemView.swift b/mlbTVOS/Views/Components/FeedItemView.swift index 07ef375..7db1e19 100644 --- a/mlbTVOS/Views/Components/FeedItemView.swift +++ b/mlbTVOS/Views/Components/FeedItemView.swift @@ -1,112 +1,3 @@ import SwiftUI -struct FeedItemView: View { - let item: FeedItem - - private var accentColor: Color { - switch item.type { - case .news: DS.Colors.interactive - case .transaction: DS.Colors.positive - case .scoring: DS.Colors.live - } - } - - private var iconName: String { - switch item.type { - case .news: "newspaper.fill" - case .transaction: "arrow.left.arrow.right" - case .scoring: "sportscourt.fill" - } - } - - private var typeLabel: String { - switch item.type { - case .news: "NEWS" - case .transaction: "TRANSACTION" - case .scoring: "SCORING" - } - } - - var body: some View { - HStack(spacing: 0) { - // Colored edge bar - RoundedRectangle(cornerRadius: 1.5) - .fill(accentColor) - .frame(width: 3) - .padding(.vertical, 8) - - HStack(spacing: 12) { - Image(systemName: iconName) - .font(.system(size: iconSize, weight: .semibold)) - .foregroundStyle(accentColor) - .frame(width: iconFrame, height: iconFrame) - - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 8) { - Text(typeLabel) - .font(DS.Fonts.caption) - .foregroundStyle(accentColor) - .kerning(1) - - if let code = item.teamCode { - HStack(spacing: 4) { - RoundedRectangle(cornerRadius: 1) - .fill(TeamAssets.color(for: code)) - .frame(width: 2, height: 10) - Text(code) - .font(DS.Fonts.caption) - .foregroundStyle(DS.Colors.textTertiary) - } - } - - Spacer() - - Text(timeAgo(item.timestamp)) - .font(DS.Fonts.caption) - .foregroundStyle(DS.Colors.textQuaternary) - } - - Text(item.title) - .font(titleFont) - .foregroundStyle(DS.Colors.textPrimary) - .lineLimit(2) - - if !item.subtitle.isEmpty { - Text(item.subtitle) - .font(subtitleFont) - .foregroundStyle(DS.Colors.textTertiary) - .lineLimit(1) - } - } - } - .padding(.horizontal, 14) - .padding(.vertical, 12) - } - .background(DS.Colors.panelFill) - .clipShape(RoundedRectangle(cornerRadius: DS.Radii.compact)) - .overlay( - RoundedRectangle(cornerRadius: DS.Radii.compact) - .strokeBorder(DS.Colors.panelStroke, lineWidth: 0.5) - ) - } - - 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 iconSize: CGFloat { 18 } - private var iconFrame: CGFloat { 36 } - private var titleFont: Font { .system(size: 18, weight: .semibold) } - private var subtitleFont: Font { DS.Fonts.bodySmall } - #else - private var iconSize: CGFloat { 14 } - private var iconFrame: CGFloat { 28 } - private var titleFont: Font { .system(size: 15, weight: .semibold) } - private var subtitleFont: Font { .system(size: 12, weight: .medium) } - #endif -} +// Placeholder — highlight cards are now inline in FeedView diff --git a/mlbTVOS/Views/DashboardView.swift b/mlbTVOS/Views/DashboardView.swift index eb9f2d7..bd1f044 100644 --- a/mlbTVOS/Views/DashboardView.swift +++ b/mlbTVOS/Views/DashboardView.swift @@ -147,9 +147,6 @@ struct DashboardView: View { } } - if !viewModel.liveGames.isEmpty { - gameShelf(title: "Live", icon: "antenna.radiowaves.left.and.right", games: viewModel.liveGames, excludeId: viewModel.featuredGame?.id) - } if !viewModel.scheduledGames.isEmpty { gameShelf(title: "Upcoming", icon: "calendar", games: viewModel.scheduledGames, excludeId: viewModel.featuredGame?.id) } diff --git a/mlbTVOS/Views/FeedView.swift b/mlbTVOS/Views/FeedView.swift index 9f64c05..66793f0 100644 --- a/mlbTVOS/Views/FeedView.swift +++ b/mlbTVOS/Views/FeedView.swift @@ -1,19 +1,22 @@ +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.cardGap) { + VStack(alignment: .leading, spacing: DS.Spacing.sectionGap) { // Header HStack { VStack(alignment: .leading, spacing: 4) { - Text("FEED") + Text("HIGHLIGHTS") .font(DS.Fonts.caption) .foregroundStyle(DS.Colors.textQuaternary) .kerning(3) - Text("Latest Intel") + Text("Across the League") #if os(tvOS) .font(DS.Fonts.tvSectionTitle) #else @@ -29,43 +32,174 @@ struct FeedView: View { } } - if viewModel.items.isEmpty && !viewModel.isLoading { - VStack(spacing: 16) { - Image(systemName: "newspaper") - .font(.system(size: 44)) - .foregroundStyle(DS.Colors.textQuaternary) - Text("No feed items yet") - .font(DS.Fonts.body) - .foregroundStyle(DS.Colors.textTertiary) - } - .frame(maxWidth: .infinity) - .padding(.top, 80) + if viewModel.highlights.isEmpty && !viewModel.isLoading { + emptyState } else { - LazyVStack(spacing: DS.Spacing.itemGap) { - ForEach(viewModel.items) { item in - FeedItemView(item: item) - } + // 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) } - .background(DS.Colors.background) .task { - await viewModel.loadFeed() + await viewModel.loadHighlights(games: gamesViewModel.games) } - .onAppear { - viewModel.startAutoRefresh() + .onChange(of: gamesViewModel.games.count) { + Task { await viewModel.loadHighlights(games: gamesViewModel.games) } } - .onDisappear { - viewModel.stopAutoRefresh() + .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 } diff --git a/mlbTVOS/Views/LeagueCenterView.swift b/mlbTVOS/Views/LeagueCenterView.swift index de91d85..576b612 100644 --- a/mlbTVOS/Views/LeagueCenterView.swift +++ b/mlbTVOS/Views/LeagueCenterView.swift @@ -34,7 +34,6 @@ struct LeagueCenterView: View { messagePanel(overviewErrorMessage, tint: .orange) } - scheduleSection standingsSection // League Leaders @@ -79,7 +78,7 @@ struct LeagueCenterView: View { .font(.system(size: 42, weight: .bold, design: .rounded)) .foregroundStyle(.white) - Text("Schedules, standings, team context, roster access, and player snapshots in one control room.") + Text("Standings, league leaders, team context, roster access, and player snapshots in one control room.") .font(.system(size: 16, weight: .medium)) .foregroundStyle(.white.opacity(0.58)) } @@ -87,7 +86,7 @@ struct LeagueCenterView: View { Spacer() HStack(spacing: 12) { - infoPill(title: "\(viewModel.scheduleGames.count)", label: "Games", color: .blue) + infoPill(title: "\(viewModel.leagueLeaders.count)", label: "Leaders", color: .blue) infoPill(title: "\(viewModel.teams.count)", label: "Teams", color: .green) infoPill(title: "\(viewModel.standings.count)", label: "Divisions", color: .orange) } @@ -144,8 +143,10 @@ struct LeagueCenterView: View { selectedGame = linkedGame } } label: { - HStack(spacing: 18) { + HStack(spacing: 0) { teamMiniColumn(team: game.teams.away) + .frame(width: scheduleTeamColWidth, alignment: .leading) + VStack(spacing: 6) { Text(scoreText(for: game)) .font(.system(size: 28, weight: .black, design: .rounded)) @@ -156,11 +157,10 @@ struct LeagueCenterView: View { .font(.system(size: 14, weight: .semibold)) .foregroundStyle(statusColor(for: game)) } - .frame(width: 160) + .frame(width: scheduleScoreColWidth) teamMiniColumn(team: game.teams.home, alignTrailing: true) - - Spacer() + .frame(width: scheduleTeamColWidth, alignment: .trailing) VStack(alignment: .trailing, spacing: 6) { if let venue = game.venue?.name { @@ -174,6 +174,7 @@ struct LeagueCenterView: View { .font(.system(size: 13, weight: .bold, design: .rounded)) .foregroundStyle(linkedGame != nil ? .blue.opacity(0.95) : .white.opacity(0.34)) } + .frame(width: scheduleVenueColWidth, alignment: .trailing) } .padding(22) .background(sectionPanel) @@ -182,6 +183,16 @@ struct LeagueCenterView: View { .disabled(linkedGame == nil) } + #if os(tvOS) + private var scheduleTeamColWidth: CGFloat { 340 } + private var scheduleScoreColWidth: CGFloat { 160 } + private var scheduleVenueColWidth: CGFloat { 220 } + #else + private var scheduleTeamColWidth: CGFloat { 200 } + private var scheduleScoreColWidth: CGFloat { 120 } + private var scheduleVenueColWidth: CGFloat { 160 } + #endif + private func teamMiniColumn(team: StatsTeamGameInfo, alignTrailing: Bool = false) -> some View { let info = TeamInfo( code: team.team.abbreviation ?? "MLB", @@ -217,7 +228,6 @@ struct LeagueCenterView: View { TeamLogoView(team: info, size: 56) } } - .frame(maxWidth: .infinity, alignment: alignTrailing ? .trailing : .leading) } private var leadersSection: some View { @@ -239,10 +249,12 @@ struct LeagueCenterView: View { ForEach(viewModel.leagueLeaders) { category in LeaderboardView(category: category) .frame(width: leaderCardWidth) + .platformFocusable() } } .padding(.vertical, 8) } + .platformFocusSection() .scrollClipDisabled() } } @@ -265,13 +277,14 @@ struct LeagueCenterView: View { loadingPanel(title: "Loading standings...") } else { ScrollView(.horizontal) { - HStack(spacing: 18) { + LazyHStack(spacing: 18) { ForEach(viewModel.standings, id: \.division?.id) { record in standingsCard(record) .frame(width: 360) + .platformFocusable() } } - .padding(.vertical, 4) + .padding(.vertical, 8) } .platformFocusSection() .scrollClipDisabled()