diff --git a/apps/admin-ui/src/app/scripts/F1tvPanel.tsx b/apps/admin-ui/src/app/scripts/F1tvPanel.tsx
index 916fd5d..18591d5 100644
--- a/apps/admin-ui/src/app/scripts/F1tvPanel.tsx
+++ b/apps/admin-ui/src/app/scripts/F1tvPanel.tsx
@@ -302,6 +302,14 @@ export function F1tvPanel({ onChanged }: { onChanged?: (o: F1tvOverview) => void
)}
+
F1TV
diff --git a/apps/admin-ui/src/app/settings/page.tsx b/apps/admin-ui/src/app/settings/page.tsx
index 30cc379..a1e369b 100644
--- a/apps/admin-ui/src/app/settings/page.tsx
+++ b/apps/admin-ui/src/app/settings/page.tsx
@@ -10,6 +10,7 @@ type SettingsTab =
| "download-station"
| "opensubtitles"
| "omdb"
+ | "openf1"
| "google-play"
| "tv";
@@ -19,6 +20,7 @@ function parseTab(v: string | null): SettingsTab {
v === "download-station" ||
v === "opensubtitles" ||
v === "omdb" ||
+ v === "openf1" ||
v === "shelves" ||
v === "google-play" ||
v === "tv"
@@ -70,6 +72,7 @@ const TABS: Array<{ id: SettingsTab; label: string; short: string }> = [
{ id: "download-station", label: "Download Station", short: "DS" },
{ id: "opensubtitles", label: "OpenSubtitles", short: "Subs" },
{ id: "omdb", label: "OMDb", short: "OMDb" },
+ { id: "openf1", label: "OpenF1", short: "F1" },
{ id: "google-play", label: "Google Play", short: "Play" },
];
@@ -133,6 +136,16 @@ export default function SettingsPage() {
const [omdbErr, setOmdbErr] = useState(null);
const [omdbWarn, setOmdbWarn] = useState(null);
+ const [openF1Username, setOpenF1Username] = useState("");
+ const [openF1Password, setOpenF1Password] = useState("");
+ const [openF1Enabled, setOpenF1Enabled] = useState(true);
+ const [openF1Configured, setOpenF1Configured] = useState(false);
+ const [openF1HasPassword, setOpenF1HasPassword] = useState(false);
+ const [openF1Busy, setOpenF1Busy] = useState(false);
+ const [openF1Msg, setOpenF1Msg] = useState(null);
+ const [openF1Err, setOpenF1Err] = useState(null);
+ const [openF1Warn, setOpenF1Warn] = useState(null);
+
const [adminEmail, setAdminEmail] = useState("");
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
@@ -242,6 +255,21 @@ export default function SettingsPage() {
.catch(() => undefined);
}, []);
+ const loadOpenF1 = useCallback(() => {
+ fetch("/api/v1/admin/settings/openf1", { credentials: "include" })
+ .then((r) => r.json())
+ .then((d) => {
+ const s = d.settings;
+ if (!s) return;
+ setOpenF1Enabled(s.enabled !== false);
+ setOpenF1Configured(!!s.configured);
+ setOpenF1HasPassword(!!s.hasPassword);
+ setOpenF1Username(typeof s.username === "string" ? s.username : "");
+ setOpenF1Warn(s.warning || null);
+ })
+ .catch(() => undefined);
+ }, []);
+
const loadGooglePlay = useCallback(() => {
fetch("/api/v1/admin/settings/google-play", { credentials: "include" })
.then((r) => r.json())
@@ -311,6 +339,7 @@ export default function SettingsPage() {
loadDs();
loadOpenSubtitles();
loadOmdb();
+ loadOpenF1();
loadGooglePlay();
loadTvSettings();
loadTokenTray();
@@ -326,7 +355,7 @@ export default function SettingsPage() {
}
})
.catch(() => undefined);
- }, [loadShelves, loadDs, loadOpenSubtitles, loadOmdb, loadGooglePlay, loadTvSettings, loadTokenTray]);
+ }, [loadShelves, loadDs, loadOpenSubtitles, loadOmdb, loadOpenF1, loadGooglePlay, loadTvSettings, loadTokenTray]);
async function saveInstallPageEnabled(enabled: boolean) {
setInstallPageBusy(true);
@@ -580,6 +609,36 @@ export default function SettingsPage() {
}
}
+ async function saveOpenF1() {
+ setOpenF1Busy(true);
+ setOpenF1Msg(null);
+ setOpenF1Err(null);
+ setOpenF1Warn(null);
+ try {
+ const res = await fetch("/api/v1/admin/settings/openf1", {
+ method: "PUT",
+ credentials: "include",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ username: openF1Username.trim() || undefined,
+ password: openF1Password || undefined,
+ enabled: openF1Enabled,
+ }),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ setOpenF1Err(data.error?.message ?? "Opslaan mislukt");
+ return;
+ }
+ setOpenF1Msg(data.settings?.configured ? "Opgeslagen — OpenF1 OK" : "Opgeslagen");
+ setOpenF1Warn(data.settings?.warning || null);
+ setOpenF1Password("");
+ loadOpenF1();
+ } finally {
+ setOpenF1Busy(false);
+ }
+ }
+
async function saveGooglePlay() {
setGpBusy(true);
setGpMsg(null);
@@ -1482,6 +1541,64 @@ export default function SettingsPage() {
)}
+ {tab === "openf1" && (
+
+
+
OpenF1
+
+ Live timing, circuitkaart en telemetrie voor de F1-hub. Account van{" "}
+
+ openf1.org
+
+ . E-mail en wachtwoord worden versleuteld in de database bewaard (niet in env/git).
+
+
+
+
+ )}
+
{tab === "google-play" && (
diff --git a/apps/ios/VMC.xcodeproj/project.pbxproj b/apps/ios/VMC.xcodeproj/project.pbxproj
index dca88d3..08e1ea5 100644
--- a/apps/ios/VMC.xcodeproj/project.pbxproj
+++ b/apps/ios/VMC.xcodeproj/project.pbxproj
@@ -34,6 +34,7 @@
A72DE8139DAE48EC9975E0CF /* LiveTvScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */; };
E5F6A7B8C9D0E1F2A3B4C5D6 /* EventsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A2B3C4D5E6F7A8B9C0D1E2 /* EventsScreen.swift */; };
B7C3E9F1B2D4068E5A1C3E71 /* EventHubsScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7C3E9F1B2D4068E5A1C3E71 /* EventHubsScreens.swift */; };
+ D7C3E9F1B2D4068E5A1C3E80 /* F1LiveScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7C3E9F1B2D4068E5A1C3E80 /* F1LiveScreen.swift */; };
A1CFC814F3894773A5BEA652 /* SettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */; };
B8D4E1F2A3C54B6D9E0F1A2B /* DevicesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */; };
B7C2E91A4F8D4A1E9C3B6D20 /* QualityBadges.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */; };
@@ -71,6 +72,7 @@
98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTvScreen.swift; sourceTree = "
"; };
F1A2B3C4D5E6F7A8B9C0D1E2 /* EventsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventsScreen.swift; sourceTree = ""; };
A7C3E9F1B2D4068E5A1C3E71 /* EventHubsScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventHubsScreens.swift; sourceTree = ""; };
+ C7C3E9F1B2D4068E5A1C3E80 /* F1LiveScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = F1LiveScreen.swift; sourceTree = ""; };
470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = ""; };
C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesScreen.swift; sourceTree = ""; };
D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QualityBadges.swift; sourceTree = ""; };
@@ -189,6 +191,7 @@
98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */,
F1A2B3C4D5E6F7A8B9C0D1E2 /* EventsScreen.swift */,
A7C3E9F1B2D4068E5A1C3E71 /* EventHubsScreens.swift */,
+ C7C3E9F1B2D4068E5A1C3E80 /* F1LiveScreen.swift */,
470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */,
C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */,
);
@@ -295,6 +298,7 @@
A72DE8139DAE48EC9975E0CF /* LiveTvScreen.swift in Sources */,
E5F6A7B8C9D0E1F2A3B4C5D6 /* EventsScreen.swift in Sources */,
B7C3E9F1B2D4068E5A1C3E71 /* EventHubsScreens.swift in Sources */,
+ D7C3E9F1B2D4068E5A1C3E80 /* F1LiveScreen.swift in Sources */,
A1CFC814F3894773A5BEA652 /* SettingsScreen.swift in Sources */,
B8D4E1F2A3C54B6D9E0F1A2B /* DevicesScreen.swift in Sources */,
);
@@ -345,7 +349,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 72;
+ CURRENT_PROJECT_VERSION = 73;
DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -360,7 +364,7 @@
"$(inherited)",
"@executable_path/Frameworks"
);
- MARKETING_VERSION = 0.5.59;
+ MARKETING_VERSION = 0.5.60;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -378,7 +382,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 72;
+ CURRENT_PROJECT_VERSION = 73;
DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -393,7 +397,7 @@
"$(inherited)",
"@executable_path/Frameworks"
);
- MARKETING_VERSION = 0.5.59;
+ MARKETING_VERSION = 0.5.60;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)";
diff --git a/apps/ios/VMC/Data/ApiClient.swift b/apps/ios/VMC/Data/ApiClient.swift
index e956e89..db6051f 100644
--- a/apps/ios/VMC/Data/ApiClient.swift
+++ b/apps/ios/VMC/Data/ApiClient.swift
@@ -354,6 +354,10 @@ actor ApiClient {
try await get("/api/v1/client/f1/home")
}
+ func f1Live() async throws -> F1LiveDashboard {
+ try await get("/api/v1/client/f1/live")
+ }
+
func f1Meeting(meetingKey: String) async throws -> F1MeetingDetail {
let enc = Self.encodePathSegment(meetingKey)
return try await get("/api/v1/client/f1/meetings/\(enc)")
diff --git a/apps/ios/VMC/Data/Models.swift b/apps/ios/VMC/Data/Models.swift
index d6f5f61..67ddf18 100644
--- a/apps/ios/VMC/Data/Models.swift
+++ b/apps/ios/VMC/Data/Models.swift
@@ -768,7 +768,175 @@ struct ViaplayHubPayload: Decodable {
catchup = try c.decodeIfPresent([ScheduleEvent].self, forKey: .catchup) ?? []
sports = try c.decodeIfPresent([ViaplaySport].self, forKey: .sports) ?? []
events = try c.decodeIfPresent([ScheduleEvent].self, forKey: .events) ?? []
- rails = try c.decodeIfPresent([ViaplayHubRail].self, forKey: .rails) ?? []
+ rails = (try? c.decodeIfPresent([ViaplayHubRail].self, forKey: .rails)) ?? []
+ }
+}
+
+// MARK: - F1 Live (OpenF1)
+
+struct F1NewsItem: Decodable, Identifiable, Hashable {
+ let id: String
+ let title: String
+ let url: String
+ let imageUrl: String?
+ let publishedAt: String?
+ let source: String?
+}
+
+struct F1LiveDriver: Decodable, Identifiable, Hashable {
+ var id: Int { driverNumber }
+ let position: Int
+ let driverNumber: Int
+ let acronym: String
+ let fullName: String
+ let teamName: String
+ let teamColor: String
+ let headshotUrl: String?
+ let gap: String
+ let interval: String
+ let lastLap: String
+ let bestLap: String
+ let tyre: String
+ let tyreAge: Int?
+ let laps: Int?
+
+ enum CodingKeys: String, CodingKey {
+ case position, driverNumber, acronym, fullName, teamName, teamColor, headshotUrl
+ case gap, interval, lastLap, bestLap, tyre, tyreAge, laps
+ }
+
+ init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ position = (try? c.decode(Int.self, forKey: .position)) ?? 0
+ driverNumber = (try? c.decode(Int.self, forKey: .driverNumber)) ?? 0
+ acronym = (try? c.decode(String.self, forKey: .acronym)) ?? ""
+ fullName = (try? c.decode(String.self, forKey: .fullName)) ?? ""
+ teamName = (try? c.decode(String.self, forKey: .teamName)) ?? ""
+ teamColor = (try? c.decode(String.self, forKey: .teamColor)) ?? "888888"
+ headshotUrl = try c.decodeIfPresent(String.self, forKey: .headshotUrl)
+ gap = (try? c.decode(String.self, forKey: .gap)) ?? "—"
+ interval = (try? c.decode(String.self, forKey: .interval)) ?? "—"
+ lastLap = (try? c.decode(String.self, forKey: .lastLap)) ?? "—"
+ bestLap = (try? c.decode(String.self, forKey: .bestLap)) ?? "—"
+ tyre = (try? c.decode(String.self, forKey: .tyre)) ?? "—"
+ if let i = try? c.decode(Int.self, forKey: .tyreAge) { tyreAge = i }
+ else if let d = try? c.decode(Double.self, forKey: .tyreAge) { tyreAge = Int(d) }
+ else { tyreAge = nil }
+ if let i = try? c.decode(Int.self, forKey: .laps) { laps = i }
+ else if let d = try? c.decode(Double.self, forKey: .laps) { laps = Int(d) }
+ else { laps = nil }
+ }
+}
+
+struct F1LiveCar: Decodable, Hashable {
+ let driverNumber: Int
+ let acronym: String
+ let teamColor: String
+ let x: Double
+ let y: Double
+}
+
+struct F1LivePoint: Decodable, Hashable {
+ let x: Double
+ let y: Double
+}
+
+struct F1LiveMap: Decodable {
+ let name: String?
+ let path: [F1LivePoint]
+ let cars: [F1LiveCar]
+
+ enum CodingKeys: String, CodingKey { case name, path, cars }
+
+ init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ name = try c.decodeIfPresent(String.self, forKey: .name)
+ path = (try? c.decode([F1LivePoint].self, forKey: .path)) ?? []
+ cars = (try? c.decode([F1LiveCar].self, forKey: .cars)) ?? []
+ }
+}
+
+struct F1LiveTelemetry: Decodable {
+ let driverNumber: Int
+ let acronym: String
+ let speed: Double?
+ let throttle: Double?
+ let brake: Double?
+ let rpm: Double?
+ let gear: Double?
+ let drs: Bool?
+}
+
+struct F1LiveWeather: Decodable {
+ let airTemperature: Double?
+ let trackTemperature: Double?
+ let humidity: Double?
+ let windSpeed: Double?
+ let windDirection: Double?
+ let rainfall: Bool?
+}
+
+struct F1LiveRaceControl: Decodable, Identifiable, Hashable {
+ var id: String { "\(date ?? "")-\(message)" }
+ let date: String?
+ let category: String?
+ let flag: String?
+ let message: String
+ let driverNumber: Int?
+}
+
+struct F1LiveScheduleItem: Decodable, Identifiable, Hashable {
+ var id: String { "\(sessionName)-\(dateStart ?? "")" }
+ let sessionName: String
+ let dateStart: String?
+ let dateEnd: String?
+ let status: String
+}
+
+struct F1LiveSession: Decodable {
+ let sessionKey: Int?
+ let meetingKey: Int?
+ let meetingName: String?
+ let sessionName: String?
+ let sessionType: String?
+ let circuitName: String?
+ let countryName: String?
+ let countryCode: String?
+ let location: String?
+ let dateStart: String?
+ let dateEnd: String?
+ let year: Int?
+ let currentLap: Int?
+}
+
+struct F1LiveDashboard: Decodable {
+ let live: Bool
+ let source: String?
+ let session: F1LiveSession
+ let weather: F1LiveWeather?
+ let timing: [F1LiveDriver]
+ let map: F1LiveMap?
+ let telemetry: F1LiveTelemetry?
+ let raceControl: [F1LiveRaceControl]
+ let schedule: [F1LiveScheduleItem]
+ let news: [F1NewsItem]
+
+ enum CodingKeys: String, CodingKey {
+ case live, source, session, weather, timing, map, telemetry, raceControl, schedule, news
+ }
+
+ init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ live = (try? c.decode(Bool.self, forKey: .live)) ?? false
+ source = try c.decodeIfPresent(String.self, forKey: .source)
+ session = try c.decode(F1LiveSession.self, forKey: .session)
+ weather = try c.decodeIfPresent(F1LiveWeather.self, forKey: .weather)
+ timing = (try? c.decode([F1LiveDriver].self, forKey: .timing)) ?? []
+ map = try c.decodeIfPresent(F1LiveMap.self, forKey: .map)
+ telemetry = try c.decodeIfPresent(F1LiveTelemetry.self, forKey: .telemetry)
+ raceControl = (try? c.decode([F1LiveRaceControl].self, forKey: .raceControl)) ?? []
+ schedule = (try? c.decode([F1LiveScheduleItem].self, forKey: .schedule)) ?? []
+ news = (try? c.decode([F1NewsItem].self, forKey: .news)) ?? []
}
}
diff --git a/apps/ios/VMC/UI/EventHubsScreens.swift b/apps/ios/VMC/UI/EventHubsScreens.swift
index 96a9bbc..c1db9f0 100644
--- a/apps/ios/VMC/UI/EventHubsScreens.swift
+++ b/apps/ios/VMC/UI/EventHubsScreens.swift
@@ -4,6 +4,7 @@ import SwiftUI
enum EventHubRoute: Hashable {
case f1
+ case f1Live
case f1Meeting(String)
case viaplay
@@ -22,6 +23,7 @@ struct EventHubDestination: View {
var body: some View {
switch route {
case .f1: F1HubScreen()
+ case .f1Live: F1LiveScreen()
case .f1Meeting(let key): F1MeetingScreen(meetingKey: key)
case .viaplay: ViaplayHubScreen()
}
@@ -169,6 +171,7 @@ struct F1HubScreen: View {
VStack(alignment: .leading, spacing: 32) {
hero(p, layout)
+ liveTimingEntry(live: !p.live.isEmpty)
if isEmpty {
ContentUnavailableView(
@@ -245,20 +248,22 @@ struct F1HubScreen: View {
Label(state == .live ? "Kijk live" : "Terugkijken", systemImage: "play.fill")
}
.buttonStyle(HubPrimaryButtonStyle())
- if let meeting {
- NavigationLink(value: EventHubRoute.f1Meeting(meeting.meetingKey)) {
- Label("Weekend", systemImage: "calendar")
- }
- .buttonStyle(HubGlassButtonStyle())
+ NavigationLink(value: EventHubRoute.f1Live) {
+ Label("Timing", systemImage: "timer")
}
+ .buttonStyle(HubGlassButtonStyle())
} else if let meeting {
NavigationLink(value: EventHubRoute.f1Meeting(meeting.meetingKey)) {
Label("Bekijk weekend", systemImage: "calendar")
}
.buttonStyle(HubPrimaryButtonStyle())
+ NavigationLink(value: EventHubRoute.f1Live) {
+ Label("Timing", systemImage: "timer")
+ }
+ .buttonStyle(HubGlassButtonStyle())
} else {
- NavigationLink(value: featured.id) {
- Label("Details", systemImage: "info.circle")
+ NavigationLink(value: EventHubRoute.f1Live) {
+ Label("Live timing", systemImage: "timer")
}
.buttonStyle(HubPrimaryButtonStyle())
}
@@ -279,6 +284,10 @@ struct F1HubScreen: View {
Label("Bekijk weekend", systemImage: "calendar")
}
.buttonStyle(HubPrimaryButtonStyle())
+ NavigationLink(value: EventHubRoute.f1Live) {
+ Label("Timing", systemImage: "timer")
+ }
+ .buttonStyle(HubGlassButtonStyle())
}
} else {
HubHero(
@@ -289,10 +298,58 @@ struct F1HubScreen: View {
isLive: false,
title: "Formule 1",
subtitle: "Live sessies en replays"
- ) { EmptyView() }
+ ) {
+ NavigationLink(value: EventHubRoute.f1Live) {
+ Label("Live timing", systemImage: "timer")
+ }
+ .buttonStyle(HubPrimaryButtonStyle())
+ }
}
}
+ private func liveTimingEntry(live: Bool) -> some View {
+ NavigationLink(value: EventHubRoute.f1Live) {
+ HStack(spacing: 14) {
+ ZStack {
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
+ .fill(theme.accent)
+ Image(systemName: "flag.checkered")
+ .font(.system(size: 18, weight: .bold))
+ .foregroundStyle(.white)
+ }
+ .frame(width: 48, height: 48)
+ VStack(alignment: .leading, spacing: 3) {
+ Text("Live timing")
+ .font(.system(size: 17, weight: .bold))
+ .foregroundStyle(.white)
+ Text("Posities, circuitkaart en telemetrie")
+ .font(.system(size: 13, weight: .medium))
+ .foregroundStyle(.white.opacity(0.62))
+ }
+ Spacer(minLength: 8)
+ if live {
+ Text("LIVE")
+ .font(.system(size: 11, weight: .heavy))
+ .foregroundStyle(.white)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(HubTheme.liveRed, in: Capsule())
+ }
+ Image(systemName: "chevron.right")
+ .font(.system(size: 13, weight: .bold))
+ .foregroundStyle(.white.opacity(0.45))
+ }
+ .padding(14)
+ .background(Color.white.opacity(0.07), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
+ .strokeBorder(.white.opacity(0.12), lineWidth: 1)
+ )
+ .padding(.horizontal, 20)
+ }
+ .buttonStyle(HubPressStyle())
+ }
+
private func seasonSection(_ p: F1HubPayload) -> some View {
let nowMs = now.timeIntervalSince1970 * 1000
let nextKey = p.nextMeeting?.meetingKey
diff --git a/apps/ios/VMC/UI/F1LiveScreen.swift b/apps/ios/VMC/UI/F1LiveScreen.swift
new file mode 100644
index 0000000..6dc76dc
--- /dev/null
+++ b/apps/ios/VMC/UI/F1LiveScreen.swift
@@ -0,0 +1,697 @@
+import SwiftUI
+
+private enum F1LiveLook {
+ static let bg = Color(red: 0.043, green: 0.051, blue: 0.071)
+ static let card = Color(red: 0.086, green: 0.102, blue: 0.137)
+ static let stroke = Color.white.opacity(0.10)
+ static let live = Color(red: 1.0, green: 0.231, blue: 0.188)
+ static let gold = Color(red: 1.0, green: 0.82, blue: 0.32)
+}
+
+struct F1LiveScreen: View {
+ enum Tab: String, CaseIterable, Identifiable {
+ case overview, timing, map, telemetry, news
+ var id: String { rawValue }
+ var label: String {
+ switch self {
+ case .overview: return "Overzicht"
+ case .timing: return "Live Timing"
+ case .map: return "Track Map"
+ case .telemetry: return "Telemetrie"
+ case .news: return "Nieuws"
+ }
+ }
+ }
+
+ @EnvironmentObject private var app: AppModel
+ @Environment(\.dismiss) private var dismiss
+ @Environment(\.openURL) private var openURL
+
+ @State private var tab: Tab = .overview
+ @State private var payload: F1LiveDashboard?
+ @State private var error: String?
+ @State private var speedTrail: [Double] = []
+
+ private let clock = Timer.publish(every: 4, on: .main, in: .common).autoconnect()
+
+ var body: some View {
+ ZStack(alignment: .top) {
+ F1LiveLook.bg.ignoresSafeArea()
+ if let payload {
+ loaded(payload)
+ } else if let error {
+ VStack(spacing: 14) {
+ header(live: false, title: "F1 Live", subtitle: nil)
+ ContentUnavailableView {
+ Label("Live timing niet beschikbaar", systemImage: "flag.checkered")
+ } description: {
+ Text(error)
+ } actions: {
+ Button("Opnieuw") { Task { await load() } }
+ .buttonStyle(McPrimaryButtonStyle())
+ }
+ Spacer()
+ }
+ } else {
+ VStack {
+ header(live: false, title: "F1 Live", subtitle: nil)
+ Spacer()
+ ProgressView().controlSize(.large).tint(.white)
+ Spacer()
+ }
+ }
+ }
+ .environment(\.colorScheme, .dark)
+ .navigationBarBackButtonHidden(true)
+ .toolbar(.hidden, for: .navigationBar)
+ .task { await load() }
+ .onReceive(clock) { _ in Task { await load(silent: true) } }
+ }
+
+ @ViewBuilder
+ private func loaded(_ p: F1LiveDashboard) -> some View {
+ VStack(spacing: 0) {
+ header(
+ live: p.live,
+ title: p.session.meetingName ?? "F1 Live",
+ subtitle: [
+ p.session.sessionName,
+ p.session.circuitName,
+ p.session.currentLap.map { "Ronde \($0)" },
+ ].compactMap { $0 }.joined(separator: " · ")
+ )
+ tabBar
+ ScrollView {
+ Group {
+ switch tab {
+ case .overview: overview(p)
+ case .timing: timingTable(p.timing)
+ case .map: mapBlock(p)
+ case .telemetry: telemetryBlock(p)
+ case .news: newsList(p.news)
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.top, 14)
+ .padding(.bottom, 40)
+ }
+ }
+ }
+
+ private func header(live: Bool, title: String, subtitle: String?) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(spacing: 10) {
+ Button { dismiss() } label: {
+ Image(systemName: "chevron.left")
+ .font(.system(size: 15, weight: .semibold))
+ .foregroundStyle(.white)
+ .frame(width: 36, height: 36)
+ .background(.white.opacity(0.10), in: Circle())
+ }
+ VStack(alignment: .leading, spacing: 2) {
+ Text("F1 Live")
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(.white.opacity(0.55))
+ Text(title)
+ .font(.system(size: 20, weight: .bold))
+ .foregroundStyle(.white)
+ .lineLimit(1)
+ }
+ Spacer(minLength: 8)
+ if live {
+ Text("LIVE")
+ .font(.system(size: 11, weight: .heavy))
+ .foregroundStyle(.white)
+ .padding(.horizontal, 10)
+ .padding(.vertical, 5)
+ .background(F1LiveLook.live, in: Capsule())
+ }
+ }
+ HStack {
+ Text("Aangedreven door OpenF1")
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(.white.opacity(0.4))
+ Spacer()
+ if let subtitle, !subtitle.isEmpty {
+ Text(subtitle)
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(.white.opacity(0.45))
+ .lineLimit(1)
+ }
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.top, 6)
+ .padding(.bottom, 10)
+ }
+
+ private var tabBar: some View {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 8) {
+ ForEach(Tab.allCases) { item in
+ Button {
+ tab = item
+ } label: {
+ Text(item.label)
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(tab == item ? .white : .white.opacity(0.55))
+ .padding(.horizontal, 14)
+ .frame(height: 34)
+ .background(tab == item ? F1LiveLook.live : Color.white.opacity(0.08), in: Capsule())
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(.horizontal, 16)
+ }
+ .padding(.bottom, 4)
+ }
+
+ private func overview(_ p: F1LiveDashboard) -> some View {
+ VStack(alignment: .leading, spacing: 16) {
+ if let w = p.weather {
+ weatherCard(w, circuit: p.session.circuitName)
+ }
+ card {
+ sectionTitle("Live posities")
+ ForEach(Array(p.timing.prefix(8))) { d in
+ timingRow(d, compact: true)
+ }
+ if p.timing.count > 8 {
+ Button {
+ tab = .timing
+ } label: {
+ Text("Bekijk alles")
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(.white.opacity(0.7))
+ .frame(maxWidth: .infinity)
+ .padding(.top, 6)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ HStack(spacing: 10) {
+ shortcut("Live Timing", "timer") { tab = .timing }
+ shortcut("Track Map", "map") { tab = .map }
+ shortcut("Telemetrie", "speedometer") { tab = .telemetry }
+ }
+ if let t = p.telemetry {
+ telemetryCard(t)
+ }
+ if !p.news.isEmpty {
+ sectionTitle("Nieuws")
+ ForEach(p.news.prefix(4)) { item in
+ newsCard(item)
+ }
+ }
+ }
+ }
+
+ private func timingTable(_ rows: [F1LiveDriver]) -> some View {
+ card {
+ HStack {
+ Text("POS").frame(width: 28, alignment: .leading)
+ Text("Coureur").frame(maxWidth: .infinity, alignment: .leading)
+ Text("Verschil").frame(width: 72, alignment: .trailing)
+ Text("Lap").frame(width: 64, alignment: .trailing)
+ Text("").frame(width: 22)
+ }
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundStyle(.white.opacity(0.4))
+ ForEach(rows) { d in
+ timingRow(d, compact: false)
+ }
+ }
+ }
+
+ private func timingRow(_ d: F1LiveDriver, compact: Bool) -> some View {
+ HStack(spacing: 8) {
+ Text("\(d.position)")
+ .font(.system(size: 13, weight: .bold, design: .rounded))
+ .monospacedDigit()
+ .foregroundStyle(.white)
+ .frame(width: 22, alignment: .leading)
+ Circle()
+ .fill(F1Hex.color(d.teamColor))
+ .frame(width: 8, height: 8)
+ VStack(alignment: .leading, spacing: 1) {
+ Text(d.acronym)
+ .font(.system(size: 14, weight: .bold))
+ .foregroundStyle(.white)
+ if !compact {
+ Text(d.teamName)
+ .font(.system(size: 11))
+ .foregroundStyle(.white.opacity(0.45))
+ .lineLimit(1)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ Text(d.position == 1 ? "—" : d.gap)
+ .font(.system(size: 12, weight: .semibold, design: .monospaced))
+ .foregroundStyle(d.position == 1 ? F1LiveLook.gold : .white.opacity(0.85))
+ if !compact {
+ Text(d.lastLap)
+ .font(.system(size: 11, weight: .medium, design: .monospaced))
+ .foregroundStyle(.white.opacity(0.65))
+ .frame(width: 64, alignment: .trailing)
+ }
+ tyreBadge(d.tyre)
+ }
+ .padding(.vertical, 6)
+ }
+
+ private func mapBlock(_ p: F1LiveDashboard) -> some View {
+ VStack(alignment: .leading, spacing: 14) {
+ card {
+ HStack {
+ Text(p.map?.name ?? p.session.circuitName ?? "Circuit")
+ .font(.system(size: 16, weight: .bold))
+ .foregroundStyle(.white)
+ Spacer()
+ Text("Van boven · live dots")
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(.white.opacity(0.4))
+ }
+ F1TrackMapCanvas(path: p.map?.path ?? [], cars: p.map?.cars ?? [])
+ .frame(height: 280)
+ .padding(.top, 8)
+ }
+ if let leader = p.timing.first {
+ driverSpotlight(leader)
+ }
+ if let t = p.telemetry {
+ telemetryCard(t)
+ }
+ }
+ }
+
+ private func telemetryBlock(_ p: F1LiveDashboard) -> some View {
+ VStack(alignment: .leading, spacing: 14) {
+ if let t = p.telemetry {
+ telemetryCard(t)
+ if speedTrail.count > 1 {
+ card {
+ sectionTitle("Snelheid")
+ F1Sparkline(values: speedTrail)
+ .stroke(F1LiveLook.live, lineWidth: 2)
+ .frame(height: 120)
+ }
+ }
+ } else {
+ Text("Geen telemetrie voor deze sessie.")
+ .font(.system(size: 14))
+ .foregroundStyle(.white.opacity(0.5))
+ }
+ if !p.raceControl.isEmpty {
+ card {
+ sectionTitle("Race control")
+ ForEach(p.raceControl.prefix(8)) { item in
+ HStack(alignment: .top, spacing: 10) {
+ Circle()
+ .fill(flagColor(item.flag))
+ .frame(width: 8, height: 8)
+ .padding(.top, 5)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(item.message)
+ .font(.system(size: 13, weight: .medium))
+ .foregroundStyle(.white)
+ if let date = item.date {
+ Text(F1LiveFormat.clock(date))
+ .font(.system(size: 11))
+ .foregroundStyle(.white.opacity(0.4))
+ }
+ }
+ }
+ .padding(.vertical, 4)
+ }
+ }
+ }
+ }
+ }
+
+ private func newsList(_ items: [F1NewsItem]) -> some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("RacingNews365")
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(.white.opacity(0.45))
+ ForEach(items) { item in
+ newsCard(item)
+ }
+ }
+ }
+
+ private func weatherCard(_ w: F1LiveWeather, circuit: String?) -> some View {
+ card {
+ HStack {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(circuit ?? "Weer")
+ .font(.system(size: 15, weight: .bold))
+ .foregroundStyle(.white)
+ Text(w.rainfall == true ? "Regen" : "Droog")
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(.white.opacity(0.5))
+ }
+ Spacer()
+ weatherStat("Lucht", w.airTemperature.map { "\(Int($0.rounded()))°" } ?? "—")
+ weatherStat("Baan", w.trackTemperature.map { "\(Int($0.rounded()))°" } ?? "—")
+ weatherStat("Vocht", w.humidity.map { "\(Int($0.rounded()))%" } ?? "—")
+ }
+ }
+ }
+
+ private func weatherStat(_ label: String, _ value: String) -> some View {
+ VStack(spacing: 2) {
+ Text(value)
+ .font(.system(size: 16, weight: .bold, design: .rounded))
+ .foregroundStyle(.white)
+ Text(label)
+ .font(.system(size: 10, weight: .medium))
+ .foregroundStyle(.white.opacity(0.4))
+ }
+ .frame(width: 54)
+ }
+
+ private func telemetryCard(_ t: F1LiveTelemetry) -> some View {
+ card {
+ HStack {
+ sectionTitle("Live telemetrie")
+ Spacer()
+ Text(t.acronym)
+ .font(.system(size: 13, weight: .bold))
+ .foregroundStyle(.white.opacity(0.6))
+ }
+ HStack(alignment: .bottom, spacing: 16) {
+ HStack(alignment: .firstTextBaseline, spacing: 4) {
+ Text("\(Int(t.speed ?? 0))")
+ .font(.system(size: 44, weight: .bold, design: .rounded))
+ .foregroundStyle(.white)
+ Text("km/h")
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundStyle(.white.opacity(0.45))
+ }
+ Spacer()
+ Text("\(Int(t.gear ?? 0))")
+ .font(.system(size: 36, weight: .bold, design: .rounded))
+ .foregroundStyle(F1LiveLook.gold)
+ }
+ meter("Gas", (t.throttle ?? 0) / 100, .green)
+ meter("Rem", (t.brake ?? 0) / 100, F1LiveLook.live)
+ HStack {
+ Text("RPM \(t.rpm.map { "\(Int($0))" } ?? "—")")
+ Spacer()
+ if t.drs == true {
+ Text("DRS")
+ .font(.system(size: 11, weight: .heavy))
+ .padding(.horizontal, 8)
+ .padding(.vertical, 3)
+ .background(Color.green.opacity(0.25), in: Capsule())
+ }
+ }
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundStyle(.white.opacity(0.55))
+ }
+ }
+
+ private func meter(_ label: String, _ value: Double, _ color: Color) -> some View {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text(label)
+ Spacer()
+ Text("\(Int((value * 100).clamped(to: 0...100)))%")
+ }
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundStyle(.white.opacity(0.5))
+ GeometryReader { geo in
+ ZStack(alignment: .leading) {
+ Capsule().fill(Color.white.opacity(0.08))
+ Capsule()
+ .fill(color)
+ .frame(width: max(4, geo.size.width * value.clamped(to: 0...1)))
+ }
+ }
+ .frame(height: 8)
+ }
+ }
+
+ private func driverSpotlight(_ d: F1LiveDriver) -> some View {
+ card {
+ HStack(spacing: 12) {
+ ZStack {
+ Circle().fill(F1Hex.color(d.teamColor).opacity(0.25))
+ if let url = d.headshotUrl {
+ RemoteImage(url: url, contentMode: .fill)
+ .clipShape(Circle())
+ }
+ }
+ .frame(width: 64, height: 64)
+ VStack(alignment: .leading, spacing: 3) {
+ Text(d.fullName)
+ .font(.system(size: 16, weight: .bold))
+ .foregroundStyle(.white)
+ Text(d.teamName)
+ .font(.system(size: 12))
+ .foregroundStyle(.white.opacity(0.5))
+ }
+ Spacer()
+ Text("P\(d.position)")
+ .font(.system(size: 22, weight: .black, design: .rounded))
+ .foregroundStyle(.white)
+ }
+ HStack {
+ Text("Laatste \(d.lastLap)")
+ Spacer()
+ Text("Beste \(d.bestLap)")
+ }
+ .font(.system(size: 12, weight: .medium, design: .monospaced))
+ .foregroundStyle(.white.opacity(0.55))
+ .padding(.top, 6)
+ }
+ }
+
+ private func newsCard(_ item: F1NewsItem) -> some View {
+ Button {
+ if let url = URL(string: item.url) { openURL(url) }
+ } label: {
+ HStack(alignment: .top, spacing: 12) {
+ RemoteImage(url: item.imageUrl, contentMode: .fill)
+ .frame(width: 92, height: 64)
+ .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
+ VStack(alignment: .leading, spacing: 4) {
+ Text(item.title)
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundStyle(.white)
+ .multilineTextAlignment(.leading)
+ .lineLimit(3)
+ Text(F1LiveFormat.newsDate(item.publishedAt) + " · RacingNews365")
+ .font(.system(size: 11))
+ .foregroundStyle(.white.opacity(0.4))
+ }
+ Spacer(minLength: 0)
+ }
+ .padding(10)
+ .background(F1LiveLook.card, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
+ .strokeBorder(F1LiveLook.stroke, lineWidth: 1)
+ )
+ }
+ .buttonStyle(.plain)
+ }
+
+ private func shortcut(_ title: String, _ icon: String, action: @escaping () -> Void) -> some View {
+ Button(action: action) {
+ VStack(spacing: 8) {
+ Image(systemName: icon)
+ .font(.system(size: 18, weight: .semibold))
+ Text(title)
+ .font(.system(size: 11, weight: .semibold))
+ .lineLimit(1)
+ .minimumScaleFactor(0.8)
+ }
+ .foregroundStyle(.white)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 14)
+ .background(F1LiveLook.card, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
+ .strokeBorder(F1LiveLook.stroke, lineWidth: 1)
+ )
+ }
+ .buttonStyle(.plain)
+ }
+
+ private func card(@ViewBuilder _ content: () -> Content) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ content()
+ }
+ .padding(14)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(F1LiveLook.card, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
+ .strokeBorder(F1LiveLook.stroke, lineWidth: 1)
+ )
+ }
+
+ private func sectionTitle(_ t: String) -> some View {
+ Text(t)
+ .font(.system(size: 15, weight: .bold))
+ .foregroundStyle(.white)
+ }
+
+ private func tyreBadge(_ tyre: String) -> some View {
+ Text(tyre)
+ .font(.system(size: 10, weight: .heavy))
+ .foregroundStyle(tyre == "H" ? .black : .white)
+ .frame(width: 22, height: 22)
+ .background(tyreColor(tyre), in: Circle())
+ }
+
+ private func tyreColor(_ tyre: String) -> Color {
+ switch tyre.uppercased() {
+ case "S", "SS", "US": return .red
+ case "M": return .yellow
+ case "H": return .white
+ case "I": return .green
+ case "W": return .blue
+ default: return .gray
+ }
+ }
+
+ private func flagColor(_ flag: String?) -> Color {
+ switch (flag ?? "").uppercased() {
+ case "YELLOW", "DOUBLE YELLOW": return .yellow
+ case "GREEN": return .green
+ case "RED": return .red
+ case "BLUE": return .blue
+ case "BLACK AND WHITE": return .white
+ default: return F1LiveLook.live
+ }
+ }
+
+ private func load(silent: Bool = false) async {
+ do {
+ let next = try await app.api.f1Live()
+ payload = next
+ error = nil
+ if let speed = next.telemetry?.speed {
+ speedTrail.append(speed)
+ if speedTrail.count > 40 { speedTrail.removeFirst(speedTrail.count - 40) }
+ }
+ } catch {
+ if payload == nil && !silent { self.error = error.localizedDescription }
+ }
+ }
+}
+
+private struct F1TrackMapCanvas: View {
+ let path: [F1LivePoint]
+ let cars: [F1LiveCar]
+
+ var body: some View {
+ GeometryReader { geo in
+ let inset: CGFloat = 28
+ let w = max(geo.size.width - inset * 2, 1)
+ let h = max(geo.size.height - inset * 2, 1)
+ ZStack(alignment: .topLeading) {
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .fill(Color.black.opacity(0.35))
+ if path.count > 1 {
+ Path { p in
+ p.move(to: point(path[0], w: w, h: h, inset: inset))
+ for pt in path.dropFirst() {
+ p.addLine(to: point(pt, w: w, h: h, inset: inset))
+ }
+ }
+ .stroke(Color.white.opacity(0.82), style: StrokeStyle(lineWidth: 5, lineJoin: .round, lineCap: .round))
+ } else {
+ Text("Geen circuitkaart")
+ .font(.system(size: 13))
+ .foregroundStyle(.white.opacity(0.4))
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ ForEach(cars, id: \.driverNumber) { car in
+ let c = point(F1LivePoint(x: car.x, y: car.y), w: w, h: h, inset: inset)
+ Text(car.acronym)
+ .font(.system(size: 8, weight: .heavy))
+ .foregroundStyle(.white)
+ .padding(.horizontal, 4)
+ .padding(.vertical, 2)
+ .background(F1Hex.color(car.teamColor), in: Capsule())
+ .position(c)
+ }
+ }
+ }
+ }
+
+ private func point(_ p: F1LivePoint, w: CGFloat, h: CGFloat, inset: CGFloat) -> CGPoint {
+ CGPoint(x: inset + CGFloat(p.x) * w, y: inset + CGFloat(p.y) * h)
+ }
+}
+
+private struct F1Sparkline: Shape {
+ let values: [Double]
+
+ func path(in rect: CGRect) -> Path {
+ guard let minV = values.min(), let maxV = values.max(), values.count > 1 else {
+ return Path()
+ }
+ let span = max(maxV - minV, 1)
+ var p = Path()
+ for (i, v) in values.enumerated() {
+ let x = rect.width * CGFloat(i) / CGFloat(values.count - 1)
+ let y = rect.height - CGFloat((v - minV) / span) * rect.height
+ if i == 0 { p.move(to: CGPoint(x: x, y: y)) }
+ else { p.addLine(to: CGPoint(x: x, y: y)) }
+ }
+ return p
+ }
+}
+
+private enum F1Hex {
+ static func color(_ hex: String) -> Color {
+ var h = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
+ if h.count == 3 {
+ h = h.map { "\($0)\($0)" }.joined()
+ }
+ guard h.count == 6, let n = UInt64(h, radix: 16) else { return .gray }
+ return Color(
+ .sRGB,
+ red: Double((n >> 16) & 0xFF) / 255,
+ green: Double((n >> 8) & 0xFF) / 255,
+ blue: Double(n & 0xFF) / 255,
+ opacity: 1
+ )
+ }
+}
+
+private enum F1LiveFormat {
+ static func clock(_ iso: String) -> String {
+ let f = ISO8601DateFormatter()
+ f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ let d = f.date(from: iso) ?? ISO8601DateFormatter().date(from: iso)
+ guard let d else { return iso }
+ let out = DateFormatter()
+ out.locale = Locale(identifier: "nl_NL")
+ out.dateFormat = "HH:mm:ss"
+ return out.string(from: d)
+ }
+
+ static func newsDate(_ iso: String?) -> String {
+ guard let iso, !iso.isEmpty else { return "" }
+ let f = ISO8601DateFormatter()
+ f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ let d = f.date(from: iso) ?? ISO8601DateFormatter().date(from: iso)
+ guard let d else { return iso }
+ let out = DateFormatter()
+ out.locale = Locale(identifier: "nl_NL")
+ out.dateFormat = "d MMM HH:mm"
+ return out.string(from: d)
+ }
+}
+
+private extension Comparable {
+ func clamped(to range: ClosedRange) -> Self {
+ min(max(self, range.lowerBound), range.upperBound)
+ }
+}
diff --git a/apps/master-api/.env.example b/apps/master-api/.env.example
index d818d7d..11d8b7a 100644
--- a/apps/master-api/.env.example
+++ b/apps/master-api/.env.example
@@ -29,6 +29,10 @@ HIDE_UNAVAILABLE_TITLES=true
# Voorkeur: URL's beheren in Admin UI (meerdere feeds mogelijk).
IPTV_EPG_XML_URL=https://epg.faabserver.nl/epg/odido.xml
+# OpenF1 live timing: liever Admin → Instellingen → OpenF1 (versleuteld in DB).
+# OPENF1_USERNAME=
+# OPENF1_PASSWORD=
+
# Google Play Closed Testing (optioneel — zie GOOGLE_PLAY_ACCESS_SETUP.md)
# GOOGLE_CLOUD_PROJECT_ID=
# GOOGLE_SERVICE_ACCOUNT_EMAIL=
diff --git a/apps/master-api/package.json b/apps/master-api/package.json
index da1d11d..ce9fc19 100644
--- a/apps/master-api/package.json
+++ b/apps/master-api/package.json
@@ -6,7 +6,7 @@
"dev": "tsx watch src/app.ts",
"build": "tsc",
"start": "node dist/app.js",
- "test": "tsx src/metadata/filename-parser.test.ts && tsx src/metadata/title-locale.test.ts && tsx src/google-play/service.test.ts && tsx src/viewer/iptv-categories.test.ts && tsx src/viewer/schedule-events-sport.test.ts && tsx src/viewer/ziggo-epg-art.test.ts && tsx src/viewer/espn-watch-art.test.ts && tsx src/viewer/event-fallbacks.test.ts",
+ "test": "tsx src/metadata/filename-parser.test.ts && tsx src/metadata/title-locale.test.ts && tsx src/google-play/service.test.ts && tsx src/viewer/iptv-categories.test.ts && tsx src/viewer/schedule-events-sport.test.ts && tsx src/viewer/ziggo-epg-art.test.ts && tsx src/viewer/espn-watch-art.test.ts && tsx src/viewer/event-fallbacks.test.ts && tsx src/openf1/circuit.test.ts && tsx src/openf1/news.test.ts",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate deploy",
"db:migrate:dev": "prisma migrate dev",
diff --git a/apps/master-api/src/openf1/auth.ts b/apps/master-api/src/openf1/auth.ts
new file mode 100644
index 0000000..de3d95e
--- /dev/null
+++ b/apps/master-api/src/openf1/auth.ts
@@ -0,0 +1,88 @@
+const TOKEN_URL = "https://api.openf1.org/token";
+const SKEW_MS = 120_000;
+
+type TokenState = {
+ accessToken: string;
+ expiresAt: number;
+};
+
+let cached: TokenState | null = null;
+let inflight: Promise | null = null;
+
+function sessionSecret(): string {
+ return process.env.SESSION_SECRET?.trim() || "";
+}
+
+export function clearOpenF1TokenCache() {
+ cached = null;
+ inflight = null;
+}
+
+export async function resolveOpenF1Login(): Promise<{ username: string; password: string } | null> {
+ const secret = sessionSecret();
+ if (secret) {
+ const { resolveOpenF1Credentials } = await import("../settings/openf1");
+ const fromDb = await resolveOpenF1Credentials(secret);
+ if (fromDb) return fromDb;
+ }
+ return null;
+}
+
+export async function isOpenF1Configured(): Promise {
+ return (await resolveOpenF1Login()) != null;
+}
+
+export async function testOpenF1Login(username: string, password: string): Promise {
+ await fetchToken({ username, password }, false);
+}
+
+export async function getOpenF1AccessToken(): Promise {
+ const creds = await resolveOpenF1Login();
+ if (!creds) {
+ throw new Error("OpenF1 is niet geconfigureerd (Admin → Instellingen → OpenF1)");
+ }
+ if (cached && cached.expiresAt - SKEW_MS > Date.now()) return cached.accessToken;
+ if (inflight) return inflight;
+ inflight = fetchToken(creds, true)
+ .then((token) => token)
+ .finally(() => {
+ inflight = null;
+ });
+ return inflight;
+}
+
+async function fetchToken(
+ creds: { username: string; password: string },
+ store: boolean
+): Promise {
+ const body = new URLSearchParams();
+ body.set("username", creds.username);
+ body.set("password", creds.password);
+ const ac = new AbortController();
+ const timer = setTimeout(() => ac.abort(), 20_000);
+ try {
+ const res = await fetch(TOKEN_URL, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
+ body,
+ signal: ac.signal,
+ });
+ const text = await res.text();
+ if (!res.ok) {
+ throw new Error(`OpenF1 token HTTP ${res.status}`);
+ }
+ const json = JSON.parse(text) as { access_token?: string; expires_in?: string | number };
+ const accessToken = String(json.access_token || "").trim();
+ if (!accessToken) throw new Error("OpenF1 token-response zonder access_token");
+ if (store) {
+ const expiresIn = Number(json.expires_in || 3600);
+ cached = {
+ accessToken,
+ expiresAt: Date.now() + Math.max(60, expiresIn) * 1000,
+ };
+ }
+ return accessToken;
+ } finally {
+ clearTimeout(timer);
+ }
+}
diff --git a/apps/master-api/src/openf1/circuit.test.ts b/apps/master-api/src/openf1/circuit.test.ts
new file mode 100644
index 0000000..c0e0f46
--- /dev/null
+++ b/apps/master-api/src/openf1/circuit.test.ts
@@ -0,0 +1,25 @@
+import assert from "node:assert/strict";
+import { projectCircuit, projectCars } from "./circuit";
+
+const raw = {
+ rotation: 90,
+ x: [0, 10, 10, 0, 0],
+ y: [0, 0, 10, 10, 0],
+ circuitKey: 1,
+ circuitName: "Box",
+};
+
+const map = projectCircuit(raw);
+assert.ok(map.path.length >= 4);
+assert.ok(map.path.every((p) => p.x >= -0.01 && p.x <= 1.01 && p.y >= -0.01 && p.y <= 1.01));
+
+const cars = projectCars(
+ [
+ { driverNumber: 1, x: 5, y: 0 },
+ { driverNumber: 2, x: 10, y: 5 },
+ ],
+ raw
+);
+assert.equal(cars.length, 2);
+assert.ok(cars[0].x >= 0 && cars[0].x <= 1);
+console.log("openf1/circuit.test.ts ok");
diff --git a/apps/master-api/src/openf1/circuit.ts b/apps/master-api/src/openf1/circuit.ts
new file mode 100644
index 0000000..a140dca
--- /dev/null
+++ b/apps/master-api/src/openf1/circuit.ts
@@ -0,0 +1,132 @@
+export type CircuitPoint = { x: number; y: number };
+
+export type CircuitMap = {
+ circuitKey: number | null;
+ name: string | null;
+ rotation: number;
+ path: CircuitPoint[];
+ corners: Array<{ number: number; letter?: string; x: number; y: number }>;
+};
+
+type RawCircuit = {
+ circuitKey?: number;
+ circuitName?: string;
+ rotation?: number;
+ x?: number[];
+ y?: number[];
+ corners?: Array<{ number?: number; letter?: string; x?: number; y?: number }>;
+};
+
+function rotate(x: number, y: number, deg: number): CircuitPoint {
+ const rad = (deg * Math.PI) / 180;
+ const cos = Math.cos(rad);
+ const sin = Math.sin(rad);
+ return { x: x * cos + y * sin, y: -x * sin + y * cos };
+}
+
+function bounds(points: CircuitPoint[]): { minX: number; maxX: number; minY: number; maxY: number } {
+ let minX = Infinity;
+ let maxX = -Infinity;
+ let minY = Infinity;
+ let maxY = -Infinity;
+ for (const p of points) {
+ if (p.x < minX) minX = p.x;
+ if (p.x > maxX) maxX = p.x;
+ if (p.y < minY) minY = p.y;
+ if (p.y > maxY) maxY = p.y;
+ }
+ return { minX, maxX, minY, maxY };
+}
+
+function normalize(p: CircuitPoint, b: { minX: number; maxX: number; minY: number; maxY: number }): CircuitPoint {
+ const dx = b.maxX - b.minX || 1;
+ const dy = b.maxY - b.minY || 1;
+ return {
+ x: (p.x - b.minX) / dx,
+ y: 1 - (p.y - b.minY) / dy,
+ };
+}
+
+export function projectCircuit(raw: RawCircuit): CircuitMap {
+ const rotation = Number(raw.rotation || 0);
+ const xs = Array.isArray(raw.x) ? raw.x : [];
+ const ys = Array.isArray(raw.y) ? raw.y : [];
+ const n = Math.min(xs.length, ys.length);
+ const rotated: CircuitPoint[] = [];
+ for (let i = 0; i < n; i++) {
+ const x = Number(xs[i]);
+ const y = Number(ys[i]);
+ if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
+ rotated.push(rotate(x, y, rotation));
+ }
+ const b = rotated.length ? bounds(rotated) : { minX: 0, maxX: 1, minY: 0, maxY: 1 };
+ const path = rotated.map((p) => normalize(p, b));
+ const corners = (raw.corners || [])
+ .map((c) => {
+ const x = Number(c.x);
+ const y = Number(c.y);
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
+ const p = normalize(rotate(x, y, rotation), b);
+ return { number: Number(c.number || 0), letter: c.letter, x: p.x, y: p.y };
+ })
+ .filter((c): c is { number: number; letter?: string; x: number; y: number } => c != null);
+
+ return {
+ circuitKey: raw.circuitKey ?? null,
+ name: raw.circuitName ?? null,
+ rotation,
+ path,
+ corners,
+ };
+}
+
+export function projectCars(
+ cars: Array<{ driverNumber: number; x: number; y: number }>,
+ raw: RawCircuit
+): Array<{ driverNumber: number; x: number; y: number }> {
+ const rotation = Number(raw.rotation || 0);
+ const xs = Array.isArray(raw.x) ? raw.x : [];
+ const ys = Array.isArray(raw.y) ? raw.y : [];
+ const n = Math.min(xs.length, ys.length);
+ const track: CircuitPoint[] = [];
+ for (let i = 0; i < n; i++) {
+ const x = Number(xs[i]);
+ const y = Number(ys[i]);
+ if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
+ track.push(rotate(x, y, rotation));
+ }
+ const b = track.length ? bounds(track) : { minX: 0, maxX: 1, minY: 0, maxY: 1 };
+ return cars
+ .filter((c) => Number.isFinite(c.x) && Number.isFinite(c.y))
+ .map((c) => {
+ const p = normalize(rotate(c.x, c.y, rotation), b);
+ return { driverNumber: c.driverNumber, x: p.x, y: p.y };
+ });
+}
+
+const circuitCache = new Map();
+
+export async function loadCircuitMap(circuitKey: number, year: number): Promise<{ map: CircuitMap; raw: RawCircuit } | null> {
+ const cacheKey = `${circuitKey}:${year}`;
+ const hit = circuitCache.get(cacheKey);
+ if (hit && Date.now() - hit.at < 24 * 3600_000) return hit;
+ const years = [year, year - 1, 2025, 2024, 2023, 2022];
+ for (const y of [...new Set(years)]) {
+ try {
+ const res = await fetch(`https://api.multiviewer.app/api/v1/circuits/${circuitKey}/${y}`, {
+ headers: { Accept: "application/json" },
+ signal: AbortSignal.timeout(12_000),
+ });
+ if (!res.ok) continue;
+ const raw = (await res.json()) as RawCircuit;
+ const map = projectCircuit(raw);
+ if (map.path.length < 20) continue;
+ const packed = { at: Date.now(), map, raw };
+ circuitCache.set(cacheKey, packed);
+ return packed;
+ } catch {
+ /* try older year */
+ }
+ }
+ return null;
+}
diff --git a/apps/master-api/src/openf1/client.ts b/apps/master-api/src/openf1/client.ts
new file mode 100644
index 0000000..b041c32
--- /dev/null
+++ b/apps/master-api/src/openf1/client.ts
@@ -0,0 +1,99 @@
+import { getOpenF1AccessToken } from "./auth";
+
+const BASE = "https://api.openf1.org/v1";
+
+export type OpenF1Params = Record;
+
+function buildUrl(path: string, params: OpenF1Params): string {
+ let url = `${BASE}/${path.replace(/^\//, "")}`;
+ const parts: string[] = [];
+ for (const [key, raw] of Object.entries(params)) {
+ if (raw === undefined || raw === null || raw === "") continue;
+ const value = encodeURIComponent(String(raw));
+ if (/[<>]=?$/.test(key) || key.includes(">=") || key.includes("<=")) {
+ parts.push(`${key}=${value}`);
+ } else {
+ parts.push(`${encodeURIComponent(key)}=${value}`);
+ }
+ }
+ if (parts.length) url += `?${parts.join("&")}`;
+ return url;
+}
+
+export async function openF1Get(path: string, params: OpenF1Params = {}, timeoutMs = 18_000): Promise {
+ let lastErr: Error | null = null;
+ for (let attempt = 0; attempt < 3; attempt++) {
+ const token = await getOpenF1AccessToken();
+ const ac = new AbortController();
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
+ try {
+ const res = await fetch(buildUrl(path, params), {
+ headers: {
+ Accept: "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ signal: ac.signal,
+ });
+ const text = await res.text();
+ if (res.status === 429 || res.status >= 500) {
+ lastErr = new Error(`OpenF1 ${path} HTTP ${res.status}`);
+ await sleep(400 * (attempt + 1));
+ continue;
+ }
+ if (!res.ok) {
+ throw new Error(`OpenF1 ${path} HTTP ${res.status}`);
+ }
+ return JSON.parse(text) as T;
+ } catch (err) {
+ lastErr = err instanceof Error ? err : new Error(String(err));
+ if (attempt < 2) await sleep(400 * (attempt + 1));
+ } finally {
+ clearTimeout(timer);
+ }
+ }
+ throw lastErr ?? new Error(`OpenF1 ${path} mislukt`);
+}
+
+function sleep(ms: number) {
+ return new Promise((r) => setTimeout(r, ms));
+}
+
+export function asRows>(raw: unknown): T[] {
+ return Array.isArray(raw) ? (raw as T[]) : raw ? [raw as T] : [];
+}
+
+export function latestBy(
+ rows: T[],
+ keyFn: (row: T) => string | number,
+ dateFn: (row: T) => string
+): T[] {
+ const map = new Map();
+ for (const row of rows) {
+ const key = String(keyFn(row));
+ const prev = map.get(key);
+ if (!prev || dateFn(row) > dateFn(prev)) map.set(key, row);
+ }
+ return [...map.values()];
+}
+
+export function rec(row: unknown): Record {
+ return row && typeof row === "object" ? (row as Record) : {};
+}
+
+export function num(v: unknown): number | null {
+ if (typeof v === "number" && Number.isFinite(v)) return v;
+ if (typeof v === "string" && v.trim()) {
+ const n = Number(v);
+ return Number.isFinite(n) ? n : null;
+ }
+ return null;
+}
+
+export function str(v: unknown): string | null {
+ if (typeof v === "string") {
+ const t = v.trim();
+ return t || null;
+ }
+ if (typeof v === "number" && Number.isFinite(v)) return String(v);
+ return null;
+}
diff --git a/apps/master-api/src/openf1/live.ts b/apps/master-api/src/openf1/live.ts
new file mode 100644
index 0000000..3bc4f79
--- /dev/null
+++ b/apps/master-api/src/openf1/live.ts
@@ -0,0 +1,385 @@
+import { AppError } from "../security/errors";
+import { isOpenF1Configured } from "./auth";
+import { asRows, latestBy, num, openF1Get, rec, str } from "./client";
+import { loadCircuitMap, projectCars, type CircuitMap } from "./circuit";
+import { getF1News, type F1NewsItem } from "./news";
+
+export type F1LiveDriver = {
+ position: number;
+ driverNumber: number;
+ acronym: string;
+ fullName: string;
+ teamName: string;
+ teamColor: string;
+ headshotUrl: string | null;
+ gap: string;
+ interval: string;
+ lastLap: string;
+ bestLap: string;
+ tyre: string;
+ tyreAge: number | null;
+ laps: number | null;
+};
+
+export type F1LiveCar = {
+ driverNumber: number;
+ acronym: string;
+ teamColor: string;
+ x: number;
+ y: number;
+};
+
+export type F1LiveTelemetry = {
+ driverNumber: number;
+ acronym: string;
+ speed: number | null;
+ throttle: number | null;
+ brake: number | null;
+ rpm: number | null;
+ gear: number | null;
+ drs: boolean;
+};
+
+export type F1LiveDashboard = {
+ live: boolean;
+ source: string;
+ session: {
+ sessionKey: number | null;
+ meetingKey: number | null;
+ meetingName: string | null;
+ sessionName: string | null;
+ sessionType: string | null;
+ circuitName: string | null;
+ countryName: string | null;
+ countryCode: string | null;
+ location: string | null;
+ dateStart: string | null;
+ dateEnd: string | null;
+ year: number | null;
+ currentLap: number | null;
+ };
+ weather: {
+ airTemperature: number | null;
+ trackTemperature: number | null;
+ humidity: number | null;
+ windSpeed: number | null;
+ windDirection: number | null;
+ rainfall: boolean;
+ } | null;
+ timing: F1LiveDriver[];
+ map: {
+ name: string | null;
+ path: Array<{ x: number; y: number }>;
+ corners: CircuitMap["corners"];
+ cars: F1LiveCar[];
+ } | null;
+ telemetry: F1LiveTelemetry | null;
+ raceControl: Array<{
+ date: string | null;
+ category: string | null;
+ flag: string | null;
+ message: string;
+ driverNumber: number | null;
+ }>;
+ schedule: Array<{
+ sessionName: string;
+ dateStart: string | null;
+ dateEnd: string | null;
+ status: "live" | "done" | "upcoming";
+ }>;
+ news: F1NewsItem[];
+};
+
+const TYRE: Record = {
+ SOFT: "S",
+ MEDIUM: "M",
+ HARD: "H",
+ INTERMEDIATE: "I",
+ WET: "W",
+ SUPERSOFT: "SS",
+ ULTRASOFT: "US",
+};
+
+let snapCache: { at: number; data: F1LiveDashboard } | null = null;
+
+function formatLap(v: unknown): string {
+ const n = num(v);
+ if (n == null || n <= 0) return "—";
+ const m = Math.floor(n / 60);
+ const s = n - m * 60;
+ return `${m}:${s.toFixed(3).padStart(6, "0")}`;
+}
+
+function formatGap(v: unknown, isLeader: boolean): string {
+ if (isLeader) return "—";
+ if (typeof v === "string") {
+ const t = v.trim();
+ if (!t) return "—";
+ if (/lap/i.test(t)) return t.replace(/^(\+)?/, "+");
+ const n = Number(t);
+ if (Number.isFinite(n)) return n >= 0 ? `+${n.toFixed(3)}` : n.toFixed(3);
+ return t;
+ }
+ const n = num(v);
+ if (n == null) return "—";
+ return n >= 0 ? `+${n.toFixed(3)}` : n.toFixed(3);
+}
+
+function tyreLetter(compound: unknown): string {
+ const c = str(compound)?.toUpperCase() || "";
+ return TYRE[c] || (c ? c.slice(0, 1) : "—");
+}
+
+function isLiveWindow(start: string | null, end: string | null, now = Date.now()): boolean {
+ if (!start) return false;
+ const a = Date.parse(start);
+ const b = end ? Date.parse(end) : a + 3 * 3600_000;
+ if (!Number.isFinite(a)) return false;
+ const pad = 20 * 60_000;
+ return now >= a - pad && now <= (Number.isFinite(b) ? b + pad : a + 4 * 3600_000);
+}
+
+async function settled(p: Promise, fallback: T): Promise {
+ try {
+ return await p;
+ } catch {
+ return fallback;
+ }
+}
+
+export async function getF1LiveDashboard(): Promise {
+ if (!(await isOpenF1Configured())) {
+ throw new AppError("NOT_CONFIGURED", "OpenF1 is niet geconfigureerd", 503);
+ }
+ if (snapCache && Date.now() - snapCache.at < 2500) return snapCache.data;
+
+ const sessions = asRows(await openF1Get("sessions", { session_key: "latest" }));
+ const session = rec(sessions[0]);
+ const sessionKey = num(session.session_key);
+ const meetingKey = num(session.meeting_key);
+ if (sessionKey == null) {
+ throw new AppError("NOT_FOUND", "Geen OpenF1-sessie gevonden", 404);
+ }
+
+ const dateStart = str(session.date_start);
+ const dateEnd = str(session.date_end);
+ const live = isLiveWindow(dateStart, dateEnd);
+ const locEnd = dateEnd ? Date.parse(dateEnd) : Date.now();
+ const locFrom = new Date((live ? Date.now() : locEnd) - (live ? 12_000 : 90_000)).toISOString();
+ const locTo = live ? undefined : new Date(locEnd + 2_000).toISOString();
+
+ const year = num(session.year) ?? new Date().getUTCFullYear();
+ const circuitKey = num(session.circuit_key);
+
+ const driverRows = asRows(await openF1Get("drivers", { session_key: sessionKey }));
+
+ const [
+ meetingRows,
+ positionRows,
+ intervalRows,
+ lapRows,
+ stintRows,
+ weatherRows,
+ raceControlRows,
+ locationRows,
+ scheduleRows,
+ circuitPack,
+ news,
+ ] = await Promise.all([
+ settled(openF1Get("meetings", { meeting_key: meetingKey ?? "latest" }).then(asRows), []),
+ settled(openF1Get("position", { session_key: sessionKey }).then(asRows), []),
+ settled(openF1Get("intervals", { session_key: sessionKey }).then(asRows), []),
+ settled(openF1Get("laps", { session_key: sessionKey }).then(asRows), []),
+ settled(openF1Get("stints", { session_key: sessionKey }).then(asRows), []),
+ settled(openF1Get("weather", { session_key: sessionKey }).then(asRows), []),
+ settled(openF1Get("race_control", { session_key: sessionKey }).then(asRows), []),
+ settled(
+ openF1Get("location", {
+ session_key: sessionKey,
+ "date>=": locFrom,
+ ...(locTo ? { "date<": locTo } : {}),
+ }).then(asRows),
+ []
+ ),
+ meetingKey
+ ? settled(openF1Get("sessions", { meeting_key: meetingKey }).then(asRows), [])
+ : Promise.resolve([]),
+ circuitKey ? loadCircuitMap(circuitKey, year) : Promise.resolve(null),
+ settled(getF1News(12), []),
+ ]);
+
+ const meeting = rec(meetingRows[0]);
+ const drivers = driverRows.map(rec);
+ const lastPos = latestBy(positionRows.map(rec), (r) => num(r.driver_number) ?? 0, (r) => str(r.date) || "");
+ const lastInt = latestBy(intervalRows.map(rec), (r) => num(r.driver_number) ?? 0, (r) => str(r.date) || "");
+ const lastStint = latestBy(stintRows.map(rec), (r) => num(r.driver_number) ?? 0, (r) => String(num(r.stint_number) ?? 0));
+ const lastLoc = latestBy(locationRows.map(rec), (r) => num(r.driver_number) ?? 0, (r) => str(r.date) || "");
+ const lastWeather = weatherRows.length ? rec(weatherRows[weatherRows.length - 1]) : null;
+
+ const lapsByDriver = new Map[]>();
+ for (const row of lapRows.map(rec)) {
+ const dn = num(row.driver_number);
+ if (dn == null) continue;
+ const list = lapsByDriver.get(dn) || [];
+ list.push(row);
+ lapsByDriver.set(dn, list);
+ }
+
+ const posMap = new Map(lastPos.map((r) => [num(r.driver_number) ?? -1, r]));
+ const intMap = new Map(lastInt.map((r) => [num(r.driver_number) ?? -1, r]));
+ const stintMap = new Map(lastStint.map((r) => [num(r.driver_number) ?? -1, r]));
+
+ const timing: F1LiveDriver[] = drivers
+ .map((d) => {
+ const n = num(d.driver_number) ?? 0;
+ const pos = num(posMap.get(n)?.position) ?? 99;
+ const laps = lapsByDriver.get(n) || [];
+ const last = laps.length ? laps.reduce((a, b) => ((num(a.lap_number) ?? 0) >= (num(b.lap_number) ?? 0) ? a : b)) : null;
+ const best = laps
+ .map((l) => num(l.lap_duration))
+ .filter((v): v is number => v != null && v > 0)
+ .sort((a, b) => a - b)[0];
+ const iv = intMap.get(n);
+ const st = stintMap.get(n);
+ const isLeader = pos === 1;
+ return {
+ position: pos,
+ driverNumber: n,
+ acronym: str(d.name_acronym) || `#${n}`,
+ fullName: [str(d.first_name), str(d.last_name)].filter(Boolean).join(" ") || str(d.full_name) || "",
+ teamName: str(d.team_name) || "",
+ teamColor: str(d.team_colour) || "888888",
+ headshotUrl: str(d.headshot_url),
+ gap: formatGap(iv?.gap_to_leader, isLeader),
+ interval: formatGap(iv?.interval, isLeader),
+ lastLap: formatLap(last?.lap_duration),
+ bestLap: formatLap(best),
+ tyre: tyreLetter(st?.compound),
+ tyreAge: num(st?.tyre_age_at_start) != null && last?.lap_number != null
+ ? (num(st?.tyre_age_at_start) || 0) + Math.max(0, (num(last.lap_number) || 1) - (num(st?.lap_start) || 1))
+ : num(st?.tyre_age_at_start),
+ laps: num(last?.lap_number),
+ };
+ })
+ .sort((a, b) => a.position - b.position);
+
+ const currentLap = timing.reduce((m, d) => Math.max(m, d.laps ?? 0), 0) || null;
+ const leader = timing[0] || null;
+
+ let telemetry: F1LiveTelemetry | null = null;
+ if (leader) {
+ const carRows = await settled(
+ openF1Get("car_data", {
+ session_key: sessionKey,
+ driver_number: leader.driverNumber,
+ "date>=": locFrom,
+ ...(locTo ? { "date<": locTo } : {}),
+ }).then(asRows),
+ []
+ );
+ const lastCar = carRows.length ? rec(carRows[carRows.length - 1]) : null;
+ if (lastCar) {
+ const drs = num(lastCar.drs) ?? 0;
+ telemetry = {
+ driverNumber: leader.driverNumber,
+ acronym: leader.acronym,
+ speed: num(lastCar.speed),
+ throttle: num(lastCar.throttle),
+ brake: num(lastCar.brake),
+ rpm: num(lastCar.rpm),
+ gear: num(lastCar.n_gear),
+ drs: drs >= 10,
+ };
+ }
+ }
+
+ const locByNum = new Map(lastLoc.map((r) => [num(r.driver_number) ?? -1, r]));
+ const carsRaw = timing
+ .map((d) => {
+ const loc = locByNum.get(d.driverNumber);
+ const x = num(loc?.x);
+ const y = num(loc?.y);
+ if (x == null || y == null) return null;
+ return { driverNumber: d.driverNumber, x, y };
+ })
+ .filter((c): c is { driverNumber: number; x: number; y: number } => c != null);
+
+ const projected = circuitPack ? projectCars(carsRaw, circuitPack.raw) : [];
+ const projMap = new Map(projected.map((c) => [c.driverNumber, c]));
+ const cars: F1LiveCar[] = timing
+ .map((d) => {
+ const p = projMap.get(d.driverNumber);
+ if (!p) return null;
+ return { driverNumber: d.driverNumber, acronym: d.acronym, teamColor: d.teamColor, x: p.x, y: p.y };
+ })
+ .filter((c): c is F1LiveCar => c != null);
+
+ const now = Date.now();
+ const schedule = scheduleRows
+ .map(rec)
+ .sort((a, b) => (str(a.date_start) || "").localeCompare(str(b.date_start) || ""))
+ .map((s) => {
+ const name = str(s.session_name) || str(s.session_type) || "Sessie";
+ const start = str(s.date_start);
+ const end = str(s.date_end);
+ const status: "live" | "done" | "upcoming" = isLiveWindow(start, end, now)
+ ? "live"
+ : start && Date.parse(start) > now
+ ? "upcoming"
+ : "done";
+ return { sessionName: name, dateStart: start, dateEnd: end, status };
+ });
+
+ const raceControl = raceControlRows
+ .map(rec)
+ .slice(-12)
+ .reverse()
+ .map((r) => ({
+ date: str(r.date),
+ category: str(r.category),
+ flag: str(r.flag),
+ message: str(r.message) || "",
+ driverNumber: num(r.driver_number),
+ }))
+ .filter((r) => r.message);
+
+ const data: F1LiveDashboard = {
+ live,
+ source: "OpenF1",
+ session: {
+ sessionKey,
+ meetingKey,
+ meetingName: str(meeting.meeting_name) || str(meeting.meeting_official_name),
+ sessionName: str(session.session_name),
+ sessionType: str(session.session_type),
+ circuitName: str(session.circuit_short_name) || str(meeting.circuit_short_name),
+ countryName: str(session.country_name) || str(meeting.country_name),
+ countryCode: str(session.country_code) || str(meeting.country_code),
+ location: str(session.location) || str(meeting.location),
+ dateStart,
+ dateEnd,
+ year,
+ currentLap,
+ },
+ weather: lastWeather
+ ? {
+ airTemperature: num(lastWeather.air_temperature),
+ trackTemperature: num(lastWeather.track_temperature),
+ humidity: num(lastWeather.humidity),
+ windSpeed: num(lastWeather.wind_speed),
+ windDirection: num(lastWeather.wind_direction),
+ rainfall: (num(lastWeather.rainfall) ?? 0) > 0,
+ }
+ : null,
+ timing,
+ map: circuitPack
+ ? { name: circuitPack.map.name, path: circuitPack.map.path, corners: circuitPack.map.corners, cars }
+ : null,
+ telemetry,
+ raceControl,
+ schedule,
+ news,
+ };
+ snapCache = { at: Date.now(), data };
+ return data;
+}
diff --git a/apps/master-api/src/openf1/news.test.ts b/apps/master-api/src/openf1/news.test.ts
new file mode 100644
index 0000000..1f85f3b
--- /dev/null
+++ b/apps/master-api/src/openf1/news.test.ts
@@ -0,0 +1,24 @@
+import assert from "node:assert/strict";
+import { parseRacingNews365 } from "./news";
+
+const html = `
+skip
+
+ Hadjar doet boekje open over polsbreuk: "Heel stom"
+ 30 september 20:30
+
+
+Coureurs
+`;
+
+const items = parseRacingNews365(html);
+assert.equal(items.length, 1);
+assert.equal(items[0].title, 'Hadjar doet boekje open over polsbreuk: "Heel stom"');
+assert.equal(items[0].id, "14443134");
+assert.ok(items[0].imageUrl?.includes("cdn.racingnews365.com"));
+console.log("openf1/news.test.ts ok");
diff --git a/apps/master-api/src/openf1/news.ts b/apps/master-api/src/openf1/news.ts
new file mode 100644
index 0000000..af9a809
--- /dev/null
+++ b/apps/master-api/src/openf1/news.ts
@@ -0,0 +1,107 @@
+const LIST_URL = "https://racingnews365.nl/f1-nieuws";
+const UA =
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36";
+
+const SKIP = new Set([
+ "f1-nieuws",
+ "liveblog-formule-1",
+ "coureurs",
+ "formule-1-op-tv",
+ "f1-podcast",
+ "uitslag-formule-1",
+ "indycar",
+ "abonnementen",
+ "premium",
+ "video",
+ "interview",
+ "podcast",
+ "f1",
+]);
+
+export type F1NewsItem = {
+ id: string;
+ title: string;
+ url: string;
+ imageUrl: string | null;
+ publishedAt: string | null;
+ source: string;
+};
+
+function decodeHtml(s: string): string {
+ return s
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/'/g, "'")
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/(\d+);/g, (_, n) => String.fromCharCode(Number(n)));
+}
+
+function slugOf(url: string): string {
+ try {
+ return new URL(url).pathname.replace(/^\/+|\/+$/g, "");
+ } catch {
+ return "";
+ }
+}
+
+export function parseRacingNews365(html: string, limit = 20): F1NewsItem[] {
+ const items: F1NewsItem[] = [];
+ const seen = new Set();
+ const openRe = /]+)>/gi;
+ let m: RegExpExecArray | null;
+ while ((m = openRe.exec(html)) && items.length < limit) {
+ const attrs = m[1] || "";
+ if (!/\bcard\b/i.test(attrs)) continue;
+ const href = attrs.match(/href="(https:\/\/racingnews365\.nl\/[^"]+)"/i)?.[1];
+ if (!href) continue;
+ const slug = slugOf(href);
+ if (!slug || SKIP.has(slug) || slug.includes("/") || seen.has(href) || slug.length < 12) continue;
+ const chunk = html.slice(m.index, m.index + 4500);
+ const titleMatch =
+ chunk.match(/data-ab-title="\d+">([^<]+)/i) ||
+ chunk.match(/class="card__title"[^>]*>\s*]*>([^<]+)/i);
+ if (!titleMatch) continue;
+ const title = decodeHtml(titleMatch[1].trim());
+ if (!title) continue;
+ const timeMatch = chunk.match(/datetime="([^"]+)"/i);
+ const imgMatch = chunk.match(/https:\/\/cdn\.racingnews365\.com\/[^"'>\s]+/);
+ let imageUrl = imgMatch ? imgMatch[0].replace(/&/g, "&") : null;
+ if (imageUrl) {
+ imageUrl = imageUrl.replace(/width=\d+/i, "width=720").replace(/height=\d+/i, "height=405");
+ }
+ const idMatch = attrs.match(/data-id="(\d+)"/) || chunk.match(/data-id="(\d+)"/);
+ seen.add(href);
+ items.push({
+ id: idMatch?.[1] || slug,
+ title,
+ url: href,
+ imageUrl,
+ publishedAt: timeMatch?.[1] || null,
+ source: "RacingNews365",
+ });
+ }
+ return items;
+}
+
+let newsCache: { at: number; items: F1NewsItem[] } | null = null;
+
+export async function getF1News(limit = 16): Promise {
+ if (newsCache && Date.now() - newsCache.at < 8 * 60_000) {
+ return newsCache.items.slice(0, limit);
+ }
+ const res = await fetch(LIST_URL, {
+ headers: {
+ Accept: "text/html,application/xhtml+xml",
+ "User-Agent": UA,
+ "Accept-Language": "nl-NL,nl;q=0.9",
+ },
+ signal: AbortSignal.timeout(18_000),
+ });
+ if (!res.ok) throw new Error(`RacingNews365 HTTP ${res.status}`);
+ const html = await res.text();
+ const items = parseRacingNews365(html, 24);
+ newsCache = { at: Date.now(), items };
+ return items.slice(0, limit);
+}
diff --git a/apps/master-api/src/settings/openf1.ts b/apps/master-api/src/settings/openf1.ts
new file mode 100644
index 0000000..6790147
--- /dev/null
+++ b/apps/master-api/src/settings/openf1.ts
@@ -0,0 +1,87 @@
+import { prisma } from "../database/client";
+import { decryptSecret, encryptSecret } from "../security/crypto";
+import { AppError } from "../security/errors";
+import { clearOpenF1TokenCache, testOpenF1Login } from "../openf1/auth";
+
+const OPENF1_ID = "openf1";
+
+export type OpenF1SettingsPublic = {
+ enabled: boolean;
+ configured: boolean;
+ username: string;
+ hasPassword: boolean;
+ warning: string | null;
+};
+
+export async function getOpenF1SettingsPublic(): Promise {
+ const row = await prisma.integrationSetting.findUnique({ where: { id: OPENF1_ID } });
+ return {
+ enabled: row?.enabled ?? true,
+ configured: !!(row?.enabled !== false && row?.username?.trim() && row?.passwordEnc),
+ username: row?.username?.trim() || "",
+ hasPassword: !!row?.passwordEnc,
+ warning: null,
+ };
+}
+
+export async function resolveOpenF1Credentials(
+ sessionSecret: string
+): Promise<{ username: string; password: string } | null> {
+ const row = await prisma.integrationSetting.findUnique({ where: { id: OPENF1_ID } });
+ if (row && row.enabled === false) return null;
+ const username = row?.username?.trim() || "";
+ if (username && row?.passwordEnc && sessionSecret) {
+ try {
+ const password = decryptSecret(row.passwordEnc, sessionSecret);
+ if (password) return { username, password };
+ } catch {
+ /* fall through */
+ }
+ }
+ return null;
+}
+
+export async function upsertOpenF1Settings(
+ sessionSecret: string,
+ input: { username?: string; password?: string; enabled?: boolean }
+): Promise {
+ const existing = await prisma.integrationSetting.findUnique({ where: { id: OPENF1_ID } });
+ const username = (input.username ?? existing?.username ?? "").trim();
+ const passwordTrim = input.password?.trim();
+ const passwordEnc = passwordTrim
+ ? encryptSecret(passwordTrim, sessionSecret)
+ : existing?.passwordEnc ?? null;
+ const enabled = input.enabled ?? existing?.enabled ?? true;
+
+ if (enabled && (!username || !passwordEnc)) {
+ throw new AppError("OPENF1", "E-mail en wachtwoord zijn verplicht", 400);
+ }
+
+ let warning: string | null = null;
+ if (enabled && username && passwordEnc) {
+ const password = decryptSecret(passwordEnc, sessionSecret);
+ try {
+ await testOpenF1Login(username, password);
+ } catch (err) {
+ warning = `Opgeslagen, maar login-test faalde: ${err instanceof Error ? err.message : String(err)}`;
+ }
+ }
+
+ await prisma.integrationSetting.upsert({
+ where: { id: OPENF1_ID },
+ create: {
+ id: OPENF1_ID,
+ username: username || null,
+ passwordEnc,
+ enabled,
+ },
+ update: {
+ username: username || null,
+ passwordEnc,
+ enabled,
+ },
+ });
+ clearOpenF1TokenCache();
+ const pub = await getOpenF1SettingsPublic();
+ return { ...pub, warning };
+}
diff --git a/apps/master-api/src/settings/routes.ts b/apps/master-api/src/settings/routes.ts
index a57bfa5..4427c2c 100644
--- a/apps/master-api/src/settings/routes.ts
+++ b/apps/master-api/src/settings/routes.ts
@@ -65,6 +65,18 @@ export function registerSettingsRoutes(
return { settings };
});
+ app.get("/api/v1/admin/settings/openf1", { preHandler: requireAdmin }, async () => {
+ const { getOpenF1SettingsPublic } = await import("./openf1");
+ return { settings: await getOpenF1SettingsPublic() };
+ });
+
+ app.put("/api/v1/admin/settings/openf1", { preHandler: requireAdmin }, async (request) => {
+ const body = request.body as { username?: string; password?: string; enabled?: boolean };
+ const { upsertOpenF1Settings } = await import("./openf1");
+ const settings = await upsertOpenF1Settings(config.SESSION_SECRET, body);
+ return { settings };
+ });
+
app.get("/api/v1/admin/settings/google-play", { preHandler: requireAdmin }, async () => {
return { settings: await getGooglePlaySettingsPublic(config) };
});
diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts
index 8e11bdf..7cd042a 100644
--- a/apps/master-api/src/viewer/routes.ts
+++ b/apps/master-api/src/viewer/routes.ts
@@ -910,6 +910,23 @@ export function registerViewerRoutes(
return getF1tvHome();
});
+ app.get("/api/v1/client/f1/live", async (request) => {
+ const auth = await viewers.authFromBearer(request.headers.authorization);
+ const { resolveViewerLiveEventsAccess } = await import("./schedule-events");
+ const access = await resolveViewerLiveEventsAccess(auth.viewerId, config);
+ if (!access.enabled) {
+ throw new AppError("FORBIDDEN", "Live Events is uitgeschakeld voor dit account", 403);
+ }
+ const { getF1LiveDashboard } = await import("../openf1/live");
+ return getF1LiveDashboard();
+ });
+
+ app.get("/api/v1/client/f1/news", async (request) => {
+ await viewers.authFromBearer(request.headers.authorization);
+ const { getF1News } = await import("../openf1/news");
+ return { items: await getF1News(20) };
+ });
+
app.get("/api/v1/client/viaplay/home", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
const { resolveViewerLiveEventsAccess } = await import("./schedule-events");