Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d872d69
feat: dynamic match cache + team setup + spotting board modes
Apr 19, 2026
894a0da
feat(research): per-player storylines, matchup notes, NewsService
Apr 19, 2026
80efee7
feat(news): News tab + Gemini synthesis to Research notes
Apr 19, 2026
7cb589f
feat: tailored setup, notes drawer, article selection, curated synthesis
Apr 19, 2026
6cb9761
Merge remote-tracking branch 'origin/main' into feat/dynamic-match-fetch
NewCoder3294 Apr 19, 2026
d025c63
Merge main into feat/dynamic-match-fetch (drops PlayByPlayKit/NewMatc…
NewCoder3294 Apr 19, 2026
aa4e83c
fix: restore PlayByPlayKit + Plays/PlaysDB surfaces (vital infra drop…
NewCoder3294 Apr 19, 2026
9c91a7f
fix: restore main's UI architecture; layer teammate's TeamSetupView/N…
NewCoder3294 Apr 19, 2026
8d1c3c2
style: align sidebar BrandHeader and page StatusBarView to matching 7…
NewCoder3294 Apr 19, 2026
9fc709d
style(research): add DottedGrid behind CommentatorStylePickerView
NewCoder3294 Apr 19, 2026
328ec84
fix(research): remove duplicate StatusBarView from Stats/Story/Tactic…
NewCoder3294 Apr 19, 2026
e6f0ffb
style(research): stronger dots on empty-state background (2.4pt, 22pt…
NewCoder3294 Apr 19, 2026
b3ef136
fix(research): remove CASES; MY STYLE becomes standalone back pill; N…
NewCoder3294 Apr 19, 2026
5913a72
feat(sidebar): LANDING PAGE button in footer — opens localhost:3000 i…
NewCoder3294 Apr 19, 2026
df3adf4
rebrand: BroadcastBrain → Kleos (sidebar logo, setup header, app disp…
NewCoder3294 Apr 19, 2026
527c89b
chore(sidebar): remove LANDING PAGE footer button
NewCoder3294 Apr 19, 2026
575eab4
Merge remote-tracking branch 'origin/main' into feat/dynamic-match-fetch
NewCoder3294 Apr 19, 2026
bc11287
Merge branch 'feat/dynamic-match-fetch' of https://github.com/NewCode…
NewCoder3294 Apr 19, 2026
95493e4
docs(landing): remove Apple STT from pipeline, lean on Gemma 4 native…
NewCoder3294 Apr 19, 2026
dfffbad
feat(landing): swap SVG mockups for real product screenshots
NewCoder3294 Apr 19, 2026
1fd2346
fix: force TeamSetupView on first launch + structured per-article digest
Apr 19, 2026
4e0aac5
Merge remote-tracking branch 'origin/feat/dynamic-match-fetch' into f…
Apr 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions BroadcastBrain.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

33 changes: 21 additions & 12 deletions BroadcastBrain/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,28 @@ struct ContentView: View {
var body: some View {
@Bindable var bindable = store

HStack(spacing: 0) {
SidebarView()
.frame(width: theme.sidebarCollapsed ? 68 : 260)
Group {
if store.showingSetup {
TeamSetupView()
.transition(.opacity)
} else {
HStack(spacing: 0) {
SidebarView()
.frame(width: theme.sidebarCollapsed ? 68 : 260)

detailView
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.ignoresSafeArea(.container, edges: .top)
.background(Color.bgBase)
.animation(.easeInOut(duration: 0.2), value: theme.sidebarCollapsed)
.sheet(isPresented: $bindable.showNewMatchSheet) {
NewMatchSheet()
.environment(store)
detailView
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.ignoresSafeArea(.container, edges: .top)
.background(Color.bgBase)
.animation(.easeInOut(duration: 0.2), value: theme.sidebarCollapsed)
.sheet(isPresented: $bindable.showNewMatchSheet) {
NewMatchSheet()
.environment(store)
}
}
}
.animation(.easeInOut(duration: 0.2), value: store.showingSetup)
}

@ViewBuilder
Expand All @@ -29,6 +37,7 @@ struct ContentView: View {
case .live: LivePaneView()
case .squads: SquadsView()
case .research: ResearchCenterView()
case .news: NewsTabView()
case .archive: ArchivesListView()
case .plays: PlaysSearchView()
case .playsDB: PlaysDBView()
Expand Down
10 changes: 6 additions & 4 deletions BroadcastBrain/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Kleos</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<string>Kleos</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
Expand All @@ -21,10 +23,10 @@
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>NSHumanReadableCopyright</key>
<string>© 2026 BroadcastBrain</string>
<string>© 2026 Kleos</string>
<key>NSMicrophoneUsageDescription</key>
<string>BroadcastBrain listens to match commentary to surface stats.</string>
<string>Kleos listens to match commentary to surface stats.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>BroadcastBrain transcribes your voice on-device to know which stat to surface.</string>
<string>Kleos transcribes your voice on-device to know which stat to surface.</string>
</dict>
</plist>
583 changes: 583 additions & 0 deletions BroadcastBrain/Services/GameFetchService.swift

Large diffs are not rendered by default.

111 changes: 111 additions & 0 deletions BroadcastBrain/Services/GeminiService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import Foundation

enum GeminiError: LocalizedError {
case missingKey(String)
case badResponse(String)
case empty

var errorDescription: String? {
switch self {
case .missingKey(let path):
return "No Gemini API key. Save it to \(path)"
case .badResponse(let s): return "Gemini error: \(s)"
case .empty: return "Gemini returned an empty response."
}
}
}

enum GeminiService {

private static let model = "gemini-2.5-flash"

private static var keyPath: URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("BroadcastBrain/gemini_key.txt")
}

static func apiKey() -> String? {
guard let data = try? Data(contentsOf: keyPath),
let raw = String(data: data, encoding: .utf8) else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}

static func synthesizeNews(headlines: [NewsItem], matchTitle: String?, playerNames: [String], userCurated: Bool) async throws -> String {
guard let key = apiKey() else { throw GeminiError.missingKey(keyPath.path) }

let headlineList = headlines.prefix(80).enumerated().map { idx, h in
"\(idx + 1). [\(h.leagueLabel)] \(h.headline)\(h.description.isEmpty ? "" : " — \(h.description)")"
}.joined(separator: "\n")

let systemInstruction: String
let userPrompt: String

if userCurated {
// User hand-picked these — produce a structured per-article block
// with concrete facts the commentator can reach for mid-call.
systemInstruction = """
You are a broadcast prep assistant. The broadcaster hand-picked the headlines below.

For EVERY headline, output exactly one block in this format — never skip, never combine, never summarize multiple headlines into one block:

[N]. [Short title, ≤ 60 chars]
WHO: names of the people, teams, and clubs involved
WHAT: the concrete facts — scores, statlines, dates, injury status, trade terms, exact quotes. Numbers and names are mandatory when the headline provides them.
ANGLE: why a live commentator would mention this — the implication, narrative hook, or record on the line

Separate blocks with a blank line. Number sequentially starting at 1. Use plain text only (no markdown symbols like **, #, or \\*). If the headline is vague, still produce a block and say "details not in headline" for the missing field instead of omitting it. Total length under 500 words.
"""
userPrompt = "Selected headlines:\n\(headlineList)"
} else {
let matchLine = matchTitle.map { "Match: \($0)" } ?? "No specific match loaded."
let playerLine = playerNames.isEmpty ? "" : "Players on the match roster: \(playerNames.prefix(20).joined(separator: ", "))"
systemInstruction = """
You are a broadcast prep assistant. From the headlines, produce a tight set of talking points for a live commentator.
Prefer items relevant to the loaded match and its players, but still surface broader league context when nothing ties directly — never refuse with "no relevant information".
Group findings under these headings (omit any that truly have no content):
INJURIES & AVAILABILITY
FORM & RECENT RESULTS
STORYLINES & RIVALRY
WILDCARDS
Use short bullet points (1–2 sentences each). Keep the full response under 300 words. Plain text only, no markdown syntax.
"""
userPrompt = """
\(matchLine)
\(playerLine)

Headlines:
\(headlineList)
"""
}

let body: [String: Any] = [
"systemInstruction": ["parts": [["text": systemInstruction]]],
"contents": [["role": "user", "parts": [["text": userPrompt]]]],
"generationConfig": ["temperature": 0.4, "maxOutputTokens": 1024],
]
let jsonData = try JSONSerialization.data(withJSONObject: body)

var req = URLRequest(url: URL(string: "https://generativelanguage.googleapis.com/v1beta/models/\(model):generateContent?key=\(key)")!)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = jsonData

let (data, resp) = try await URLSession.shared.data(for: req)
guard let http = resp as? HTTPURLResponse else { throw GeminiError.badResponse("no response") }
guard (200..<300).contains(http.statusCode) else {
let msg = String(data: data, encoding: .utf8) ?? "status \(http.statusCode)"
throw GeminiError.badResponse(msg)
}

guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let candidates = json["candidates"] as? [[String: Any]],
let content = candidates.first?["content"] as? [String: Any],
let parts = content["parts"] as? [[String: Any]],
let text = parts.first?["text"] as? String,
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else { throw GeminiError.empty }

return text.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
175 changes: 175 additions & 0 deletions BroadcastBrain/Services/NewsService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import Foundation

// MARK: - Types

struct NewsItem: Codable, Identifiable, Hashable {
let id: String
let headline: String
let description: String
let published: String
let imageUrl: String?
let articleUrl: String?
let leagueKey: String
let leagueLabel: String
let source: NewsSource

enum NewsSource: String, Codable {
case espn
case googleNews = "google_news"
}
}

// MARK: - Service

enum NewsService {

private struct League {
let key: String
let sport: String
let league: String
let label: String
}

private static let leagues: [League] = [
League(key: "mlb", sport: "baseball", league: "mlb", label: "MLB"),
League(key: "nba", sport: "basketball", league: "nba", label: "NBA"),
League(key: "wnba", sport: "basketball", league: "wnba", label: "WNBA"),
League(key: "nfl", sport: "football", league: "nfl", label: "NFL"),
League(key: "ncaaf", sport: "football", league: "college-football",label: "NCAAF"),
League(key: "nhl", sport: "hockey", league: "nhl", label: "NHL"),
League(key: "epl", sport: "soccer", league: "eng.1", label: "EPL"),
League(key: "laliga", sport: "soccer", league: "esp.1", label: "La Liga"),
League(key: "seriea", sport: "soccer", league: "ita.1", label: "Serie A"),
League(key: "bundesliga", sport: "soccer", league: "ger.1", label: "Bundesliga"),
League(key: "ligue1", sport: "soccer", league: "fra.1", label: "Ligue 1"),
League(key: "ucl", sport: "soccer", league: "uefa.champions", label: "UCL"),
League(key: "mls", sport: "soccer", league: "usa.1", label: "MLS"),
]

private static let headers: [String: String] = [
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36",
"Accept-Language": "en-US,en;q=0.9",
]

// MARK: - Public API

static func fetchLeagueNews(leagueKey: String, limit: Int = 20) async -> [NewsItem] {
guard let league = leagues.first(where: { $0.key == leagueKey }),
let url = URL(string: "https://site.api.espn.com/apis/site/v2/sports/\(league.sport)/\(league.league)/news?limit=\(limit)"),
let data = try? await httpGet(url: url),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let articles = json["articles"] as? [[String: Any]] else { return [] }
return articles.map { espnArticleToNewsItem($0, leagueKey: league.key, leagueLabel: league.label) }
}

static func fetchAllSportsNews(limit: Int = 10) async -> [NewsItem] {
let mainLeagues = ["nfl", "nba", "mlb", "nhl", "epl", "mls"]
var all: [NewsItem] = []
await withTaskGroup(of: [NewsItem].self) { group in
for key in mainLeagues {
group.addTask { await fetchLeagueNews(leagueKey: key, limit: limit) }
}
for await items in group { all.append(contentsOf: items) }
}
return all.sorted {
let df = ISO8601DateFormatter()
let a = df.date(from: $0.published) ?? Date.distantPast
let b = df.date(from: $1.published) ?? Date.distantPast
return a > b
}
}

static func fetchPlayerNews(playerName: String, teamName: String = "", limit: Int = 5) async -> [NewsItem] {
let query = teamName.isEmpty ? playerName : "\(playerName) \(teamName)"
guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: "https://news.google.com/rss/search?q=\(encoded)&hl=en-US&gl=US&ceid=US:en"),
let data = try? await httpGet(url: url),
let xml = String(data: data, encoding: .utf8) else { return [] }
return parseGoogleNewsRSS(xml: xml, limit: limit, source: .googleNews)
}

// MARK: - Parsing

private static func espnArticleToNewsItem(_ a: [String: Any], leagueKey: String, leagueLabel: String) -> NewsItem {
let id = a["id"].map { "espn-\(leagueKey)-\($0)" } ?? "espn-\(leagueKey)-\(UUID().uuidString)"
let images = a["images"] as? [[String: Any]]
let links = a["links"] as? [String: Any]
let web = links?["web"] as? [String: Any]
return NewsItem(
id: id,
headline: a["headline"] as? String ?? "",
description: a["description"] as? String ?? "",
published: a["published"] as? String ?? ISO8601DateFormatter().string(from: Date()),
imageUrl: images?.first?["url"] as? String,
articleUrl: web?["href"] as? String,
leagueKey: leagueKey,
leagueLabel: leagueLabel,
source: .espn
)
}

private static func parseGoogleNewsRSS(xml: String, limit: Int, source: NewsItem.NewsSource) -> [NewsItem] {
var items: [NewsItem] = []
let pattern = try! NSRegularExpression(pattern: "<item>([\\s\\S]*?)</item>")
let range = NSRange(xml.startIndex..., in: xml)
for match in pattern.matches(in: xml, range: range) {
guard let contentRange = Range(match.range(at: 1), in: xml) else { continue }
let content = String(xml[contentRange])
let rawTitle = extractTag(xml: content, tag: "title") ?? ""
let headline = rawTitle.replacingOccurrences(
of: "\\s*-\\s*[^-]+$", with: "", options: .regularExpression
).trimmingCharacters(in: .whitespaces)
let description = stripHtml(extractTag(xml: content, tag: "description") ?? "")
let published = extractTag(xml: content, tag: "pubDate") ?? ISO8601DateFormatter().string(from: Date())
let link = extractTag(xml: content, tag: "link") ?? ""
guard !headline.isEmpty, headline != "Google News" else { continue }
let idBase = Data(link.utf8).base64EncodedString().prefix(16)
items.append(NewsItem(
id: "gnews-\(idBase)",
headline: headline,
description: description,
published: published,
imageUrl: nil,
articleUrl: link.isEmpty ? nil : link,
leagueKey: "player",
leagueLabel: "Player News",
source: source
))
if items.count >= limit { break }
}
return items
}

private static func extractTag(xml: String, tag: String) -> String? {
let open = "<\(tag)"
let close = "</\(tag)>"
guard let startRange = xml.range(of: open),
let gtRange = xml.range(of: ">", range: startRange.upperBound..<xml.endIndex),
let endRange = xml.range(of: close, range: gtRange.upperBound..<xml.endIndex) else { return nil }
var value = String(xml[gtRange.upperBound..<endRange.lowerBound]).trimmingCharacters(in: .whitespacesAndNewlines)
if value.hasPrefix("<![CDATA[") && value.hasSuffix("]]>") {
value = String(value.dropFirst(9).dropLast(3)).trimmingCharacters(in: .whitespacesAndNewlines)
}
return value.isEmpty ? nil : value
}

private static func stripHtml(_ html: String) -> String {
html.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
.replacingOccurrences(of: "&lt;", with: "<")
.replacingOccurrences(of: "&gt;", with: ">")
.replacingOccurrences(of: "&amp;", with: "&")
.trimmingCharacters(in: .whitespacesAndNewlines)
}

// MARK: - HTTP

private static func httpGet(url: URL) async throws -> Data {
var request = URLRequest(url: url)
for (k, v) in headers { request.setValue(v, forHTTPHeaderField: k) }
let (data, response) = try await URLSession.shared.data(for: request)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw URLError(.badServerResponse)
}
return data
}
}
Loading