index.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. /**
  2. * Design by fromScratch Studio - 2022, 2023 (fromscratch.io)
  3. * Implementation in HTML/CSS/JS by Timendus - 2024 (https://github.com/Timendus)
  4. *
  5. * See https://github.com/librespeed/speedtest/issues/585
  6. */
  7. // States the UI can be in
  8. const INITIALIZING = 0;
  9. const READY = 1;
  10. const RUNNING = 2;
  11. const FINISHED = 3;
  12. // Keep some global state here
  13. const testState = {
  14. state: INITIALIZING,
  15. speedtest: null,
  16. servers: [],
  17. initialGaugeScrollPending: false,
  18. initialGaugeScrollScheduled: false,
  19. selectedServerDirty: false,
  20. testData: null,
  21. testDataDirty: false,
  22. telemetryEnabled: false,
  23. };
  24. // Bootstrap the application when the DOM is ready
  25. window.addEventListener("DOMContentLoaded", async () => {
  26. createSpeedtest();
  27. hookUpButtons();
  28. startRenderingLoop();
  29. applySettingsJSON();
  30. applyServerListJSON();
  31. });
  32. /**
  33. * Create a new Speedtest and hook it into the global state
  34. */
  35. function createSpeedtest() {
  36. testState.speedtest = new Speedtest();
  37. testState.speedtest.onupdate = (data) => {
  38. testState.testData = data;
  39. testState.testDataDirty = true;
  40. };
  41. testState.speedtest.onend = (aborted) =>
  42. (testState.state = aborted ? READY : FINISHED);
  43. }
  44. /**
  45. * Make all the buttons respond to the right clicks
  46. */
  47. function hookUpButtons() {
  48. document
  49. .querySelector("#start-button")
  50. .addEventListener("click", startButtonClickHandler);
  51. document
  52. .querySelector("#choose-privacy")
  53. .addEventListener("click", () =>
  54. document.querySelector("#privacy").showModal()
  55. );
  56. document
  57. .querySelector("#share-results")
  58. .addEventListener("click", () =>
  59. document.querySelector("#share").showModal()
  60. );
  61. document
  62. .querySelector("#copy-link")
  63. .addEventListener("click", copyLinkButtonClickHandler);
  64. document
  65. .querySelectorAll(".close-dialog, #close-privacy")
  66. .forEach((element) => {
  67. element.addEventListener("click", () =>
  68. document.querySelectorAll("dialog").forEach((modal) => modal.close())
  69. );
  70. });
  71. }
  72. /**
  73. * Event listener for clicks on the main start button
  74. */
  75. function startButtonClickHandler() {
  76. switch (testState.state) {
  77. case READY:
  78. case FINISHED:
  79. testState.speedtest.start();
  80. testState.initialGaugeScrollPending = true;
  81. testState.state = RUNNING;
  82. return;
  83. case RUNNING:
  84. testState.speedtest.abort();
  85. // testState.state is updated by `onend` handler of speedtest
  86. return;
  87. default:
  88. return;
  89. }
  90. }
  91. /**
  92. * Scroll the initial download gauge into view on narrow viewports when starting a test
  93. */
  94. function scrollInitialDownloadGaugeIntoView() {
  95. if (!window.matchMedia("(max-width: 800px)").matches) {
  96. return;
  97. }
  98. const downloadGauge = document.querySelector("#download-gauge");
  99. if (!downloadGauge) {
  100. return;
  101. }
  102. const { top, bottom } = downloadGauge.getBoundingClientRect();
  103. if (top >= 0 && bottom <= window.innerHeight) {
  104. return;
  105. }
  106. downloadGauge.scrollIntoView({
  107. block: "center",
  108. inline: "nearest",
  109. });
  110. }
  111. /**
  112. * Event listener for clicks on the "Copy link" button in the modal
  113. */
  114. async function copyLinkButtonClickHandler() {
  115. const link = document.querySelector("img#results").src;
  116. await navigator.clipboard.writeText(link);
  117. const button = document.querySelector("#copy-link");
  118. button.classList.add("active");
  119. button.textContent = "Copied!";
  120. setTimeout(() => {
  121. button.classList.remove("active");
  122. button.textContent = "Copy link";
  123. }, 3000);
  124. }
  125. /**
  126. * Load settings from settings.json on the server and apply them
  127. */
  128. async function applySettingsJSON() {
  129. try {
  130. const response = await fetch("settings.json");
  131. const settings = await response.json();
  132. if (!settings || typeof settings !== "object") {
  133. return console.error("Settings are empty or malformed");
  134. }
  135. for (let setting in settings) {
  136. testState.speedtest.setParameter(setting, settings[setting]);
  137. if (
  138. setting == "telemetry_level" &&
  139. settings[setting] &&
  140. settings[setting] != "off" &&
  141. settings[setting] != "disabled" &&
  142. settings[setting] != "false"
  143. ) {
  144. testState.telemetryEnabled = true;
  145. document.querySelector("#privacy-warning").classList.remove("hidden");
  146. }
  147. }
  148. } catch (error) {
  149. console.error("Failed to fetch settings:", error);
  150. }
  151. }
  152. /**
  153. * Load server list from the configured source and populate the dropdown
  154. */
  155. async function applyServerListJSON() {
  156. try {
  157. const serverSource =
  158. typeof globalThis.SPEEDTEST_SERVERS !== "undefined"
  159. ? globalThis.SPEEDTEST_SERVERS
  160. : "server-list.json";
  161. const servers = Array.isArray(serverSource)
  162. ? serverSource
  163. : await fetch(serverSource).then((response) => response.json());
  164. if (!servers || !Array.isArray(servers) || servers.length === 0) {
  165. return console.error("Server list is empty or malformed");
  166. }
  167. testState.servers = servers;
  168. // If there's only one server, just show it. No reachability checks needed.
  169. if (servers.length === 1) {
  170. populateDropdown(servers);
  171. return;
  172. }
  173. // For multiple servers: first run the built-in selection (which pings servers
  174. // and annotates them with pingT). Only then populate the dropdown so that
  175. // dead servers don't appear.
  176. testState.speedtest.addTestPoints(servers);
  177. testState.speedtest.selectServer((bestServer) => {
  178. const aliveServers = testState.servers.filter((s) => {
  179. // Keep servers that responded to ping (pingT !== -1).
  180. if (s.pingT !== -1) return true;
  181. // Also keep protocol-relative servers ("//...") as a defensive fallback.
  182. // LibreSpeed normalizes them to the page protocol before pinging, so they
  183. // are normally treated like any other server and get a real pingT value.
  184. return typeof s.server === "string" && s.server.startsWith("//");
  185. });
  186. // Prefer to show only reachable servers, but if none are reachable,
  187. // fall back to the full list so users can still pick a server manually.
  188. if (aliveServers.length > 0) {
  189. testState.servers = aliveServers;
  190. }
  191. populateDropdown(testState.servers);
  192. if (bestServer) {
  193. selectServer(bestServer);
  194. } else {
  195. alert(
  196. "Can't reach any of the speedtest servers! But you're on this page. Something weird is going on with your network."
  197. );
  198. }
  199. });
  200. } catch (error) {
  201. console.error("Failed to load server list:", error);
  202. }
  203. }
  204. /**
  205. * Add all the servers to the server selection dropdown and make it actually
  206. * work.
  207. * @param {Array} servers - an array of server objects
  208. */
  209. function populateDropdown(servers) {
  210. const serverSelector = document.querySelector("div.server-selector");
  211. const serverList = serverSelector.querySelector("ul.servers");
  212. // Reset previous state (populateDropdown can be called multiple times)
  213. serverSelector.classList.remove("single-server");
  214. serverSelector.classList.remove("active");
  215. serverList.classList.remove("active");
  216. serverList.innerHTML = "";
  217. // If we have only a single server, just show it
  218. if (servers.length === 1) {
  219. serverSelector.classList.add("single-server");
  220. selectServer(servers[0]);
  221. return;
  222. }
  223. serverSelector.classList.add("active");
  224. // Make the dropdown open and close (hook only once)
  225. if (serverSelector.dataset.hooked !== "1") {
  226. serverSelector.dataset.hooked = "1";
  227. serverSelector.addEventListener("click", () => {
  228. serverList.classList.toggle("active");
  229. });
  230. document.addEventListener("click", (e) => {
  231. if (e.target.closest("div.server-selector") !== serverSelector)
  232. serverList.classList.remove("active");
  233. });
  234. }
  235. // Sort servers by country, then by city within the same country.
  236. // Name formats: "City, Country", "City, Country (qualifier)", "City, Country, Provider", "Country"
  237. const parseServerName = (name) => {
  238. const parts = (name || "").split(",").map((s) => s.trim());
  239. let country, city;
  240. if (parts.length >= 3) {
  241. // "City, Country, Provider" — use second part as country
  242. country = parts[1];
  243. city = parts[0];
  244. } else if (parts.length === 2) {
  245. country = parts[1];
  246. city = parts[0];
  247. } else {
  248. country = parts[0];
  249. city = "";
  250. }
  251. // Strip parenthetical qualifiers for sorting: "Germany (1) (Hetzner)" → "Germany"
  252. country = country.replace(/\s*\([^)]*\)\s*/g, "").trim();
  253. return { country, city };
  254. };
  255. const sorted = [...servers].sort((a, b) => {
  256. const pa = parseServerName(a.name);
  257. const pb = parseServerName(b.name);
  258. return pa.country.localeCompare(pb.country) || pa.city.localeCompare(pb.city);
  259. });
  260. // Populate the list to choose from
  261. sorted.forEach((server) => {
  262. const item = document.createElement("li");
  263. const link = document.createElement("a");
  264. link.href = "#";
  265. link.innerHTML = `${server.name}${
  266. server.sponsorName ? ` <span>(${server.sponsorName})</span>` : ""
  267. }`;
  268. link.addEventListener("click", () => selectServer(server));
  269. item.appendChild(link);
  270. serverList.appendChild(item);
  271. });
  272. }
  273. /**
  274. * Set the given server as the selected server for the speedtest
  275. * @param {Object} server - a server object
  276. */
  277. function selectServer(server) {
  278. testState.speedtest.setSelectedServer(server);
  279. testState.selectedServerDirty = true;
  280. testState.state = READY;
  281. }
  282. /**
  283. * Start the requestAnimationFrame UI rendering loop
  284. */
  285. function startRenderingLoop() {
  286. // Do these queries once to speed up the rendering itself
  287. const serverSelector = document.querySelector("div.server-selector");
  288. const selectedServer = serverSelector.querySelector("#selected-server");
  289. const sponsor = serverSelector.querySelector("#sponsor");
  290. const startButton = document.querySelector("#start-button");
  291. const privacyWarning = document.querySelector("#privacy-warning");
  292. const gauges = document.querySelectorAll("#download-gauge, #upload-gauge");
  293. const downloadProgress = document.querySelector("#download-gauge .progress");
  294. const uploadProgress = document.querySelector("#upload-gauge .progress");
  295. const downloadGauge = document.querySelector("#download-gauge .speed");
  296. const uploadGauge = document.querySelector("#upload-gauge .speed");
  297. const downloadText = document.querySelector("#download-gauge span");
  298. const uploadText = document.querySelector("#upload-gauge span");
  299. const pingAndJitter = document.querySelectorAll(".ping, .jitter");
  300. const ping = document.querySelector("#ping");
  301. const jitter = document.querySelector("#jitter");
  302. const shareResults = document.querySelector("#share-results");
  303. const copyLink = document.querySelector("#copy-link");
  304. const resultsImage = document.querySelector("#results");
  305. const buttonTexts = {
  306. [INITIALIZING]: "Loading...",
  307. [READY]: "Let's start",
  308. [RUNNING]: "Abort",
  309. [FINISHED]: "Restart",
  310. };
  311. // Show copy link button only if navigator.clipboard is available
  312. copyLink.classList.toggle("hidden", !navigator.clipboard);
  313. function renderUI() {
  314. // Make the main button reflect the current state
  315. startButton.textContent = buttonTexts[testState.state];
  316. startButton.classList.toggle("disabled", testState.state === INITIALIZING);
  317. startButton.classList.toggle("active", testState.state === RUNNING);
  318. // Disable the server selector while test is running
  319. serverSelector.classList.toggle("disabled", testState.state === RUNNING);
  320. // Show selected server
  321. if (testState.selectedServerDirty) {
  322. const server = testState.speedtest.getSelectedServer();
  323. selectedServer.textContent = server.name;
  324. if (server.sponsorName) {
  325. if (server.sponsorURL) {
  326. sponsor.innerHTML = `Sponsor: <a href="${server.sponsorURL}">${server.sponsorName}</a>`;
  327. } else {
  328. sponsor.textContent = `Sponsor: ${server.sponsorName}`;
  329. }
  330. } else {
  331. sponsor.innerHTML = "&nbsp;";
  332. }
  333. testState.selectedServerDirty = false;
  334. }
  335. // Activate the gauges when test running or finished
  336. gauges.forEach((e) =>
  337. e.classList.toggle(
  338. "enabled",
  339. testState.state === RUNNING || testState.state === FINISHED
  340. )
  341. );
  342. if (
  343. testState.state === RUNNING &&
  344. testState.initialGaugeScrollPending &&
  345. !testState.initialGaugeScrollScheduled
  346. ) {
  347. testState.initialGaugeScrollScheduled = true;
  348. requestAnimationFrame(() => {
  349. if (testState.state === RUNNING) {
  350. scrollInitialDownloadGaugeIntoView();
  351. }
  352. testState.initialGaugeScrollPending = false;
  353. testState.initialGaugeScrollScheduled = false;
  354. });
  355. }
  356. // Show ping and jitter if data is available
  357. pingAndJitter.forEach((e) =>
  358. e.classList.toggle(
  359. "hidden",
  360. !(
  361. testState.testData &&
  362. testState.testData.pingStatus &&
  363. testState.testData.jitterStatus
  364. )
  365. )
  366. );
  367. // Show share button after test if server supports it
  368. shareResults.classList.toggle(
  369. "hidden",
  370. !(
  371. testState.state === FINISHED &&
  372. testState.telemetryEnabled &&
  373. testState.testData.testId
  374. )
  375. );
  376. if (testState.testDataDirty) {
  377. // Set gauge rotations
  378. downloadProgress.style = `--progress-rotation: ${
  379. testState.testData.dlProgress * 180
  380. }deg`;
  381. uploadProgress.style = `--progress-rotation: ${
  382. testState.testData.ulProgress * 180
  383. }deg`;
  384. downloadGauge.style = `--speed-rotation: ${mbpsToRotation(
  385. testState.testData.dlStatus,
  386. testState.testData.testState === 1
  387. )}deg`;
  388. uploadGauge.style = `--speed-rotation: ${mbpsToRotation(
  389. testState.testData.ulStatus,
  390. testState.testData.testState === 3
  391. )}deg`;
  392. // Set numeric values
  393. downloadText.textContent = numberToText(testState.testData.dlStatus);
  394. uploadText.textContent = numberToText(testState.testData.ulStatus);
  395. ping.textContent = numberToText(testState.testData.pingStatus);
  396. jitter.textContent = numberToText(testState.testData.jitterStatus);
  397. // Set user's IP and provider
  398. if (testState.testData.clientIp) {
  399. // Clear previous content
  400. privacyWarning.innerHTML = '';
  401. const connectedThrough = document.createElement('span');
  402. connectedThrough.textContent = 'You are connected through:';
  403. const ipAddress = document.createTextNode(testState.testData.clientIp);
  404. privacyWarning.appendChild(connectedThrough);
  405. privacyWarning.appendChild(document.createElement('br'));
  406. privacyWarning.appendChild(ipAddress);
  407. privacyWarning.classList.remove("hidden");
  408. }
  409. // Set image for sharing results
  410. if (testState.testData.testId) {
  411. resultsImage.src =
  412. window.location.href.substring(
  413. 0,
  414. window.location.href.lastIndexOf("/")
  415. ) +
  416. "/results/?id=" +
  417. testState.testData.testId +
  418. // Ask for the design this frontend matches; the classic frontend
  419. // links the same URL and gets the classic image without asking.
  420. "&style=modern";
  421. }
  422. testState.testDataDirty = false;
  423. }
  424. requestAnimationFrame(renderUI);
  425. }
  426. renderUI();
  427. }
  428. /**
  429. * Convert a speed in Mbits per second to a rotation for the gauge
  430. * @param {string} speed Speed in Mbits
  431. * @param {boolean} oscillate If the gauge should wiggle a bit
  432. * @returns {number} Rotation for the gauge in degrees
  433. */
  434. function mbpsToRotation(speed, oscillate) {
  435. speed = Number(speed);
  436. if (speed <= 0) return 0;
  437. const minSpeed = 0;
  438. const maxSpeed = 10000; // 10 Gbps maxes out the gauge
  439. const minRotation = 0;
  440. const maxRotation = 180;
  441. // Can't do log10 of values less than one, +1 all to keep it fair
  442. const logMinSpeed = Math.log10(minSpeed + 1);
  443. const logMaxSpeed = Math.log10(maxSpeed + 1);
  444. const logSpeed = Math.log10(speed + 1);
  445. const power = (logSpeed - logMinSpeed) / (logMaxSpeed - logMinSpeed);
  446. const oscillation = oscillate ? 1 + 0.01 * Math.sin(Date.now() / 100) : 1;
  447. const rotation = power * oscillation * maxRotation;
  448. // Make sure we stay within bounds at all times
  449. return Math.max(Math.min(rotation, maxRotation), minRotation);
  450. }
  451. /**
  452. * Convert a number to a user friendly version
  453. * @param {string} value Speed, ping or jitter
  454. * @returns {string} A text version with proper decimals
  455. */
  456. function numberToText(value) {
  457. if (!value) return "00";
  458. value = Number(value);
  459. if (value < 10) return value.toFixed(2);
  460. if (value < 100) return value.toFixed(1);
  461. return value.toFixed(0);
  462. }