| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408 |
- /**
- * Design by fromScratch Studio - 2022, 2023 (fromscratch.io)
- * Implementation in HTML/CSS/JS by Timendus - 2024 (https://github.com/Timendus)
- *
- * See https://github.com/librespeed/speedtest/issues/585
- */
- // States the UI can be in
- const INITIALIZING = 0;
- const READY = 1;
- const RUNNING = 2;
- const FINISHED = 3;
- // Keep some global state here
- const testState = {
- state: INITIALIZING,
- speedtest: null,
- servers: [],
- selectedServerDirty: false,
- testData: null,
- testDataDirty: false,
- telemetryEnabled: false,
- };
- // Bootstrap the application when the DOM is ready
- window.addEventListener("DOMContentLoaded", async () => {
- createSpeedtest();
- hookUpButtons();
- startRenderingLoop();
- applySettingsJSON();
- applyServerListJSON();
- });
- /**
- * Create a new Speedtest and hook it into the global state
- */
- function createSpeedtest() {
- testState.speedtest = new Speedtest();
- testState.speedtest.onupdate = (data) => {
- testState.testData = data;
- testState.testDataDirty = true;
- };
- testState.speedtest.onend = (aborted) =>
- (testState.state = aborted ? READY : FINISHED);
- }
- /**
- * Make all the buttons respond to the right clicks
- */
- function hookUpButtons() {
- document
- .querySelector("#start-button")
- .addEventListener("click", startButtonClickHandler);
- document
- .querySelector("#choose-privacy")
- .addEventListener("click", () =>
- document.querySelector("#privacy").showModal()
- );
- document
- .querySelector("#share-results")
- .addEventListener("click", () =>
- document.querySelector("#share").showModal()
- );
- document
- .querySelector("#copy-link")
- .addEventListener("click", copyLinkButtonClickHandler);
- document
- .querySelectorAll(".close-dialog, #close-privacy")
- .forEach((element) => {
- element.addEventListener("click", () =>
- document.querySelectorAll("dialog").forEach((modal) => modal.close())
- );
- });
- }
- /**
- * Event listener for clicks on the main start button
- */
- function startButtonClickHandler() {
- switch (testState.state) {
- case READY:
- case FINISHED:
- testState.speedtest.start();
- testState.state = RUNNING;
- return;
- case RUNNING:
- testState.speedtest.abort();
- // testState.state is updated by `onend` handler of speedtest
- return;
- default:
- return;
- }
- }
- /**
- * Event listener for clicks on the "Copy link" button in the modal
- */
- async function copyLinkButtonClickHandler() {
- const link = document.querySelector("img#results").src;
- await navigator.clipboard.writeText(link);
- const button = document.querySelector("#copy-link");
- button.classList.add("active");
- button.textContent = "Copied!";
- setTimeout(() => {
- button.classList.remove("active");
- button.textContent = "Copy link";
- }, 3000);
- }
- /**
- * Load settings from settings.json on the server and apply them
- */
- async function applySettingsJSON() {
- try {
- const response = await fetch("settings.json");
- const settings = await response.json();
- if (!settings || typeof settings !== "object") {
- return console.error("Settings are empty or malformed");
- }
- for (let setting in settings) {
- testState.speedtest.setParameter(setting, settings[setting]);
- if (
- setting == "telemetry_level" &&
- settings[setting] &&
- settings[setting] != "off" &&
- settings[setting] != "disabled" &&
- settings[setting] != "false"
- ) {
- testState.telemetryEnabled = true;
- document.querySelector("#privacy-warning").classList.remove("hidden");
- }
- }
- } catch (error) {
- console.error("Failed to fetch settings:", error);
- }
- }
- /**
- * Load server list from server-list.json on the server and populate the
- * dropdown
- */
- async function applyServerListJSON() {
- try {
- const response = await fetch("server-list.json");
- const servers = await response.json();
- if (!servers || !Array.isArray(servers) || servers.length === 0) {
- return console.error("Server list is empty or malformed");
- }
- testState.servers = servers;
- populateDropdown(testState.servers);
- if (servers.length > 1) {
- testState.speedtest.addTestPoints(servers);
- testState.speedtest.selectServer((server) => {
- if (server) {
- selectServer(server);
- } else {
- alert(
- "Can't reach any of the speedtest servers! But you're on this page. Something weird is going on with your network."
- );
- }
- });
- }
- } catch (error) {
- console.error("Failed to fetch server list:", error);
- }
- }
- /**
- * Add all the servers to the server selection dropdown and make it actually
- * work.
- * @param {Array} servers - an array of server objects
- */
- function populateDropdown(servers) {
- const serverSelector = document.querySelector("div.server-selector");
- const serverList = serverSelector.querySelector("ul.servers");
- // If we have only a single server, just show it
- if (servers.length === 1) {
- serverSelector.classList.add("single-server");
- selectServer(servers[0]);
- return;
- }
- serverSelector.classList.add("active");
- // Make the dropdown open and close
- serverSelector.addEventListener("click", () => {
- serverList.classList.toggle("active");
- });
- document.addEventListener("click", (e) => {
- if (e.target.closest("div.server-selector") !== serverSelector)
- serverList.classList.remove("active");
- });
- // Populate the list to choose from
- servers.forEach((server) => {
- const item = document.createElement("li");
- const link = document.createElement("a");
- link.href = "#";
- link.innerHTML = `${server.name}${
- server.sponsorName ? ` <span>(${server.sponsorName})</span>` : ""
- }`;
- link.addEventListener("click", () => selectServer(server));
- item.appendChild(link);
- serverList.appendChild(item);
- });
- }
- /**
- * Set the given server as the selected server for the speedtest
- * @param {Object} server - a server object
- */
- function selectServer(server) {
- testState.speedtest.setSelectedServer(server);
- testState.selectedServerDirty = true;
- testState.state = READY;
- }
- /**
- * Start the requestAnimationFrame UI rendering loop
- */
- function startRenderingLoop() {
- // Do these queries once to speed up the rendering itself
- const serverSelector = document.querySelector("div.server-selector");
- const selectedServer = serverSelector.querySelector("#selected-server");
- const sponsor = serverSelector.querySelector("#sponsor");
- const startButton = document.querySelector("#start-button");
- const privacyWarning = document.querySelector("#privacy-warning");
- const gauges = document.querySelectorAll("#download-gauge, #upload-gauge");
- const downloadProgress = document.querySelector("#download-gauge .progress");
- const uploadProgress = document.querySelector("#upload-gauge .progress");
- const downloadGauge = document.querySelector("#download-gauge .speed");
- const uploadGauge = document.querySelector("#upload-gauge .speed");
- const downloadText = document.querySelector("#download-gauge span");
- const uploadText = document.querySelector("#upload-gauge span");
- const pingAndJitter = document.querySelectorAll(".ping, .jitter");
- const ping = document.querySelector("#ping");
- const jitter = document.querySelector("#jitter");
- const shareResults = document.querySelector("#share-results");
- const copyLink = document.querySelector("#copy-link");
- const resultsImage = document.querySelector("#results");
- const buttonTexts = {
- [INITIALIZING]: "Loading...",
- [READY]: "Let's start",
- [RUNNING]: "Abort",
- [FINISHED]: "Restart",
- };
- // Show copy link button only if navigator.clipboard is available
- copyLink.classList.toggle("hidden", !navigator.clipboard);
- function renderUI() {
- // Make the main button reflect the current state
- startButton.textContent = buttonTexts[testState.state];
- startButton.classList.toggle("disabled", testState.state === INITIALIZING);
- startButton.classList.toggle("active", testState.state === RUNNING);
- // Disable the server selector while test is running
- serverSelector.classList.toggle("disabled", testState.state === RUNNING);
- // Show selected server
- if (testState.selectedServerDirty) {
- const server = testState.speedtest.getSelectedServer();
- selectedServer.textContent = server.name;
- if (server.sponsorName) {
- if (server.sponsorURL) {
- sponsor.innerHTML = `Sponsor: <a href="${server.sponsorURL}">${server.sponsorName}</a>`;
- } else {
- sponsor.textContent = `Sponsor: ${server.sponsorName}`;
- }
- } else {
- sponsor.innerHTML = " ";
- }
- testState.selectedServerDirty = false;
- }
- // Activate the gauges when test running or finished
- gauges.forEach((e) =>
- e.classList.toggle(
- "enabled",
- testState.state === RUNNING || testState.state === FINISHED
- )
- );
- // Show ping and jitter if data is available
- pingAndJitter.forEach((e) =>
- e.classList.toggle(
- "hidden",
- !(
- testState.testData &&
- testState.testData.pingStatus &&
- testState.testData.jitterStatus
- )
- )
- );
- // Show share button after test if server supports it
- shareResults.classList.toggle(
- "hidden",
- !(
- testState.state === FINISHED &&
- testState.telemetryEnabled &&
- testState.testData.testId
- )
- );
- if (testState.testDataDirty) {
- // Set gauge rotations
- downloadProgress.style = `--progress-rotation: ${
- testState.testData.dlProgress * 180
- }deg`;
- uploadProgress.style = `--progress-rotation: ${
- testState.testData.ulProgress * 180
- }deg`;
- downloadGauge.style = `--speed-rotation: ${mbpsToRotation(
- testState.testData.dlStatus,
- testState.testData.testState === 1
- )}deg`;
- uploadGauge.style = `--speed-rotation: ${mbpsToRotation(
- testState.testData.ulStatus,
- testState.testData.testState === 3
- )}deg`;
- // Set numeric values
- downloadText.textContent = numberToText(testState.testData.dlStatus);
- uploadText.textContent = numberToText(testState.testData.ulStatus);
- ping.textContent = numberToText(testState.testData.pingStatus);
- jitter.textContent = numberToText(testState.testData.jitterStatus);
- // Set user's IP and provider
- if (testState.testData.clientIp) {
- // Clear previous content
- privacyWarning.innerHTML = '';
- const connectedThrough = document.createElement('span');
- connectedThrough.textContent = 'You are connected through:';
-
- const ipAddress = document.createTextNode(testState.testData.clientIp);
- privacyWarning.appendChild(connectedThrough);
- privacyWarning.appendChild(document.createElement('br'));
- privacyWarning.appendChild(ipAddress);
-
- privacyWarning.classList.remove("hidden");
- }
- // Set image for sharing results
- if (testState.testData.testId) {
- resultsImage.src =
- window.location.href.substring(
- 0,
- window.location.href.lastIndexOf("/")
- ) +
- "/results/?id=" +
- testState.testData.testId;
- }
- testState.testDataDirty = false;
- }
- requestAnimationFrame(renderUI);
- }
- renderUI();
- }
- /**
- * Convert a speed in Mbits per second to a rotation for the gauge
- * @param {string} speed Speed in Mbits
- * @param {boolean} oscillate If the gauge should wiggle a bit
- * @returns {number} Rotation for the gauge in degrees
- */
- function mbpsToRotation(speed, oscillate) {
- speed = Number(speed);
- if (speed <= 0) return 0;
- const minSpeed = 0;
- const maxSpeed = 10000; // 10 Gbps maxes out the gauge
- const minRotation = 0;
- const maxRotation = 180;
- // Can't do log10 of values less than one, +1 all to keep it fair
- const logMinSpeed = Math.log10(minSpeed + 1);
- const logMaxSpeed = Math.log10(maxSpeed + 1);
- const logSpeed = Math.log10(speed + 1);
- const power = (logSpeed - logMinSpeed) / (logMaxSpeed - logMinSpeed);
- const oscillation = oscillate ? 1 + 0.01 * Math.sin(Date.now() / 100) : 1;
- const rotation = power * oscillation * maxRotation;
- // Make sure we stay within bounds at all times
- return Math.max(Math.min(rotation, maxRotation), minRotation);
- }
- /**
- * Convert a number to a user friendly version
- * @param {string} value Speed, ping or jitter
- * @returns {string} A text version with proper decimals
- */
- function numberToText(value) {
- if (!value) return "00";
- value = Number(value);
- if (value < 10) return value.toFixed(2);
- if (value < 100) return value.toFixed(1);
- return value.toFixed(0);
- }
|