index.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  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 server-list.json on the server and populate the
  131. * dropdown
  132. */
  133. async function applyServerListJSON() {
  134. try {
  135. const response = await fetch("server-list.json");
  136. const servers = await response.json();
  137. if (!servers || !Array.isArray(servers) || servers.length === 0) {
  138. return console.error("Server list is empty or malformed");
  139. }
  140. testState.servers = servers;
  141. populateDropdown(testState.servers);
  142. if (servers.length > 1) {
  143. testState.speedtest.addTestPoints(servers);
  144. testState.speedtest.selectServer((server) => {
  145. if (server) {
  146. selectServer(server);
  147. } else {
  148. alert(
  149. "Can't reach any of the speedtest servers! But you're on this page. Something weird is going on with your network."
  150. );
  151. }
  152. });
  153. }
  154. } catch (error) {
  155. console.error("Failed to fetch server list:", error);
  156. }
  157. }
  158. /**
  159. * Add all the servers to the server selection dropdown and make it actually
  160. * work.
  161. * @param {Array} servers - an array of server objects
  162. */
  163. function populateDropdown(servers) {
  164. const serverSelector = document.querySelector("div.server-selector");
  165. const serverList = serverSelector.querySelector("ul.servers");
  166. // If we have only a single server, just show it
  167. if (servers.length === 1) {
  168. serverSelector.classList.add("single-server");
  169. selectServer(servers[0]);
  170. return;
  171. }
  172. serverSelector.classList.add("active");
  173. // Make the dropdown open and close
  174. serverSelector.addEventListener("click", () => {
  175. serverList.classList.toggle("active");
  176. });
  177. document.addEventListener("click", (e) => {
  178. if (e.target.closest("div.server-selector") !== serverSelector)
  179. serverList.classList.remove("active");
  180. });
  181. // Populate the list to choose from
  182. servers.forEach((server) => {
  183. const item = document.createElement("li");
  184. const link = document.createElement("a");
  185. link.href = "#";
  186. link.innerHTML = `${server.name}${
  187. server.sponsorName ? ` <span>(${server.sponsorName})</span>` : ""
  188. }`;
  189. link.addEventListener("click", () => selectServer(server));
  190. item.appendChild(link);
  191. serverList.appendChild(item);
  192. });
  193. }
  194. /**
  195. * Set the given server as the selected server for the speedtest
  196. * @param {Object} server - a server object
  197. */
  198. function selectServer(server) {
  199. testState.speedtest.setSelectedServer(server);
  200. testState.selectedServerDirty = true;
  201. testState.state = READY;
  202. }
  203. /**
  204. * Start the requestAnimationFrame UI rendering loop
  205. */
  206. function startRenderingLoop() {
  207. // Do these queries once to speed up the rendering itself
  208. const serverSelector = document.querySelector("div.server-selector");
  209. const selectedServer = serverSelector.querySelector("#selected-server");
  210. const sponsor = serverSelector.querySelector("#sponsor");
  211. const startButton = document.querySelector("#start-button");
  212. const privacyWarning = document.querySelector("#privacy-warning");
  213. const gauges = document.querySelectorAll("#download-gauge, #upload-gauge");
  214. const downloadProgress = document.querySelector("#download-gauge .progress");
  215. const uploadProgress = document.querySelector("#upload-gauge .progress");
  216. const downloadGauge = document.querySelector("#download-gauge .speed");
  217. const uploadGauge = document.querySelector("#upload-gauge .speed");
  218. const downloadText = document.querySelector("#download-gauge span");
  219. const uploadText = document.querySelector("#upload-gauge span");
  220. const pingAndJitter = document.querySelectorAll(".ping, .jitter");
  221. const ping = document.querySelector("#ping");
  222. const jitter = document.querySelector("#jitter");
  223. const shareResults = document.querySelector("#share-results");
  224. const copyLink = document.querySelector("#copy-link");
  225. const resultsImage = document.querySelector("#results");
  226. const buttonTexts = {
  227. [INITIALIZING]: "Loading...",
  228. [READY]: "Let's start",
  229. [RUNNING]: "Abort",
  230. [FINISHED]: "Restart",
  231. };
  232. // Show copy link button only if navigator.clipboard is available
  233. copyLink.classList.toggle("hidden", !navigator.clipboard);
  234. function renderUI() {
  235. // Make the main button reflect the current state
  236. startButton.textContent = buttonTexts[testState.state];
  237. startButton.classList.toggle("disabled", testState.state === INITIALIZING);
  238. startButton.classList.toggle("active", testState.state === RUNNING);
  239. // Disable the server selector while test is running
  240. serverSelector.classList.toggle("disabled", testState.state === RUNNING);
  241. // Show selected server
  242. if (testState.selectedServerDirty) {
  243. const server = testState.speedtest.getSelectedServer();
  244. selectedServer.textContent = server.name;
  245. if (server.sponsorName) {
  246. if (server.sponsorURL) {
  247. sponsor.innerHTML = `Sponsor: <a href="${server.sponsorURL}">${server.sponsorName}</a>`;
  248. } else {
  249. sponsor.textContent = `Sponsor: ${server.sponsorName}`;
  250. }
  251. } else {
  252. sponsor.innerHTML = "&nbsp;";
  253. }
  254. testState.selectedServerDirty = false;
  255. }
  256. // Activate the gauges when test running or finished
  257. gauges.forEach((e) =>
  258. e.classList.toggle(
  259. "enabled",
  260. testState.state === RUNNING || testState.state === FINISHED
  261. )
  262. );
  263. // Show ping and jitter if data is available
  264. pingAndJitter.forEach((e) =>
  265. e.classList.toggle(
  266. "hidden",
  267. !(
  268. testState.testData &&
  269. testState.testData.pingStatus &&
  270. testState.testData.jitterStatus
  271. )
  272. )
  273. );
  274. // Show share button after test if server supports it
  275. shareResults.classList.toggle(
  276. "hidden",
  277. !(
  278. testState.state === FINISHED &&
  279. testState.telemetryEnabled &&
  280. testState.testData.testId
  281. )
  282. );
  283. if (testState.testDataDirty) {
  284. // Set gauge rotations
  285. downloadProgress.style = `--progress-rotation: ${
  286. testState.testData.dlProgress * 180
  287. }deg`;
  288. uploadProgress.style = `--progress-rotation: ${
  289. testState.testData.ulProgress * 180
  290. }deg`;
  291. downloadGauge.style = `--speed-rotation: ${mbpsToRotation(
  292. testState.testData.dlStatus,
  293. testState.testData.testState === 1
  294. )}deg`;
  295. uploadGauge.style = `--speed-rotation: ${mbpsToRotation(
  296. testState.testData.ulStatus,
  297. testState.testData.testState === 3
  298. )}deg`;
  299. // Set numeric values
  300. downloadText.textContent = numberToText(testState.testData.dlStatus);
  301. uploadText.textContent = numberToText(testState.testData.ulStatus);
  302. ping.textContent = numberToText(testState.testData.pingStatus);
  303. jitter.textContent = numberToText(testState.testData.jitterStatus);
  304. // Set user's IP and provider
  305. if (testState.testData.clientIp) {
  306. // Clear previous content
  307. privacyWarning.innerHTML = '';
  308. const connectedThrough = document.createElement('span');
  309. connectedThrough.textContent = 'You are connected through:';
  310. const ipAddress = document.createTextNode(testState.testData.clientIp);
  311. privacyWarning.appendChild(connectedThrough);
  312. privacyWarning.appendChild(document.createElement('br'));
  313. privacyWarning.appendChild(ipAddress);
  314. privacyWarning.classList.remove("hidden");
  315. }
  316. // Set image for sharing results
  317. if (testState.testData.testId) {
  318. resultsImage.src =
  319. window.location.href.substring(
  320. 0,
  321. window.location.href.lastIndexOf("/")
  322. ) +
  323. "/results/?id=" +
  324. testState.testData.testId;
  325. }
  326. testState.testDataDirty = false;
  327. }
  328. requestAnimationFrame(renderUI);
  329. }
  330. renderUI();
  331. }
  332. /**
  333. * Convert a speed in Mbits per second to a rotation for the gauge
  334. * @param {string} speed Speed in Mbits
  335. * @param {boolean} oscillate If the gauge should wiggle a bit
  336. * @returns {number} Rotation for the gauge in degrees
  337. */
  338. function mbpsToRotation(speed, oscillate) {
  339. speed = Number(speed);
  340. if (speed <= 0) return 0;
  341. const minSpeed = 0;
  342. const maxSpeed = 10000; // 10 Gbps maxes out the gauge
  343. const minRotation = 0;
  344. const maxRotation = 180;
  345. // Can't do log10 of values less than one, +1 all to keep it fair
  346. const logMinSpeed = Math.log10(minSpeed + 1);
  347. const logMaxSpeed = Math.log10(maxSpeed + 1);
  348. const logSpeed = Math.log10(speed + 1);
  349. const power = (logSpeed - logMinSpeed) / (logMaxSpeed - logMinSpeed);
  350. const oscillation = oscillate ? 1 + 0.01 * Math.sin(Date.now() / 100) : 1;
  351. const rotation = power * oscillation * maxRotation;
  352. // Make sure we stay within bounds at all times
  353. return Math.max(Math.min(rotation, maxRotation), minRotation);
  354. }
  355. /**
  356. * Convert a number to a user friendly version
  357. * @param {string} value Speed, ping or jitter
  358. * @returns {string} A text version with proper decimals
  359. */
  360. function numberToText(value) {
  361. if (!value) return "00";
  362. value = Number(value);
  363. if (value < 10) return value.toFixed(2);
  364. if (value < 100) return value.toFixed(1);
  365. return value.toFixed(0);
  366. }