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()