Refactors the browser UI into a reusable `CO_Browser.create()` module and adds embedded modal styling so MULTIPLAYER opens inside the main menu instead of swapping to a separate view. Updates main-menu JS/CSS to host, animate, and route input for the browser panel while keeping nav and prompts in sync. On the C++ side, PrismaUI now drives browser show/hide and event dispatch through the main menu view, and `ServerBrowserBridge::SetView()` was added so bridge events target the active host view.
1019 lines
32 KiB
JavaScript
1019 lines
32 KiB
JavaScript
(function () {
|
|
"use strict";
|
|
|
|
function buildMarkup(layout) {
|
|
var embeddedClass = layout === "embedded" ? " co-browser--embedded" : "";
|
|
return (
|
|
'<div class="co-root' +
|
|
embeddedClass +
|
|
'" id="coRoot">' +
|
|
'<div class="co-panel" id="coPanel">' +
|
|
'<svg class="co-panel-border co-panel-border-top" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1075.97 11" preserveAspectRatio="none" aria-hidden="true">' +
|
|
'<polyline class="co-panel-border-line" points="0 10.97 0 0 1075.97 0 1075.97 10.5"/>' +
|
|
"</svg>" +
|
|
'<svg class="co-panel-border co-panel-border-bottom" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1075.97 11" preserveAspectRatio="none" aria-hidden="true">' +
|
|
'<polyline class="co-panel-border-line" points="0 0.47 0 8.91 1075.97 8.97 1075.97 0.47"/>' +
|
|
"</svg>" +
|
|
'<div class="co-subpanel">' +
|
|
'<nav class="co-nav-list"></nav>' +
|
|
'<div class="co-subpanel-content">' +
|
|
'<div class="co-tab-content">' +
|
|
'<div class="co-tab-pane" data-pane="direct-connect">' +
|
|
'<header class="co-subpanel-header">' +
|
|
'<h2 class="co-subpanel-title">Direct Connect</h2>' +
|
|
"</header>" +
|
|
'<div class="co-direct-connect"></div>' +
|
|
"</div>" +
|
|
'<div class="co-tab-pane active" data-pane="server-browser">' +
|
|
'<header class="co-subpanel-header">' +
|
|
'<h2 class="co-subpanel-title" id="coSubpanelTitle">Servers</h2>' +
|
|
'<div class="co-subpanel-meta">' +
|
|
'<span id="coConnectionStatus">Online</span>' +
|
|
'<span class="co-count" id="coServerCount"></span>' +
|
|
"</div>" +
|
|
"</header>" +
|
|
'<div class="co-filters"></div>' +
|
|
'<div class="co-server-list-wrap">' +
|
|
'<div class="co-server-list"></div>' +
|
|
'<div class="co-scroll-indicator" aria-hidden="true">»»</div>' +
|
|
"</div>" +
|
|
"</div>" +
|
|
'<div class="co-tab-pane" data-pane="local">' +
|
|
'<header class="co-subpanel-header">' +
|
|
'<h2 class="co-subpanel-title" id="coLocalTitle">Local Servers</h2>' +
|
|
'<div class="co-subpanel-meta">' +
|
|
'<span class="co-count" id="coLocalCount"></span>' +
|
|
"</div>" +
|
|
"</header>" +
|
|
'<div class="co-server-list-wrap">' +
|
|
'<div class="co-server-list co-local-list"></div>' +
|
|
'<div class="co-scroll-indicator" aria-hidden="true">»»</div>' +
|
|
"</div>" +
|
|
"</div>" +
|
|
'<div class="co-tab-pane" data-pane="favorites">' +
|
|
'<header class="co-subpanel-header">' +
|
|
'<h2 class="co-subpanel-title">Favorites</h2>' +
|
|
"</header>" +
|
|
'<div class="co-preview-area"></div>' +
|
|
'<div class="co-server-list-wrap">' +
|
|
'<div class="co-server-list co-favorites-list"></div>' +
|
|
'<div class="co-scroll-indicator" aria-hidden="true">»»</div>' +
|
|
"</div>" +
|
|
"</div>" +
|
|
'<div class="co-tab-pane" data-pane="recent">' +
|
|
'<header class="co-subpanel-header">' +
|
|
'<h2 class="co-subpanel-title">Recent</h2>' +
|
|
"</header>" +
|
|
'<div class="co-preview-area"></div>' +
|
|
'<div class="co-server-list-wrap">' +
|
|
'<div class="co-server-list co-recent-list"></div>' +
|
|
'<div class="co-scroll-indicator" aria-hidden="true">»»</div>' +
|
|
"</div>" +
|
|
"</div>" +
|
|
"</div>" +
|
|
"</div>" +
|
|
"</div>" +
|
|
'<footer class="co-action-bar">' +
|
|
'<div class="co-prompts"></div>' +
|
|
"</footer>" +
|
|
'<div class="co-join-overlay hidden" id="coJoinOverlay">' +
|
|
'<div class="co-spinner"></div>' +
|
|
'<p id="coJoinMessage">Connecting...</p>' +
|
|
"</div>" +
|
|
"</div>" +
|
|
"</div>"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Reusable Commonwealth Online server browser UI.
|
|
*
|
|
* var browser = CO_Browser.create({
|
|
* mount: document.getElementById("browserPanel"),
|
|
* layout: "embedded",
|
|
* onBack: function () { ... },
|
|
* exposeGlobals: true,
|
|
* });
|
|
* browser.show();
|
|
*/
|
|
function create(options) {
|
|
if (!options || !options.mount) {
|
|
throw new Error("CO_Browser.create requires a mount element");
|
|
}
|
|
|
|
var mountEl = options.mount;
|
|
var layout = options.layout || "embedded";
|
|
var onBack = typeof options.onBack === "function" ? options.onBack : function () {};
|
|
var exposeGlobals = options.exposeGlobals !== false;
|
|
var captureKeys = options.captureKeys === true;
|
|
var visible = false;
|
|
|
|
if (!mountEl.querySelector("#coRoot")) {
|
|
mountEl.innerHTML = buildMarkup(layout);
|
|
}
|
|
|
|
var root = mountEl.querySelector("#coRoot");
|
|
var panel = mountEl.querySelector("#coPanel");
|
|
var joinOverlay = mountEl.querySelector("#coJoinOverlay");
|
|
var joinMessage = mountEl.querySelector("#coJoinMessage");
|
|
|
|
if (!root || !panel || !joinOverlay || !joinMessage) {
|
|
throw new Error("CO_Browser.create failed to mount browser markup");
|
|
}
|
|
|
|
var FOCUS_ZONES = ["tabs", "filters", "server-list"];
|
|
var REPEAT_INITIAL_MS = 400;
|
|
var REPEAT_RATE_MS = 80;
|
|
|
|
var repeatTimer = null;
|
|
var repeatAction = null;
|
|
var RECENT_STORAGE_KEY = "co_recent_servers";
|
|
var MAX_RECENT = 8;
|
|
|
|
var state = {
|
|
activeTab: "server-browser",
|
|
focusZone: "server-list",
|
|
selectedTabIndex: 1,
|
|
selectedServerIndex: 0,
|
|
selectedFilterIndex: 0,
|
|
filters: {
|
|
search: "",
|
|
region: "Any Region",
|
|
mode: "Any Mode",
|
|
worldspace: "Any Worldspace",
|
|
ping: "Any",
|
|
showFull: false,
|
|
hasPassword: false,
|
|
modded: false
|
|
},
|
|
allServers: [],
|
|
servers: [],
|
|
favorites: [],
|
|
recent: [],
|
|
localServers: [],
|
|
connectionStatus: "Online",
|
|
isRefreshing: false,
|
|
isScanningLocal: false,
|
|
directConnect: { address: "127.0.0.1", port: "7777" },
|
|
directConnectFieldIndex: 0,
|
|
joinOverlay: null
|
|
};
|
|
|
|
function sendAction(action, payload) {
|
|
var msg = JSON.stringify(Object.assign({ action: action }, payload || {}));
|
|
if (typeof window.coAction === "function") {
|
|
window.coAction(msg);
|
|
return;
|
|
}
|
|
console.warn("[CO] coAction bridge unavailable:", action);
|
|
handleActionLocal(action, payload || {});
|
|
}
|
|
|
|
function handleActionLocal(action, payload) {
|
|
switch (action) {
|
|
case "refreshServerList":
|
|
refreshLocalMock();
|
|
break;
|
|
case "scanLocalServers":
|
|
scanLocalMock();
|
|
break;
|
|
case "setFilter":
|
|
state.filters[payload.filterName] = payload.value;
|
|
applyLocalFilters();
|
|
render();
|
|
break;
|
|
case "resetFilters":
|
|
resetLocalFilters();
|
|
break;
|
|
case "toggleFavorite":
|
|
toggleFavoriteLocal(payload.serverId);
|
|
break;
|
|
case "joinServer":
|
|
joinServerLocal(payload.serverId);
|
|
break;
|
|
case "openDirectConnect":
|
|
joinDirectLocal(payload.address, payload.port);
|
|
break;
|
|
case "switchTab":
|
|
switchTab(payload.tabName, { skipAction: true });
|
|
if (payload.tabName === "local") {
|
|
scanLocalMock();
|
|
}
|
|
break;
|
|
case "closeBrowser":
|
|
if (typeof window.closeBrowser === "function") {
|
|
window.closeBrowser();
|
|
} else {
|
|
hide();
|
|
onBack();
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
function refreshLocalMock() {
|
|
state.isRefreshing = true;
|
|
render();
|
|
setTimeout(function () {
|
|
state.allServers = (window.CO_MOCK_SERVERS || []).slice();
|
|
applyLocalFilters();
|
|
state.isRefreshing = false;
|
|
render();
|
|
}, 600);
|
|
}
|
|
|
|
function scanLocalMock() {
|
|
state.isScanningLocal = true;
|
|
render();
|
|
setTimeout(function () {
|
|
state.localServers = (window.CO_MOCK_SERVERS || []).filter(function (s) {
|
|
return s.region === "Local" ||
|
|
(s.host && window.CO_ServerList && CO_ServerList.isLocalHost(s.host));
|
|
}).map(function (s) {
|
|
return {
|
|
id: s.id || ("local:" + (s.host || "127.0.0.1") + ":" + portNumFrom(s.port)),
|
|
name: s.name || "",
|
|
host: s.host || "127.0.0.1",
|
|
port: portNumFrom(s.port),
|
|
region: "Local",
|
|
players: s.players || 0,
|
|
maxPlayers: s.maxPlayers || 16,
|
|
ping: s.ping || 1,
|
|
worldspace: s.worldspace || "",
|
|
mode: s.mode || ""
|
|
};
|
|
});
|
|
state.isScanningLocal = false;
|
|
if (state.activeTab === "local" && state.selectedServerIndex >= state.localServers.length) {
|
|
state.selectedServerIndex = Math.max(0, state.localServers.length - 1);
|
|
}
|
|
render();
|
|
}, 800);
|
|
}
|
|
|
|
function resetLocalFilters() {
|
|
state.filters = {
|
|
search: "",
|
|
region: "Any Region",
|
|
mode: "Any Mode",
|
|
worldspace: "Any Worldspace",
|
|
ping: "Any",
|
|
showFull: false,
|
|
hasPassword: false,
|
|
modded: false
|
|
};
|
|
sendAction("resetFilters", {});
|
|
applyLocalFilters();
|
|
render();
|
|
}
|
|
|
|
function applyLocalFilters() {
|
|
var list = state.allServers.slice();
|
|
var f = state.filters;
|
|
|
|
if (f.search) {
|
|
var q = f.search.toLowerCase();
|
|
list = list.filter(function (s) {
|
|
return s.name.toLowerCase().indexOf(q) >= 0;
|
|
});
|
|
}
|
|
if (f.region !== "Any Region") {
|
|
list = list.filter(function (s) { return s.region === f.region; });
|
|
}
|
|
if (f.mode !== "Any Mode") {
|
|
list = list.filter(function (s) { return s.mode === f.mode; });
|
|
}
|
|
if (f.worldspace !== "Any Worldspace") {
|
|
list = list.filter(function (s) { return s.worldspace === f.worldspace; });
|
|
}
|
|
if (f.ping === "< 50 ms") list = list.filter(function (s) { return s.ping < 50; });
|
|
else if (f.ping === "< 100 ms") list = list.filter(function (s) { return s.ping < 100; });
|
|
else if (f.ping === "< 150 ms") list = list.filter(function (s) { return s.ping < 150; });
|
|
if (!f.showFull) list = list.filter(function (s) { return s.status !== "Full"; });
|
|
if (f.hasPassword) list = list.filter(function (s) { return s.passworded; });
|
|
if (f.modded) list = list.filter(function (s) { return s.modded; });
|
|
|
|
state.servers = list;
|
|
state.favorites = state.allServers.filter(function (s) { return s.favorite; });
|
|
if (state.selectedServerIndex >= state.servers.length) {
|
|
state.selectedServerIndex = Math.max(0, state.servers.length - 1);
|
|
}
|
|
updateServerCount();
|
|
}
|
|
|
|
function updateServerCount() {
|
|
var countEl = root.querySelector("#coServerCount");
|
|
var titleEl = root.querySelector("#coSubpanelTitle");
|
|
var count = state.servers.length;
|
|
if (countEl) {
|
|
countEl.textContent = count + " Found";
|
|
}
|
|
if (titleEl && state.activeTab === "server-browser") {
|
|
titleEl.textContent = count === 1 ? "1 Server" : count + " Servers";
|
|
}
|
|
}
|
|
|
|
function updateLocalCount() {
|
|
var countEl = root.querySelector("#coLocalCount");
|
|
var titleEl = root.querySelector("#coLocalTitle");
|
|
if (!countEl && !titleEl) {
|
|
return;
|
|
}
|
|
|
|
var count = state.localServers.length;
|
|
if (countEl) {
|
|
if (state.isScanningLocal) {
|
|
countEl.textContent = "Scanning";
|
|
} else {
|
|
countEl.textContent = count + " Found";
|
|
}
|
|
}
|
|
if (titleEl && state.activeTab === "local") {
|
|
if (state.isScanningLocal) {
|
|
titleEl.textContent = "Scanning...";
|
|
} else {
|
|
titleEl.textContent = count === 1 ? "1 Local Server" : count + " Local Servers";
|
|
}
|
|
}
|
|
}
|
|
|
|
function toggleFavoriteLocal(serverId) {
|
|
var server = findServerById(serverId);
|
|
if (!server) {
|
|
return;
|
|
}
|
|
server.favorite = !server.favorite;
|
|
sendAction("toggleFavorite", { serverId: serverId });
|
|
applyLocalFilters();
|
|
render();
|
|
}
|
|
|
|
function findServerById(id) {
|
|
for (var i = 0; i < state.allServers.length; i++) {
|
|
if (state.allServers[i].id === id) {
|
|
return state.allServers[i];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function findLocalServerById(id) {
|
|
for (var i = 0; i < state.localServers.length; i++) {
|
|
if (state.localServers[i].id === id) {
|
|
return state.localServers[i];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getSelectedServer() {
|
|
return getActiveList()[state.selectedServerIndex] || null;
|
|
}
|
|
|
|
function getActiveList() {
|
|
if (state.activeTab === "favorites") return state.favorites;
|
|
if (state.activeTab === "recent") return state.recent;
|
|
if (state.activeTab === "local") return state.localServers;
|
|
return state.servers;
|
|
}
|
|
|
|
function recentEntryKey(server) {
|
|
if (!server) return "";
|
|
var host = server.host || server.address || "";
|
|
if (host) {
|
|
return host + ":" + portNumFrom(server.port);
|
|
}
|
|
return server.id || "";
|
|
}
|
|
|
|
function snapshotRecentEntry(server) {
|
|
return {
|
|
id: server.id || recentEntryKey(server),
|
|
name: server.name || "",
|
|
host: server.host || server.address || "",
|
|
port: portNumFrom(server.port),
|
|
region: server.region || "",
|
|
players: server.players || 0,
|
|
maxPlayers: server.maxPlayers || 0,
|
|
ping: server.ping || 0,
|
|
worldspace: server.worldspace || "",
|
|
mode: server.mode || "",
|
|
favorite: !!server.favorite
|
|
};
|
|
}
|
|
|
|
function loadRecent() {
|
|
try {
|
|
var raw = localStorage.getItem(RECENT_STORAGE_KEY);
|
|
if (!raw) return;
|
|
var parsed = JSON.parse(raw);
|
|
if (Array.isArray(parsed)) {
|
|
state.recent = parsed.slice(0, MAX_RECENT);
|
|
}
|
|
} catch (err) {
|
|
console.warn("[CO] Failed to load recent servers:", err);
|
|
}
|
|
}
|
|
|
|
function saveRecent() {
|
|
try {
|
|
localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(state.recent));
|
|
} catch (err) {
|
|
console.warn("[CO] Failed to save recent servers:", err);
|
|
}
|
|
}
|
|
|
|
function joinServerEntry(server) {
|
|
if (!server) return;
|
|
var label = window.CO_ServerList
|
|
? CO_ServerList.displayName(server)
|
|
: (server.name || server.host || "server");
|
|
showJoinOverlay("Connecting to " + label + "...");
|
|
|
|
var browserEntry = server.id ? (findServerById(server.id) || findLocalServerById(server.id)) : null;
|
|
if (browserEntry) {
|
|
sendAction("joinServer", { serverId: server.id });
|
|
return;
|
|
}
|
|
|
|
var host = server.host || server.address;
|
|
if (host) {
|
|
sendAction("openDirectConnect", {
|
|
address: host,
|
|
port: String(portNumFrom(server.port))
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (server.id) {
|
|
sendAction("joinServer", { serverId: server.id });
|
|
return;
|
|
}
|
|
|
|
showJoinOverlay("Server connection details unavailable.", true);
|
|
}
|
|
|
|
function findServerInActiveList(serverId) {
|
|
var list = getActiveList();
|
|
for (var i = 0; i < list.length; i++) {
|
|
if (list[i].id === serverId) {
|
|
return list[i];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function joinServerLocal(serverId) {
|
|
var server = findServerById(serverId) || findServerInActiveList(serverId);
|
|
if (!server) return;
|
|
joinServerEntry(server);
|
|
}
|
|
|
|
function validateDirectConnect(address, port) {
|
|
if (!address || !String(address).trim()) {
|
|
return "Server address is required.";
|
|
}
|
|
var portNum = parseInt(port, 10);
|
|
if (!port || isNaN(portNum) || portNum < 1 || portNum > 65535) {
|
|
return "Port must be a number between 1 and 65535.";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function joinDirectLocal(address, port) {
|
|
var validationError = validateDirectConnect(address, port);
|
|
if (validationError) {
|
|
showJoinOverlay(validationError, true);
|
|
return;
|
|
}
|
|
showJoinOverlay("Connecting to " + address + ":" + port + "...");
|
|
sendAction("openDirectConnect", { address: address, port: String(portNumFrom(port)) });
|
|
}
|
|
|
|
function portNumFrom(port) {
|
|
var portNum = parseInt(port, 10);
|
|
return isNaN(portNum) ? 7777 : portNum;
|
|
}
|
|
|
|
function showJoinOverlay(message, isError) {
|
|
state.joinOverlay = { message: message, error: !!isError };
|
|
joinOverlay.classList.remove("hidden");
|
|
if (isError) joinOverlay.classList.add("co-error");
|
|
else joinOverlay.classList.remove("co-error");
|
|
joinMessage.textContent = message;
|
|
var spinner = joinOverlay.querySelector(".co-spinner");
|
|
if (spinner) spinner.style.display = isError ? "none" : "block";
|
|
render();
|
|
}
|
|
|
|
function hideJoinOverlay() {
|
|
state.joinOverlay = null;
|
|
joinOverlay.classList.add("hidden");
|
|
render();
|
|
}
|
|
|
|
function switchTab(tabName, switchOptions) {
|
|
switchOptions = switchOptions || {};
|
|
state.activeTab = tabName;
|
|
state.selectedTabIndex = CO_Tabs.TAB_ORDER.indexOf(tabName);
|
|
if (state.selectedTabIndex < 0) state.selectedTabIndex = 0;
|
|
if (tabName === "server-browser") state.focusZone = "server-list";
|
|
else if (tabName === "direct-connect") {
|
|
state.focusZone = "tabs";
|
|
state.directConnectFieldIndex = 0;
|
|
} else if (tabName === "favorites" || tabName === "recent" || tabName === "local") {
|
|
var list = state.servers;
|
|
if (tabName === "favorites") list = state.favorites;
|
|
else if (tabName === "recent") list = state.recent;
|
|
else if (tabName === "local") list = state.localServers;
|
|
if (state.selectedServerIndex >= list.length) {
|
|
state.selectedServerIndex = Math.max(0, list.length - 1);
|
|
}
|
|
if (tabName === "local") {
|
|
state.focusZone = "server-list";
|
|
}
|
|
}
|
|
if (!switchOptions.skipAction) {
|
|
sendAction("switchTab", { tabName: tabName });
|
|
}
|
|
render();
|
|
}
|
|
|
|
function render() {
|
|
if (!root) return;
|
|
|
|
panel.classList.toggle("co-loading", state.isRefreshing || state.isScanningLocal);
|
|
|
|
CO_Tabs.render(root, state);
|
|
CO_ControllerPrompts.render(root, state);
|
|
|
|
var statusEl = root.querySelector("#coConnectionStatus");
|
|
if (statusEl) {
|
|
statusEl.textContent = String(state.connectionStatus || "Online").toUpperCase();
|
|
}
|
|
|
|
root.querySelectorAll(".co-tab-pane").forEach(function (pane) {
|
|
pane.classList.toggle("active", pane.getAttribute("data-pane") === state.activeTab);
|
|
});
|
|
|
|
if (state.activeTab === "direct-connect") {
|
|
CO_DirectConnect.render(root, state);
|
|
} else {
|
|
var listServers = state.servers;
|
|
if (state.activeTab === "favorites") listServers = state.favorites;
|
|
if (state.activeTab === "recent") listServers = state.recent;
|
|
if (state.activeTab === "local") listServers = state.localServers;
|
|
|
|
var listState = Object.assign({}, state, { servers: listServers });
|
|
if (state.activeTab === "favorites" || state.activeTab === "recent" || state.activeTab === "local") {
|
|
listState.focusZone = state.focusZone === "filters" ? "server-list" : state.focusZone;
|
|
}
|
|
|
|
if (state.activeTab === "server-browser") {
|
|
CO_Filters.render(root, listState);
|
|
}
|
|
|
|
if (state.activeTab !== "direct-connect") {
|
|
CO_ServerDetails.render(root, listState);
|
|
}
|
|
|
|
var activePane = root.querySelector('.co-tab-pane[data-pane="' + state.activeTab + '"]');
|
|
if (activePane) {
|
|
var listEl = activePane.querySelector(".co-server-list");
|
|
if (listEl) {
|
|
var tempRoot = {
|
|
querySelector: function (sel) {
|
|
if (sel === ".co-server-list-wrap") return activePane.querySelector(".co-server-list-wrap");
|
|
if (sel === ".co-server-list") return listEl;
|
|
return root.querySelector(sel);
|
|
}
|
|
};
|
|
CO_ServerList.render(tempRoot, listState);
|
|
}
|
|
}
|
|
}
|
|
|
|
updateServerCount();
|
|
updateLocalCount();
|
|
}
|
|
|
|
function bindUi() {
|
|
if (window.CO_Hover) {
|
|
CO_Hover.bind(root);
|
|
}
|
|
|
|
CO_Tabs.bind(root, function (tabName) {
|
|
switchTab(tabName);
|
|
});
|
|
|
|
CO_Filters.bind(root, function (name, value) {
|
|
state.filters[name] = value;
|
|
sendAction("setFilter", { filterName: name, value: value });
|
|
applyLocalFilters();
|
|
render();
|
|
}, resetLocalFilters);
|
|
|
|
root.addEventListener("click", function (e) {
|
|
var row = e.target.closest(".co-tab-pane.active .co-server-row");
|
|
if (!row) return;
|
|
state.selectedServerIndex = parseInt(row.getAttribute("data-index"), 10);
|
|
render();
|
|
});
|
|
|
|
root.addEventListener("dblclick", function (e) {
|
|
var row = e.target.closest(".co-tab-pane.active .co-server-row");
|
|
if (!row) return;
|
|
state.selectedServerIndex = parseInt(row.getAttribute("data-index"), 10);
|
|
joinSelected();
|
|
});
|
|
|
|
CO_DirectConnect.bind(root, function (address, port) {
|
|
state.directConnect = { address: address, port: port };
|
|
joinDirectLocal(address, port);
|
|
});
|
|
|
|
if (captureKeys) {
|
|
document.addEventListener("keydown", onKeyDown);
|
|
}
|
|
}
|
|
|
|
function onKeyDown(e) {
|
|
if (!visible) {
|
|
return;
|
|
}
|
|
if (state.joinOverlay && state.joinOverlay.error) {
|
|
if (e.key === "Escape" || e.key === "Enter" || e.key === "b" || e.key === "B") {
|
|
hideJoinOverlay();
|
|
return;
|
|
}
|
|
}
|
|
if (e.repeat) return;
|
|
handleControllerButton(mapKeyToButton(e.key), "down");
|
|
}
|
|
|
|
function mapKeyToButton(key) {
|
|
var map = {
|
|
ArrowUp: "dpad_up", ArrowDown: "dpad_down", ArrowLeft: "dpad_left", ArrowRight: "dpad_right",
|
|
Enter: "a", Escape: "b", " ": "a", r: "x", f: "y", q: "lb", e: "rb"
|
|
};
|
|
return map[key] || null;
|
|
}
|
|
|
|
function handleControllerButton(button, phase) {
|
|
if (!visible || !button || phase !== "down") return;
|
|
if (state.joinOverlay) {
|
|
if (button === "b" || (state.joinOverlay.error && button === "a")) {
|
|
hideJoinOverlay();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (button === "lb" || button === "rb") {
|
|
var idx = state.selectedTabIndex;
|
|
idx = button === "lb" ? idx - 1 : idx + 1;
|
|
if (idx < 0) idx = CO_Tabs.TAB_ORDER.length - 1;
|
|
if (idx >= CO_Tabs.TAB_ORDER.length) idx = 0;
|
|
switchTab(CO_Tabs.TAB_ORDER[idx]);
|
|
return;
|
|
}
|
|
|
|
if (button === "b") {
|
|
sendAction("closeBrowser", {});
|
|
return;
|
|
}
|
|
|
|
if (state.activeTab === "direct-connect") {
|
|
if (button === "dpad_up") {
|
|
state.directConnectFieldIndex = Math.max(0, state.directConnectFieldIndex - 1);
|
|
render();
|
|
return;
|
|
}
|
|
if (button === "dpad_down") {
|
|
state.directConnectFieldIndex = Math.min(1, state.directConnectFieldIndex + 1);
|
|
render();
|
|
return;
|
|
}
|
|
if (button === "a") {
|
|
var vals = CO_DirectConnect.getValues(root);
|
|
joinDirectLocal(vals.address, vals.port);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (button === "a") {
|
|
joinSelected();
|
|
return;
|
|
}
|
|
if (button === "x") {
|
|
if (state.activeTab === "local") {
|
|
sendAction("scanLocalServers", {});
|
|
} else {
|
|
sendAction("refreshServerList", {});
|
|
}
|
|
return;
|
|
}
|
|
if (button === "y") {
|
|
var sel = getSelectedServer();
|
|
if (sel) toggleFavoriteLocal(sel.id);
|
|
return;
|
|
}
|
|
|
|
navigateFocus(button);
|
|
}
|
|
|
|
function cycleFilterValue(filterKey, filterOptions) {
|
|
var current = state.filters[filterKey];
|
|
var index = filterOptions.indexOf(current);
|
|
if (index < 0) index = 0;
|
|
var next = filterOptions[(index + 1) % filterOptions.length];
|
|
state.filters[filterKey] = next;
|
|
sendAction("setFilter", { filterName: filterKey, value: next });
|
|
applyLocalFilters();
|
|
}
|
|
|
|
function activateFocusedFilter() {
|
|
var maxFilter = CO_Filters.FILTER_ROWS.length;
|
|
if (state.selectedFilterIndex === maxFilter) {
|
|
resetLocalFilters();
|
|
return;
|
|
}
|
|
|
|
var row = CO_Filters.FILTER_ROWS[state.selectedFilterIndex];
|
|
if (!row) return;
|
|
|
|
if (row.type === "select") {
|
|
cycleFilterValue(row.key, row.options);
|
|
} else if (row.type === "checkbox") {
|
|
state.filters[row.key] = !state.filters[row.key];
|
|
sendAction("setFilter", { filterName: row.key, value: state.filters[row.key] });
|
|
applyLocalFilters();
|
|
}
|
|
}
|
|
|
|
function joinSelected() {
|
|
var sel = getSelectedServer();
|
|
if (!sel) return;
|
|
joinServerEntry(sel);
|
|
}
|
|
|
|
function nextFocusZone(fromIdx, direction) {
|
|
var zones = FOCUS_ZONES.slice();
|
|
if (state.activeTab !== "server-browser") {
|
|
zones = zones.filter(function (z) { return z !== "filters"; });
|
|
}
|
|
var current = zones.indexOf(state.focusZone);
|
|
if (current < 0) current = fromIdx;
|
|
var next = current + direction;
|
|
if (next < 0 || next >= zones.length) return null;
|
|
return zones[next];
|
|
}
|
|
|
|
function navigateFocus(button) {
|
|
var list = getActiveList();
|
|
|
|
if (button === "dpad_left") {
|
|
var prevZone = nextFocusZone(FOCUS_ZONES.indexOf(state.focusZone), -1);
|
|
if (prevZone) {
|
|
state.focusZone = prevZone;
|
|
render();
|
|
}
|
|
return;
|
|
}
|
|
if (button === "dpad_right") {
|
|
if (state.activeTab === "direct-connect") return;
|
|
var nextZone = nextFocusZone(FOCUS_ZONES.indexOf(state.focusZone), 1);
|
|
if (nextZone) {
|
|
state.focusZone = nextZone;
|
|
render();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (state.focusZone === "tabs") {
|
|
if (button === "dpad_up" || button === "dpad_down") {
|
|
var dir = button === "dpad_up" ? -1 : 1;
|
|
state.selectedTabIndex = (state.selectedTabIndex + dir + CO_Tabs.TAB_ORDER.length) % CO_Tabs.TAB_ORDER.length;
|
|
switchTab(CO_Tabs.TAB_ORDER[state.selectedTabIndex]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (state.focusZone === "filters") {
|
|
var maxFilter = CO_Filters.FILTER_ROWS.length;
|
|
if (button === "dpad_up") state.selectedFilterIndex = Math.max(0, state.selectedFilterIndex - 1);
|
|
if (button === "dpad_down") state.selectedFilterIndex = Math.min(maxFilter, state.selectedFilterIndex + 1);
|
|
if (button === "a") activateFocusedFilter();
|
|
render();
|
|
return;
|
|
}
|
|
|
|
if (state.focusZone === "server-list") {
|
|
if (button === "dpad_up") state.selectedServerIndex = Math.max(0, state.selectedServerIndex - 1);
|
|
if (button === "dpad_down") state.selectedServerIndex = Math.min(list.length - 1, state.selectedServerIndex + 1);
|
|
render();
|
|
CO_ServerList.scrollToSelected(root, state.selectedServerIndex);
|
|
}
|
|
}
|
|
|
|
function startRepeat(action) {
|
|
stopRepeat();
|
|
repeatAction = action;
|
|
repeatTimer = setTimeout(function tick() {
|
|
if (repeatAction) repeatAction();
|
|
repeatTimer = setTimeout(tick, REPEAT_RATE_MS);
|
|
}, REPEAT_INITIAL_MS);
|
|
}
|
|
|
|
function stopRepeat() {
|
|
if (repeatTimer) clearTimeout(repeatTimer);
|
|
repeatTimer = null;
|
|
repeatAction = null;
|
|
}
|
|
|
|
function dispatchCoEvent(event) {
|
|
if (!event || !event.type) return;
|
|
|
|
switch (event.type) {
|
|
case "serverListUpdated":
|
|
state.allServers = event.servers || [];
|
|
applyLocalFilters();
|
|
break;
|
|
case "serverListRefreshStarted":
|
|
state.isRefreshing = true;
|
|
break;
|
|
case "serverListRefreshFinished":
|
|
state.isRefreshing = false;
|
|
if (!event.success && event.error) {
|
|
console.warn("[CO] Refresh failed:", event.error);
|
|
}
|
|
break;
|
|
case "localScanStarted":
|
|
state.isScanningLocal = true;
|
|
break;
|
|
case "localServersUpdated":
|
|
state.localServers = event.servers || [];
|
|
if (state.activeTab === "local" && state.selectedServerIndex >= state.localServers.length) {
|
|
state.selectedServerIndex = Math.max(0, state.localServers.length - 1);
|
|
}
|
|
break;
|
|
case "localScanFinished":
|
|
state.isScanningLocal = false;
|
|
if (!event.success) {
|
|
console.warn("[CO] Local scan found no servers.", event.error || "");
|
|
}
|
|
break;
|
|
case "joinStarted":
|
|
showJoinOverlay("Connecting...");
|
|
break;
|
|
case "joinLoading":
|
|
showJoinOverlay(event.message || "Loading...");
|
|
break;
|
|
case "joinFailed":
|
|
showJoinOverlay(event.reason || "Connection failed", true);
|
|
break;
|
|
case "joinSucceeded":
|
|
hideJoinOverlay();
|
|
addRecent(event.server || event.serverId);
|
|
break;
|
|
case "connectionStatusChanged":
|
|
state.connectionStatus = event.status || "Online";
|
|
break;
|
|
case "uiTheme":
|
|
if (typeof window.applyCoTheme === "function") {
|
|
window.applyCoTheme(event);
|
|
}
|
|
break;
|
|
case "menuOpened":
|
|
break;
|
|
case "menuClosed":
|
|
hideJoinOverlay();
|
|
break;
|
|
case "controllerInput":
|
|
if (event.phase === "down") {
|
|
handleControllerButton(event.button, "down");
|
|
if (event.button === "dpad_up" || event.button === "dpad_down") {
|
|
startRepeat(function () { handleControllerButton(event.button, "down"); });
|
|
}
|
|
} else if (event.phase === "up") {
|
|
stopRepeat();
|
|
}
|
|
break;
|
|
case "uiSound":
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
render();
|
|
}
|
|
|
|
function addRecent(serverOrId, serverSnapshot) {
|
|
var server = serverSnapshot;
|
|
if (!server) {
|
|
if (serverOrId && typeof serverOrId === "object") {
|
|
server = serverOrId;
|
|
} else if (serverOrId) {
|
|
server = findServerById(serverOrId);
|
|
}
|
|
}
|
|
if (!server) return;
|
|
|
|
var entry = snapshotRecentEntry(server);
|
|
var key = recentEntryKey(entry);
|
|
state.recent = state.recent.filter(function (s) {
|
|
return recentEntryKey(s) !== key && s.id !== entry.id;
|
|
});
|
|
state.recent.unshift(entry);
|
|
if (state.recent.length > MAX_RECENT) {
|
|
state.recent.length = MAX_RECENT;
|
|
}
|
|
saveRecent();
|
|
}
|
|
|
|
function setConnectionStatus(connected) {
|
|
state.connectionStatus = connected ? "Connected" : "Online";
|
|
render();
|
|
}
|
|
|
|
function setPointerMode(mode) {
|
|
if (!root) return;
|
|
var gamepad = mode === "gamepad";
|
|
root.classList.toggle("co-gamepad-mode", gamepad);
|
|
if (window.CO_Hover && CO_Hover.clear) {
|
|
CO_Hover.clear();
|
|
}
|
|
}
|
|
|
|
function onShown() {
|
|
if (window.CO_Layout && CO_Layout.update) {
|
|
CO_Layout.update();
|
|
}
|
|
dispatchCoEvent({ type: "menuOpened" });
|
|
render();
|
|
}
|
|
|
|
function show() {
|
|
visible = true;
|
|
render();
|
|
}
|
|
|
|
function hide() {
|
|
visible = false;
|
|
hideJoinOverlay();
|
|
stopRepeat();
|
|
}
|
|
|
|
function handleKeydown(event) {
|
|
if (!visible) {
|
|
return false;
|
|
}
|
|
onKeyDown(event);
|
|
return true;
|
|
}
|
|
|
|
function handleResize() {
|
|
if (window.CO_Layout && CO_Layout.update) {
|
|
CO_Layout.update();
|
|
}
|
|
render();
|
|
}
|
|
|
|
if (exposeGlobals) {
|
|
window.dispatchCoEvent = dispatchCoEvent;
|
|
window.setConnectionStatus = setConnectionStatus;
|
|
window.onBrowserShown = onShown;
|
|
window.coAction = window.coAction || function () {
|
|
console.warn("[CO] coAction bridge not ready");
|
|
};
|
|
window.CO_App = window.CO_App || {};
|
|
window.CO_App.joinSelected = joinSelected;
|
|
window.CO_App.getState = function () { return state; };
|
|
window.CO_App.setPointerMode = setPointerMode;
|
|
}
|
|
|
|
loadRecent();
|
|
state.allServers = (window.CO_MOCK_SERVERS || []).slice();
|
|
applyLocalFilters();
|
|
bindUi();
|
|
window.addEventListener("resize", handleResize);
|
|
render();
|
|
|
|
return {
|
|
show: show,
|
|
hide: hide,
|
|
onShown: onShown,
|
|
isVisible: function () { return visible; },
|
|
handleKeydown: handleKeydown,
|
|
handleControllerInput: handleControllerButton,
|
|
render: render,
|
|
dispatchCoEvent: dispatchCoEvent,
|
|
setPointerMode: setPointerMode
|
|
};
|
|
}
|
|
|
|
window.CO_Browser = {
|
|
create: create
|
|
};
|
|
})();
|