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>
This commit is contained in:
@@ -305,6 +305,7 @@ actor MLBServerAPI {
|
|||||||
struct Highlight: Codable, Sendable, Identifiable {
|
struct Highlight: Codable, Sendable, Identifiable {
|
||||||
let id: String?
|
let id: String?
|
||||||
let headline: String?
|
let headline: String?
|
||||||
|
let date: String?
|
||||||
let playbacks: [Playback]?
|
let playbacks: [Playback]?
|
||||||
|
|
||||||
struct Playback: Codable, Sendable {
|
struct Playback: Codable, Sendable {
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ private func logFeed(_ message: String) {
|
|||||||
print("[Feed] \(message)")
|
print("[Feed] \(message)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func parseHighlightDate(_ string: String?) -> Date? {
|
||||||
|
guard let string else { return nil }
|
||||||
|
let iso = ISO8601DateFormatter()
|
||||||
|
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
if let d = iso.date(from: string) { return d }
|
||||||
|
iso.formatOptions = [.withInternetDateTime]
|
||||||
|
return iso.date(from: string)
|
||||||
|
}
|
||||||
|
|
||||||
struct HighlightItem: Identifiable, Sendable {
|
struct HighlightItem: Identifiable, Sendable {
|
||||||
let id: String
|
let id: String
|
||||||
let headline: String
|
let headline: String
|
||||||
@@ -18,6 +27,7 @@ struct HighlightItem: Identifiable, Sendable {
|
|||||||
let hlsURL: URL?
|
let hlsURL: URL?
|
||||||
let mp4URL: URL?
|
let mp4URL: URL?
|
||||||
let isCondensedGame: Bool
|
let isCondensedGame: Bool
|
||||||
|
let timestamp: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
@@ -37,7 +47,6 @@ final class FeedViewModel {
|
|||||||
|
|
||||||
let gamesWithPk = games.filter { $0.gamePk != nil }
|
let gamesWithPk = games.filter { $0.gamePk != nil }
|
||||||
|
|
||||||
// Fetch highlights for all games concurrently
|
|
||||||
await withTaskGroup(of: [HighlightItem].self) { group in
|
await withTaskGroup(of: [HighlightItem].self) { group in
|
||||||
for game in gamesWithPk {
|
for game in gamesWithPk {
|
||||||
group.addTask { [serverAPI] in
|
group.addTask { [serverAPI] in
|
||||||
@@ -46,7 +55,7 @@ final class FeedViewModel {
|
|||||||
gamePk: game.gamePk!,
|
gamePk: game.gamePk!,
|
||||||
gameDate: game.gameDate
|
gameDate: game.gameDate
|
||||||
)
|
)
|
||||||
return raw.compactMap { highlight -> HighlightItem? in
|
return raw.enumerated().compactMap { index, highlight -> HighlightItem? in
|
||||||
guard let headline = highlight.headline,
|
guard let headline = highlight.headline,
|
||||||
let hlsStr = highlight.hlsURL ?? highlight.mp4URL,
|
let hlsStr = highlight.hlsURL ?? highlight.mp4URL,
|
||||||
let _ = URL(string: hlsStr) else { return nil }
|
let _ = URL(string: hlsStr) else { return nil }
|
||||||
@@ -54,15 +63,19 @@ final class FeedViewModel {
|
|||||||
let isCondensed = headline.lowercased().contains("condensed")
|
let isCondensed = headline.lowercased().contains("condensed")
|
||||||
|| headline.lowercased().contains("recap")
|
|| headline.lowercased().contains("recap")
|
||||||
|
|
||||||
|
let timestamp = parseHighlightDate(highlight.date)
|
||||||
|
?? Date(timeIntervalSince1970: TimeInterval(index))
|
||||||
|
|
||||||
return HighlightItem(
|
return HighlightItem(
|
||||||
id: highlight.id ?? UUID().uuidString,
|
id: highlight.id ?? "\(game.gamePk!)-\(index)",
|
||||||
headline: headline,
|
headline: headline,
|
||||||
gameTitle: "\(game.awayTeam.code) @ \(game.homeTeam.code)",
|
gameTitle: "\(game.awayTeam.code) @ \(game.homeTeam.code)",
|
||||||
awayCode: game.awayTeam.code,
|
awayCode: game.awayTeam.code,
|
||||||
homeCode: game.homeTeam.code,
|
homeCode: game.homeTeam.code,
|
||||||
hlsURL: highlight.hlsURL.flatMap(URL.init),
|
hlsURL: highlight.hlsURL.flatMap(URL.init),
|
||||||
mp4URL: highlight.mp4URL.flatMap(URL.init),
|
mp4URL: highlight.mp4URL.flatMap(URL.init),
|
||||||
isCondensedGame: isCondensed
|
isCondensedGame: isCondensed,
|
||||||
|
timestamp: timestamp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -75,7 +88,8 @@ final class FeedViewModel {
|
|||||||
for await batch in group {
|
for await batch in group {
|
||||||
allHighlights.append(contentsOf: batch)
|
allHighlights.append(contentsOf: batch)
|
||||||
}
|
}
|
||||||
highlights = allHighlights
|
// Sort all highlights by time, most recent first
|
||||||
|
highlights = allHighlights.sorted { $0.timestamp > $1.timestamp }
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
|||||||
@@ -121,25 +121,6 @@ struct DashboardView: View {
|
|||||||
}
|
}
|
||||||
.padding(.top, 80)
|
.padding(.top, 80)
|
||||||
} else {
|
} else {
|
||||||
// Live situation bar — compact strip of all live games
|
|
||||||
if !viewModel.liveGames.isEmpty {
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
LiveIndicator()
|
|
||||||
Text("LIVE NOW")
|
|
||||||
.font(DS.Fonts.caption)
|
|
||||||
.foregroundStyle(DS.Colors.live)
|
|
||||||
.kerning(1.5)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 4)
|
|
||||||
|
|
||||||
LiveSituationBar(games: viewModel.liveGames) { game in
|
|
||||||
selectedGame = game
|
|
||||||
}
|
|
||||||
.padding(.horizontal, -horizontalPadding)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hero featured game
|
// Hero featured game
|
||||||
if let featured = viewModel.featuredGame {
|
if let featured = viewModel.featuredGame {
|
||||||
FeaturedGameCard(game: featured) {
|
FeaturedGameCard(game: featured) {
|
||||||
@@ -147,6 +128,9 @@ 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 {
|
if !viewModel.scheduledGames.isEmpty {
|
||||||
gameShelf(title: "Upcoming", icon: "calendar", games: viewModel.scheduledGames, excludeId: viewModel.featuredGame?.id)
|
gameShelf(title: "Upcoming", icon: "calendar", games: viewModel.scheduledGames, excludeId: viewModel.featuredGame?.id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,23 +35,18 @@ struct FeedView: View {
|
|||||||
if viewModel.highlights.isEmpty && !viewModel.isLoading {
|
if viewModel.highlights.isEmpty && !viewModel.isLoading {
|
||||||
emptyState
|
emptyState
|
||||||
} else {
|
} else {
|
||||||
// Condensed Games
|
// All highlights in one horizontal scroll, ordered by time
|
||||||
if !viewModel.condensedGames.isEmpty {
|
ScrollView(.horizontal) {
|
||||||
highlightShelf(
|
LazyHStack(spacing: DS.Spacing.cardGap) {
|
||||||
title: "Condensed Games",
|
ForEach(viewModel.highlights) { item in
|
||||||
icon: "film.stack",
|
highlightCard(item)
|
||||||
items: viewModel.condensedGames
|
.frame(width: cardWidth)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
// Latest Highlights
|
|
||||||
if !viewModel.latestHighlights.isEmpty {
|
|
||||||
highlightShelf(
|
|
||||||
title: "Latest Highlights",
|
|
||||||
icon: "play.circle.fill",
|
|
||||||
items: viewModel.latestHighlights
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
.platformFocusSection()
|
||||||
|
.scrollClipDisabled()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, edgeInset)
|
.padding(.horizontal, edgeInset)
|
||||||
@@ -79,31 +74,6 @@ struct FeedView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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
|
@ViewBuilder
|
||||||
private func highlightCard(_ item: HighlightItem) -> some View {
|
private func highlightCard(_ item: HighlightItem) -> some View {
|
||||||
Button {
|
Button {
|
||||||
@@ -136,6 +106,25 @@ struct FeedView: View {
|
|||||||
.font(.system(size: playIconSize))
|
.font(.system(size: playIconSize))
|
||||||
.foregroundStyle(.white.opacity(0.8))
|
.foregroundStyle(.white.opacity(0.8))
|
||||||
.shadow(radius: 4)
|
.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)
|
.frame(height: thumbnailHeight)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: DS.Radii.compact))
|
.clipShape(RoundedRectangle(cornerRadius: DS.Radii.compact))
|
||||||
|
|||||||
Reference in New Issue
Block a user