Stabilize F1 Live scrolling and news reader, and speed up the dashboard fetch.

This commit is contained in:
Jos Vooges | STH 2026-10-01 02:06:50 +02:00
parent b911a4a7a7
commit 00efebdf84
5 changed files with 155 additions and 59 deletions

View file

@ -358,6 +358,12 @@ actor ApiClient {
try await get("/api/v1/client/f1/live")
}
func f1News() async throws -> [F1NewsItem] {
struct Wrap: Decodable { let items: [F1NewsItem]? }
let wrap: Wrap = try await get("/api/v1/client/f1/news")
return wrap.items ?? []
}
func f1NewsArticle(id: String) async throws -> F1NewsArticle {
let enc = Self.encodePathSegment(id)
return try await get("/api/v1/client/f1/news/\(enc)")

View file

@ -250,8 +250,9 @@ struct F1HubScreen: View {
.buttonStyle(HubPrimaryButtonStyle())
NavigationLink(value: EventHubRoute.f1Live) {
Label("Timing", systemImage: "timer")
.modifier(HubNavCapsule(kind: .glass))
}
.buttonStyle(HubGlassButtonStyle())
.buttonStyle(.plain)
} else if let meeting {
NavigationLink(value: EventHubRoute.f1Meeting(meeting.meetingKey)) {
Label("Bekijk weekend", systemImage: "calendar")
@ -259,13 +260,15 @@ struct F1HubScreen: View {
.buttonStyle(HubPrimaryButtonStyle())
NavigationLink(value: EventHubRoute.f1Live) {
Label("Timing", systemImage: "timer")
.modifier(HubNavCapsule(kind: .glass))
}
.buttonStyle(HubGlassButtonStyle())
.buttonStyle(.plain)
} else {
NavigationLink(value: EventHubRoute.f1Live) {
Label("Live timing", systemImage: "timer")
.modifier(HubNavCapsule(kind: .primary))
}
.buttonStyle(HubPrimaryButtonStyle())
.buttonStyle(.plain)
}
}
} else if let meeting {
@ -286,8 +289,9 @@ struct F1HubScreen: View {
.buttonStyle(HubPrimaryButtonStyle())
NavigationLink(value: EventHubRoute.f1Live) {
Label("Timing", systemImage: "timer")
.modifier(HubNavCapsule(kind: .glass))
}
.buttonStyle(HubGlassButtonStyle())
.buttonStyle(.plain)
}
} else {
HubHero(
@ -301,8 +305,9 @@ struct F1HubScreen: View {
) {
NavigationLink(value: EventHubRoute.f1Live) {
Label("Live timing", systemImage: "timer")
.modifier(HubNavCapsule(kind: .primary))
}
.buttonStyle(HubPrimaryButtonStyle())
.buttonStyle(.plain)
}
}
}
@ -347,7 +352,7 @@ struct F1HubScreen: View {
)
.padding(.horizontal, 20)
}
.buttonStyle(HubPressStyle())
.buttonStyle(.plain)
}
private func seasonSection(_ p: F1HubPayload) -> some View {
@ -1579,6 +1584,22 @@ struct HubPressStyle: ButtonStyle {
}
}
private struct HubNavCapsule: ViewModifier {
enum Kind { case glass, primary }
let kind: Kind
func body(content: Content) -> some View {
content
.font(.system(size: 16, weight: .semibold))
.lineLimit(1)
.foregroundStyle(kind == .primary ? Color.black : Color.white)
.padding(.horizontal, kind == .primary ? 22 : 20)
.frame(height: 46)
.background(kind == .primary ? Color.white : Color.white.opacity(0.16), in: Capsule())
.overlay(Capsule().strokeBorder(.white.opacity(kind == .primary ? 0 : 0.18), lineWidth: 1))
}
}
private struct HubPrimaryButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label

View file

