speedtest/frontend/javascript/index.js
valer23 4ec3a755fa feat: sort server list by country name instead of city name
Sort the server dropdown by country first, then by city within the same
country. This makes it easier to find servers in a specific country when
the list is long. Applies to both modern and classic UIs.
2026-05-16 18:30:54 +02:00

458 lines
15 KiB
JavaScript

/**
* 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 the configured source and populate the dropdown
*/
async function applyServerListJSON() {
try {
const serverSource =
typeof globalThis.SPEEDTEST_SERVERS !== "undefined"
? globalThis.SPEEDTEST_SERVERS
: "server-list.json";
const servers = Array.isArray(serverSource)
? serverSource
: await fetch(serverSource).then((response) => response.json());
if (!servers || !Array.isArray(servers) || servers.length === 0) {
return console.error("Server list is empty or malformed");
}
testState.servers = servers;
// If there's only one server, just show it. No reachability checks needed.
if (servers.length === 1) {
populateDropdown(servers);
return;
}
// For multiple servers: first run the built-in selection (which pings servers
// and annotates them with pingT). Only then populate the dropdown so that
// dead servers don't appear.
testState.speedtest.addTestPoints(servers);
testState.speedtest.selectServer((bestServer) => {
const aliveServers = testState.servers.filter((s) => {
// Keep servers that responded to ping (pingT !== -1).
if (s.pingT !== -1) return true;
// Also keep protocol-relative servers ("//...") as a defensive fallback.
// LibreSpeed normalizes them to the page protocol before pinging, so they
// are normally treated like any other server and get a real pingT value.
return typeof s.server === "string" && s.server.startsWith("//");
});
// Prefer to show only reachable servers, but if none are reachable,
// fall back to the full list so users can still pick a server manually.
if (aliveServers.length > 0) {
testState.servers = aliveServers;
}
populateDropdown(testState.servers);
if (bestServer) {
selectServer(bestServer);
} 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 load 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");
// Reset previous state (populateDropdown can be called multiple times)
serverSelector.classList.remove("single-server");
serverSelector.classList.remove("active");
serverList.classList.remove("active");
serverList.innerHTML = "";
// 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 (hook only once)
if (serverSelector.dataset.hooked !== "1") {
serverSelector.dataset.hooked = "1";
serverSelector.addEventListener("click", () => {
serverList.classList.toggle("active");
});
document.addEventListener("click", (e) => {
if (e.target.closest("div.server-selector") !== serverSelector)
serverList.classList.remove("active");
});
}
// Sort servers by country, then by city within the same country
servers.sort((a, b) => {
const commaA = a.name.lastIndexOf(",");
const commaB = b.name.lastIndexOf(",");
const countryA = commaA >= 0 ? a.name.substring(commaA + 1).trim() : a.name;
const countryB = commaB >= 0 ? b.name.substring(commaB + 1).trim() : b.name;
const cityA = commaA >= 0 ? a.name.substring(0, commaA).trim() : "";
const cityB = commaB >= 0 ? b.name.substring(0, commaB).trim() : "";
return countryA.localeCompare(countryB) || cityA.localeCompare(cityB);
});
// 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 = "&nbsp;";
}
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);
}