cloud-game/web/js/settings.js
2026-06-04 22:36:36 +03:00

648 lines
18 KiB
JavaScript

import {
pub,
sub,
SETTINGS_CHANGED,
KEYBOARD_KEY_PRESSED,
KEYBOARD_TOGGLE_FILTER_MODE,
} from "event";
import { gui } from "gui";
import { log } from "log";
/**
* Stores app wide option names.
*
* Use the following format:
* UPPERCASE_NAME: 'uppercase.name'
*
* @version 1
*/
export const opts = {
_VERSION: "_version",
LOG_LEVEL: "log.level",
INPUT_KEYBOARD_MAP: "input.keyboard.map",
MIRROR_SCREEN: "mirror.screen",
VOLUME: "volume",
FORCE_FULLSCREEN: "force.fullscreen",
WEBRTC_INIT_OFFER: "webrtc.init.offer",
};
// internal structure version
const revision = 1.7;
// default settings
// keep them for revert to defaults option
const _defaults = Object.create(null);
_defaults[opts._VERSION] = revision;
/**
* The main store with settings passed around by reference
* (because of that we need a wrapper object)
* don't do this at work (it's faster to write than immutable code).
*
* @type {{settings: {_version: number}}}
*/
let store = {
settings: {
..._defaults,
},
};
let provider;
/**
* Enum for settings types (the explicit type of a key-value pair).
*
* @readonly
* @enum {number}
*/
const option = Object.freeze({
undefined: 0,
string: 1,
number: 2,
object: 3,
list: 4,
});
const exportFileName = `cloud-game.settings.v${revision}.txt`;
const getStore = () => store.settings;
/**
* The NullObject provider if everything else fails.
*/
const voidProvider = (store_ = { settings: {} }) => {
const nil = () => ({});
return {
get: (key) => store_.settings[key],
set: nil,
remove: nil,
save: nil,
loadSettings: nil,
reset: nil,
};
};
/**
* The LocalStorage backend for our settings (store).
*
* For simplicity it will rewrite all the settings on every store change.
* If you want to roll your own, then use its "interface".
*/
const localStorageProvider = (store_ = { settings: {} }) => {
if (!_isSupported()) return;
const root = "settings";
const _serialize = (data) => JSON.stringify(data, null, 2);
const save = () => localStorage.setItem(root, _serialize(store_.settings));
function _isSupported() {
const testKey = "_test_42";
try {
// check if it's writable and isn't full
localStorage.setItem(testKey, testKey);
localStorage.removeItem(testKey);
return true;
} catch (e) {
log.error(e);
return false;
}
}
const get = (key) => JSON.parse(localStorage.getItem(key));
const set = () => save();
const remove = () => save();
const loadSettings = () => {
if (!localStorage.getItem(root)) save();
store_.settings = JSON.parse(localStorage.getItem(root));
};
const reset = () => {
localStorage.removeItem(root);
localStorage.setItem(root, _serialize(store_.settings));
};
return {
get,
clear: () => localStorage.removeItem(root),
set,
remove,
save,
loadSettings,
reset,
};
};
/**
* Nuke existing settings with provided data.
* @param text The text to extract data from.
* @private
*/
const _import = (text) => {
try {
for (const property of Object.getOwnPropertyNames(store.settings))
delete store.settings[property];
Object.assign(store.settings, JSON.parse(text).settings);
provider.save();
pub(SETTINGS_CHANGED);
} catch (e) {
log.error(`Your import file is broken!`, e);
}
_render();
};
const _export = () => {
let el = document.createElement("a");
el.setAttribute(
"href",
`data:text/plain;charset=utf-8,${encodeURIComponent(JSON.stringify(store, null, 2))}`,
);
el.setAttribute("download", exportFileName);
el.style.display = "none";
document.body.appendChild(el);
el.click();
document.body.removeChild(el);
};
const init = () => {
// try to load settings from the localStorage with fallback to null-object
provider = localStorageProvider(store) || voidProvider(store);
provider.loadSettings();
const lastRev = (store.settings || { _version: 0 })._version;
if (revision > lastRev) {
log.warn(
`Your settings are in older format (v${lastRev}) and will be reset to (v${revision})!`,
);
_reset();
}
};
const get = () => store.settings;
const _isLoaded = (key) => store.settings.hasOwnProperty(key);
/**
* Tries to load settings by some key.
*
* @param key A key to find values with.
* @param default_ The default values to set if none exist.
* @returns A slice of the settings with the given key or a copy of the value.
*/
const loadOr = (key, default_) => {
// preserve defaults
_defaults[key] = default_;
if (!_isLoaded(key)) {
store.settings[key] = {};
set(key, default_);
} else {
// !to check if settings do have new properties from default & update
// or it have ones that defaults doesn't
}
return store.settings[key];
};
const set = (key, value, updateProvider = true) => {
const type = _getType(value);
// mutate settings w/o changing the reference
switch (type) {
case option.list:
store.settings[key].splice(0, Infinity, ...value);
break;
case option.object:
for (let option of Object.keys(value)) {
log.debug(
`Change key [${option}] from ${store.settings[key][option]} to ${value[option]}`,
);
store.settings[key][option] = value[option];
}
break;
case option.string:
case option.number:
case option.undefined:
default:
store.settings[key] = value;
}
if (updateProvider) {
provider.set(key, value);
pub(SETTINGS_CHANGED);
}
};
const changed = (key, obj, key2) => {
if (!store.settings.hasOwnProperty(key)) return;
const newValue = store.settings[key];
const changed = newValue !== obj[key2];
if (changed) obj[key2] = newValue;
return changed;
};
const _reset = () => {
for (let _option of Object.keys(_defaults)) {
const value = _defaults[_option];
// delete all sub-options not in defaults
if (_getType(value) === option.object) {
for (let opt of Object.keys(store.settings[_option])) {
const prev = store.settings[_option][opt];
const isDeleted = delete store.settings[_option][opt];
log.debug(
`User option [${opt}=${prev}] has been deleted (${isDeleted}) from the [${_option}]`,
);
}
}
set(_option, value, false);
}
provider.reset();
pub(SETTINGS_CHANGED);
};
const remove = (key, subKey) => {
const isRemoved =
subKey !== undefined
? delete store.settings[key][subKey]
: delete store.settings[key];
if (!isRemoved)
log.warn(
`The key: ${key + (subKey ? "." + subKey : "")} wasn't deleted!`,
);
provider.remove(key, subKey);
};
const _render = () => {
renderer.data = panel.contentEl;
renderer.render();
};
const panel = gui.panel(
document.getElementById("settings"),
"> OPTIONS",
"settings",
null,
[
{ caption: "Export", handler: () => _export(), title: "Save" },
{
caption: "Import",
handler: () => _fileReader.read(onFileLoad),
title: "Load",
},
{
caption: "Reset",
handler: () => {
if (
window.confirm("Are you sure want to reset your settings?")
) {
_reset();
pub(SETTINGS_CHANGED);
}
},
title: "Reset",
},
{},
],
(show) => {
if (show) {
_render();
return;
}
// to make sure it's disabled, but it's a tad verbose
pub(KEYBOARD_TOGGLE_FILTER_MODE, { mode: true });
},
);
function _getType(value) {
if (value === undefined) return option.undefined;
else if (Array.isArray(value)) return option.list;
else if (typeof value === "object" && value !== null) return option.object;
else if (typeof value === "string") return option.string;
else if (typeof value === "number") return option.number;
else return option.undefined;
}
const _fileReader = (() => {
let callback_ = () => ({});
const el = document.createElement("input");
const reader = new FileReader();
el.type = "file";
el.accept = ".txt";
el.onchange = (event) =>
event.target.files.length &&
reader.readAsBinaryString(event.target.files[0]);
reader.onload = (event) => callback_(event.target.result);
return {
read: (callback) => {
callback_ = callback;
el.click();
},
};
})();
const onFileLoad = (text) => {
try {
_import(text);
} catch (e) {
log.error(`Couldn't read your settings!`, e);
}
};
sub(SETTINGS_CHANGED, _render);
/**
* App settings module.
*
* So the basic idea is to let app modules request their settings
* from an abstract store first, and if the store doesn't contain such settings yet,
* then let the store to take default values from the module to save them before that.
* The return value with the settings is gonna be a slice of in-memory structure
* backed by a data provider (localStorage).
* Doing it this way allows us to considerably simplify the code and make sure that
* exposed settings will have the latest values without additional update/get calls.
*/
export const settings = {
init,
loadOr,
getStore,
get,
set,
changed,
remove,
import: _import,
export: _export,
ui: {
set onToggle(fn) {
panel.onToggle(fn);
},
toggle: () => panel.toggle(),
},
};
// don't show these options (i.e. ignored = {'_version': 1})
const ignored = { _version: 1 };
// the main display data holder element
let data = null;
const scrollState = ((sx = 0, sy = 0, el) => ({
track(_el) {
el = _el;
el.addEventListener(
"scroll",
() => ({ scrollTop: sx, scrollLeft: sy } = el),
{ passive: true },
);
},
restore() {
el.scrollTop = sx;
el.scrollLeft = sy;
},
}))();
// a fast way to clear data holder.
const clearData = () => {
while (data.firstChild) data.removeChild(data.firstChild);
};
const _option = (holderEl) => {
const wrapperEl = document.createElement("div");
wrapperEl.classList.add("settings__option");
const titleEl = document.createElement("div");
titleEl.classList.add("settings__option-title");
wrapperEl.append(titleEl);
const nameEl = document.createElement("div");
const valueEl = document.createElement("div");
valueEl.classList.add("settings__option-value");
wrapperEl.append(valueEl);
return {
withName: function (name = "") {
if (name === "") return this;
nameEl.classList.add("settings__option-name");
nameEl.textContent = name;
titleEl.append(nameEl);
return this;
},
withClass: function (name = "") {
wrapperEl.classList.add(name);
return this;
},
withDescription(text = "") {
if (text === "") return this;
const descEl = document.createElement("div");
descEl.classList.add("settings__option-desc");
descEl.textContent = text;
titleEl.append(descEl);
return this;
},
restartNeeded: function () {
nameEl.classList.add("restart-needed-asterisk");
return this;
},
add: function (...elements) {
if (elements.length)
for (let _el of elements.flat()) valueEl.append(_el);
return this;
},
build: () => holderEl.append(wrapperEl),
};
};
const onKeyChange = (key, oldValue, newValue, handler) => {
if (newValue !== "Escape") {
const _settings = settings.get()[opts.INPUT_KEYBOARD_MAP];
if (_settings[newValue] !== undefined) {
log.warn(
`There are old settings for key: ${_settings[newValue]}, won't change!`,
);
} else {
settings.remove(opts.INPUT_KEYBOARD_MAP, oldValue);
settings.set(opts.INPUT_KEYBOARD_MAP, { [newValue]: key });
}
}
handler?.unsub();
pub(KEYBOARD_TOGGLE_FILTER_MODE);
pub(SETTINGS_CHANGED);
};
const _keyChangeOverlay = (keyName, oldValue) => {
const wrapperEl = document.createElement("div");
wrapperEl.classList.add("settings__key-wait");
wrapperEl.textContent = `Let's choose a ${keyName} key...`;
let handler = sub(KEYBOARD_KEY_PRESSED, (button) =>
onKeyChange(keyName, oldValue, button.key, handler),
);
return wrapperEl;
};
/**
* Handles a normal option change.
*
* @param key The name (id) of an option.
* @param newValue A new value to set.
*/
const onChange = (key, newValue) => {
settings.set(key, newValue);
scrollState.restore(data);
};
const onKeyBindingChange = (key, oldValue) => {
clearData();
data.append(_keyChangeOverlay(key, oldValue));
pub(KEYBOARD_TOGGLE_FILTER_MODE);
};
const render = function () {
const _settings = settings.getStore();
clearData();
for (let k of Object.keys(_settings).sort()) {
if (ignored[k]) continue;
const value = _settings[k];
switch (k) {
case opts._VERSION:
_option(data)
.withName("Options format version")
.add(value)
.build();
break;
case opts.LOG_LEVEL:
_option(data)
.withName("Log level")
.add(
gui.select(
k,
onChange,
{
labels: ["trace", "debug", "warning", "info"],
values: [
log.TRACE,
log.DEBUG,
log.WARN,
log.INFO,
].map(String),
},
value,
),
)
.build();
break;
case opts.INPUT_KEYBOARD_MAP:
_option(data)
.withName("Keyboard bindings")
.withClass("keyboard-bindings")
.withDescription(
"Bindings for RetroPad. There is an alternate ESC key [Shift+`] (tilde) for cores with keyboard+mouse controls (DosBox)",
)
.add(
Object.keys(value).map((k) =>
gui.binding(value[k], k, onKeyBindingChange),
),
)
.build();
break;
case opts.MIRROR_SCREEN:
_option(data)
.withName("Video mirroring")
.add(
gui.select(
k,
onChange,
{ values: ["mirror"], labels: [] },
value,
),
)
.withDescription(
"Disables video image smoothing by rendering the video on a canvas (much more demanding for browser)",
)
.build();
break;
case opts.VOLUME:
_option(data)
.withName("Volume (%)")
.add(gui.inputN(k, onChange, value))
.build();
break;
case opts.FORCE_FULLSCREEN:
_option(data)
.withName("Force fullscreen")
.withDescription(
"Whether games should open in full-screen mode after starting up (excluding mobile devices)",
)
.add(
gui.checkbox(
k,
onChange,
value,
"Enabled",
"settings__option-checkbox",
),
)
.build();
break;
case opts.WEBRTC_INIT_OFFER:
_option(data)
.withName("WebRTC: wait offer")
.withDescription(
"Switch to wait for the remote offer before connecting",
)
.restartNeeded()
.add(
gui.checkbox(
k,
onChange,
value,
"Enabled",
"settings__option-checkbox",
),
)
.build();
break;
default:
_option(data).withName(k).add(value).build();
}
}
data.append(
gui.create("br"),
gui.create("div", (el) => {
el.classList.add("settings__info", "restart-needed-asterisk-b");
el.innerText = " -- applied after page reload";
}),
gui.create("div", (el) => {
el.classList.add("settings__info");
el.innerText = `Options format version: ${_settings?._version}`;
}),
);
};
const renderer = {
render,
set data(el) {
data = el;
scrollState.track(el);
},
};