Add OpenF1 live timing in the iOS F1 hub, with credentials stored in admin.
Login lives encrypted in the database (Instellingen → OpenF1) so the phone can use live data without env secrets.
This commit is contained in:
parent
97e76c3551
commit
84cc1e99f9
19 changed files with 2050 additions and 15 deletions
|
|
@ -302,6 +302,14 @@ export function F1tvPanel({ onChanged }: { onChanged?: (o: F1tvOverview) => void
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="card" style={{ maxWidth: 760, marginBottom: "1rem" }}>
|
||||||
|
<h2 style={{ marginTop: 0 }}>OpenF1 live timing</h2>
|
||||||
|
<p className="muted" style={{ marginTop: 0 }}>
|
||||||
|
Posities, circuitkaart en telemetrie in de F1-hub. Login staat onder{" "}
|
||||||
|
<a href="/settings?tab=openf1">Instellingen → OpenF1</a> (versleuteld in de database).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="card" style={{ maxWidth: 760 }}>
|
<div className="card" style={{ maxWidth: 760 }}>
|
||||||
<h2 style={{ marginTop: 0 }}>F1TV</h2>
|
<h2 style={{ marginTop: 0 }}>F1TV</h2>
|
||||||
<p className="muted" style={{ marginTop: 0 }}>
|
<p className="muted" style={{ marginTop: 0 }}>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ type SettingsTab =
|
||||||
| "download-station"
|
| "download-station"
|
||||||
| "opensubtitles"
|
| "opensubtitles"
|
||||||
| "omdb"
|
| "omdb"
|
||||||
|
| "openf1"
|
||||||
| "google-play"
|
| "google-play"
|
||||||
| "tv";
|
| "tv";
|
||||||
|
|
||||||
|
|
@ -19,6 +20,7 @@ function parseTab(v: string | null): SettingsTab {
|
||||||
v === "download-station" ||
|
v === "download-station" ||
|
||||||
v === "opensubtitles" ||
|
v === "opensubtitles" ||
|
||||||
v === "omdb" ||
|
v === "omdb" ||
|
||||||
|
v === "openf1" ||
|
||||||
v === "shelves" ||
|
v === "shelves" ||
|
||||||
v === "google-play" ||
|
v === "google-play" ||
|
||||||
v === "tv"
|
v === "tv"
|
||||||
|
|
@ -70,6 +72,7 @@ const TABS: Array<{ id: SettingsTab; label: string; short: string }> = [
|
||||||
{ id: "download-station", label: "Download Station", short: "DS" },
|
{ id: "download-station", label: "Download Station", short: "DS" },
|
||||||
{ id: "opensubtitles", label: "OpenSubtitles", short: "Subs" },
|
{ id: "opensubtitles", label: "OpenSubtitles", short: "Subs" },
|
||||||
{ id: "omdb", label: "OMDb", short: "OMDb" },
|
{ id: "omdb", label: "OMDb", short: "OMDb" },
|
||||||
|
{ id: "openf1", label: "OpenF1", short: "F1" },
|
||||||
{ id: "google-play", label: "Google Play", short: "Play" },
|
{ id: "google-play", label: "Google Play", short: "Play" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -133,6 +136,16 @@ export default function SettingsPage() {
|
||||||
const [omdbErr, setOmdbErr] = useState<string | null>(null);
|
const [omdbErr, setOmdbErr] = useState<string | null>(null);
|
||||||
const [omdbWarn, setOmdbWarn] = useState<string | null>(null);
|
const [omdbWarn, setOmdbWarn] = useState<string | null>(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<string | null>(null);
|
||||||
|
const [openF1Err, setOpenF1Err] = useState<string | null>(null);
|
||||||
|
const [openF1Warn, setOpenF1Warn] = useState<string | null>(null);
|
||||||
|
|
||||||
const [adminEmail, setAdminEmail] = useState("");
|
const [adminEmail, setAdminEmail] = useState("");
|
||||||
const [currentPassword, setCurrentPassword] = useState("");
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
|
@ -242,6 +255,21 @@ export default function SettingsPage() {
|
||||||
.catch(() => undefined);
|
.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(() => {
|
const loadGooglePlay = useCallback(() => {
|
||||||
fetch("/api/v1/admin/settings/google-play", { credentials: "include" })
|
fetch("/api/v1/admin/settings/google-play", { credentials: "include" })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
|
|
@ -311,6 +339,7 @@ export default function SettingsPage() {
|
||||||
loadDs();
|
loadDs();
|
||||||
loadOpenSubtitles();
|
loadOpenSubtitles();
|
||||||
loadOmdb();
|
loadOmdb();
|
||||||
|
loadOpenF1();
|
||||||
loadGooglePlay();
|
loadGooglePlay();
|
||||||
loadTvSettings();
|
loadTvSettings();
|
||||||
loadTokenTray();
|
loadTokenTray();
|
||||||
|
|
@ -326,7 +355,7 @@ export default function SettingsPage() {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}, [loadShelves, loadDs, loadOpenSubtitles, loadOmdb, loadGooglePlay, loadTvSettings, loadTokenTray]);
|
}, [loadShelves, loadDs, loadOpenSubtitles, loadOmdb, loadOpenF1, loadGooglePlay, loadTvSettings, loadTokenTray]);
|
||||||
|
|
||||||
async function saveInstallPageEnabled(enabled: boolean) {
|
async function saveInstallPageEnabled(enabled: boolean) {
|
||||||
setInstallPageBusy(true);
|
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() {
|
async function saveGooglePlay() {
|
||||||
setGpBusy(true);
|
setGpBusy(true);
|
||||||
setGpMsg(null);
|
setGpMsg(null);
|
||||||
|
|
@ -1482,6 +1541,64 @@ export default function SettingsPage() {
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tab === "openf1" && (
|
||||||
|
<section className="stack-section">
|
||||||
|
<div className="section-head">
|
||||||
|
<h2 className="section-title">OpenF1</h2>
|
||||||
|
<p className="muted">
|
||||||
|
Live timing, circuitkaart en telemetrie voor de F1-hub. Account van{" "}
|
||||||
|
<a href="https://openf1.org/" target="_blank" rel="noreferrer">
|
||||||
|
openf1.org
|
||||||
|
</a>
|
||||||
|
. E-mail en wachtwoord worden versleuteld in de database bewaard (niet in env/git).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="form-grid">
|
||||||
|
<label>
|
||||||
|
E-mail
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={openF1Username}
|
||||||
|
onChange={(e) => setOpenF1Username(e.target.value)}
|
||||||
|
placeholder="account@openf1.org"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Wachtwoord {openF1HasPassword ? "(leeg = behouden)" : ""}
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={openF1Password}
|
||||||
|
onChange={(e) => setOpenF1Password(e.target.value)}
|
||||||
|
placeholder={openF1HasPassword ? "••••••••" : "OpenF1-wachtwoord"}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="check-label" style={{ alignSelf: "end" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={openF1Enabled}
|
||||||
|
onChange={(e) => setOpenF1Enabled(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Ingeschakeld
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="inline-form" style={{ marginTop: "0.75rem" }}>
|
||||||
|
<button type="button" disabled={openF1Busy} onClick={() => void saveOpenF1()}>
|
||||||
|
{openF1Busy ? "Bezig…" : "Opslaan + testen"}
|
||||||
|
</button>
|
||||||
|
<span className="muted">
|
||||||
|
Status: {openF1Configured && openF1Enabled ? "aan" : "uit"}
|
||||||
|
</span>
|
||||||
|
{openF1Msg && <span className="ok-text">{openF1Msg}</span>}
|
||||||
|
{openF1Warn && <span className="danger-text">{openF1Warn}</span>}
|
||||||
|
{openF1Err && <span className="danger-text">{openF1Err}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{tab === "google-play" && (
|
{tab === "google-play" && (
|
||||||
<section className="stack-section">
|
<section className="stack-section">
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@
|
||||||
A72DE8139DAE48EC9975E0CF /* LiveTvScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */; };
|
A72DE8139DAE48EC9975E0CF /* LiveTvScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */; };
|
||||||
E5F6A7B8C9D0E1F2A3B4C5D6 /* EventsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A2B3C4D5E6F7A8B9C0D1E2 /* EventsScreen.swift */; };
|
E5F6A7B8C9D0E1F2A3B4C5D6 /* EventsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A2B3C4D5E6F7A8B9C0D1E2 /* EventsScreen.swift */; };
|
||||||
B7C3E9F1B2D4068E5A1C3E71 /* EventHubsScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7C3E9F1B2D4068E5A1C3E71 /* EventHubsScreens.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 */; };
|
A1CFC814F3894773A5BEA652 /* SettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */; };
|
||||||
B8D4E1F2A3C54B6D9E0F1A2B /* DevicesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */; };
|
B8D4E1F2A3C54B6D9E0F1A2B /* DevicesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */; };
|
||||||
B7C2E91A4F8D4A1E9C3B6D20 /* QualityBadges.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.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 = "<group>"; };
|
98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTvScreen.swift; sourceTree = "<group>"; };
|
||||||
F1A2B3C4D5E6F7A8B9C0D1E2 /* EventsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventsScreen.swift; sourceTree = "<group>"; };
|
F1A2B3C4D5E6F7A8B9C0D1E2 /* EventsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventsScreen.swift; sourceTree = "<group>"; };
|
||||||
A7C3E9F1B2D4068E5A1C3E71 /* EventHubsScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventHubsScreens.swift; sourceTree = "<group>"; };
|
A7C3E9F1B2D4068E5A1C3E71 /* EventHubsScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventHubsScreens.swift; sourceTree = "<group>"; };
|
||||||
|
C7C3E9F1B2D4068E5A1C3E80 /* F1LiveScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = F1LiveScreen.swift; sourceTree = "<group>"; };
|
||||||
470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = "<group>"; };
|
470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = "<group>"; };
|
||||||
C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesScreen.swift; sourceTree = "<group>"; };
|
C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesScreen.swift; sourceTree = "<group>"; };
|
||||||
D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QualityBadges.swift; sourceTree = "<group>"; };
|
D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QualityBadges.swift; sourceTree = "<group>"; };
|
||||||
|
|
@ -189,6 +191,7 @@
|
||||||
98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */,
|
98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */,
|
||||||
F1A2B3C4D5E6F7A8B9C0D1E2 /* EventsScreen.swift */,
|
F1A2B3C4D5E6F7A8B9C0D1E2 /* EventsScreen.swift */,
|
||||||
A7C3E9F1B2D4068E5A1C3E71 /* EventHubsScreens.swift */,
|
A7C3E9F1B2D4068E5A1C3E71 /* EventHubsScreens.swift */,
|
||||||
|
C7C3E9F1B2D4068E5A1C3E80 /* F1LiveScreen.swift */,
|
||||||
470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */,
|
470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */,
|
||||||
C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */,
|
C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */,
|
||||||
);
|
);
|
||||||
|
|
@ -295,6 +298,7 @@
|
||||||
A72DE8139DAE48EC9975E0CF /* LiveTvScreen.swift in Sources */,
|
A72DE8139DAE48EC9975E0CF /* LiveTvScreen.swift in Sources */,
|
||||||
E5F6A7B8C9D0E1F2A3B4C5D6 /* EventsScreen.swift in Sources */,
|
E5F6A7B8C9D0E1F2A3B4C5D6 /* EventsScreen.swift in Sources */,
|
||||||
B7C3E9F1B2D4068E5A1C3E71 /* EventHubsScreens.swift in Sources */,
|
B7C3E9F1B2D4068E5A1C3E71 /* EventHubsScreens.swift in Sources */,
|
||||||
|
D7C3E9F1B2D4068E5A1C3E80 /* F1LiveScreen.swift in Sources */,
|
||||||
A1CFC814F3894773A5BEA652 /* SettingsScreen.swift in Sources */,
|
A1CFC814F3894773A5BEA652 /* SettingsScreen.swift in Sources */,
|
||||||
B8D4E1F2A3C54B6D9E0F1A2B /* DevicesScreen.swift in Sources */,
|
B8D4E1F2A3C54B6D9E0F1A2B /* DevicesScreen.swift in Sources */,
|
||||||
);
|
);
|
||||||
|
|
@ -345,7 +349,7 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 72;
|
CURRENT_PROJECT_VERSION = 73;
|
||||||
DEVELOPMENT_TEAM = X25NYX3K49;
|
DEVELOPMENT_TEAM = X25NYX3K49;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
|
@ -360,7 +364,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks"
|
"@executable_path/Frameworks"
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.5.59;
|
MARKETING_VERSION = 0.5.60;
|
||||||
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
|
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
|
@ -378,7 +382,7 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 72;
|
CURRENT_PROJECT_VERSION = 73;
|
||||||
DEVELOPMENT_TEAM = X25NYX3K49;
|
DEVELOPMENT_TEAM = X25NYX3K49;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
|
@ -393,7 +397,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks"
|
"@executable_path/Frameworks"
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.5.59;
|
MARKETING_VERSION = 0.5.60;
|
||||||
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
|
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
|
|
||||||
|
|
@ -354,6 +354,10 @@ actor ApiClient {
|
||||||
try await get("/api/v1/client/f1/home")
|
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 {
|
func f1Meeting(meetingKey: String) async throws -> F1MeetingDetail {
|
||||||
let enc = Self.encodePathSegment(meetingKey)
|
let enc = Self.encodePathSegment(meetingKey)
|
||||||
return try await get("/api/v1/client/f1/meetings/\(enc)")
|
return try await get("/api/v1/client/f1/meetings/\(enc)")
|
||||||
|
|
|
||||||
|
|
@ -768,7 +768,175 @@ struct ViaplayHubPayload: Decodable {
|
||||||
catchup = try c.decodeIfPresent([ScheduleEvent].self, forKey: .catchup) ?? []
|
catchup = try c.decodeIfPresent([ScheduleEvent].self, forKey: .catchup) ?? []
|
||||||
sports = try c.decodeIfPresent([ViaplaySport].self, forKey: .sports) ?? []
|
sports = try c.decodeIfPresent([ViaplaySport].self, forKey: .sports) ?? []
|
||||||
events = try c.decodeIfPresent([ScheduleEvent].self, forKey: .events) ?? []
|
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)) ?? []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import SwiftUI
|
||||||
|
|
||||||
enum EventHubRoute: Hashable {
|
enum EventHubRoute: Hashable {
|
||||||
case f1
|
case f1
|
||||||
|
case f1Live
|
||||||
case f1Meeting(String)
|
case f1Meeting(String)
|
||||||
case viaplay
|
case viaplay
|
||||||
|
|
||||||
|
|
@ -22,6 +23,7 @@ struct EventHubDestination: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
switch route {
|
switch route {
|
||||||
case .f1: F1HubScreen()
|
case .f1: F1HubScreen()
|
||||||
|
case .f1Live: F1LiveScreen()
|
||||||
case .f1Meeting(let key): F1MeetingScreen(meetingKey: key)
|
case .f1Meeting(let key): F1MeetingScreen(meetingKey: key)
|
||||||
case .viaplay: ViaplayHubScreen()
|
case .viaplay: ViaplayHubScreen()
|
||||||
}
|
}
|
||||||
|
|
@ -169,6 +171,7 @@ struct F1HubScreen: View {
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 32) {
|
VStack(alignment: .leading, spacing: 32) {
|
||||||
hero(p, layout)
|
hero(p, layout)
|
||||||
|
liveTimingEntry(live: !p.live.isEmpty)
|
||||||
|
|
||||||
if isEmpty {
|
if isEmpty {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
|
|
@ -245,20 +248,22 @@ struct F1HubScreen: View {
|
||||||
Label(state == .live ? "Kijk live" : "Terugkijken", systemImage: "play.fill")
|
Label(state == .live ? "Kijk live" : "Terugkijken", systemImage: "play.fill")
|
||||||
}
|
}
|
||||||
.buttonStyle(HubPrimaryButtonStyle())
|
.buttonStyle(HubPrimaryButtonStyle())
|
||||||
if let meeting {
|
NavigationLink(value: EventHubRoute.f1Live) {
|
||||||
NavigationLink(value: EventHubRoute.f1Meeting(meeting.meetingKey)) {
|
Label("Timing", systemImage: "timer")
|
||||||
Label("Weekend", systemImage: "calendar")
|
|
||||||
}
|
}
|
||||||
.buttonStyle(HubGlassButtonStyle())
|
.buttonStyle(HubGlassButtonStyle())
|
||||||
}
|
|
||||||
} else if let meeting {
|
} else if let meeting {
|
||||||
NavigationLink(value: EventHubRoute.f1Meeting(meeting.meetingKey)) {
|
NavigationLink(value: EventHubRoute.f1Meeting(meeting.meetingKey)) {
|
||||||
Label("Bekijk weekend", systemImage: "calendar")
|
Label("Bekijk weekend", systemImage: "calendar")
|
||||||
}
|
}
|
||||||
.buttonStyle(HubPrimaryButtonStyle())
|
.buttonStyle(HubPrimaryButtonStyle())
|
||||||
|
NavigationLink(value: EventHubRoute.f1Live) {
|
||||||
|
Label("Timing", systemImage: "timer")
|
||||||
|
}
|
||||||
|
.buttonStyle(HubGlassButtonStyle())
|
||||||
} else {
|
} else {
|
||||||
NavigationLink(value: featured.id) {
|
NavigationLink(value: EventHubRoute.f1Live) {
|
||||||
Label("Details", systemImage: "info.circle")
|
Label("Live timing", systemImage: "timer")
|
||||||
}
|
}
|
||||||
.buttonStyle(HubPrimaryButtonStyle())
|
.buttonStyle(HubPrimaryButtonStyle())
|
||||||
}
|
}
|
||||||
|
|
@ -279,6 +284,10 @@ struct F1HubScreen: View {
|
||||||
Label("Bekijk weekend", systemImage: "calendar")
|
Label("Bekijk weekend", systemImage: "calendar")
|
||||||
}
|
}
|
||||||
.buttonStyle(HubPrimaryButtonStyle())
|
.buttonStyle(HubPrimaryButtonStyle())
|
||||||
|
NavigationLink(value: EventHubRoute.f1Live) {
|
||||||
|
Label("Timing", systemImage: "timer")
|
||||||
|
}
|
||||||
|
.buttonStyle(HubGlassButtonStyle())
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
HubHero(
|
HubHero(
|
||||||
|
|
@ -289,8 +298,56 @@ struct F1HubScreen: View {
|
||||||
isLive: false,
|
isLive: false,
|
||||||
title: "Formule 1",
|
title: "Formule 1",
|
||||||
subtitle: "Live sessies en replays"
|
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 {
|
private func seasonSection(_ p: F1HubPayload) -> some View {
|
||||||
|
|
|
||||||
697
apps/ios/VMC/UI/F1LiveScreen.swift
Normal file
697
apps/ios/VMC/UI/F1LiveScreen.swift
Normal file
|
|
@ -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<Content: View>(@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>) -> Self {
|
||||||
|
min(max(self, range.lowerBound), range.upperBound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,10 @@ HIDE_UNAVAILABLE_TITLES=true
|
||||||
# Voorkeur: URL's beheren in Admin UI (meerdere feeds mogelijk).
|
# Voorkeur: URL's beheren in Admin UI (meerdere feeds mogelijk).
|
||||||
IPTV_EPG_XML_URL=https://epg.faabserver.nl/epg/odido.xml
|
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 Play Closed Testing (optioneel — zie GOOGLE_PLAY_ACCESS_SETUP.md)
|
||||||
# GOOGLE_CLOUD_PROJECT_ID=
|
# GOOGLE_CLOUD_PROJECT_ID=
|
||||||
# GOOGLE_SERVICE_ACCOUNT_EMAIL=
|
# GOOGLE_SERVICE_ACCOUNT_EMAIL=
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"dev": "tsx watch src/app.ts",
|
"dev": "tsx watch src/app.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/app.js",
|
"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:generate": "prisma generate",
|
||||||
"db:migrate": "prisma migrate deploy",
|
"db:migrate": "prisma migrate deploy",
|
||||||
"db:migrate:dev": "prisma migrate dev",
|
"db:migrate:dev": "prisma migrate dev",
|
||||||
|
|
|
||||||
88
apps/master-api/src/openf1/auth.ts
Normal file
88
apps/master-api/src/openf1/auth.ts
Normal file
|
|
@ -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<string> | 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<boolean> {
|
||||||
|
return (await resolveOpenF1Login()) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testOpenF1Login(username: string, password: string): Promise<void> {
|
||||||
|
await fetchToken({ username, password }, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOpenF1AccessToken(): Promise<string> {
|
||||||
|
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<string> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
apps/master-api/src/openf1/circuit.test.ts
Normal file
25
apps/master-api/src/openf1/circuit.test.ts
Normal file
|
|
@ -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");
|
||||||
132
apps/master-api/src/openf1/circuit.ts
Normal file
132
apps/master-api/src/openf1/circuit.ts
Normal file
|
|
@ -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<string, { at: number; map: CircuitMap; raw: RawCircuit }>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
99
apps/master-api/src/openf1/client.ts
Normal file
99
apps/master-api/src/openf1/client.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { getOpenF1AccessToken } from "./auth";
|
||||||
|
|
||||||
|
const BASE = "https://api.openf1.org/v1";
|
||||||
|
|
||||||
|
export type OpenF1Params = Record<string, string | number | boolean | undefined | null>;
|
||||||
|
|
||||||
|
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<T = unknown>(path: string, params: OpenF1Params = {}, timeoutMs = 18_000): Promise<T> {
|
||||||
|
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<T = Record<string, unknown>>(raw: unknown): T[] {
|
||||||
|
return Array.isArray(raw) ? (raw as T[]) : raw ? [raw as T] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function latestBy<T>(
|
||||||
|
rows: T[],
|
||||||
|
keyFn: (row: T) => string | number,
|
||||||
|
dateFn: (row: T) => string
|
||||||
|
): T[] {
|
||||||
|
const map = new Map<string, T>();
|
||||||
|
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<string, unknown> {
|
||||||
|
return row && typeof row === "object" ? (row as Record<string, unknown>) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
385
apps/master-api/src/openf1/live.ts
Normal file
385
apps/master-api/src/openf1/live.ts
Normal file
|
|
@ -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<string, string> = {
|
||||||
|
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<T>(p: Promise<T>, fallback: T): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await p;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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<number, Record<string, unknown>[]>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
24
apps/master-api/src/openf1/news.test.ts
Normal file
24
apps/master-api/src/openf1/news.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { parseRacingNews365 } from "./news";
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<a href="https://racingnews365.nl/f1-nieuws" class="card card--default">skip</a>
|
||||||
|
<a
|
||||||
|
href="https://racingnews365.nl/hadjar-doet-boekje-open-over-polsbreuk-heel-stom"
|
||||||
|
class="card card--default
|
||||||
|
"
|
||||||
|
data-id="14443134"
|
||||||
|
>
|
||||||
|
<span class="card__title"><span data-ab-title="14443134">Hadjar doet boekje open over polsbreuk: "Heel stom"</span></span>
|
||||||
|
<time datetime="2026-09-30T20:30:00+02:00">30 september 20:30</time>
|
||||||
|
<img srcset="https://cdn.racingnews365.com/2026/Hadjar/XPB.jpg?width=360&height=203 1x">
|
||||||
|
</a>
|
||||||
|
<a href="https://racingnews365.nl/coureurs" class="card card--default"><span data-ab-title="1">Coureurs</span></a>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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");
|
||||||
107
apps/master-api/src/openf1/news.ts
Normal file
107
apps/master-api/src/openf1/news.ts
Normal file
|
|
@ -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<string>();
|
||||||
|
const openRe = /<a\b([^>]+)>/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*<span[^>]*>([^<]+)/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<F1NewsItem[]> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
87
apps/master-api/src/settings/openf1.ts
Normal file
87
apps/master-api/src/settings/openf1.ts
Normal file
|
|
@ -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<OpenF1SettingsPublic> {
|
||||||
|
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<OpenF1SettingsPublic> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
@ -65,6 +65,18 @@ export function registerSettingsRoutes(
|
||||||
return { settings };
|
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 () => {
|
app.get("/api/v1/admin/settings/google-play", { preHandler: requireAdmin }, async () => {
|
||||||
return { settings: await getGooglePlaySettingsPublic(config) };
|
return { settings: await getGooglePlaySettingsPublic(config) };
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -910,6 +910,23 @@ export function registerViewerRoutes(
|
||||||
return getF1tvHome();
|
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) => {
|
app.get("/api/v1/client/viaplay/home", async (request) => {
|
||||||
const auth = await viewers.authFromBearer(request.headers.authorization);
|
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||||
const { resolveViewerLiveEventsAccess } = await import("./schedule-events");
|
const { resolveViewerLiveEventsAccess } = await import("./schedule-events");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue