Files
Commonwealth-Online-Public/ui/views/CommonwealthOnline/browser/app.js
T
andrew fb4e238fcc Add dynamic UI theme for server browser
Introduce runtime-driven UI theming for the server browser

- Expose PushUITheme() in F4TServerBrowserBridge and implement ColorToJson + PushUIThemeInternal to serialize HUD colors and invoke a JS hook.
- Call PushUITheme on UI initialization and when the browser is shown so the webview receives current HUD colors.
- Add frontend handler for "uiTheme" in app.js and a new components/theme.js that implements window.applyCoTheme to set CSS vars and tint the cursor SVG.
- Update styles.css to use RGB component CSS variables (--co-r/--co-g/--co-b and background equivalents) so colors can be overridden at runtime.
- Update index.html to include the new theme script and tweak cursor SVG stroke width; cursor asset updated as well.

This enables the plugin to synchronize the in-game HUD colors with the browser UI dynamically.
2026-06-09 15:19:03 +12:00

547 lines
16 KiB
JavaScript

(function () {
"use strict";
var root = document.getElementById("coRoot");
var panel = document.getElementById("coPanel");
var joinOverlay = document.getElementById("coJoinOverlay");
var joinMessage = document.getElementById("coJoinMessage");
var FOCUS_ZONES = ["tabs", "filters", "server-list"];
var REPEAT_INITIAL_MS = 400;
var REPEAT_RATE_MS = 80;
var repeatTimer = null;
var repeatAction = null;
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: [],
connectionStatus: "Online",
isRefreshing: false,
directConnect: { address: "127.0.0.1", port: "7777" },
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 "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);
break;
case "closeBrowser":
if (typeof window.closeBrowser === "function") window.closeBrowser();
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 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 = document.getElementById("coServerCount");
var titleEl = document.getElementById("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 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 getSelectedServer() {
return getActiveList()[state.selectedServerIndex] || null;
}
function getActiveList() {
if (state.activeTab === "favorites") return state.favorites;
if (state.activeTab === "recent") return state.recent;
return state.servers;
}
function joinServerLocal(serverId) {
var server = findServerById(serverId);
if (!server) return;
showJoinOverlay("Connecting to " + server.name + "...");
sendAction("joinServer", { serverId: serverId });
}
function joinDirectLocal(address, port) {
showJoinOverlay("Connecting to " + address + ":" + port + "...");
sendAction("openDirectConnect", { address: address, port: port });
}
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) {
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";
sendAction("switchTab", { tabName: tabName });
render();
}
function render() {
if (!root) return;
panel.classList.toggle("co-loading", state.isRefreshing);
CO_Tabs.render(root, state);
CO_ControllerPrompts.render(root, state);
var statusEl = document.getElementById("coConnectionStatus");
if (statusEl) {
statusEl.textContent = String(state.connectionStatus || "Online").toUpperCase();
}
document.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;
var listState = Object.assign({}, state, { servers: listServers });
if (state.activeTab === "favorites" || state.activeTab === "recent") {
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();
}
function bindUi() {
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);
});
document.addEventListener("keydown", onKeyDown);
}
function onKeyDown(e) {
if (state.joinOverlay && state.joinOverlay.error && e.key === "Escape") {
hideJoinOverlay();
return;
}
if (state.activeTab === "direct-connect") 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 (!button || phase !== "down") return;
if (state.joinOverlay) {
if (button === "b") 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 === "a") {
var vals = CO_DirectConnect.getValues(root);
joinDirectLocal(vals.address, vals.port);
}
return;
}
if (button === "a") {
joinSelected();
return;
}
if (button === "x") {
sendAction("refreshServerList", {});
return;
}
if (button === "y") {
var sel = getSelectedServer();
if (sel) toggleFavoriteLocal(sel.id);
return;
}
navigateFocus(button);
}
function joinSelected() {
var sel = getSelectedServer();
if (!sel) return;
joinServerLocal(sel.id);
}
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" && state.selectedFilterIndex === maxFilter) resetLocalFilters();
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);
return;
}
}
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;
}
window.dispatchCoEvent = function (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 "joinStarted":
showJoinOverlay("Connecting...");
break;
case "joinFailed":
showJoinOverlay(event.reason || "Connection failed", true);
break;
case "joinSucceeded":
hideJoinOverlay();
addRecent(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":
// TODO: Wire to Fallout 4 menu sound via C++ when audio hooks are available.
break;
default:
break;
}
render();
};
function addRecent(serverId) {
var server = findServerById(serverId);
if (!server) return;
state.recent = state.recent.filter(function (s) { return s.id !== serverId; });
state.recent.unshift(server);
if (state.recent.length > 8) state.recent.pop();
}
window.setConnectionStatus = function (connected, logPrefix) {
state.connectionStatus = connected ? "Connected" : "Online";
render();
};
window.onBrowserShown = function () {
dispatchCoEvent({ type: "menuOpened" });
render();
};
window.coAction = window.coAction || function () {
console.warn("[CO] coAction bridge not ready");
};
window.CO_App = {
joinSelected: joinSelected,
getState: function () { return state; }
};
state.allServers = (window.CO_MOCK_SERVERS || []).slice();
applyLocalFilters();
bindUi();
render();
console.log("[CO] Server browser loaded");
})();