index.js 14 KB

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