Stabilize F1 Live scrolling and news reader, and speed up the dashboard fetch.
This commit is contained in:
parent
b911a4a7a7
commit
00efebdf84
5 changed files with 155 additions and 59 deletions
|
|
@ -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)")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue