(function () { "use strict"; function buildMarkup(layout) { var layoutClass = layout === "embedded" ? " co-browser--embedded" : " co-browser--standalone"; return ( '
' + '
' + '

MULTIPLAYER

' + '
' + '' + '
' + '
' + '
' + '
' + '

Direct Connect

' + "
" + '
' + "
" + '
' + '
' + '

Servers

' + '
' + 'Online' + '' + "
" + "
" + '
' + '
' + '
' + '' + "
" + "
" + '
' + '
' + '

Local Servers

' + '
' + '' + "
" + "
" + '
' + '
' + '' + "
" + "
" + '
' + '
' + '

Favorites

' + "
" + '
' + '
' + '
' + '' + "
" + "
" + '
' + '
' + '

Recent

' + "
" + '
' + '
' + '
' + '' + "
" + "
" + "
" + "
" + "
" + '
' + '
' + "
" + '" + "
" + "
" ); } /** * 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 }; })();