@ -25,13 +25,17 @@ struct F1LiveScreen: View {
@EnvironmentObject private var app: AppModel
@Environment(\.dismiss) private var dismiss
@Environment(\.scenePhase) private var scenePhase
@State private var tab: Tab = .overview
@State private var payload: F1LiveDashboard?
@State private var error: String?
@State private var speedTrail: [Double] = []
@State private var selectedNews: F1NewsItem?
@State private var loading = false
@State private var newsItems: [F1NewsItem] = []
private let clock = Timer.publish(every: 4, on: .main, in: .common).autoconnect()
private let clock = Timer.publish(every: 8, on: .main, in: .common).autoconnect()
var body: some View {
ZStack(alignment: .top) {
@ -63,8 +67,18 @@ struct F1LiveScreen: View {
.environment(\.colorScheme, .dark)
.navigationBarBackButtonHidden(true)
.toolbar(.hidden, for: .navigationBar)
.task { await load() }
.onReceive(clock) { _ in Task { await load(silent: true) } }
.safeAreaPadding(.top)
.onAppear {
if payload == nil { Task { await load() } }
}
.onReceive(clock) { _ in
guard scenePhase == .active, selectedNews == nil, payload != nil else { return }
Task { await load(silent: true) }
}
.fullScreenCover(item: $selectedNews) { item in
F1NewsArticleScreen(preview: item)
.environmentObject(app)
}
}
@ViewBuilder
@ -80,20 +94,21 @@ struct F1LiveScreen: View {
].compactMap { $0 }.joined(separator: " · ")
)
tabBar
ScrollView {
ScrollView(.vertical, showsIndicators: false) {
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)
case .news: newsList(newsItems)
}
}
.padding(.horizontal, 16)
.padding(.top, 14)
.padding(.bottom, 40)
}
.scrollBounceBehavior(.basedOnSize, axes: .horizontal)
}
}
@ -157,6 +172,7 @@ struct F1LiveScreen: View {
}
.padding(.horizontal, 16)
}
.frame(height: 38)
.padding(.bottom, 4)
}
@ -191,9 +207,9 @@ struct F1LiveScreen: View {
if let t = p.telemetry {
telemetryCard(t)
}
if !p.news.isEmpty {
if !newsItems.isEmpty {
sectionTitle("Nieuws")
ForEach(p.news.prefix(4)) { item in
ForEach(newsItems.prefix(4)) { item in
newsCard(item)
}
}
@ -465,8 +481,8 @@ struct F1LiveScreen: View {
}
private func newsCard(_ item: F1NewsItem) -> some View {
NavigationLink {
F1NewsArticleScreen(preview: item)
Button {
selectedNews = item
} label: {
HStack(alignment: .top, spacing: 12) {
RemoteImage(url: item.imageUrl, contentMode: .fill)
@ -574,15 +590,30 @@ struct F1LiveScreen: View {
}
private func load(silent: Bool = false) async {
if loading { return }
loading = true
defer { loading = false }
do {
let next = try await app.api.f1Live()
if Task.isCancelled { return }
payload = next
error = nil
if newsItems.isEmpty { newsItems = next.news }
if let speed = next.telemetry?.speed {
speedTrail.append(speed)
if speedTrail.count > 40 { speedTrail.removeFirst(speedTrail.count - 40) }
}
if newsItems.isEmpty {
if let items = try? await app.api.f1News(), !items.isEmpty, !Task.isCancelled {
newsItems = items
}
}
} catch is CancellationError {
return
} catch let err as URLError where err.code == .cancelled {
return
} catch {
if Task.isCancelled { return }
if payload == nil && !silent { self.error = error.localizedDescription }
}
}
@ -710,14 +741,14 @@ private struct F1NewsArticleScreen: View {
@State private var error: String?
var body: some View {
ZStack(alignment: .topLeading) {
ZStack(alignment: .top) {
F1LiveLook.bg.ignoresSafeArea()
ScrollView(showsIndicators: false) {
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
hero
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 16) {
Text(article?.title ?? preview.title)
.font(.system(size: 28, weight: .bold))
.font(.system(size: 26, weight: .bold))
.foregroundStyle(.white)
.fixedSize(horizontal: false, vertical: true)
metaRow
@ -741,22 +772,34 @@ private struct F1NewsArticleScreen: View {
}
}
.padding(.horizontal, 20)
.padding(.top, 22)
.padding(.bottom, 56)
.padding(.top, 18)
.padding(.bottom, 40)
}
}
backButton
}
.safeAreaInset(edge: .top, spacing: 0) {
HStack {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(.white)
.frame(width: 36, height: 36)
.background(.white.opacity(0.14), in: Circle())
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(F1LiveLook.bg.opacity(0.92))
}
.environment(\.colorScheme, .dark)
.navigationBarBackButtonHidden(true)
.toolbar(.hidden, for: .navigationBar)
.task { await load() }
}
private var hero: some View {
ZStack(alignment: .bottom) {
RemoteImage(url: article?.imageUrl ?? preview.imageUrl, contentMode: .fill)
.frame(height: 280)
.frame(height: 220)
.frame(maxWidth: .infinity)
.clipped()
LinearGradient(
@ -766,7 +809,7 @@ private struct F1NewsArticleScreen: View {
)
.frame(height: 140)
}
.frame(height: 280)
.frame(height: 220)
.clipped()
}
@ -825,24 +868,14 @@ private struct F1NewsArticleScreen: View {
}
}
private var backButton: some View {
Button { dismiss() } label: {
Image(systemName: "chevron.left")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 36, height: 36)
.background(.black.opacity(0.45), in: Circle())
.overlay(Circle().strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
}
.padding(.leading, 16)
.padding(.top, 6)
}
private func load() async {
do {
article = try await app.api.f1NewsArticle(id: preview.id)
error = nil
} catch is CancellationError {
return
} catch {
if Task.isCancelled { return }
self.error = "Artikel kon niet worden geladen."
}
}

View file

@ -22,7 +22,7 @@ function buildUrl(path: string, params: OpenF1Params): string {
export async function openF1Get<T = unknown>(path: string, params: OpenF1Params = {}, timeoutMs = 18_000): Promise<T> {
let lastErr: Error | null = null;
for (let attempt = 0; attempt < 3; attempt++) {
for (let attempt = 0; attempt < 2; attempt++) {
const token = await getOpenF1AccessToken();
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);

View file

@ -101,6 +101,8 @@ const TYRE: Record<string, string> = {
};
let snapCache: { at: number; data: F1LiveDashboard } | null = null;
let inflight: Promise<F1LiveDashboard> | null = null;
const OF1_MS = 8_000;
function formatLap(v: unknown): string {
const n = num(v);
@ -151,9 +153,35 @@ export async function getF1LiveDashboard(): Promise<F1LiveDashboard> {
if (!(await isOpenF1Configured())) {
throw new AppError("NOT_CONFIGURED", "OpenF1 is niet geconfigureerd", 503);
}
if (snapCache && Date.now() - snapCache.at < 2500) return snapCache.data;
const now = Date.now();
const ttl = snapCache?.data.live ? 2_000 : 10_000;
if (snapCache && now - snapCache.at < ttl) return snapCache.data;
if (snapCache && now - snapCache.at < 45_000) {
if (!inflight) {
inflight = buildDashboard().finally(() => {
inflight = null;
});
}
return snapCache.data;
}
if (inflight) return inflight;
inflight = buildDashboard().finally(() => {
inflight = null;
});
return inflight;
}
const sessions = asRows(await openF1Get("sessions", { session_key: "latest" }));
async function buildDashboard(): Promise<F1LiveDashboard> {
try {
return await buildDashboardInner();
} catch (err) {
if (snapCache) return snapCache.data;
throw err;
}
}
async function buildDashboardInner(): Promise<F1LiveDashboard> {
const sessions = asRows(await openF1Get("sessions", { session_key: "latest" }, OF1_MS));
const session = rec(sessions[0]);
const sessionKey = num(session.session_key);
const meetingKey = num(session.meeting_key);
@ -171,9 +199,8 @@ export async function getF1LiveDashboard(): Promise<F1LiveDashboard> {
const year = num(session.year) ?? new Date().getUTCFullYear();
const circuitKey = num(session.circuit_key);
const driverRows = asRows(await openF1Get("drivers", { session_key: sessionKey }));
const [
driverRows,
meetingRows,
positionRows,
intervalRows,
@ -184,29 +211,38 @@ export async function getF1LiveDashboard(): Promise<F1LiveDashboard> {
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("drivers", { session_key: sessionKey }, OF1_MS).then(asRows), []),
settled(openF1Get("meetings", { meeting_key: meetingKey ?? "latest" }, OF1_MS).then(asRows), []),
settled(openF1Get("position", { session_key: sessionKey }, OF1_MS).then(asRows), []),
settled(openF1Get("intervals", { session_key: sessionKey }, OF1_MS).then(asRows), []),
settled(openF1Get("laps", { session_key: sessionKey }, OF1_MS).then(asRows), []),
settled(openF1Get("stints", { session_key: sessionKey }, OF1_MS).then(asRows), []),
settled(openF1Get("weather", { session_key: sessionKey }, OF1_MS).then(asRows), []),
settled(openF1Get("race_control", { session_key: sessionKey }, OF1_MS).then(asRows), []),
settled(
openF1Get("location", {
openF1Get(
"location",
{
session_key: sessionKey,
"date>=": locFrom,
...(locTo ? { "date<": locTo } : {}),
}).then(asRows),
},
OF1_MS
).then(asRows),
[]
),
meetingKey
? settled(openF1Get("sessions", { meeting_key: meetingKey }).then(asRows), [])
? settled(openF1Get("sessions", { meeting_key: meetingKey }, OF1_MS).then(asRows), [])
: Promise.resolve([]),
circuitKey ? loadCircuitMap(circuitKey, year) : Promise.resolve(null),
settled(getF1News(12), []),
]);
const news = snapCache?.data.news ?? [];
void getF1News(12)
.then((items) => {
if (snapCache) snapCache.data = { ...snapCache.data, news: items };
})
.catch(() => undefined);
const meeting = rec(meetingRows[0]);
const drivers = driverRows.map(rec);