/**
* 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 ? ` (${server.sponsorName})` : ""
}`;
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: ${server.sponsorName}`;
} 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);
}