Cloudretro (ɔ) 2025
+
+
+
+ ↑
+ ↓
+ ⟲
+ X
+
+
Options
+
+
+ * -- applied after application restart
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
{{if .Analytics.Inject}}
diff --git a/web/js/api.js b/web/js/api.js
deleted file mode 100644
index 906342b0..00000000
--- a/web/js/api.js
+++ /dev/null
@@ -1,337 +0,0 @@
-import {log} from 'log';
-
-const endpoints = {
- LATENCY_CHECK: 3,
- INIT: 4,
- INIT_WEBRTC: 100,
- OFFER: 101,
- ANSWER: 102,
- ICE_CANDIDATE: 103,
- GAME_START: 104,
- GAME_QUIT: 105,
- GAME_SAVE: 106,
- GAME_LOAD: 107,
- GAME_SET_PLAYER_INDEX: 108,
- GAME_RECORDING: 110,
- GET_WORKER_LIST: 111,
- GAME_ERROR_NO_FREE_SLOTS: 112,
- GAME_RESET: 113,
-
- APP_VIDEO_CHANGE: 150,
-}
-
-let transport = {
- send: (packet) => {
- log.warn('Default transport is used! Change it with the api.transport variable.', packet)
- },
- keyboard: (packet) => {
- log.warn('Default transport is used! Change it with the api.transport variable.', packet)
- },
- mouse: (packet) => {
- log.warn('Default transport is used! Change it with the api.transport variable.', packet)
- }
-}
-
-const packet = (type, payload, id) => {
- const packet = {t: type}
- if (id !== undefined) packet.id = id
- if (payload !== undefined) packet.p = payload
- transport.send(packet)
-}
-
-const decodeBytes = (b) => String.fromCharCode.apply(null, new Uint8Array(b))
-
-const keyboardPress = (() => {
- // 0 1 2 3 4 5 6
- // [CODE ] P MOD
- const buffer = new ArrayBuffer(7)
- const dv = new DataView(buffer)
-
- return (pressed = false, e) => {
- if (e.repeat) return // skip pressed key events
-
- const key = libretro.mod
- let code = libretro.map('', e.code)
- let shift = e.shiftKey
-
- // a special Esc for &$&!& Firefox
- if (shift && code === 96) {
- code = 27
- shift = false
- }
-
- const mod = 0
- | (e.altKey && key.ALT)
- | (e.ctrlKey && key.CTRL)
- | (e.metaKey && key.META)
- | (shift && key.SHIFT)
- | (e.getModifierState('NumLock') && key.NUMLOCK)
- | (e.getModifierState('CapsLock') && key.CAPSLOCK)
- | (e.getModifierState('ScrollLock') && key.SCROLLOCK)
- dv.setUint32(0, code)
- dv.setUint8(4, +pressed)
- dv.setUint16(5, mod)
- transport.keyboard(buffer)
- }
-})()
-
-const mouse = {
- MOVEMENT: 0,
- BUTTONS: 1
-}
-
-const mouseMove = (() => {
- // 0 1 2 3 4
- // T DX DY
- const buffer = new ArrayBuffer(5)
- const dv = new DataView(buffer)
-
- return (dx = 0, dy = 0) => {
- dv.setUint8(0, mouse.MOVEMENT)
- dv.setInt16(1, dx)
- dv.setInt16(3, dy)
- transport.mouse(buffer)
- }
-})()
-
-const mousePress = (() => {
- // 0 1
- // T B
- const buffer = new ArrayBuffer(2)
- const dv = new DataView(buffer)
-
- // 0: Main button pressed, usually the left button or the un-initialized state
- // 1: Auxiliary button pressed, usually the wheel button or the middle button (if present)
- // 2: Secondary button pressed, usually the right button
- // 3: Fourth button, typically the Browser Back button
- // 4: Fifth button, typically the Browser Forward button
-
- const b2r = [1, 4, 2, 0, 0] // browser mouse button to retro button
- // assumed that only one button pressed / released
-
- return (button = 0, pressed = false) => {
- dv.setUint8(0, mouse.BUTTONS)
- dv.setUint8(1, pressed ? b2r[button] : 0)
- transport.mouse(buffer)
- }
-})()
-
-
-const libretro = function () {// RETRO_KEYBOARD
- const retro = {
- '': 0,
- 'Unidentified': 0,
- 'Unknown': 0, // ???
- 'First': 0, // ???
- 'Backspace': 8,
- 'Tab': 9,
- 'Clear': 12,
- 'Enter': 13, 'Return': 13,
- 'Pause': 19,
- 'Escape': 27,
- 'Space': 32,
- 'Exclaim': 33,
- 'Quotedbl': 34,
- 'Hash': 35,
- 'Dollar': 36,
- 'Ampersand': 38,
- 'Quote': 39,
- 'Leftparen': 40, '(': 40,
- 'Rightparen': 41, ')': 41,
- 'Asterisk': 42,
- 'Plus': 43,
- 'Comma': 44,
- 'Minus': 45,
- 'Period': 46,
- 'Slash': 47,
- 'Digit0': 48,
- 'Digit1': 49,
- 'Digit2': 50,
- 'Digit3': 51,
- 'Digit4': 52,
- 'Digit5': 53,
- 'Digit6': 54,
- 'Digit7': 55,
- 'Digit8': 56,
- 'Digit9': 57,
- 'Colon': 58, ':': 58,
- 'Semicolon': 59, ';': 59,
- 'Less': 60, '<': 60,
- 'Equal': 61, '=': 61,
- 'Greater': 62, '>': 62,
- 'Question': 63, '?': 63,
- // RETROK_AT = 64,
- 'BracketLeft': 91, '[': 91,
- 'Backslash': 92, '\\': 92,
- 'BracketRight': 93, ']': 93,
- // RETROK_CARET = 94,
- // RETROK_UNDERSCORE = 95,
- 'Backquote': 96, '`': 96,
- 'KeyA': 97,
- 'KeyB': 98,
- 'KeyC': 99,
- 'KeyD': 100,
- 'KeyE': 101,
- 'KeyF': 102,
- 'KeyG': 103,
- 'KeyH': 104,
- 'KeyI': 105,
- 'KeyJ': 106,
- 'KeyK': 107,
- 'KeyL': 108,
- 'KeyM': 109,
- 'KeyN': 110,
- 'KeyO': 111,
- 'KeyP': 112,
- 'KeyQ': 113,
- 'KeyR': 114,
- 'KeyS': 115,
- 'KeyT': 116,
- 'KeyU': 117,
- 'KeyV': 118,
- 'KeyW': 119,
- 'KeyX': 120,
- 'KeyY': 121,
- 'KeyZ': 122,
- '{': 123,
- '|': 124,
- '}': 125,
- 'Tilde': 126, '~': 126,
- 'Delete': 127,
-
- 'Numpad0': 256,
- 'Numpad1': 257,
- 'Numpad2': 258,
- 'Numpad3': 259,
- 'Numpad4': 260,
- 'Numpad5': 261,
- 'Numpad6': 262,
- 'Numpad7': 263,
- 'Numpad8': 264,
- 'Numpad9': 265,
- 'NumpadDecimal': 266,
- 'NumpadDivide': 267,
- 'NumpadMultiply': 268,
- 'NumpadSubtract': 269,
- 'NumpadAdd': 270,
- 'NumpadEnter': 271,
- 'NumpadEqual': 272,
-
- 'ArrowUp': 273,
- 'ArrowDown': 274,
- 'ArrowRight': 275,
- 'ArrowLeft': 276,
- 'Insert': 277,
- 'Home': 278,
- 'End': 279,
- 'PageUp': 280,
- 'PageDown': 281,
-
- 'F1': 282,
- 'F2': 283,
- 'F3': 284,
- 'F4': 285,
- 'F5': 286,
- 'F6': 287,
- 'F7': 288,
- 'F8': 289,
- 'F9': 290,
- 'F10': 291,
- 'F11': 292,
- 'F12': 293,
- 'F13': 294,
- 'F14': 295,
- 'F15': 296,
-
- 'NumLock': 300,
- 'CapsLock': 301,
- 'ScrollLock': 302,
- 'ShiftRight': 303,
- 'ShiftLeft': 304,
- 'ControlRight': 305,
- 'ControlLeft': 306,
- 'AltRight': 307,
- 'AltLeft': 308,
- 'MetaRight': 309,
- 'MetaLeft': 310,
- // RETROK_LSUPER = 311,
- // RETROK_RSUPER = 312,
- // RETROK_MODE = 313,
- // RETROK_COMPOSE = 314,
-
- // RETROK_HELP = 315,
- // RETROK_PRINT = 316,
- // RETROK_SYSREQ = 317,
- // RETROK_BREAK = 318,
- // RETROK_MENU = 319,
- 'Power': 320,
- // RETROK_EURO = 321,
- // RETROK_UNDO = 322,
- // RETROK_OEM_102 = 323,
- }
-
- const retroMod = {
- NONE: 0x0000,
- SHIFT: 0x01,
- CTRL: 0x02,
- ALT: 0x04,
- META: 0x08,
- NUMLOCK: 0x10,
- CAPSLOCK: 0x20,
- SCROLLOCK: 0x40,
- }
-
- const _map = (key = '', code = '') => {
- return retro[code] || retro[key] || 0
- }
-
- return {
- map: _map,
- mod: retroMod,
- }
-}()
-
-/**
- * Server API.
- *
- * Requires the actual api.transport implementation.
- */
-export const api = {
- set transport(t) {
- transport = t;
- },
- endpoint: endpoints,
- decode: (b) => JSON.parse(decodeBytes(b)),
- server: {
- initWebrtc: () => packet(endpoints.INIT_WEBRTC),
- sendIceCandidate: (candidate) => packet(endpoints.ICE_CANDIDATE, btoa(JSON.stringify(candidate))),
- sendSdp: (sdp) => packet(endpoints.ANSWER, btoa(JSON.stringify(sdp))),
- latencyCheck: (id, list) => packet(endpoints.LATENCY_CHECK, list, id),
- getWorkerList: () => packet(endpoints.GET_WORKER_LIST),
- },
- game: {
- input: {
- keyboard: {
- press: keyboardPress,
- },
- mouse: {
- move: mouseMove,
- press: mousePress,
- }
- },
- load: () => packet(endpoints.GAME_LOAD),
- reset: (roomId) => packet(endpoints.GAME_RESET, {room_id: roomId}),
- save: () => packet(endpoints.GAME_SAVE),
- setPlayerIndex: (i) => packet(endpoints.GAME_SET_PLAYER_INDEX, i),
- start: (game, roomId, record, recordUser, player) => packet(endpoints.GAME_START, {
- game_name: game,
- room_id: roomId,
- player_index: player,
- record: record,
- record_user: recordUser,
- }),
- toggleRecording: (active = false, userName = '') =>
- packet(endpoints.GAME_RECORDING, {active: active, user: userName}),
- quit: (roomId) => packet(endpoints.GAME_QUIT, {room_id: roomId}),
- }
-}
diff --git a/web/js/api/api.js b/web/js/api/api.js
new file mode 100644
index 00000000..7fafe225
--- /dev/null
+++ b/web/js/api/api.js
@@ -0,0 +1,64 @@
+/**
+ * Server API.
+ *
+ * @version 1
+ *
+ */
+const api = (() => {
+ const endpoints = Object.freeze({
+ LATENCY_CHECK: 3,
+ INIT: 4,
+ INIT_WEBRTC: 100,
+ OFFER: 101,
+ ANSWER: 102,
+ ICE_CANDIDATE: 103,
+ GAME_START: 104,
+ GAME_QUIT: 105,
+ GAME_SAVE: 106,
+ GAME_LOAD: 107,
+ GAME_SET_PLAYER_INDEX: 108,
+ GAME_TOGGLE_MULTITAP: 109,
+ GAME_RECORDING: 110,
+ GET_WORKER_LIST: 111,
+ });
+
+ const packet = (type, payload, id) => {
+ const packet = {t: type};
+ if (id !== undefined) packet.id = id;
+ if (payload !== undefined) packet.p = payload;
+
+ socket.send(packet);
+ };
+
+ return Object.freeze({
+ endpoint: endpoints,
+ server:
+ Object.freeze({
+ initWebrtc: () => packet(endpoints.INIT_WEBRTC),
+ sendIceCandidate: (candidate) => packet(endpoints.ICE_CANDIDATE, btoa(JSON.stringify(candidate))),
+ sendSdp: (sdp) => packet(endpoints.ANSWER, btoa(JSON.stringify(sdp))),
+ latencyCheck: (id, list) => packet(endpoints.LATENCY_CHECK, list, id),
+ getWorkerList: () => packet(endpoints.GET_WORKER_LIST),
+ }),
+ game:
+ Object.freeze({
+ load: () => packet(endpoints.GAME_LOAD),
+ save: () => packet(endpoints.GAME_SAVE),
+ setPlayerIndex: (i) => packet(endpoints.GAME_SET_PLAYER_INDEX, i),
+ start: (game, roomId, record, recordUser, player) => packet(endpoints.GAME_START, {
+ game_name: game,
+ room_id: roomId,
+ player_index: player,
+ record: record,
+ record_user: recordUser,
+ }),
+ toggleMultitap: () => packet(endpoints.GAME_TOGGLE_MULTITAP),
+ toggleRecording: (active = false, userName = '') =>
+ packet(endpoints.GAME_RECORDING, {
+ active: active,
+ user: userName,
+ }),
+ quit: (roomId) => packet(endpoints.GAME_QUIT, {room_id: roomId}),
+ })
+ })
+})(socket);
diff --git a/web/js/app.js b/web/js/app.js
deleted file mode 100644
index 3d58dc89..00000000
--- a/web/js/app.js
+++ /dev/null
@@ -1,627 +0,0 @@
-import {log} from 'log';
-import {opts, settings} from 'settings';
-import {api} from 'api';
-import {
- APP_VIDEO_CHANGED,
- AXIS_CHANGED,
- CONTROLLER_UPDATED,
- DPAD_TOGGLE,
- FULLSCREEN_CHANGE,
- GAME_ERROR_NO_FREE_SLOTS,
- GAME_PLAYER_IDX,
- GAME_PLAYER_IDX_SET,
- GAME_ROOM_AVAILABLE,
- GAME_SAVED,
- GAMEPAD_CONNECTED,
- GAMEPAD_DISCONNECTED,
- HELP_OVERLAY_TOGGLED,
- KB_MOUSE_FLAG,
- KEY_PRESSED,
- KEY_RELEASED,
- KEYBOARD_KEY_DOWN,
- KEYBOARD_KEY_UP,
- LATENCY_CHECK_REQUESTED,
- MESSAGE,
- MOUSE_MOVED,
- MOUSE_PRESSED,
- POINTER_LOCK_CHANGE,
- RECORDING_STATUS_CHANGED,
- RECORDING_TOGGLED,
- REFRESH_INPUT,
- SETTINGS_CHANGED,
- WEBRTC_CONNECTION_CLOSED,
- WEBRTC_CONNECTION_READY,
- WEBRTC_ICE_CANDIDATE_FOUND,
- WEBRTC_ICE_CANDIDATE_RECEIVED,
- WEBRTC_ICE_CANDIDATES_FLUSH,
- WEBRTC_NEW_CONNECTION,
- WEBRTC_SDP_ANSWER,
- WEBRTC_SDP_OFFER,
- WORKER_LIST_FETCHED,
- pub,
- sub,
-} from 'event';
-import {gui} from 'gui';
-import {input, KEY} from 'input';
-import {socket, webrtc} from 'network';
-import {debounce} from 'utils';
-
-import {gameList} from './gameList.js?v=3';
-import {menu} from './menu.js?v=3';
-import {message} from './message.js?v=3';
-import {recording} from './recording.js?v=3';
-import {room} from './room.js?v=3';
-import {screen} from './screen.js?v=3';
-import {stats} from './stats.js?v=3';
-import {stream} from './stream.js?v=3';
-import {workerManager} from "./workerManager.js?v=3";
-
-settings.init();
-log.level = settings.loadOr(opts.LOG_LEVEL, log.DEFAULT);
-
-// application display state
-let state;
-let lastState;
-
-// first user interaction
-let interacted = false;
-
-const helpOverlay = document.getElementById('help-overlay');
-const playerIndex = document.getElementById('playeridx');
-
-// screen init
-screen.add(menu, stream);
-
-// keymap
-const keyButtons = {};
-Object.keys(KEY).forEach(button => {
- keyButtons[KEY[button]] = document.getElementById(`btn-${KEY[button]}`);
-});
-
-/**
- * State machine transition.
- * @param newState A new state strictly from app.state.*
- * @example
- * setState(app.state.eden)
- */
-const setState = (newState = app.state.eden) => {
- if (newState === state) return;
-
- const prevState = state;
-
- // keep the current state intact for one of the "uber" states
- if (state && state._uber) {
- // if we are done with the uber state
- if (lastState === newState) state = newState;
- lastState = newState;
- } else {
- lastState = state
- state = newState;
- }
-
- if (log.level === log.DEBUG) {
- const previous = prevState ? prevState.name : '???';
- const current = state ? state.name : '???';
- const kept = lastState ? lastState.name : '???';
-
- log.debug(`[state] ${previous} -> ${current} [${kept}]`);
- }
-};
-
-const onConnectionReady = () => room.id ? startGame() : state.menuReady()
-
-const onLatencyCheck = async (data) => {
- message.show('Connecting to fastest server...');
- const servers = await workerManager.checkLatencies(data);
- const latencies = Object.assign({}, ...servers);
- log.info('[ping] <->', latencies);
- api.server.latencyCheck(data.packetId, latencies);
-};
-
-const helpScreen = {
- shown: false,
- show: function (show, event) {
- if (this.shown === show) return;
-
- const isGameScreen = state === app.state.game
- screen.toggle(undefined, !show);
-
- gui.toggle(keyButtons[KEY.SAVE], show || isGameScreen);
- gui.toggle(keyButtons[KEY.LOAD], show || isGameScreen);
-
- gui.toggle(helpOverlay, show)
-
- this.shown = show;
-
- if (event) pub(HELP_OVERLAY_TOGGLED, {shown: show});
- }
-};
-
-const showMenuScreen = () => {
- log.debug('[control] loading menu screen');
-
- gui.hide(keyButtons[KEY.SAVE]);
- gui.hide(keyButtons[KEY.LOAD]);
-
- gameList.show();
- screen.toggle(menu);
-
- setState(app.state.menu);
-};
-
-const startGame = () => {
- if (!webrtc.isConnected()) {
- message.show('Game cannot load. Please refresh');
- return;
- }
-
- if (!webrtc.isInputReady()) {
- message.show('Game is not ready yet. Please wait');
- return;
- }
-
- log.info('[control] game start');
-
- setState(app.state.game);
-
- screen.toggle(stream)
-
- api.game.start(
- gameList.selected,
- room.id,
- recording.isActive(),
- recording.getUser(),
- +playerIndex.value - 1,
- )
-
- gameList.disable()
- input.retropad.toggle(false)
- gui.show(keyButtons[KEY.SAVE]);
- gui.show(keyButtons[KEY.LOAD]);
- input.retropad.toggle(true)
-};
-
-const saveGame = debounce(() => api.game.save(), 1000);
-const loadGame = debounce(() => api.game.load(), 1000);
-
-const onMessage = (m) => {
- const {id, t, p: payload} = m;
- switch (t) {
- case api.endpoint.INIT:
- pub(WEBRTC_NEW_CONNECTION, payload);
- break;
- case api.endpoint.OFFER:
- pub(WEBRTC_SDP_OFFER, {sdp: payload});
- break;
- case api.endpoint.ICE_CANDIDATE:
- pub(WEBRTC_ICE_CANDIDATE_RECEIVED, {candidate: payload});
- break;
- case api.endpoint.GAME_START:
- payload.av && pub(APP_VIDEO_CHANGED, payload.av)
- payload.kb_mouse && pub(KB_MOUSE_FLAG)
- pub(GAME_ROOM_AVAILABLE, {roomId: payload.roomId});
- break;
- case api.endpoint.GAME_SAVE:
- pub(GAME_SAVED);
- break;
- case api.endpoint.GAME_LOAD:
- break;
- case api.endpoint.GAME_SET_PLAYER_INDEX:
- pub(GAME_PLAYER_IDX_SET, payload);
- break;
- case api.endpoint.GET_WORKER_LIST:
- pub(WORKER_LIST_FETCHED, payload);
- break;
- case api.endpoint.LATENCY_CHECK:
- pub(LATENCY_CHECK_REQUESTED, {packetId: id, addresses: payload});
- break;
- case api.endpoint.GAME_RECORDING:
- pub(RECORDING_STATUS_CHANGED, payload);
- break;
- case api.endpoint.GAME_ERROR_NO_FREE_SLOTS:
- pub(GAME_ERROR_NO_FREE_SLOTS);
- break;
- case api.endpoint.APP_VIDEO_CHANGE:
- pub(APP_VIDEO_CHANGED, {...payload})
- break;
- }
-}
-
-const _dpadArrowKeys = [KEY.UP, KEY.DOWN, KEY.LEFT, KEY.RIGHT];
-
-// pre-state key press handler
-const onKeyPress = (data) => {
- const button = keyButtons[data.key];
-
- if (_dpadArrowKeys.includes(data.key)) {
- button.classList.add('dpad-pressed');
- } else {
- if (button) button.classList.add('pressed');
- }
-
- if (state !== app.state.settings) {
- if (KEY.HELP === data.key) helpScreen.show(true, event);
- }
-
- state.keyPress(data.key, data.code)
-};
-
-// pre-state key release handler
-const onKeyRelease = data => {
- const button = keyButtons[data.key];
-
- if (_dpadArrowKeys.includes(data.key)) {
- button.classList.remove('dpad-pressed');
- } else {
- if (button) button.classList.remove('pressed');
- }
-
- if (state !== app.state.settings) {
- if (KEY.HELP === data.key) helpScreen.show(false, event);
- }
-
- // maybe move it somewhere
- if (!interacted) {
- // unmute when there is user interaction
- stream.audio.mute(false);
- interacted = true;
- }
-
- // change app state if settings
- if (KEY.SETTINGS === data.key) setState(app.state.settings);
-
- state.keyRelease(data.key, data.code);
-};
-
-const updatePlayerIndex = (idx, not_game = false) => {
- playerIndex.value = idx + 1;
- !not_game && api.game.setPlayerIndex(idx);
-};
-
-// noop function for the state
-const _nil = () => ({/*_*/})
-
-const onAxisChanged = (data) => {
- // maybe move it somewhere
- if (!interacted) {
- // unmute when there is user interaction
- stream.audio.mute(false);
- interacted = true;
- }
-
- state.axisChanged(data.id, data.value);
-};
-
-const handleToggle = (force = false) => {
- const toggle = document.getElementById('dpad-toggle');
-
- force && toggle.setAttribute('checked', '')
- toggle.checked = !toggle.checked;
- pub(DPAD_TOGGLE, {checked: toggle.checked});
-};
-
-const handleRecording = (data) => {
- const {recording, userName} = data;
- api.game.toggleRecording(recording, userName);
-}
-
-const handleRecordingStatus = (data) => {
- if (data === 'ok') {
- message.show(`Recording ${recording.isActive() ? 'on' : 'off'}`)
- if (recording.isActive()) {
- recording.setIndicator(true)
- }
- } else {
- message.show(`Recording failed ):`)
- recording.setIndicator(false)
- }
- log.debug("recording is ", recording.isActive())
-}
-
-const _default = {
- name: 'default',
- axisChanged: _nil,
- keyPress: _nil,
- keyRelease: _nil,
- menuReady: _nil,
-}
-const app = {
- state: {
- eden: {
- ..._default,
- name: 'eden',
- menuReady: showMenuScreen
- },
-
- settings: {
- ..._default,
- _uber: true,
- name: 'settings',
- keyRelease: (() => {
- settings.ui.onToggle = (o) => !o && setState(lastState);
- return (key) => key === KEY.SETTINGS && settings.ui.toggle()
- })(),
- menuReady: showMenuScreen
- },
-
- menu: {
- ..._default,
- name: 'menu',
- axisChanged: (id, val) => id === 1 && gameList.scroll(val < -.5 ? -1 : val > .5 ? 1 : 0),
- keyPress: (key) => {
- switch (key) {
- case KEY.UP:
- case KEY.DOWN:
- gameList.scroll(key === KEY.UP ? -1 : 1)
- break;
- }
- },
- keyRelease: (key) => {
- switch (key) {
- case KEY.UP:
- case KEY.DOWN:
- gameList.scroll(0);
- break;
- case KEY.JOIN:
- case KEY.A:
- case KEY.B:
- case KEY.X:
- case KEY.Y:
- case KEY.START:
- case KEY.SELECT:
- startGame();
- break;
- case KEY.QUIT:
- message.show('You are already in menu screen!');
- break;
- case KEY.LOAD:
- message.show('Loading the game.');
- break;
- case KEY.SAVE:
- message.show('Saving the game.');
- break;
- case KEY.STATS:
- stats.toggle();
- break;
- case KEY.SETTINGS:
- break;
- case KEY.DTOGGLE:
- handleToggle();
- break;
- }
- },
- },
-
- game: {
- ..._default,
- name: 'game',
- axisChanged: (id, value) => input.retropad.setAxisChanged(id, value),
- keyboardInput: (pressed, e) => api.game.input.keyboard.press(pressed, e),
- mouseMove: (e) => api.game.input.mouse.move(e.dx, e.dy),
- mousePress: (e) => api.game.input.mouse.press(e.b, e.p),
- keyPress: (key) => input.retropad.setKeyState(key, true),
- keyRelease: function (key) {
- input.retropad.setKeyState(key, false);
-
- switch (key) {
- case KEY.JOIN: // or SHARE
- // save when click share
- saveGame();
- room.copyToClipboard();
- message.show('Shared link copied to the clipboard!');
- break;
- case KEY.SAVE:
- saveGame();
- break;
- case KEY.LOAD:
- loadGame();
- break;
- case KEY.FULL:
- screen.fullscreen();
- break;
- case KEY.PAD1:
- updatePlayerIndex(0);
- break;
- case KEY.PAD2:
- updatePlayerIndex(1);
- break;
- case KEY.PAD3:
- updatePlayerIndex(2);
- break;
- case KEY.PAD4:
- updatePlayerIndex(3);
- break;
- case KEY.QUIT:
- input.retropad.toggle(false)
- api.game.quit(room.id)
- room.reset();
- window.location = window.location.pathname;
- break;
- case KEY.RESET:
- api.game.reset(room.id)
- break;
- case KEY.STATS:
- stats.toggle();
- break;
- case KEY.DTOGGLE:
- handleToggle();
- break;
- }
- },
- }
- }
-};
-
-// switch keyboard+mouse / retropad
-const kbmEl = document.getElementById('kbm')
-const kbmEl2 = document.getElementById('kbm2')
-let kbmSkip = false
-const kbmCb = () => {
- input.kbm = kbmSkip
- kbmSkip = !kbmSkip
- pub(REFRESH_INPUT)
-}
-gui.multiToggle([kbmEl, kbmEl2], {
- list: [
- {caption: '⌨️+🖱️', cb: kbmCb},
- {caption: ' 🎮 ', cb: kbmCb}
- ]
-})
-sub(KB_MOUSE_FLAG, () => {
- gui.show(kbmEl, kbmEl2)
- handleToggle(true)
- message.show('Keyboard and mouse work in fullscreen')
-})
-
-// Browser lock API
-document.onpointerlockchange = () => pub(POINTER_LOCK_CHANGE, document.pointerLockElement)
-document.onfullscreenchange = () => pub(FULLSCREEN_CHANGE, document.fullscreenElement)
-
-// subscriptions
-sub(MESSAGE, onMessage);
-
-sub(GAME_ROOM_AVAILABLE, async () => {
- stream.play()
-}, 2)
-sub(GAME_SAVED, () => message.show('Saved'));
-sub(GAME_PLAYER_IDX, data => {
- updatePlayerIndex(+data.index, state !== app.state.game);
-});
-sub(GAME_PLAYER_IDX_SET, idx => {
- if (!isNaN(+idx)) message.show(+idx + 1);
-});
-sub(GAME_ERROR_NO_FREE_SLOTS, () => message.show("No free slots :(", 2500));
-sub(WEBRTC_NEW_CONNECTION, (data) => {
- workerManager.whoami(data.wid);
- webrtc.onData = (x) => onMessage(api.decode(x.data))
- webrtc.start(data.ice);
- api.server.initWebrtc()
- gameList.set(data.games);
-});
-sub(WEBRTC_ICE_CANDIDATE_FOUND, (data) => api.server.sendIceCandidate(data.candidate));
-sub(WEBRTC_SDP_ANSWER, (data) => api.server.sendSdp(data.sdp));
-sub(WEBRTC_SDP_OFFER, (data) => webrtc.setRemoteDescription(data.sdp, stream.video.el));
-sub(WEBRTC_ICE_CANDIDATE_RECEIVED, (data) => webrtc.addCandidate(data.candidate));
-sub(WEBRTC_ICE_CANDIDATES_FLUSH, () => webrtc.flushCandidates());
-sub(WEBRTC_CONNECTION_READY, onConnectionReady);
-sub(WEBRTC_CONNECTION_CLOSED, () => {
- input.retropad.toggle(false)
- webrtc.stop();
-});
-sub(LATENCY_CHECK_REQUESTED, onLatencyCheck);
-sub(GAMEPAD_CONNECTED, () => message.show('Gamepad connected'));
-sub(GAMEPAD_DISCONNECTED, () => message.show('Gamepad disconnected'));
-
-// keyboard handler in the Screen Lock mode
-sub(KEYBOARD_KEY_DOWN, (v) => state.keyboardInput?.(true, v))
-sub(KEYBOARD_KEY_UP, (v) => state.keyboardInput?.(false, v))
-
-// mouse handler in the Screen Lock mode
-sub(MOUSE_MOVED, (e) => state.mouseMove?.(e))
-sub(MOUSE_PRESSED, (e) => state.mousePress?.(e))
-
-// general keyboard handler
-sub(KEY_PRESSED, onKeyPress);
-sub(KEY_RELEASED, onKeyRelease);
-
-sub(SETTINGS_CHANGED, () => message.show('Settings have been updated'));
-sub(AXIS_CHANGED, onAxisChanged);
-sub(CONTROLLER_UPDATED, data => webrtc.input(data));
-sub(RECORDING_TOGGLED, handleRecording);
-sub(RECORDING_STATUS_CHANGED, handleRecordingStatus);
-
-sub(SETTINGS_CHANGED, () => {
- const s = settings.get();
- log.level = s[opts.LOG_LEVEL];
-});
-
-// initial app state
-setState(app.state.eden);
-
-input.init()
-
-stream.init();
-screen.init();
-
-let [roomId, zone] = room.loadMaybe();
-// find worker id if present
-const wid = new URLSearchParams(document.location.search).get('wid');
-// if from URL -> start game immediately!
-socket.init(roomId, wid, zone);
-api.transport = {
- send: socket.send,
- keyboard: webrtc.keyboard,
- mouse: webrtc.mouse,
-}
-
-// stats
-let WEBRTC_STATS_RTT;
-let VIDEO_BITRATE;
-let GET_V_CODEC, SET_CODEC;
-
-const bitrate = (() => {
- let bytesPrev, timestampPrev
- const w = [0, 0, 0, 0, 0, 0]
- const n = w.length
- let i = 0
- return (now, bytes) => {
- w[i++ % n] = timestampPrev ? Math.floor(8 * (bytes - bytesPrev) / (now - timestampPrev)) : 0
- bytesPrev = bytes
- timestampPrev = now
- return Math.floor(w.reduce((a, b) => a + b) / n)
- }
-})()
-
-stats.modules = [
- {
- mui: stats.mui('', '<1'),
- init() {
- WEBRTC_STATS_RTT = (v) => (this.val = v)
- },
- },
- {
- mui: stats.mui('', '', false, () => ''),
- init() {
- GET_V_CODEC = (v) => (this.val = v + ' @ ')
- }
- },
- {
- mui: stats.mui('', '', false, () => ''),
- init() {
- sub(APP_VIDEO_CHANGED, ({s = 1, w, h}) => (this.val = `${w * s}x${h * s}`))
- },
- },
- {
- mui: stats.mui('', '', false, () => ' kb/s', 'stats-bitrate'),
- init() {
- VIDEO_BITRATE = (v) => (this.val = v)
- }
- },
- {
- async stats() {
- const stats = await webrtc.stats();
- if (!stats) return;
-
- stats.forEach(report => {
- if (!SET_CODEC && report.mimeType?.startsWith('video/')) {
- GET_V_CODEC(report.mimeType.replace('video/', '').toLowerCase())
- SET_CODEC = 1
- }
- const {nominated, currentRoundTripTime, type, kind} = report;
- if (nominated && currentRoundTripTime !== undefined) {
- WEBRTC_STATS_RTT(currentRoundTripTime * 1000);
- }
- if (type === 'inbound-rtp' && kind === 'video') {
- VIDEO_BITRATE(bitrate(report.timestamp, report.bytesReceived))
- }
- });
- },
- enable() {
- this.interval = window.setInterval(this.stats, 999);
- },
- disable() {
- window.clearInterval(this.interval);
- },
- }]
-
-stats.toggle()
diff --git a/web/js/controller.js b/web/js/controller.js
new file mode 100644
index 00000000..ab0ad129
--- /dev/null
+++ b/web/js/controller.js
@@ -0,0 +1,537 @@
+/**
+ * App controller module.
+ * @version 1
+ */
+(() => {
+ // application state
+ let state;
+ let lastState;
+
+ // first user interaction
+ let interacted = false;
+
+ // ping-pong
+ // let pingPong = 0;
+
+ const DIR = (() => {
+ return {
+ IDLE: 'idle',
+ UP: 'up',
+ DOWN: 'down',
+ }
+ })();
+ let prevDir = DIR.IDLE;
+
+ const menuScreen = document.getElementById('menu-screen');
+ const helpOverlay = document.getElementById('help-overlay');
+ const playerIndex = document.getElementById('playeridx');
+
+ // keymap
+ const keyButtons = {};
+ Object.keys(KEY).forEach(button => {
+ keyButtons[KEY[button]] = document.getElementById(`btn-${KEY[button]}`);
+ });
+
+ /**
+ * State machine transition.
+ * @param newState A new state strictly from app.state.*
+ * @example
+ * setState(app.state.eden)
+ */
+ const setState = (newState = app.state.eden) => {
+ if (newState === state) return;
+
+ const prevState = state;
+
+ // keep the current state intact for one of the "uber" states
+ if (state && state._uber) {
+ // if we are done with the uber state
+ if (lastState === newState) state = newState;
+ lastState = newState;
+ } else {
+ lastState = state
+ state = newState;
+ }
+
+ if (log.level === log.DEBUG) {
+ const previous = prevState ? prevState.name : '???';
+ const current = state ? state.name : '???';
+ const kept = lastState ? lastState.name : '???';
+
+ log.debug(`[state] ${previous} -> ${current} [${kept}]`);
+ }
+ };
+
+ const onGameRoomAvailable = () => {
+ message.show('Now you can share you game!');
+ };
+
+ // const onWebrtcMessage = () => {
+ // event.pub(PING_RESPONSE);
+ // };
+
+ const onConnectionReady = () => {
+ // ping / pong
+ // if (pingPong === 0) {
+ // pingPong = setInterval(() => {
+ // if (!webrtc.message('x')) {
+ // clearInterval(pingPong);
+ // pingPong = 0;
+ // log.info("ping-pong was disabled due to remote channel error");
+ // }
+ // event.pub(PING_REQUEST, {time: Date.now()})
+ // }, 10000);
+ // }
+
+ // start a game right away or show the menu
+ if (room.getId()) {
+ startGame();
+ } else {
+ state.menuReady();
+ }
+ };
+
+ const onLatencyCheck = async (data) => {
+ message.show('Connecting to fastest server...');
+ const servers = await workerManager.checkLatencies(data);
+ const latencies = Object.assign({}, ...servers);
+ log.info('[ping] <->', latencies);
+ api.server.latencyCheck(data.packetId, latencies);
+ };
+
+ const helpScreen = {
+ // don't call $ if holding the button
+ shown: false,
+ // use function () if you need "this"
+ show: function (show, event) {
+ if (this.shown === show) return;
+
+ const isGameScreen = state === app.state.game
+ if (isGameScreen) {
+ stream.toggle(!show);
+ } else {
+ gui.toggle(menuScreen, !show);
+ }
+
+ gui.toggle(keyButtons[KEY.SAVE], show || isGameScreen);
+ gui.toggle(keyButtons[KEY.LOAD], show || isGameScreen);
+
+ gui.toggle(helpOverlay, show)
+
+ this.shown = show;
+
+ if (event) event.pub(HELP_OVERLAY_TOGGLED, {shown: show});
+ }
+ };
+
+ const showMenuScreen = () => {
+ log.debug('[control] loading menu screen');
+
+ stream.toggle(false);
+ gui.hide(keyButtons[KEY.SAVE]);
+ gui.hide(keyButtons[KEY.LOAD]);
+
+ gameList.show();
+ gui.show(menuScreen);
+
+ setState(app.state.menu);
+ };
+
+ const startGame = () => {
+ if (!webrtc.isConnected()) {
+ message.show('Game cannot load. Please refresh');
+ return;
+ }
+
+ if (!webrtc.isInputReady()) {
+ message.show('Game is not ready yet. Please wait');
+ return;
+ }
+
+ log.info('[control] game start');
+
+ setState(app.state.game);
+
+ stream.play()
+
+ // TODO get current game from the URL and not from the list?
+ // if we are opening a share link it will send the default game name to the server
+ // currently it's a game with the index 1
+ // on the server this game is ignored and the actual game will be extracted from the share link
+ // so there's no point in doing this and this' really confusing
+
+ api.game.start(
+ gameList.getCurrentGame(),
+ room.getId(),
+ recording.isActive(),
+ recording.getUser(),
+ +playerIndex.value - 1,
+ );
+
+ // clear menu screen
+ input.poll.disable();
+ gui.hide(menuScreen);
+ stream.toggle(true);
+ gui.show(keyButtons[KEY.SAVE]);
+ gui.show(keyButtons[KEY.LOAD]);
+ // end clear
+ input.poll.enable();
+ };
+
+ const saveGame = utils.debounce(() => api.game.save(), 1000);
+ const loadGame = utils.debounce(() => api.game.load(), 1000);
+
+ const onMessage = (message) => {
+ const {id, t, p: payload} = message;
+ switch (t) {
+ case api.endpoint.INIT:
+ event.pub(WEBRTC_NEW_CONNECTION, payload);
+ break;
+ case api.endpoint.OFFER:
+ event.pub(WEBRTC_SDP_OFFER, {sdp: payload});
+ break;
+ case api.endpoint.ICE_CANDIDATE:
+ event.pub(WEBRTC_ICE_CANDIDATE_RECEIVED, {candidate: payload});
+ break;
+ case api.endpoint.GAME_START:
+ event.pub(GAME_ROOM_AVAILABLE, {roomId: payload});
+ break;
+ case api.endpoint.GAME_SAVE:
+ event.pub(GAME_SAVED);
+ break;
+ case api.endpoint.GAME_LOAD:
+ event.pub(GAME_LOADED);
+ break;
+ case api.endpoint.GAME_SET_PLAYER_INDEX:
+ event.pub(GAME_PLAYER_IDX_SET, payload);
+ break;
+ case api.endpoint.GET_WORKER_LIST:
+ event.pub(WORKER_LIST_FETCHED, payload);
+ break;
+ case api.endpoint.LATENCY_CHECK:
+ event.pub(LATENCY_CHECK_REQUESTED, {packetId: id, addresses: payload});
+ break;
+ case api.endpoint.GAME_RECORDING:
+ event.pub(RECORDING_STATUS_CHANGED, payload);
+ break;
+ }
+ }
+
+ const _dpadArrowKeys = [KEY.UP, KEY.DOWN, KEY.LEFT, KEY.RIGHT];
+
+ // pre-state key press handler
+ const onKeyPress = (data) => {
+ const button = keyButtons[data.key];
+
+ if (_dpadArrowKeys.includes(data.key)) {
+ button.classList.add('dpad-pressed');
+ } else {
+ if (button) button.classList.add('pressed');
+ }
+
+ if (state !== app.state.settings) {
+ if (KEY.HELP === data.key) helpScreen.show(true, event);
+ }
+
+ state.keyPress(data.key);
+ };
+
+ // pre-state key release handler
+ const onKeyRelease = data => {
+ const button = keyButtons[data.key];
+
+ if (_dpadArrowKeys.includes(data.key)) {
+ button.classList.remove('dpad-pressed');
+ } else {
+ if (button) button.classList.remove('pressed');
+ }
+
+ if (state !== app.state.settings) {
+ if (KEY.HELP === data.key) helpScreen.show(false, event);
+ }
+
+ // maybe move it somewhere
+ if (!interacted) {
+ // unmute when there is user interaction
+ stream.audio.mute(false);
+ interacted = true;
+ }
+
+ // change app state if settings
+ if (KEY.SETTINGS === data.key) setState(app.state.settings);
+
+ state.keyRelease(data.key);
+ };
+
+ const updatePlayerIndex = idx => {
+ playerIndex.value = idx + 1;
+ api.game.setPlayerIndex(idx);
+ };
+
+ // noop function for the state
+ const _nil = () => ({/*_*/})
+
+ const onAxisChanged = (data) => {
+ // maybe move it somewhere
+ if (!interacted) {
+ // unmute when there is user interaction
+ stream.audio.mute(false);
+ interacted = true;
+ }
+
+ state.axisChanged(data.id, data.value);
+ };
+
+ const handleToggle = () => {
+ const toggle = document.getElementById('dpad-toggle');
+ toggle.checked = !toggle.checked;
+ event.pub(DPAD_TOGGLE, {checked: toggle.checked});
+ };
+
+ const handleRecording = (data) => {
+ const {recording, userName} = data;
+ api.game.toggleRecording(recording, userName);
+ }
+
+ const handleRecordingStatus = (data) => {
+ if (data === 'ok') {
+ message.show(`Recording ${recording.isActive() ? 'on' : 'off'}`, true)
+ if (recording.isActive()) {
+ recording.setIndicator(true)
+ }
+ } else {
+ message.show(`Recording failed ):`)
+ recording.setIndicator(false)
+ }
+ console.log("recording is ", recording.isActive())
+ }
+
+ const _default = {
+ name: 'default',
+ axisChanged: _nil,
+ keyPress: _nil,
+ keyRelease: _nil,
+ menuReady: _nil,
+ }
+ const app = {
+ state: {
+ eden: {
+ ..._default,
+ name: 'eden',
+ menuReady: showMenuScreen
+ },
+
+ settings: {
+ ..._default,
+ _uber: true,
+ name: 'settings',
+ keyRelease: key => {
+ if (key === KEY.SETTINGS) {
+ const isSettingsOpened = settings.ui.toggle();
+ if (!isSettingsOpened) setState(lastState);
+ }
+ },
+ menuReady: showMenuScreen
+ },
+
+ menu: {
+ ..._default,
+ name: 'menu',
+ axisChanged: (id, value) => {
+ if (id === 1) { // Left Stick, Y Axis
+ let dir = DIR.IDLE;
+ if (value < -0.5) dir = DIR.UP;
+ if (value > 0.5) dir = DIR.DOWN;
+ if (dir !== prevDir) {
+ prevDir = dir;
+ switch (dir) {
+ case DIR.IDLE:
+ gameList.stopGamePickerTimer();
+ break;
+ case DIR.UP:
+ gameList.startGamePickerTimer(true);
+ break;
+ case DIR.DOWN:
+ gameList.startGamePickerTimer(false);
+ break;
+ }
+ }
+ }
+ },
+ keyPress: (key) => {
+ switch (key) {
+ case KEY.UP:
+ case KEY.DOWN:
+ gameList.startGamePickerTimer(key === KEY.UP);
+ break;
+ }
+ },
+ keyRelease: (key) => {
+ switch (key) {
+ case KEY.UP:
+ case KEY.DOWN:
+ gameList.stopGamePickerTimer();
+ break;
+ case KEY.JOIN:
+ case KEY.A:
+ case KEY.B:
+ case KEY.X:
+ case KEY.Y:
+ case KEY.START:
+ case KEY.SELECT:
+ startGame();
+ break;
+ case KEY.QUIT:
+ message.show('You are already in menu screen!');
+ break;
+ case KEY.LOAD:
+ message.show('Loading the game.');
+ break;
+ case KEY.SAVE:
+ message.show('Saving the game.');
+ break;
+ case KEY.STATS:
+ event.pub(STATS_TOGGLE);
+ break;
+ case KEY.SETTINGS:
+ break;
+ case KEY.DTOGGLE:
+ handleToggle();
+ break;
+ }
+ },
+ },
+
+ game: {
+ ..._default,
+ name: 'game',
+ axisChanged: (id, value) => input.setAxisChanged(id, value),
+ keyPress: key => input.setKeyState(key, true),
+ keyRelease: function (key) {
+ input.setKeyState(key, false);
+
+ switch (key) {
+ case KEY.JOIN: // or SHARE
+ // save when click share
+ saveGame();
+ room.copyToClipboard();
+ message.show('Shared link copied to the clipboard!');
+ break;
+ case KEY.SAVE:
+ saveGame();
+ break;
+ case KEY.LOAD:
+ loadGame();
+ break;
+ case KEY.FULL:
+ stream.video.toggleFullscreen();
+ break;
+
+ // update player index
+ case KEY.PAD1:
+ updatePlayerIndex(0);
+ break;
+ case KEY.PAD2:
+ updatePlayerIndex(1);
+ break;
+ case KEY.PAD3:
+ updatePlayerIndex(2);
+ break;
+ case KEY.PAD4:
+ updatePlayerIndex(3);
+ break;
+
+ // toggle multitap
+ case KEY.MULTITAP:
+ api.game.toggleMultitap();
+ break;
+
+ // quit
+ case KEY.QUIT:
+ input.poll.disable();
+
+ api.game.quit(room.getId());
+ room.reset();
+
+ message.show('Quit!');
+
+ window.location = window.location.pathname;
+ break;
+
+ case KEY.STATS:
+ event.pub(STATS_TOGGLE);
+ break;
+ case KEY.DTOGGLE:
+ handleToggle();
+ break;
+ }
+ },
+ }
+ }
+ };
+
+ // subscriptions
+ event.sub(MESSAGE, onMessage);
+
+ event.sub(GAME_ROOM_AVAILABLE, onGameRoomAvailable, 2);
+ event.sub(GAME_SAVED, () => message.show('Saved'));
+ event.sub(GAME_LOADED, () => message.show('Loaded'));
+ event.sub(GAME_PLAYER_IDX, data => {
+ updatePlayerIndex(+data.index);
+ });
+ event.sub(GAME_PLAYER_IDX_SET, idx => {
+ if (!isNaN(+idx)) message.show(+idx + 1);
+ });
+ event.sub(WEBRTC_NEW_CONNECTION, (data) => {
+ // if (pingPong) {
+ // webrtc.setMessageHandler(onWebrtcMessage);
+ // }
+ workerManager.whoami(data.wid);
+ webrtc.start(data.ice);
+ api.server.initWebrtc()
+ gameList.set(data.games);
+ });
+ event.sub(WEBRTC_ICE_CANDIDATE_FOUND, (data) => api.server.sendIceCandidate(data.candidate));
+ event.sub(WEBRTC_SDP_ANSWER, (data) => api.server.sendSdp(data.sdp));
+ event.sub(WEBRTC_SDP_OFFER, (data) => webrtc.setRemoteDescription(data.sdp, stream.video.el()));
+ event.sub(WEBRTC_ICE_CANDIDATE_RECEIVED, (data) => webrtc.addCandidate(data.candidate));
+ event.sub(WEBRTC_ICE_CANDIDATES_FLUSH, () => webrtc.flushCandidates());
+ // event.sub(MEDIA_STREAM_READY, () => rtcp.start());
+ event.sub(WEBRTC_CONNECTION_READY, onConnectionReady);
+ event.sub(WEBRTC_CONNECTION_CLOSED, () => {
+ input.poll.disable();
+ // if (pingPong > 0) {
+ // clearInterval(pingPong);
+ // pingPong = 0;
+ // }
+ webrtc.stop();
+ });
+ event.sub(LATENCY_CHECK_REQUESTED, onLatencyCheck);
+ event.sub(GAMEPAD_CONNECTED, () => message.show('Gamepad connected'));
+ event.sub(GAMEPAD_DISCONNECTED, () => message.show('Gamepad disconnected'));
+ // touch stuff
+ event.sub(MENU_HANDLER_ATTACHED, (data) => {
+ menuScreen.addEventListener(data.event, data.handler, {passive: true});
+ });
+ event.sub(KEY_PRESSED, onKeyPress);
+ event.sub(KEY_RELEASED, onKeyRelease);
+ event.sub(SETTINGS_CHANGED, () => message.show('Settings have been updated'));
+ event.sub(SETTINGS_CLOSED, () => {
+ state.keyRelease(KEY.SETTINGS);
+ });
+ event.sub(AXIS_CHANGED, onAxisChanged);
+ event.sub(CONTROLLER_UPDATED, data => webrtc.input(data));
+ // recording
+ event.sub(RECORDING_TOGGLED, handleRecording);
+ event.sub(RECORDING_STATUS_CHANGED, handleRecordingStatus);
+
+ event.sub(SETTINGS_CHANGED, () => {
+ const newValue = settings.get()[opts.LOG_LEVEL];
+ if (newValue !== log.level) {
+ log.level = newValue;
+ }
+ });
+
+ // initial app state
+ setState(app.state.eden);
+})(api, document, event, env, gameList, input, KEY, log, message, recording, room, settings, socket, stats, stream, utils, webrtc, workerManager);
diff --git a/web/js/env.js b/web/js/env.js
index a725c87d..0a6ddb80 100644
--- a/web/js/env.js
+++ b/web/js/env.js
@@ -1,113 +1,120 @@
-import {
- pub,
- TRANSFORM_CHANGE
-} from 'event';
+const env = (() => {
+ // UI
+ const page = document.getElementsByTagName('html')[0];
+ const gameBoy = document.getElementById('gamebody');
+ const sourceLink = document.getElementsByClassName('source')[0];
-// UI
-const page = document.getElementsByTagName('html')[0];
-const gameBoy = document.getElementById('gamebody');
-const sourceLink = document.getElementsByClassName('source')[0];
+ let isLayoutSwitched = false;
-export const browser = {unknown: 0, firefox: 1, chrome: 2, edge: 3, safari: 4}
-export const platform = {unknown: 0, windows: 1, linux: 2, macos: 3, android: 4,}
+ // Window rerender / rotate screen if needed
+ const fixScreenLayout = () => {
+ let pw = getWidth(page),
+ ph = getHeight(page),
+ targetWidth = Math.round(pw * 0.9 / 2) * 2,
+ targetHeight = Math.round(ph * 0.9 / 2) * 2;
-let isLayoutSwitched = false;
+ // save page rotation
+ isLayoutSwitched = isPortrait();
-// Window rerender / rotate screen if needed
-const fixScreenLayout = () => {
- let pw = getWidth(page),
- ph = getHeight(page),
- targetWidth = Math.round(pw * 0.9 / 2) * 2,
- targetHeight = Math.round(ph * 0.9 / 2) * 2;
+ rescaleGameBoy(targetWidth, targetHeight);
- // save page rotation
- isLayoutSwitched = isPortrait();
+ sourceLink.style['bottom'] = isLayoutSwitched ? 0 : '';
+ if (isLayoutSwitched) {
+ sourceLink.style.removeProperty('right');
+ sourceLink.style['left'] = 5;
+ } else {
+ sourceLink.style.removeProperty('left');
+ sourceLink.style['right'] = 5;
+ }
+ sourceLink.style['transform'] = isLayoutSwitched ? 'rotate(-90deg)' : '';
+ sourceLink.style['transform-origin'] = isLayoutSwitched ? 'left top' : '';
+ };
- rescaleGameBoy(targetWidth, targetHeight);
+ const rescaleGameBoy = (targetWidth, targetHeight) => {
+ const transformations = ['translate(-50%, -50%)'];
- sourceLink.style['bottom'] = isLayoutSwitched ? 0 : '';
- if (isLayoutSwitched) {
- sourceLink.style.removeProperty('right');
- sourceLink.style['left'] = 5;
- } else {
- sourceLink.style.removeProperty('left');
- sourceLink.style['right'] = 5;
- }
- sourceLink.style['transform'] = isLayoutSwitched ? 'rotate(-90deg)' : '';
- sourceLink.style['transform-origin'] = isLayoutSwitched ? 'left top' : '';
-};
+ if (isLayoutSwitched) {
+ transformations.push('rotate(90deg)');
+ [targetWidth, targetHeight] = [targetHeight, targetWidth]
+ }
-const rescaleGameBoy = (targetWidth, targetHeight) => {
- const transformations = ['translate(-50%, -50%)'];
+ // scale, fit to target size
+ const scale = Math.min(targetWidth / getWidth(gameBoy), targetHeight / getHeight(gameBoy));
+ transformations.push(`scale(${scale})`);
- if (isLayoutSwitched) {
- transformations.push('rotate(90deg)');
- [targetWidth, targetHeight] = [targetHeight, targetWidth]
+ gameBoy.style['transform'] = transformations.join(' ');
}
- // scale, fit to target size
- const scale = Math.min(targetWidth / getWidth(gameBoy), targetHeight / getHeight(gameBoy));
- transformations.push(`scale(${scale})`);
+ const getOS = () => {
+ // linux? ios?
+ let OSName = 'unknown';
+ if (navigator.appVersion.indexOf('Win') !== -1) OSName = 'win';
+ else if (navigator.appVersion.indexOf('Mac') !== -1) OSName = 'mac';
+ else if (navigator.userAgent.indexOf('Linux') !== -1) OSName = 'linux';
+ else if (navigator.userAgent.indexOf('Android') !== -1) OSName = 'android';
+ return OSName;
+ };
- gameBoy.style['transform'] = transformations.join(' ');
-}
+ const getBrowser = () => {
+ let browserName = 'unknown';
+ if (navigator.userAgent.indexOf('Firefox') !== -1) browserName = 'firefox';
+ if (navigator.userAgent.indexOf('Chrome') !== -1) browserName = 'chrome';
+ if (navigator.userAgent.indexOf('Edge') !== -1) browserName = 'edge';
+ if (navigator.userAgent.indexOf('Version/') !== -1) browserName = 'safari';
+ if (navigator.userAgent.indexOf('UCBrowser') !== -1) browserName = 'uc';
+ return browserName;
+ };
-new MutationObserver(() => pub(TRANSFORM_CHANGE)).observe(gameBoy, {attributeFilter: ['style']})
+ const isPortrait = () => getWidth(page) < getHeight(page);
-const os = () => {
- const ua = window.navigator.userAgent;
- // noinspection JSUnresolvedReference,JSDeprecatedSymbols
- const plt = window.navigator?.userAgentData?.platform || window.navigator.platform;
- const macs = ["Macintosh", "MacIntel"];
- const wins = ["Win32", "Win64", "Windows"];
- if (wins.indexOf(plt) !== -1) return platform.windows;
- if (macs.indexOf(plt) !== -1) return platform.macos;
- if (/Linux/.test(plt)) return platform.linux;
- if (/Android/.test(ua)) return platform.android;
- return platform.unknown
-}
+ const toggleFullscreen = (enable, element) => {
+ const el = enable ? element : document;
-const _browser = () => {
- if (navigator.userAgent.indexOf('Firefox') !== -1) return browser.firefox;
- if (navigator.userAgent.indexOf('Chrome') !== -1) return browser.chrome;
- if (navigator.userAgent.indexOf('Edge') !== -1) return browser.edge;
- if (navigator.userAgent.indexOf('Version/') !== -1) return browser.safari;
- return browser.unknown;
-}
+ if (enable) {
+ if (el.requestFullscreen) {
+ el.requestFullscreen();
+ } else if (el.mozRequestFullScreen) { /* Firefox */
+ el.mozRequestFullScreen();
+ } else if (el.webkitRequestFullscreen) { /* Chrome, Safari and Opera */
+ el.webkitRequestFullscreen();
+ } else if (el.msRequestFullscreen) { /* IE/Edge */
+ el.msRequestFullscreen();
+ }
+ } else {
+ if (el.exitFullscreen) {
+ el.exitFullscreen();
+ } else if (el.mozCancelFullScreen) { /* Firefox */
+ el.mozCancelFullScreen();
+ } else if (el.webkitExitFullscreen) { /* Chrome, Safari and Opera */
+ el.webkitExitFullscreen();
+ } else if (el.msExitFullscreen) { /* IE/Edge */
+ el.msExitFullscreen();
+ }
+ }
+ };
-const isMobile = () => /Mobi|Android|iPhone/i.test(navigator.userAgent);
-
-const isPortrait = () => getWidth(page) < getHeight(page);
-
-const toggleFullscreen = (enable, element) => {
- const el = enable ? element : document;
- if (enable) {
- el.requestFullscreen?.().then().catch();
- return
+ function getHeight(el) {
+ return parseFloat(getComputedStyle(el, null).height.replace("px", ""));
}
- el.exitFullscreen?.().then().catch();
-}
-function getHeight(el) {
- return parseFloat(getComputedStyle(el, null).height.replace("px", ""));
-}
+ function getWidth(el) {
+ return parseFloat(getComputedStyle(el, null).width.replace("px", ""));
+ }
-function getWidth(el) {
- return parseFloat(getComputedStyle(el, null).width.replace("px", ""));
-}
+ window.addEventListener('resize', fixScreenLayout);
+ window.addEventListener('orientationchange', fixScreenLayout);
+ document.addEventListener('DOMContentLoaded', () => fixScreenLayout(), false);
-window.addEventListener('resize', fixScreenLayout);
-window.addEventListener('orientationchange', fixScreenLayout);
-document.addEventListener('DOMContentLoaded', () => fixScreenLayout(), false);
-
-export const env = {
- getOs: os(),
- getBrowser: _browser(),
- isMobileDevice: isMobile(),
- display: () => ({
- isPortrait,
- toggleFullscreen,
- fixScreenLayout,
- isLayoutSwitched: isLayoutSwitched
- })
-}
+ return {
+ getOs: getOS,
+ getBrowser: getBrowser,
+ // Check mobile type because different mobile can accept different video encoder.
+ isMobileDevice: () => (typeof window.orientation !== 'undefined') || (navigator.userAgent.indexOf('IEMobile') !== -1),
+ display: () => ({
+ isPortrait: isPortrait,
+ toggleFullscreen: toggleFullscreen,
+ fixScreenLayout: fixScreenLayout,
+ isLayoutSwitched: isLayoutSwitched
+ })
+ }
+})(document, log, navigator, screen, window);
diff --git a/web/js/event.js b/web/js/event.js
deleted file mode 100644
index 8ade9024..00000000
--- a/web/js/event.js
+++ /dev/null
@@ -1,109 +0,0 @@
-/**
- * Event publishing / subscribe module.
- * Just a simple observer pattern.
- */
-
-const topics = {};
-
-// internal listener index
-let _index = 0;
-
-/**
- * Subscribes onto some event.
- *
- * @param topic The name of the event.
- * @param listener A callback function to call during the event.
- * @param order A number in a queue of event handlers to run callback in ordered manner.
- * @returns {{unsub: unsub}} The function to remove this subscription.
- * @example
- * const sub01 = event.sub('rapture', () => {a}, 1)
- * ...
- * sub01.unsub()
- */
-export const sub = (topic, listener, order = undefined) => {
- if (!topics[topic]) topics[topic] = {};
- // order index * big pad + next internal index (e.g. 1*100+1=101)
- // use some arbitrary big number to not overlap with non-ordered
- let i = (order !== undefined ? order * 1000000 : 0) + _index++;
- topics[topic][i] = listener;
- return {
- unsub: () => {
- delete topics[topic][i]
- }
- }
-}
-
-/**
- * Publishes some event for handling.
- *
- * @param topic The name of the event.
- * @param data Additional data for the event handling.
- * Because of compatibility we have to use a dumb obj wrapper {a: a, b: b} for params instead of (topic, ...data).
- * @example
- * event.pub('rapture', {time: now()})
- */
-export const pub = (topic, data) => {
- if (!topics[topic]) return;
- Object.keys(topics[topic]).forEach((ls) => {
- topics[topic][ls](data !== undefined ? data : {})
- });
-}
-
-// events
-export const LATENCY_CHECK_REQUESTED = 'latencyCheckRequested';
-
-export const WORKER_LIST_FETCHED = 'workerListFetched';
-
-export const GAME_ROOM_AVAILABLE = 'gameRoomAvailable';
-export const GAME_SAVED = 'gameSaved';
-export const GAME_PLAYER_IDX = 'gamePlayerIndex';
-export const GAME_PLAYER_IDX_SET = 'gamePlayerIndexSet'
-export const GAME_ERROR_NO_FREE_SLOTS = 'gameNoFreeSlots'
-
-export const WEBRTC_CONNECTION_CLOSED = 'webrtcConnectionClosed';
-export const WEBRTC_CONNECTION_READY = 'webrtcConnectionReady';
-export const WEBRTC_ICE_CANDIDATE_FOUND = 'webrtcIceCandidateFound'
-export const WEBRTC_ICE_CANDIDATE_RECEIVED = 'webrtcIceCandidateReceived';
-export const WEBRTC_ICE_CANDIDATES_FLUSH = 'webrtcIceCandidatesFlush';
-export const WEBRTC_NEW_CONNECTION = 'webrtcNewConnection';
-export const WEBRTC_SDP_ANSWER = 'webrtcSdpAnswer'
-export const WEBRTC_SDP_OFFER = 'webrtcSdpOffer';
-
-export const MESSAGE = 'message'
-
-export const GAMEPAD_CONNECTED = 'gamepadConnected';
-export const GAMEPAD_DISCONNECTED = 'gamepadDisconnected';
-
-export const MENU_HANDLER_ATTACHED = 'menuHandlerAttached';
-export const MENU_PRESSED = 'menuPressed';
-export const MENU_RELEASED = 'menuReleased';
-
-export const KEY_PRESSED = 'keyPressed';
-export const KEY_RELEASED = 'keyReleased';
-export const KEYBOARD_TOGGLE_FILTER_MODE = 'keyboardToggleFilterMode';
-export const KEYBOARD_KEY_PRESSED = 'keyboardKeyPressed';
-export const KEYBOARD_KEY_DOWN = 'keyboardKeyDown';
-export const KEYBOARD_KEY_UP = 'keyboardKeyUp';
-
-export const AXIS_CHANGED = 'axisChanged';
-export const CONTROLLER_UPDATED = 'controllerUpdated';
-
-export const MOUSE_MOVED = 'mouseMoved'
-export const MOUSE_PRESSED = 'mousePressed'
-
-export const FULLSCREEN_CHANGE = 'fsc'
-export const POINTER_LOCK_CHANGE = 'plc'
-export const TRANSFORM_CHANGE = 'tc'
-
-export const DPAD_TOGGLE = 'dpadToggle';
-export const HELP_OVERLAY_TOGGLED = 'helpOverlayToggled';
-
-export const SETTINGS_CHANGED = 'settingsChanged';
-
-export const RECORDING_TOGGLED = 'recordingToggle'
-export const RECORDING_STATUS_CHANGED = 'recordingStatusChanged'
-
-export const APP_VIDEO_CHANGED = 'appVideoChanged'
-export const KB_MOUSE_FLAG = 'kbMouseFlag'
-
-export const REFRESH_INPUT = 'refreshInput'
diff --git a/web/js/event/event.js b/web/js/event/event.js
new file mode 100644
index 00000000..922d468f
--- /dev/null
+++ b/web/js/event/event.js
@@ -0,0 +1,102 @@
+/**
+ * Event publishing / subscribe module.
+ * Just a simple observer pattern.
+ * @version 1
+ */
+const event = (() => {
+ const topics = {};
+
+ // internal listener index
+ let _index = 0;
+
+ return {
+ /**
+ * Subscribes onto some event.
+ *
+ * @param topic The name of the event.
+ * @param listener A callback function to call during the event.
+ * @param order A number in a queue of event handlers to run callback in ordered manner.
+ * @returns {{unsub: unsub}} The function to remove this subscription.
+ * @example
+ * const sub01 = event.sub('rapture', () => {a}, 1)
+ * ...
+ * sub01.unsub()
+ */
+ sub: (topic, listener, order = undefined) => {
+ if (!topics[topic]) topics[topic] = {};
+ // order index * big pad + next internal index (e.g. 1*100+1=101)
+ // use some arbitrary big number to not overlap with non-ordered
+ let i = (order !== undefined ? order * 1000000 : 0) + _index++;
+ topics[topic][i] = listener;
+ return Object.freeze({
+ unsub: () => {
+ delete topics[topic][i]
+ }
+ });
+ },
+
+ /**
+ * Publishes some event for handling.
+ *
+ * @param topic The name of the event.
+ * @param data Additional data for the event handling.
+ * Because of compatibility we have to use a dumb obj wrapper {a: a, b: b} for params instead of (topic, ...data).
+ * @example
+ * event.pub('rapture', {time: now()})
+ */
+ pub: (topic, data) => {
+ if (!topics[topic]) return;
+ Object.keys(topics[topic]).forEach((ls) => {
+ topics[topic][ls](data !== undefined ? data : {})
+ });
+ }
+ }
+})();
+
+// events
+const LATENCY_CHECK_REQUESTED = 'latencyCheckRequested';
+const PING_REQUEST = 'pingRequest';
+const PING_RESPONSE = 'pingResponse';
+
+const WORKER_LIST_FETCHED = 'workerListFetched';
+
+const GAME_ROOM_AVAILABLE = 'gameRoomAvailable';
+const GAME_SAVED = 'gameSaved';
+const GAME_LOADED = 'gameLoaded';
+const GAME_PLAYER_IDX = 'gamePlayerIndex';
+const GAME_PLAYER_IDX_SET = 'gamePlayerIndexSet'
+
+const WEBRTC_CONNECTION_CLOSED = 'webrtcConnectionClosed';
+const WEBRTC_CONNECTION_READY = 'webrtcConnectionReady';
+const WEBRTC_ICE_CANDIDATE_FOUND = 'webrtcIceCandidateFound'
+const WEBRTC_ICE_CANDIDATE_RECEIVED = 'webrtcIceCandidateReceived';
+const WEBRTC_ICE_CANDIDATES_FLUSH = 'webrtcIceCandidatesFlush';
+const WEBRTC_NEW_CONNECTION = 'webrtcNewConnection';
+const WEBRTC_SDP_ANSWER = 'webrtcSdpAnswer'
+const WEBRTC_SDP_OFFER = 'webrtcSdpOffer';
+
+const MESSAGE = 'message'
+
+const GAMEPAD_CONNECTED = 'gamepadConnected';
+const GAMEPAD_DISCONNECTED = 'gamepadDisconnected';
+
+const MENU_HANDLER_ATTACHED = 'menuHandlerAttached';
+const MENU_PRESSED = 'menuPressed';
+const MENU_RELEASED = 'menuReleased';
+
+const KEY_PRESSED = 'keyPressed';
+const KEY_RELEASED = 'keyReleased';
+const KEYBOARD_TOGGLE_FILTER_MODE = 'keyboardToggleFilterMode';
+const KEYBOARD_KEY_PRESSED = 'keyboardKeyPressed';
+const AXIS_CHANGED = 'axisChanged';
+const CONTROLLER_UPDATED = 'controllerUpdated';
+
+const DPAD_TOGGLE = 'dpadToggle';
+const STATS_TOGGLE = 'statsToggle';
+const HELP_OVERLAY_TOGGLED = 'helpOverlayToggled';
+
+const SETTINGS_CHANGED = 'settingsChanged';
+const SETTINGS_CLOSED = 'settingsClosed';
+
+const RECORDING_TOGGLED = 'recordingToggle'
+const RECORDING_STATUS_CHANGED = 'recordingStatusChanged'
diff --git a/web/js/gameList.js b/web/js/gameList.js
index cae64220..f309295d 100644
--- a/web/js/gameList.js
+++ b/web/js/gameList.js
@@ -1,255 +1,100 @@
-import {MENU_PRESSED, MENU_RELEASED, sub} from 'event';
-import {gui} from 'gui';
-
-const TOP_POSITION = 102
-const SELECT_THRESHOLD_MS = 160
-
-const games = (() => {
- let list = [], index = 0
- return {
- get index() {
- return index
- },
- get list() {
- return list
- },
- get selected() {
- return list[index].title // selected by the game title, oof
- },
- set index(i) {
- index = i < -1 ? i = 0 :
- i > list.length ? i = list.length - 1 :
- (i % list.length + list.length) % list.length
- },
- set: (data = []) => list = data.sort((a, b) => a.title.toLowerCase() > b.title.toLowerCase() ? 1 : -1),
- empty: () => list.length === 0
- }
-})()
-
-const scroll = ((DEFAULT_INTERVAL) => {
- const state = {
- IDLE: 0, UP: -1, DOWN: 1, DRAG: 3
- }
- let last = state.IDLE
- let _si
- let onShift, onStop
-
- const shift = (delta) => {
- if (scroll.scrolling) return
- onShift(delta)
- // velocity?
- // keep rolling the game list if the button is pressed
- _si = setInterval(() => onShift(delta), DEFAULT_INTERVAL)
- }
-
- const stop = () => {
- onStop()
- _si && (clearInterval(_si) && (_si = null))
- }
-
- const handle = {[state.IDLE]: stop, [state.UP]: shift, [state.DOWN]: shift, [state.DRAG]: null}
-
- return {
- scroll: (move = state.IDLE) => {
- handle[move] && handle[move](move)
- last = move
- },
- get scrolling() {
- return last !== state.IDLE
- },
- set onShift(fn) {
- onShift = fn
- },
- set onStop(fn) {
- onStop = fn
- },
- state,
- last: () => last
- }
-})(SELECT_THRESHOLD_MS)
-
-const ui = (() => {
- const rootEl = document.getElementById('menu-container')
- const choiceMarkerEl = document.getElementById('menu-item-choice')
-
- const TRANSITION_DEFAULT = `top ${SELECT_THRESHOLD_MS}ms`
- let listTopPos = TOP_POSITION
-
- rootEl.style.transition = TRANSITION_DEFAULT
-
- let onTransitionEnd = () => ({})
-
- let items = []
-
- const marque = (() => {
- const speed = 1
- const sep = ' '.repeat(10)
-
- let el = null
- let raf = 0
- let txt = null
- let w = 0
-
- const move = () => {
- const shift = parseFloat(getComputedStyle(el).left) - speed
- el.style.left = w + shift < 1 ? `0px` : `${shift}px`
- raf = requestAnimationFrame(move)
- }
-
- return {
- reset() {
- cancelAnimationFrame(raf)
- el && (el.style.left = `0px`)
- },
- enable(cap) {
- txt && (el.textContent = txt) // restore the text
- el = cap
- txt = el.textContent
- el.textContent += sep
- w = el.scrollWidth // keep the text width
- el.textContent += txt
- cancelAnimationFrame(raf)
- raf = requestAnimationFrame(move)
- }
- }
- })()
-
- const item = (parent) => {
- const title = parent.firstChild.firstChild
- const desc = parent.children[1]
-
- const _desc = {
- hide: () => gui.hide(desc),
- show: async () => {
- gui.show(desc)
- await gui.anim.fadeIn(desc, .054321)
- },
- }
-
- const isOverflown = () => title.scrollWidth > title.clientWidth
-
- const _title = {
- pick: () => {
- title.classList.add('pick')
- isOverflown() && marque.enable(title)
- },
- reset: () => {
- title.classList.remove('pick')
- isOverflown() && marque.reset()
- }
- }
-
- const clear = () => _title.reset()
-
- return {
- get description() {
- return _desc
- },
- get title() {
- return _title
- },
- clear,
- }
- }
-
- const render = () => {
- rootEl.innerHTML = games.list.map(game =>
- ``)
- .join('')
- items = [...rootEl.querySelectorAll('.menu-item')].map(x => item(x))
- }
-
- return {
- get items() {
- return items
- },
- get selected() {
- return items[games.index]
- },
- get roundIndex() {
- const closest = Math.round((listTopPos - TOP_POSITION) / -36)
- return closest < 0 ? 0 :
- closest > games.list.length - 1 ? games.list.length - 1 :
- closest // don't wrap the list on drag
- },
- set onTransitionEnd(x) {
- onTransitionEnd = x
- },
- set pos(idx) {
- listTopPos = TOP_POSITION - idx * 36
- rootEl.style.top = `${listTopPos}px`
- },
- drag: {
- startPos: (pos) => {
- rootEl.style.top = `${listTopPos - pos}px`
- rootEl.style.transition = ''
- },
- stopPos: (pos) => {
- listTopPos -= pos
- rootEl.style.transition = TRANSITION_DEFAULT
- },
- },
- render,
- marker: {
- show: () => gui.show(choiceMarkerEl)
- },
- NO_TRANSITION: onTransitionEnd(),
- }
-})(TOP_POSITION, SELECT_THRESHOLD_MS, games)
-
-const show = () => {
- ui.render()
- ui.marker.show() // we show square pseudo-selection marker only after rendering
- scroll.scroll(scroll.state.DOWN) // interactively moves games select down
- scroll.scroll(scroll.state.IDLE)
-}
-
-const select = (index) => {
- ui.items.forEach(i => i.clear()) // !to rewrite
- games.index = index
- ui.pos = games.index
-}
-
-scroll.onShift = (delta) => select(games.index + delta)
-
-scroll.onStop = () => {
- const item = ui.selected
- item && item.title.pick()
-}
-
-sub(MENU_PRESSED, (position) => {
- if (games.empty()) return
- ui.onTransitionEnd = ui.NO_TRANSITION
- scroll.scroll(scroll.state.DRAG)
- ui.selected && ui.selected.clear()
- ui.drag.startPos(position)
-})
-
-sub(MENU_RELEASED, (position) => {
- if (games.empty()) return
- ui.drag.stopPos(position)
- select(ui.roundIndex)
- scroll.scroll(scroll.state.IDLE)
-})
-
/**
* Game list module.
+ * @version 1
*/
-export const gameList = {
- disable: () => ui.selected?.clear(),
- scroll: (x) => {
- if (games.empty()) return
- scroll.scroll(x)
- },
- get selected() {
- return games.selected
- },
- set: games.set,
- show: () => {
- if (games.empty()) return
- show()
- },
-}
+const gameList = (() => {
+ // state
+ let games = [];
+ let gameIndex = 1;
+ let gamePickTimer = null;
+
+ // UI
+ const listBox = document.getElementById('menu-container');
+ const menuItemChoice = document.getElementById('menu-item-choice');
+
+ const MENU_TOP_POSITION = 102;
+ let menuTop = MENU_TOP_POSITION;
+
+ const setGames = (gameList) => {
+ games = gameList.sort((a, b) => a > b ? 1 : -1);
+ };
+
+ const render = () => {
+ log.debug('[games] load game menu');
+
+ listBox.innerHTML = games
+ .map(game => ``)
+ .join('');
+ };
+
+ const show = () => {
+ render();
+ menuItemChoice.style.display = "block";
+ pickGame();
+ };
+
+ const pickGame = (index) => {
+ let idx = undefined !== index ? index : gameIndex;
+
+ // check boundaries
+ // cycle
+ if (idx < 0) idx = games.length - 1;
+ if (idx >= games.length) idx = 0;
+
+ // transition menu box
+ listBox.style['transition'] = 'top 0.2s';
+
+ menuTop = MENU_TOP_POSITION - idx * 36;
+ listBox.style['top'] = `${menuTop}px`;
+
+ // overflow marquee
+ let pick = document.querySelectorAll('.menu-item .pick')[0];
+ if (pick) {
+ pick.classList.remove('pick');
+ }
+ document.querySelectorAll(`.menu-item span`)[idx].classList.add('pick');
+
+ gameIndex = idx;
+ };
+
+ const startGamePickerTimer = (upDirection) => {
+ if (gamePickTimer !== null) return;
+ const shift = upDirection ? -1 : 1;
+ pickGame(gameIndex + shift);
+
+ // velocity?
+ // keep rolling the game list if the button is pressed
+ gamePickTimer = setInterval(() => {
+ pickGame(gameIndex + shift);
+ }, 200);
+ };
+
+ const stopGamePickerTimer = () => {
+ if (gamePickTimer === null) return;
+ clearInterval(gamePickTimer);
+ gamePickTimer = null;
+ };
+
+ const onMenuPressed = (newPosition) => {
+ listBox.style['transition'] = '';
+ listBox.style['top'] = `${menuTop - newPosition}px`;
+ };
+
+ const onMenuReleased = (position) => {
+ menuTop -= position;
+ const index = Math.round((menuTop - MENU_TOP_POSITION) / -36);
+ pickGame(index);
+ };
+
+ event.sub(MENU_PRESSED, onMenuPressed);
+ event.sub(MENU_RELEASED, onMenuReleased);
+
+ return {
+ startGamePickerTimer: startGamePickerTimer,
+ stopGamePickerTimer: stopGamePickerTimer,
+ pickGame: pickGame,
+ show: show,
+ set: setGames,
+ getCurrentGame: () => games[gameIndex]
+ }
+})(document, event, log);
diff --git a/web/js/gui.js b/web/js/gui.js
deleted file mode 100644
index b6eb9d94..00000000
--- a/web/js/gui.js
+++ /dev/null
@@ -1,277 +0,0 @@
-/**
- * App UI elements module.
- */
-
-const _create = (name = 'div', modFn) => {
- const el = document.createElement(name);
- if (modFn) {
- modFn(el);
- }
- return el;
-}
-
-const _option = (text = '', selected = false, label) => {
- const el = _create('option');
- if (label) {
- el.textContent = label;
- el.value = text;
- } else {
- el.textContent = text;
- }
- if (selected) el.selected = true;
-
- return el;
-}
-
-const select = (key = '', callback = () => ({}), values = {values: [], labels: []}, current = '') => {
- const el = _create();
- const select = _create('select');
- select.onchange = event => {
- callback(key, event.target.value);
- };
- el.append(select);
-
- select.append(_option(0, current === '', 'none'));
- values.values.forEach((value, index) => {
- select.append(_option(value, current == value, values.labels?.[index]));
- });
-
- return el;
-}
-
-const checkbox = (id, cb = () => ({}), checked = false, label = '', cc = '') => {
- const el = _create();
- cc !== '' && el.classList.add(cc);
-
- let parent = el;
-
- if (label) {
- const _label = _create('label', (el) => {
- el.setAttribute('htmlFor', id);
- })
- _label.innerText = label;
- el.append(_label)
- parent = _label;
- }
-
- const input = _create('input', (el) => {
- el.setAttribute('id', id);
- el.setAttribute('name', id);
- el.setAttribute('type', 'checkbox');
- el.onclick = ((e) => {
- checked = e.target.checked
- cb(id, checked)
- })
- checked && el.setAttribute('checked', '');
- });
- parent.prepend(input);
-
- return el;
-}
-
-const panel = (root, title = '', cc = '', content, buttons = [], onToggle) => {
- const state = {
- shown: false,
- loading: false,
- title: title,
- }
-
- const tHandlers = [];
- onToggle && tHandlers.push(onToggle);
-
- const _root = root || _create('div');
- _root.classList.add('panel');
- gui.hide(_root);
-
- const header = _create('div', (el) => el.classList.add('panel__header'));
- const _content = _create('div', (el) => {
- if (cc) {
- el.classList.add(cc);
- }
- el.classList.add('panel__content')
- });
-
- const _title = _create('span', (el) => {
- el.classList.add('panel__header__title');
- el.innerText = title;
- });
- header.append(_title);
-
- header.append(_create('div', (el) => {
- el.classList.add('panel__header__controls');
-
- buttons.forEach((b => el.append(_create('span', (el) => {
- if (Object.keys(b).length === 0) {
- el.classList.add('panel__button_separator');
- return
- }
- el.classList.add('panel__button');
- if (b.cl) b.cl.forEach(class_ => el.classList.add(class_));
- if (b.title) el.title = b.title;
- el.innerText = b.caption;
- el.addEventListener('click', b.handler)
- }))))
-
- el.append(_create('span', (el) => {
- el.classList.add('panel__button');
- el.innerText = 'X';
- el.title = 'Close';
- el.addEventListener('click', () => toggle(false))
- }))
- }))
-
- root.append(header, _content);
- if (content) {
- _content.append(content);
- }
-
- const setContent = (content) => _content.replaceChildren(content)
-
- const setLoad = (load = true) => {
- state.loading = load;
- _title.innerText = state.loading ? `${state.title}...` : state.title;
- }
-
- const toggle = (() => {
- let br = window.getComputedStyle(_root.parentElement).borderRadius;
- return (force) => {
- state.shown = force !== undefined ? force : !state.shown;
- // hack for not transparent jpeg corners :_;
- _root.parentElement.style.borderRadius = state.shown ? '0px' : br;
- tHandlers.forEach(h => h?.(state.shown, _root));
- state.shown ? gui.show(_root) : gui.hide(_root)
- }
- })()
-
- return {
- contentEl: _content,
- isHidden: () => !state.shown,
- onToggle: (fn) => tHandlers.push(fn),
- setContent,
- setLoad,
- toggle,
- }
-}
-
-const _bind = (cb = () => ({}), name = '', oldValue) => {
- const el = _create('button');
- el.onclick = () => cb(name, oldValue);
- el.textContent = name;
- return el;
-}
-
-const binding = (key = '', value = '', cb = () => ({})) => {
- const el = _create();
- el.setAttribute('class', 'binding-element');
-
- const k = _bind(cb, key, value);
-
- el.append(k);
-
- const v = _create();
- v.textContent = value;
- el.append(v);
-
- return el;
-}
-
-const show = (...els) => {
- els.forEach(el => el.classList.remove('hidden'))
-}
-
-const inputN = (key = '', cb = () => ({}), current = 0) => {
- const el = _create();
- const input = _create('input');
- input.type = 'number';
- input.value = current;
- input.onchange = event => cb(key, event.target.value);
- el.append(input);
- return el;
-}
-
-const hide = (el) => {
- el.classList.add('hidden');
-}
-
-const toggle = (el, what) => {
- if (what === undefined) {
- el.classList.toggle('hidden')
- return
- }
- what ? show(el) : hide(el)
-}
-
-const multiToggle = (elements = [], options = {list: []}) => {
- if (!options.list.length || !elements.length) return
-
- let i = 0
-
- const setText = () => elements.forEach(el => el.innerText = options.list[i].caption)
-
- const handleClick = () => {
- options.list[i].cb()
- i = (i + 1) % options.list.length
- setText()
- }
-
- setText()
- elements.forEach(el => el.addEventListener('click', handleClick))
-}
-
-const fadeIn = async (el, speed = .1) => {
- el.style.opacity = '0';
- el.style.display = 'block';
- return new Promise((done) => (function fade() {
- let val = parseFloat(el.style.opacity);
- const proceed = ((val += speed) <= 1);
- if (proceed) {
- el.style.opacity = '' + val;
- requestAnimationFrame(fade);
- } else {
- done();
- }
- })()
- );
-}
-
-const fadeOut = async (el, speed = .1) => {
- el.style.opacity = '1';
- return new Promise((done) => (function fade() {
- if ((el.style.opacity -= speed) < 0) {
- el.style.display = "none";
- done();
- } else {
- requestAnimationFrame(fade);
- }
- })()
- )
-}
-
-const fragment = () => document.createDocumentFragment();
-
-const sleep = async (ms) => new Promise(resolve => setTimeout(resolve, ms));
-
-const fadeInOut = async (el, wait = 1000, speed = .1) => {
- await fadeIn(el, speed)
- await sleep(wait);
- await fadeOut(el, speed)
-}
-
-export const gui = {
- anim: {
- fadeIn,
- fadeOut,
- fadeInOut,
- },
- binding,
- checkbox,
- create: _create,
- fragment,
- hide,
- inputN,
- multiToggle,
- panel,
- select,
- show,
- toggle,
-}
diff --git a/web/js/gui/gui.js b/web/js/gui/gui.js
new file mode 100644
index 00000000..361fe72b
--- /dev/null
+++ b/web/js/gui/gui.js
@@ -0,0 +1,213 @@
+/**
+ * App UI elements module.
+ *
+ * @version 1
+ */
+const gui = (() => {
+
+ const _create = (name = 'div', modFn) => {
+ const el = document.createElement(name);
+ if (modFn) {
+ modFn(el);
+ }
+ return el;
+ }
+
+ const _option = (text = '', selected = false, label) => {
+ const el = _create('option');
+ if (label) {
+ el.textContent = label;
+ el.value = text;
+ } else {
+ el.textContent = text;
+ }
+ if (selected) el.selected = true;
+
+ return el;
+ }
+
+ const select = (key = '', callback = function () {
+ }, values = {values: [], labels: []}, current = '') => {
+ const el = _create();
+ const select = _create('select');
+ select.onchange = event => {
+ callback(key, event.target.value);
+ };
+ el.append(select);
+
+ select.append(_option('none', current === ''));
+ values.values.forEach((value, index) => {
+ select.append(_option(value, current === value, values.labels?.[index]));
+ });
+
+ return el;
+ }
+
+ const panel = (root, title = '', cc = '', content, buttons = []) => {
+ const state = {
+ shown: false,
+ loading: false,
+ title: title,
+ }
+
+ const _root = root || _create('div');
+ _root.classList.add('panel');
+ const header = _create('div', (el) => el.classList.add('panel__header'));
+ const _content = _create('div', (el) => {
+ if (cc) {
+ el.classList.add(cc);
+ }
+ el.classList.add('panel__content')
+ });
+
+ const _title = _create('span', (el) => {
+ el.classList.add('panel__header__title');
+ el.innerText = title;
+ });
+ header.append(_title);
+
+ header.append(_create('div', (el) => {
+ el.classList.add('panel__header__controls');
+
+ buttons.forEach((b => el.append(_create('span', (el) => {
+ el.classList.add('panel__button');
+ if (b.cl) b.cl.forEach(class_ => el.classList.add(class_));
+ if (b.title) el.title = b.title;
+ el.innerText = b.caption;
+ el.addEventListener('click', b.handler)
+ }))))
+
+ el.append(_create('span', (el) => {
+ el.classList.add('panel__button');
+ el.innerText = 'X';
+ el.title = 'Close';
+ el.addEventListener('click', () => toggle(false))
+ }))
+ }))
+
+ root.append(header, _content);
+ if (content) {
+ _content.append(content);
+ }
+
+ const setContent = (content) => _content.replaceChildren(content)
+
+ const setLoad = (load = true) => {
+ state.loading = load;
+ _title.innerText = state.loading ? `${state.title}...` : state.title;
+ }
+
+ function toggle(show) {
+ state.shown = show;
+ if (state.shown) {
+ gui.show(_root);
+ } else {
+ gui.hide(_root);
+ }
+ }
+
+ return {
+ isHidden: () => !state.shown,
+ setContent,
+ setLoad,
+ toggle,
+ }
+ }
+
+ const _bind = (callback = function () {
+ }, name = '', oldValue) => {
+ const el = _create('button');
+ el.onclick = () => callback(name, oldValue);
+
+ el.textContent = name;
+
+ return el;
+ }
+
+ const binding = (key = '', value = '', callback = function () {
+ }) => {
+ const el = _create();
+ el.setAttribute('class', 'binding-element');
+
+ const k = _bind(callback, key, value);
+
+ el.append(k);
+
+ const v = _create();
+ v.textContent = value;
+ el.append(v);
+
+ return el;
+ }
+
+ const show = (el) => {
+ el.classList.remove('hidden');
+ }
+
+ const hide = (el) => {
+ el.classList.add('hidden');
+ }
+
+ const toggle = (el, what) => {
+ if (what) {
+ show(el)
+ } else {
+ hide(el)
+ }
+ }
+
+ const fadeIn = async (el, speed = .1) => {
+ el.style.opacity = '0';
+ el.style.display = 'block';
+ return new Promise((done) => (function fade() {
+ let val = parseFloat(el.style.opacity);
+ const proceed = ((val += speed) <= 1);
+ if (proceed) {
+ el.style.opacity = '' + val;
+ requestAnimationFrame(fade);
+ } else {
+ done();
+ }
+ })()
+ );
+ }
+
+ const fadeOut = async (el, speed = .1) => {
+ el.style.opacity = '1';
+ return new Promise((done) => (function fade() {
+ if ((el.style.opacity -= speed) < 0) {
+ el.style.display = "none";
+ done();
+ } else {
+ requestAnimationFrame(fade);
+ }
+ })()
+ )
+ }
+
+ const fragment = () => document.createDocumentFragment();
+
+ const sleep = async (ms) => new Promise(resolve => setTimeout(resolve, ms));
+
+ const fadeInOut = async (el, wait = 1000, speed = .1) => {
+ await fadeIn(el, speed)
+ await sleep(wait);
+ await fadeOut(el, speed)
+ }
+
+ return {
+ anim: {
+ fadeIn,
+ fadeOut,
+ fadeInOut,
+ },
+ binding,
+ create: _create,
+ fragment,
+ hide,
+ panel,
+ select,
+ show,
+ toggle,
+ }
+})(document);
diff --git a/web/js/gui/message.js b/web/js/gui/message.js
new file mode 100644
index 00000000..d4bf668c
--- /dev/null
+++ b/web/js/gui/message.js
@@ -0,0 +1,48 @@
+/**
+ * App UI message module.
+ *
+ * @version 1
+ */
+const message = (() => {
+ const popupBox = document.getElementById('noti-box');
+
+ // fifo queue
+ let queue = [];
+ const queueMaxSize = 5;
+
+ let isScreenFree = true;
+
+ const _popup = () => {
+ // recursion edge case:
+ // no messages in the queue or one on the screen
+ if (!(queue.length > 0 && isScreenFree)) {
+ return;
+ }
+
+ isScreenFree = false;
+ popupBox.innerText = queue.shift();
+ gui.anim.fadeInOut(popupBox, 1000, .05).finally(() => {
+ isScreenFree = true;
+ _popup();
+ })
+ }
+
+ const _storeMessage = (text) => {
+ if (queue.length <= queueMaxSize) {
+ queue.push(text);
+ }
+ }
+
+ const _proceed = (text) => {
+ _storeMessage(text);
+ _popup();
+ }
+
+ const show = (text) => {
+ _proceed(text)
+ }
+
+ return Object.freeze({
+ show: show
+ })
+})(document, gui, utils);
diff --git a/web/js/init.js b/web/js/init.js
new file mode 100644
index 00000000..d421bddb
--- /dev/null
+++ b/web/js/init.js
@@ -0,0 +1,26 @@
+settings.init();
+
+(() => {
+ let lvl = settings.loadOr(opts.LOG_LEVEL, log.DEFAULT);
+ // migrate old log level options
+ // !to remove at some point
+ if (isNaN(lvl)) {
+ console.warn(
+ `The log value [${lvl}] is not supported! ` +
+ `The default value [debug] will be used instead.`);
+ settings.set(opts.LOG_LEVEL, `${log.DEFAULT}`)
+ lvl = log.DEFAULT
+ }
+ log.level = lvl
+})();
+
+keyboard.init();
+joystick.init();
+touch.init();
+stream.init();
+
+[roomId, zone] = room.loadMaybe();
+// find worker id if present
+const wid = new URLSearchParams(document.location.search).get('wid');
+// if from URL -> start game immediately!
+socket.init(roomId, wid, zone);
diff --git a/web/js/input/input.js b/web/js/input/input.js
index 2c3fffa4..0ccf5b48 100644
--- a/web/js/input/input.js
+++ b/web/js/input/input.js
@@ -1,56 +1,114 @@
-import {
- REFRESH_INPUT,
- KB_MOUSE_FLAG,
- pub,
- sub
-} from 'event';
+const input = (() => {
+ const pollingIntervalMs = 4;
+ let controllerChangedIndex = -1;
-export {KEY, JOYPAD_KEYS} from './keys.js?v=3';
+ // Libretro config
+ let controllerState = {
+ [KEY.B]: false,
+ [KEY.Y]: false,
+ [KEY.SELECT]: false,
+ [KEY.START]: false,
+ [KEY.UP]: false,
+ [KEY.DOWN]: false,
+ [KEY.LEFT]: false,
+ [KEY.RIGHT]: false,
+ [KEY.A]: false,
+ [KEY.X]: false,
+ // extra
+ [KEY.L]: false,
+ [KEY.R]: false,
+ [KEY.L2]: false,
+ [KEY.R2]: false,
+ [KEY.L3]: false,
+ [KEY.R3]: false
+ };
-import {joystick} from './joystick.js?v=3';
-import {keyboard} from './keyboard.js?v=3'
-import {pointer} from './pointer.js?v=3';
-import {retropad} from './retropad.js?v=3';
-import {touch} from './touch.js?v=3';
-
-export {joystick, keyboard, pointer, retropad, touch};
-
-const input_state = {
- joystick: true,
- keyboard: false,
- pointer: true, // aka mouse
- retropad: true,
- touch: true,
-
- kbm: false,
-}
-
-const init = () => {
- keyboard.init()
- joystick.init()
- touch.init()
-}
-
-sub(KB_MOUSE_FLAG, () => {
- input_state.kbm = true
- pub(REFRESH_INPUT)
-})
-
-export const input = {
- state: input_state,
- init,
- retropad: {
- ...retropad,
- toggle(on = true) {
- if (on === input_state.retropad) return
- input_state.retropad = on
- retropad.toggle(on)
+ const poll = (intervalMs, callback) => {
+ let _ticker = 0;
+ return {
+ enable: () => {
+ if (_ticker > 0) return;
+ log.debug(`[input] poll set to ${intervalMs}ms`);
+ _ticker = setInterval(callback, intervalMs)
+ },
+ disable: () => {
+ if (_ticker < 1) return;
+ log.debug('[input] poll has been disabled');
+ clearInterval(_ticker);
+ _ticker = 0;
+ }
}
- },
- set kbm(v) {
- input_state.kbm = v
- },
- get kbm() {
- return input_state.kbm
+ };
+
+ const controllerEncoded = [0, 0, 0, 0, 0];
+ const keys = Object.keys(controllerState);
+
+ const compare = (a, b) => {
+ if (a.length !== b.length) return false;
+ for (let i = 0; i < a.length; i++) {
+ if (a[i] !== b[i]) {
+ return false;
+ }
+ }
+ return true;
+ };
+
+
+ // let lastState = controllerEncoded;
+
+ const sendControllerState = () => {
+ if (controllerChangedIndex >= 0) {
+ const state = _getState();
+
+ // log.debug(state)
+
+ // if (compare(lastState, state)) {
+ // log.debug('!skip')
+ // } else {
+ event.pub(CONTROLLER_UPDATED, _encodeState(state));
+ // }
+ // lastState = state;
+ controllerChangedIndex = -1;
+ }
+ };
+
+ const setKeyState = (name, state) => {
+ if (controllerState[name] !== undefined) {
+ controllerState[name] = state;
+ controllerChangedIndex = Math.max(controllerChangedIndex, 0);
+ }
+ };
+
+ const setAxisChanged = (index, value) => {
+ if (controllerEncoded[index + 1] !== undefined) {
+ controllerEncoded[index + 1] = Math.floor(32767 * value);
+ controllerChangedIndex = Math.max(controllerChangedIndex, index + 1);
+ }
+ };
+
+ /**
+ * Converts key state into a bitmap and prepends it to the axes state.
+ *
+ * @returns {Uint16Array} The controller state.
+ * First uint16 is the controller state bitmap.
+ * The other uint16 are the axes values.
+ * Truncated to the last value changed.
+ *
+ * @private
+ */
+ const _encodeState = (state) => new Uint16Array(state)
+
+ const _getState = () => {
+ controllerEncoded[0] = 0;
+ for (let i = 0, len = keys.length; i < len; i++) {
+ controllerEncoded[0] += controllerState[keys[i]] ? 1 << i : 0;
+ }
+ return controllerEncoded.slice(0, controllerChangedIndex + 1);
}
-}
+
+ return {
+ poll: poll(pollingIntervalMs, sendControllerState),
+ setKeyState,
+ setAxisChanged,
+ }
+})(event, KEY, log);
diff --git a/web/js/input/joystick.js b/web/js/input/joystick.js
index 51e22e2a..c5ee2fdb 100644
--- a/web/js/input/joystick.js
+++ b/web/js/input/joystick.js
@@ -1,260 +1,3 @@
-import {
- pub,
- sub,
- AXIS_CHANGED,
- DPAD_TOGGLE,
- GAMEPAD_CONNECTED,
- GAMEPAD_DISCONNECTED,
- KEY_PRESSED,
- KEY_RELEASED
-} from 'event';
-import {env, browser as br, platform} from 'env';
-import {KEY} from 'input';
-import {log} from 'log';
-
-const deadZone = 0.1;
-let joystickMap;
-let joystickState = {};
-let joystickAxes = [];
-let joystickIdx;
-let joystickTimer = null;
-let dpadMode = true;
-
-function onDpadToggle(checked) {
- if (dpadMode === checked) {
- return //error?
- }
- if (dpadMode) {
- dpadMode = false;
- // reset dpad keys pressed before moving to analog stick mode
- checkJoystickAxisState(KEY.LEFT, false);
- checkJoystickAxisState(KEY.RIGHT, false);
- checkJoystickAxisState(KEY.UP, false);
- checkJoystickAxisState(KEY.DOWN, false);
- } else {
- dpadMode = true;
- // reset analog stick axes before moving to dpad mode
- joystickAxes.forEach(function (value, index) {
- checkJoystickAxis(index, 0);
- });
- }
-}
-
-// check state for each axis -> dpad
-function checkJoystickAxisState(name, state) {
- if (joystickState[name] !== state) {
- joystickState[name] = state;
- pub(state === true ? KEY_PRESSED : KEY_RELEASED, {key: name});
- }
-}
-
-function checkJoystickAxis(axis, value) {
- if (-deadZone < value && value < deadZone) value = 0;
- if (joystickAxes[axis] !== value) {
- joystickAxes[axis] = value;
- pub(AXIS_CHANGED, {id: axis, value: value});
- }
-}
-
-// loop timer for checking joystick state
-function checkJoystickState() {
- let gamepad = navigator.getGamepads()[joystickIdx];
- if (gamepad) {
- if (dpadMode) {
- // axis -> dpad
- let corX = gamepad.axes[0]; // -1 -> 1, left -> right
- let corY = gamepad.axes[1]; // -1 -> 1, up -> down
- checkJoystickAxisState(KEY.LEFT, corX <= -0.5);
- checkJoystickAxisState(KEY.RIGHT, corX >= 0.5);
- checkJoystickAxisState(KEY.UP, corY <= -0.5);
- checkJoystickAxisState(KEY.DOWN, corY >= 0.5);
- } else {
- gamepad.axes.forEach(function (value, index) {
- checkJoystickAxis(index, value);
- });
- }
-
- // normal button map
- Object.keys(joystickMap).forEach(function (btnIdx) {
- const buttonState = gamepad.buttons[btnIdx];
-
- const isPressed = navigator.webkitGetGamepads ? buttonState === 1 :
- buttonState.value > 0 || buttonState.pressed === true;
-
- if (joystickState[btnIdx] !== isPressed) {
- joystickState[btnIdx] = isPressed;
- pub(isPressed === true ? KEY_PRESSED : KEY_RELEASED, {key: joystickMap[btnIdx]});
- }
- });
- }
-}
-
-// we only capture the last plugged joystick
-const onGamepadConnected = (e) => {
- let gamepad = e.gamepad;
- log.info(`Gamepad connected at index ${gamepad.index}: ${gamepad.id}. ${gamepad.buttons.length} buttons, ${gamepad.axes.length} axes.`);
-
- joystickIdx = gamepad.index;
-
- // Ref: https://github.com/giongto35/cloud-game/issues/14
- // get mapping first (default KeyMap2)
- const os = env.getOs;
- const browser = env.getBrowser;
-
- if (os === platform.android) {
- // default of android is KeyMap1
- joystickMap = {
- 2: KEY.A,
- 0: KEY.B,
- 3: KEY.START,
- 4: KEY.SELECT,
- 10: KEY.LOAD,
- 11: KEY.SAVE,
- 8: KEY.HELP,
- 9: KEY.QUIT,
- 12: KEY.UP,
- 13: KEY.DOWN,
- 14: KEY.LEFT,
- 15: KEY.RIGHT
- };
- } else {
- // default of other OS is KeyMap2
- joystickMap = {
- 0: KEY.A,
- 1: KEY.B,
- 2: KEY.START,
- 3: KEY.SELECT,
- 8: KEY.LOAD,
- 9: KEY.SAVE,
- 6: KEY.HELP,
- 7: KEY.QUIT,
- 12: KEY.UP,
- 13: KEY.DOWN,
- 14: KEY.LEFT,
- 15: KEY.RIGHT
- };
- }
-
- if (os === platform.android && browser === br.firefox) { //KeyMap2
- joystickMap = {
- 0: KEY.A,
- 1: KEY.B,
- 2: KEY.START,
- 3: KEY.SELECT,
- 8: KEY.LOAD,
- 9: KEY.SAVE,
- 6: KEY.HELP,
- 7: KEY.QUIT,
- 12: KEY.UP,
- 13: KEY.DOWN,
- 14: KEY.LEFT,
- 15: KEY.RIGHT
- };
- }
-
- if (os === platform.windows && browser === br.firefox) { //KeyMap3
- joystickMap = {
- 1: KEY.A,
- 2: KEY.B,
- 0: KEY.START,
- 3: KEY.SELECT,
- 8: KEY.LOAD,
- 9: KEY.SAVE,
- 6: KEY.HELP,
- 7: KEY.QUIT
- };
- }
-
- if (os === platform.macos && browser === br.safari) { //KeyMap4
- joystickMap = {
- 1: KEY.A,
- 2: KEY.B,
- 0: KEY.START,
- 3: KEY.SELECT,
- 8: KEY.LOAD,
- 9: KEY.SAVE,
- 6: KEY.HELP,
- 7: KEY.QUIT,
- 14: KEY.UP,
- 15: KEY.DOWN,
- 16: KEY.LEFT,
- 17: KEY.RIGHT
- };
- }
-
- if (os === platform.macos && browser === br.firefox) { //KeyMap5
- joystickMap = {
- 1: KEY.A,
- 2: KEY.B,
- 0: KEY.START,
- 3: KEY.SELECT,
- 8: KEY.LOAD,
- 9: KEY.SAVE,
- 6: KEY.HELP,
- 7: KEY.QUIT,
- 14: KEY.UP,
- 15: KEY.DOWN,
- 16: KEY.LEFT,
- 17: KEY.RIGHT
- };
- }
-
- // https://bugs.chromium.org/p/chromium/issues/detail?id=1076272
- if (gamepad.id.includes('PLAYSTATION(R)3')) {
- if (browser === br.chrome) {
- joystickMap = {
- 1: KEY.A,
- 0: KEY.B,
- 2: KEY.Y,
- 3: KEY.X,
- 4: KEY.L,
- 5: KEY.R,
- 8: KEY.SELECT,
- 9: KEY.START,
- 10: KEY.DTOGGLE,
- 11: KEY.R3,
- };
- } else {
- joystickMap = {
- 13: KEY.A,
- 14: KEY.B,
- 12: KEY.X,
- 15: KEY.Y,
- 3: KEY.START,
- 0: KEY.SELECT,
- 4: KEY.UP,
- 6: KEY.DOWN,
- 7: KEY.LEFT,
- 5: KEY.RIGHT,
- 10: KEY.L,
- 11: KEY.R,
- 8: KEY.L2,
- 9: KEY.R2,
- 1: KEY.DTOGGLE,
- 2: KEY.R3,
- };
- }
- }
-
- // reset state
- joystickState = {[KEY.LEFT]: false, [KEY.RIGHT]: false, [KEY.UP]: false, [KEY.DOWN]: false};
- Object.keys(joystickMap).forEach(function (btnIdx) {
- joystickState[btnIdx] = false;
- });
-
- joystickAxes = new Array(gamepad.axes.length).fill(0);
-
- // looper, too intense?
- if (joystickTimer !== null) {
- clearInterval(joystickTimer);
- }
-
- joystickTimer = setInterval(checkJoystickState, 10); // milliseconds per hit
- pub(GAMEPAD_CONNECTED);
-};
-
-sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked));
-
/**
* Joystick controls.
*
@@ -273,18 +16,263 @@ sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked));
*
* @version 1
*/
-export const joystick = {
- init: () => {
- // we only capture the last plugged joystick
- window.addEventListener('gamepadconnected', onGamepadConnected);
+const joystick = (() => {
+ const deadZone = 0.1;
+ let joystickMap;
+ let joystickState = {};
+ let joystickAxes = [];
+ let joystickIdx;
+ let joystickTimer = null;
+ let dpadMode = true;
- // disconnected event is triggered
- window.addEventListener('gamepaddisconnected', (event) => {
- clearInterval(joystickTimer);
- log.info(`Gamepad disconnected at index ${event.gamepad.index}`);
- pub(GAMEPAD_DISCONNECTED);
+ function onDpadToggle(checked) {
+ if (dpadMode === checked) {
+ return //error?
+ }
+ if (dpadMode) {
+ dpadMode = false;
+ // reset dpad keys pressed before moving to analog stick mode
+ checkJoystickAxisState(KEY.LEFT, false);
+ checkJoystickAxisState(KEY.RIGHT, false);
+ checkJoystickAxisState(KEY.UP, false);
+ checkJoystickAxisState(KEY.DOWN, false);
+ } else {
+ dpadMode = true;
+ // reset analog stick axes before moving to dpad mode
+ joystickAxes.forEach(function (value, index) {
+ checkJoystickAxis(index, 0);
+ });
+ }
+ }
+
+ // check state for each axis -> dpad
+ function checkJoystickAxisState(name, state) {
+ if (joystickState[name] !== state) {
+ joystickState[name] = state;
+ event.pub(state === true ? KEY_PRESSED : KEY_RELEASED, {key: name});
+ }
+ }
+
+ function checkJoystickAxis(axis, value) {
+ if (-deadZone < value && value < deadZone) value = 0;
+ if (joystickAxes[axis] !== value) {
+ joystickAxes[axis] = value;
+ event.pub(AXIS_CHANGED, {id: axis, value: value});
+ }
+ }
+
+ // loop timer for checking joystick state
+ function checkJoystickState() {
+ let gamepad = navigator.getGamepads()[joystickIdx];
+ if (gamepad) {
+ if (dpadMode) {
+ // axis -> dpad
+ let corX = gamepad.axes[0]; // -1 -> 1, left -> right
+ let corY = gamepad.axes[1]; // -1 -> 1, up -> down
+ checkJoystickAxisState(KEY.LEFT, corX <= -0.5);
+ checkJoystickAxisState(KEY.RIGHT, corX >= 0.5);
+ checkJoystickAxisState(KEY.UP, corY <= -0.5);
+ checkJoystickAxisState(KEY.DOWN, corY >= 0.5);
+ } else {
+ gamepad.axes.forEach(function (value, index) {
+ checkJoystickAxis(index, value);
+ });
+ }
+
+ // normal button map
+ Object.keys(joystickMap).forEach(function (btnIdx) {
+ const buttonState = gamepad.buttons[btnIdx];
+
+ const isPressed = navigator.webkitGetGamepads ? buttonState === 1 :
+ buttonState.value > 0 || buttonState.pressed === true;
+
+ if (joystickState[btnIdx] !== isPressed) {
+ joystickState[btnIdx] = isPressed;
+ event.pub(isPressed === true ? KEY_PRESSED : KEY_RELEASED, {key: joystickMap[btnIdx]});
+ }
+ });
+ }
+ }
+
+ // we only capture the last plugged joystick
+ const onGamepadConnected = (e) => {
+ let gamepad = e.gamepad;
+ log.info(`Gamepad connected at index ${gamepad.index}: ${gamepad.id}. ${gamepad.buttons.length} buttons, ${gamepad.axes.length} axes.`);
+
+ joystickIdx = gamepad.index;
+
+ // Ref: https://github.com/giongto35/cloud-game/issues/14
+ // get mapping first (default KeyMap2)
+ let os = env.getOs();
+ let browser = env.getBrowser();
+
+ if (os === 'android') {
+ // default of android is KeyMap1
+ joystickMap = {
+ 2: KEY.A,
+ 0: KEY.B,
+ 3: KEY.START,
+ 4: KEY.SELECT,
+ 10: KEY.LOAD,
+ 11: KEY.SAVE,
+ 8: KEY.HELP,
+ 9: KEY.QUIT,
+ 12: KEY.UP,
+ 13: KEY.DOWN,
+ 14: KEY.LEFT,
+ 15: KEY.RIGHT
+ };
+ } else {
+ // default of other OS is KeyMap2
+ joystickMap = {
+ 0: KEY.A,
+ 1: KEY.B,
+ 2: KEY.START,
+ 3: KEY.SELECT,
+ 8: KEY.LOAD,
+ 9: KEY.SAVE,
+ 6: KEY.HELP,
+ 7: KEY.QUIT,
+ 12: KEY.UP,
+ 13: KEY.DOWN,
+ 14: KEY.LEFT,
+ 15: KEY.RIGHT
+ };
+ }
+
+ if (os === 'android' && (browser === 'firefox' || browser === 'uc')) { //KeyMap2
+ joystickMap = {
+ 0: KEY.A,
+ 1: KEY.B,
+ 2: KEY.START,
+ 3: KEY.SELECT,
+ 8: KEY.LOAD,
+ 9: KEY.SAVE,
+ 6: KEY.HELP,
+ 7: KEY.QUIT,
+ 12: KEY.UP,
+ 13: KEY.DOWN,
+ 14: KEY.LEFT,
+ 15: KEY.RIGHT
+ };
+ }
+
+ if (os === 'win' && browser === 'firefox') { //KeyMap3
+ joystickMap = {
+ 1: KEY.A,
+ 2: KEY.B,
+ 0: KEY.START,
+ 3: KEY.SELECT,
+ 8: KEY.LOAD,
+ 9: KEY.SAVE,
+ 6: KEY.HELP,
+ 7: KEY.QUIT
+ };
+ }
+
+ if (os === 'mac' && browser === 'safari') { //KeyMap4
+ joystickMap = {
+ 1: KEY.A,
+ 2: KEY.B,
+ 0: KEY.START,
+ 3: KEY.SELECT,
+ 8: KEY.LOAD,
+ 9: KEY.SAVE,
+ 6: KEY.HELP,
+ 7: KEY.QUIT,
+ 14: KEY.UP,
+ 15: KEY.DOWN,
+ 16: KEY.LEFT,
+ 17: KEY.RIGHT
+ };
+ }
+
+ if (os === 'mac' && browser === 'firefox') { //KeyMap5
+ joystickMap = {
+ 1: KEY.A,
+ 2: KEY.B,
+ 0: KEY.START,
+ 3: KEY.SELECT,
+ 8: KEY.LOAD,
+ 9: KEY.SAVE,
+ 6: KEY.HELP,
+ 7: KEY.QUIT,
+ 14: KEY.UP,
+ 15: KEY.DOWN,
+ 16: KEY.LEFT,
+ 17: KEY.RIGHT
+ };
+ }
+
+ // https://bugs.chromium.org/p/chromium/issues/detail?id=1076272
+ if (gamepad.id.includes('PLAYSTATION(R)3')) {
+ if (browser === 'chrome') {
+ joystickMap = {
+ 1: KEY.A,
+ 0: KEY.B,
+ 2: KEY.Y,
+ 3: KEY.X,
+ 4: KEY.L,
+ 5: KEY.R,
+ 8: KEY.SELECT,
+ 9: KEY.START,
+ 10: KEY.DTOGGLE,
+ 11: KEY.R3,
+ };
+ } else {
+ joystickMap = {
+ 13: KEY.A,
+ 14: KEY.B,
+ 12: KEY.X,
+ 15: KEY.Y,
+ 3: KEY.START,
+ 0: KEY.SELECT,
+ 4: KEY.UP,
+ 6: KEY.DOWN,
+ 7: KEY.LEFT,
+ 5: KEY.RIGHT,
+ 10: KEY.L,
+ 11: KEY.R,
+ 8: KEY.L2,
+ 9: KEY.R2,
+ 1: KEY.DTOGGLE,
+ 2: KEY.R3,
+ };
+ }
+ }
+
+ // reset state
+ joystickState = {[KEY.LEFT]: false, [KEY.RIGHT]: false, [KEY.UP]: false, [KEY.DOWN]: false};
+ Object.keys(joystickMap).forEach(function (btnIdx) {
+ joystickState[btnIdx] = false;
});
- log.info('[input] joystick has been initialized');
+ joystickAxes = new Array(gamepad.axes.length).fill(0);
+
+ // looper, too intense?
+ if (joystickTimer !== null) {
+ clearInterval(joystickTimer);
+ }
+
+ joystickTimer = setInterval(checkJoystickState, 10); // miliseconds per hit
+ event.pub(GAMEPAD_CONNECTED);
+ };
+
+ event.sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked));
+
+ return {
+ init: () => {
+ // we only capture the last plugged joystick
+ window.addEventListener('gamepadconnected', onGamepadConnected);
+
+ // disconnected event is triggered
+ window.addEventListener('gamepaddisconnected', (event) => {
+ clearInterval(joystickTimer);
+ log.info(`Gamepad disconnected at index ${event.gamepad.index}`);
+ event.pub(GAMEPAD_DISCONNECTED);
+ });
+
+ log.info('[input] joystick has been initialized');
+ }
}
-}
+})(event, env, KEY, navigator, window);
diff --git a/web/js/input/keyboard.js b/web/js/input/keyboard.js
index 4c26e9db..91b9f548 100644
--- a/web/js/input/keyboard.js
+++ b/web/js/input/keyboard.js
@@ -1,164 +1,128 @@
-import {
- pub,
- sub,
- AXIS_CHANGED,
- DPAD_TOGGLE,
- KEY_PRESSED,
- KEY_RELEASED,
- KEYBOARD_KEY_PRESSED,
- KEYBOARD_KEY_DOWN,
- KEYBOARD_KEY_UP,
- KEYBOARD_TOGGLE_FILTER_MODE,
-} from 'event';
-import {KEY} from 'input';
-import {log} from 'log'
-import {opts, settings} from 'settings';
-
-// default keyboard bindings
-const defaultMap = Object.freeze({
- ArrowLeft: KEY.LEFT,
- ArrowUp: KEY.UP,
- ArrowRight: KEY.RIGHT,
- ArrowDown: KEY.DOWN,
- KeyZ: KEY.A,
- KeyX: KEY.B,
- KeyC: KEY.X,
- KeyV: KEY.Y,
- KeyA: KEY.L,
- KeyS: KEY.R,
- Semicolon: KEY.L2,
- Quote: KEY.R2,
- Period: KEY.L3,
- Slash: KEY.R3,
- Enter: KEY.START,
- ShiftLeft: KEY.SELECT,
- // non-game
- KeyQ: KEY.QUIT,
- KeyW: KEY.JOIN,
- KeyK: KEY.SAVE,
- KeyL: KEY.LOAD,
- Digit1: KEY.PAD1,
- Digit2: KEY.PAD2,
- Digit3: KEY.PAD3,
- Digit4: KEY.PAD4,
- KeyF: KEY.FULL,
- KeyH: KEY.HELP,
- Backslash: KEY.STATS,
- Digit9: KEY.SETTINGS,
- KeyT: KEY.DTOGGLE,
- Digit0: KEY.RESET,
-});
-
-let keyMap = {};
-// special mode for changing button bindings in the options
-let isKeysFilteredMode = true;
-// if the browser supports Keyboard Lock API (Firefox does not)
-let hasKeyboardLock = ('keyboard' in navigator) && ('lock' in navigator.keyboard)
-
-let locked = false
-
-const remap = (map = {}) => {
- settings.set(opts.INPUT_KEYBOARD_MAP, map);
- log.debug('Keyboard keys have been remapped')
-}
-
-sub(KEYBOARD_TOGGLE_FILTER_MODE, data => {
- isKeysFilteredMode = data.mode !== undefined ? data.mode : !isKeysFilteredMode;
- log.debug(`New keyboard filter mode: ${isKeysFilteredMode}`);
-});
-
-let dpadMode = true;
-let dpadState = {[KEY.LEFT]: false, [KEY.RIGHT]: false, [KEY.UP]: false, [KEY.DOWN]: false};
-
-function onDpadToggle(checked) {
- if (dpadMode === checked) {
- return //error?
- }
-
- dpadMode = !dpadMode
- if (dpadMode) {
- // reset dpad keys pressed before moving to analog stick mode
- for (const key in dpadState) {
- if (dpadState[key]) {
- dpadState[key] = false;
- pub(KEY_RELEASED, {key: key});
- }
- }
- } else {
- // reset analog stick axes before moving to dpad mode
- if (!!dpadState[KEY.RIGHT] - !!dpadState[KEY.LEFT] !== 0) {
- pub(AXIS_CHANGED, {id: 0, value: 0});
- }
- if (!!dpadState[KEY.DOWN] - !!dpadState[KEY.UP] !== 0) {
- pub(AXIS_CHANGED, {id: 1, value: 0});
- }
- dpadState = {[KEY.LEFT]: false, [KEY.RIGHT]: false, [KEY.UP]: false, [KEY.DOWN]: false};
- }
-}
-
-const lock = async (lock) => {
- locked = lock
- if (hasKeyboardLock) {
- lock ? await navigator.keyboard.lock() : navigator.keyboard.unlock()
- }
- // if the browser doesn't support keyboard lock, it will be emulated
-}
-
-const onKey = (code, evt, state) => {
- const key = keyMap[code]
-
- if (dpadState[key] !== undefined) {
- dpadState[key] = state
- if (!dpadMode) {
- const LR = key === KEY.LEFT || key === KEY.RIGHT
- pub(AXIS_CHANGED, {
- id: !LR,
- value: !!dpadState[LR ? KEY.RIGHT : KEY.DOWN] - !!dpadState[LR ? KEY.LEFT : KEY.UP]
- })
- return
- }
- }
- pub(evt, {key: key, code: code})
-}
-
-sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked));
-
/**
* Keyboard controls.
+ *
+ * @version 1
*/
-export const keyboard = {
- init: () => {
- keyMap = settings.loadOr(opts.INPUT_KEYBOARD_MAP, defaultMap);
- const body = document.body;
+const keyboard = (() => {
+ // default keyboard bindings
+ const defaultMap = Object.freeze({
+ ArrowLeft: KEY.LEFT,
+ ArrowUp: KEY.UP,
+ ArrowRight: KEY.RIGHT,
+ ArrowDown: KEY.DOWN,
+ KeyZ: KEY.A,
+ KeyX: KEY.B,
+ KeyC: KEY.X,
+ KeyV: KEY.Y,
+ KeyA: KEY.L,
+ KeyS: KEY.R,
+ Enter: KEY.START,
+ ShiftLeft: KEY.SELECT,
+ // non-game
+ KeyQ: KEY.QUIT,
+ KeyW: KEY.JOIN,
+ KeyK: KEY.SAVE,
+ KeyL: KEY.LOAD,
+ Digit1: KEY.PAD1,
+ Digit2: KEY.PAD2,
+ Digit3: KEY.PAD3,
+ Digit4: KEY.PAD4,
+ KeyF: KEY.FULL,
+ KeyH: KEY.HELP,
+ Backslash: KEY.STATS,
+ Digit9: KEY.SETTINGS,
+ KeyM: KEY.MULTITAP,
+ KeyT: KEY.DTOGGLE
+ });
- body.addEventListener('keyup', e => {
- e.stopPropagation()
- !hasKeyboardLock && locked && e.preventDefault()
+ let keyMap = {};
+ let isKeysFilteredMode = true;
- let lock = locked
- // hack with Esc up when outside of lock
- if (e.code === 'Escape') {
- lock = true
+ const remap = (map = {}) => {
+ settings.set(opts.INPUT_KEYBOARD_MAP, map);
+ log.info('Keyboard keys have been remapped')
+ }
+
+ event.sub(KEYBOARD_TOGGLE_FILTER_MODE, data => {
+ isKeysFilteredMode = data.mode !== undefined ? data.mode : !isKeysFilteredMode;
+ log.debug(`New keyboard filter mode: ${isKeysFilteredMode}`);
+ });
+
+ let dpadMode = true;
+ let dpadState = {[KEY.LEFT]: false, [KEY.RIGHT]: false, [KEY.UP]: false, [KEY.DOWN]: false};
+
+ function onDpadToggle(checked) {
+ if (dpadMode === checked) {
+ return //error?
+ }
+
+ dpadMode = !dpadMode
+ if (dpadMode) {
+ // reset dpad keys pressed before moving to analog stick mode
+ for (const key in dpadState) {
+ if (dpadState[key]) {
+ dpadState[key] = false;
+ event.pub(KEY_RELEASED, {key: key});
+ }
}
+ } else {
+ // reset analog stick axes before moving to dpad mode
+ if (!!dpadState[KEY.RIGHT] - !!dpadState[KEY.LEFT] !== 0) {
+ event.pub(AXIS_CHANGED, {id: 0, value: 0});
+ }
+ if (!!dpadState[KEY.DOWN] - !!dpadState[KEY.UP] !== 0) {
+ event.pub(AXIS_CHANGED, {id: 1, value: 0});
+ }
+ dpadState = {[KEY.LEFT]: false, [KEY.RIGHT]: false, [KEY.UP]: false, [KEY.DOWN]: false};
+ }
+ }
- isKeysFilteredMode ?
- (lock ? pub(KEYBOARD_KEY_UP, e) : onKey(e.code, KEY_RELEASED, false))
- : pub(KEYBOARD_KEY_PRESSED, {key: e.code})
- }, false)
+ const onKey = (code, evt, state) => {
+ const key = keyMap[code]
+ if (key === undefined) return
- body.addEventListener('keydown', e => {
- e.stopPropagation()
- !hasKeyboardLock && locked && e.preventDefault()
+ if (dpadState[key] !== undefined) {
+ dpadState[key] = state
+ if (!dpadMode) {
+ const LR = key === KEY.LEFT || key === KEY.RIGHT
+ event.pub(AXIS_CHANGED, {
+ id: !LR,
+ value: !!dpadState[LR ? KEY.RIGHT : KEY.DOWN] - !!dpadState[LR ? KEY.LEFT : KEY.UP]
+ })
+ return
+ }
+ }
+ event.pub(evt, {key: key})
+ }
- isKeysFilteredMode ?
- (locked ? pub(KEYBOARD_KEY_DOWN, e) : onKey(e.code, KEY_PRESSED, true)) :
- pub(KEYBOARD_KEY_PRESSED, {key: e.code})
- })
+ event.sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked));
- log.info('[input] keyboard has been initialized')
- },
- settings: {
- remap
- },
- lock,
-}
+ return {
+ init: () => {
+ keyMap = settings.loadOr(opts.INPUT_KEYBOARD_MAP, defaultMap);
+ const body = document.body;
+ // !to use prevent default as everyone
+ body.addEventListener('keyup', e => {
+ e.stopPropagation();
+ if (isKeysFilteredMode) {
+ onKey(e.code, KEY_RELEASED, false)
+ } else {
+ event.pub(KEYBOARD_KEY_PRESSED, {key: e.code});
+ }
+ }, false);
+
+ body.addEventListener('keydown', e => {
+ e.stopPropagation();
+ if (isKeysFilteredMode) {
+ onKey(e.code, KEY_PRESSED, true)
+ } else {
+ event.pub(KEYBOARD_KEY_PRESSED, {key: e.code});
+ }
+ });
+
+ log.info('[input] keyboard has been initialized');
+ }, settings: {
+ remap
+ }
+ }
+})(event, document, KEY, log, opts, settings);
diff --git a/web/js/input/keys.js b/web/js/input/keys.js
index 60e45e3e..1f798b95 100644
--- a/web/js/input/keys.js
+++ b/web/js/input/keys.js
@@ -1,41 +1,35 @@
-export const KEY = {
- A: 'a',
- B: 'b',
- X: 'x',
- Y: 'y',
- L: 'l',
- R: 'r',
- START: 'start',
- SELECT: 'select',
- LOAD: 'load',
- SAVE: 'save',
- HELP: 'help',
- JOIN: 'join',
- FULL: 'full',
- QUIT: 'quit',
- UP: 'up',
- DOWN: 'down',
- LEFT: 'left',
- RIGHT: 'right',
- PAD1: 'pad1',
- PAD2: 'pad2',
- PAD3: 'pad3',
- PAD4: 'pad4',
- STATS: 'stats',
- SETTINGS: 'settings',
- DTOGGLE: 'dtoggle',
- L2: 'l2',
- R2: 'r2',
- L3: 'l3',
- R3: 'r3',
- REC: 'rec',
- RESET: 'reset',
-};
-
-// Keys match libretro RETRO_DEVICE_ID_JOYPAD_*
-export const JOYPAD_KEYS = [
- KEY.B, KEY.Y, KEY.SELECT, KEY.START,
- KEY.UP, KEY.DOWN, KEY.LEFT, KEY.RIGHT,
- KEY.A, KEY.X, KEY.L, KEY.R,
- KEY.L2, KEY.R2, KEY.L3, KEY.R3
-]
+const KEY = (() => {
+ return {
+ A: 'a',
+ B: 'b',
+ X: 'x',
+ Y: 'y',
+ L: 'l',
+ R: 'r',
+ START: 'start',
+ SELECT: 'select',
+ LOAD: 'load',
+ SAVE: 'save',
+ HELP: 'help',
+ JOIN: 'join',
+ FULL: 'full',
+ QUIT: 'quit',
+ UP: 'up',
+ DOWN: 'down',
+ LEFT: 'left',
+ RIGHT: 'right',
+ PAD1: 'pad1',
+ PAD2: 'pad2',
+ PAD3: 'pad3',
+ PAD4: 'pad4',
+ STATS: 'stats',
+ SETTINGS: 'settings',
+ DTOGGLE: 'dtoggle',
+ L2: 'l2',
+ R2: 'r2',
+ L3: 'l3',
+ R3: 'r3',
+ MULTITAP: 'multitap',
+ REC: 'rec',
+ }
+})();
diff --git a/web/js/input/pointer.js b/web/js/input/pointer.js
deleted file mode 100644
index e0fab075..00000000
--- a/web/js/input/pointer.js
+++ /dev/null
@@ -1,153 +0,0 @@
-// Pointer (aka mouse) stuff
-import {
- MOUSE_PRESSED,
- MOUSE_MOVED,
- pub
-} from 'event';
-import {browser, env} from 'env';
-
-const hasRawPointer = 'onpointerrawupdate' in window
-
-const p = {dx: 0, dy: 0}
-
-const move = (e, cb, single = false) => {
- // !to fix ff https://github.com/w3c/pointerlock/issues/42
- if (single) {
- p.dx = e.movementX
- p.dy = e.movementY
- cb(p)
- } else {
- const _events = e.getCoalescedEvents?.()
- if (_events && (hasRawPointer || _events.length > 1)) {
- for (let i = 0; i < _events.length; i++) {
- p.dx = _events[i].movementX
- p.dy = _events[i].movementY
- cb(p)
- }
- }
- }
-}
-
-const _track = (el, cb, single) => {
- const _move = (e) => {
- move(e, cb, single)
- }
- el.addEventListener(hasRawPointer ? 'pointerrawupdate' : 'pointermove', _move)
- return () => {
- el.removeEventListener(hasRawPointer ? 'pointerrawupdate' : 'pointermove', _move)
- }
-}
-
-const dpiScaler = () => {
- let ex = 0
- let ey = 0
- let scaled = {dx: 0, dy: 0}
- return {
- scale(x, y, src_w, src_h, dst_w, dst_h) {
- scaled.dx = x / (src_w / dst_w) + ex
- scaled.dy = y / (src_h / dst_h) + ey
-
- ex = scaled.dx % 1
- ey = scaled.dy % 1
-
- scaled.dx -= ex
- scaled.dy -= ey
-
- return scaled
- }
- }
-}
-
-const dpi = dpiScaler()
-
-const handlePointerMove = (el, cb) => {
- let w, h = 0
- let s = false
- const dw = 640, dh = 480
- return (p) => {
- ({w, h, s} = cb())
- pub(MOUSE_MOVED, s ? dpi.scale(p.dx, p.dy, w, h, dw, dh) : p)
- }
-}
-
-const trackPointer = (el, cb) => {
- let mpu, mpd
- let noTrack
-
- // disable coalesced mouse move events
- const single = true
-
- // coalesced event are broken since FF 120
- const isFF = env.getBrowser === browser.firefox
-
- const pm = handlePointerMove(el, cb)
-
- return (enabled) => {
- if (enabled) {
- !noTrack && (noTrack = _track(el, pm, isFF || single))
- mpu = pointer.handle.up(el)
- mpd = pointer.handle.down(el)
- return
- }
-
- mpu?.()
- mpd?.()
- noTrack?.()
- noTrack = null
- }
-}
-
-const handleDown = ((b = {b: null, p: true}) => (e) => {
- b.b = e.button
- pub(MOUSE_PRESSED, b)
-})()
-
-const handleUp = ((b = {b: null, p: false}) => (e) => {
- b.b = e.button
- pub(MOUSE_PRESSED, b)
-})()
-
-const autoHide = (el, time = 3000) => {
- let tm
- let move
- const cl = el.classList
-
- const hide = (force = false) => {
- cl.add('no-pointer')
- !force && el.addEventListener('pointermove', move)
- }
-
- move = () => {
- cl.remove('no-pointer')
- clearTimeout(tm)
- tm = setTimeout(hide, time)
- }
-
- const show = () => {
- clearTimeout(tm)
- el.removeEventListener('pointermove', move)
- cl.remove('no-pointer')
- }
-
- return {
- autoHide: (on) => on ? show() : hide()
- }
-}
-
-export const pointer = {
- autoHide,
- lock: async (el) => {
- await el.requestPointerLock(/*{ unadjustedMovement: true}*/)
- },
- track: trackPointer,
- handle: {
- down: (el) => {
- el.onpointerdown = handleDown
- return () => (el.onpointerdown = null)
- },
- up: (el) => {
- el.onpointerup = handleUp
- return () => (el.onpointerup = null)
- }
- }
-}
diff --git a/web/js/input/retropad.js b/web/js/input/retropad.js
deleted file mode 100644
index 0e7026ee..00000000
--- a/web/js/input/retropad.js
+++ /dev/null
@@ -1,64 +0,0 @@
-import {pub, CONTROLLER_UPDATED} from 'event';
-import {JOYPAD_KEYS} from 'input';
-
-/*
- * [BUTTONS, LEFT_X, LEFT_Y, RIGHT_X, RIGHT_Y]
- *
- * Buttons are packed into a 16-bit bitmask where each bit is one button.
- * Axes are signed 16-bit values ranging from -32768 to 32767.
- * The whole thing is 10 bytes when sent over the wire.
- */
-const state = new Int16Array(5);
-let buttons = 0;
-let dirty = false;
-let rafId = 0;
-
-/*
- * Polls controller state using requestAnimationFrame which gives us
- * ~60Hz update rate that syncs with the display. As a bonus,
- * it automatically pauses when the tab goes to background.
- * We only send data when something actually changed.
- */
-const poll = () => {
- if (dirty) {
- state[0] = buttons;
- pub(CONTROLLER_UPDATED, new Uint16Array(state.buffer));
- dirty = false;
- }
- rafId = requestAnimationFrame(poll);
-};
-
-/*
- * Toggles a button on or off in the bitmask. The button's position
- * in JOYPAD_KEYS determines which bit gets flipped. For example,
- * if A is at index 8, pressing it sets bit 8.
- */
-const setKeyState = (key, pressed) => {
- const idx = JOYPAD_KEYS.indexOf(key);
- if (idx < 0) return;
-
- const prev = buttons;
- buttons = pressed ? buttons | (1 << idx) : buttons & ~(1 << idx);
- dirty ||= buttons !== prev;
-};
-
-/*
- * Updates an analog stick axis. Axes 0-1 are the left stick (X and Y),
- * axes 2-3 are the right stick. Input should be a float from -1 to 1
- * which gets converted to a signed 16-bit integer for transmission.
- */
-const setAxisChanged = (axis, value) => {
- if (axis < 0 || axis > 3) return;
-
- const v = Math.trunc(Math.max(-1, Math.min(1, value)) * 32767);
- dirty ||= state[++axis] !== v;
- state[axis] = v;
-};
-
-// Starts or stops the polling loop
-const toggle = (on) => {
- if (on === !!rafId) return;
- rafId = on ? requestAnimationFrame(poll) : (cancelAnimationFrame(rafId), 0);
-};
-
-export const retropad = {toggle, setKeyState, setAxisChanged};
\ No newline at end of file
diff --git a/web/js/input/touch.js b/web/js/input/touch.js
index f98359fc..97b45a9a 100644
--- a/web/js/input/touch.js
+++ b/web/js/input/touch.js
@@ -1,301 +1,3 @@
-import {env} from 'env';
-import {
- pub,
- sub,
- AXIS_CHANGED,
- KEY_PRESSED,
- KEY_RELEASED,
- GAME_PLAYER_IDX,
- DPAD_TOGGLE,
- MENU_HANDLER_ATTACHED,
- MENU_PRESSED,
- MENU_RELEASED
-} from 'event';
-import {KEY} from 'input';
-import {log} from 'log';
-
-const MAX_DIFF = 20; // radius of circle boundary
-
-// vpad state, use for mouse button down
-let vpadState = {[KEY.UP]: false, [KEY.DOWN]: false, [KEY.LEFT]: false, [KEY.RIGHT]: false};
-let analogState = [0, 0];
-
-let vpadTouchIdx = null;
-let vpadTouchDrag = null;
-let vpadHolder = document.getElementById('circle-pad-holder');
-let vpadCircle = document.getElementById('circle-pad');
-
-const buttons = Array.from(document.getElementsByClassName('btn'));
-const playerSlider = document.getElementById('playeridx');
-const dpad = Array.from(document.getElementsByClassName('dpad'));
-
-const dpadToggle = document.getElementById('dpad-toggle')
-dpadToggle.addEventListener('change', (e) => {
- pub(DPAD_TOGGLE, {checked: e.target.checked});
-});
-
-const getKey = (el) => el.dataset.key
-
-let dpadMode = true;
-const deadZone = 0.1;
-
-let enabled = false
-
-function onDpadToggle(checked) {
- if (dpadMode === checked) {
- return //error?
- }
- if (dpadMode) {
- dpadMode = false;
- vpadHolder.classList.add('dpad-empty');
- vpadCircle.classList.add('bong-full');
- // reset dpad keys pressed before moving to analog stick mode
- resetVpadState()
- } else {
- dpadMode = true;
- vpadHolder.classList.remove('dpad-empty');
- vpadCircle.classList.remove('bong-full');
- }
-}
-
-function resetVpadState() {
- if (dpadMode) {
- // trigger up event?
- checkVpadState(KEY.UP, false);
- checkVpadState(KEY.DOWN, false);
- checkVpadState(KEY.LEFT, false);
- checkVpadState(KEY.RIGHT, false);
- } else {
- checkAnalogState(0, 0);
- checkAnalogState(1, 0);
- }
-
- vpadTouchDrag = null;
- vpadTouchIdx = null;
-
- dpad.forEach(arrow => arrow.classList.remove('pressed'));
-}
-
-function checkVpadState(axis, state) {
- if (state !== vpadState[axis]) {
- vpadState[axis] = state;
- pub(state ? KEY_PRESSED : KEY_RELEASED, {key: axis});
- }
-}
-
-function checkAnalogState(axis, value) {
- if (-deadZone < value && value < deadZone) value = 0;
- if (analogState[axis] !== value) {
- analogState[axis] = value;
- pub(AXIS_CHANGED, {id: axis, value: value});
- }
-}
-
-function handleVpadJoystickDown(event) {
- vpadCircle.style['transition'] = '0s';
-
- if (event.changedTouches) {
- resetVpadState();
- vpadTouchIdx = event.changedTouches[0].identifier;
- event.clientX = event.changedTouches[0].clientX;
- event.clientY = event.changedTouches[0].clientY;
- }
-
- vpadTouchDrag = {x: event.clientX, y: event.clientY};
-}
-
-function handleVpadJoystickUp() {
- if (vpadTouchDrag === null) return;
-
- vpadCircle.style['transition'] = '.2s';
- vpadCircle.style['transform'] = 'translate3d(0px, 0px, 0px)';
-
- resetVpadState();
-}
-
-function handleVpadJoystickMove(event) {
- if (vpadTouchDrag === null) return;
-
- if (event.changedTouches) {
- // check if moving source is from other touch?
- for (let i = 0; i < event.changedTouches.length; i++) {
- if (event.changedTouches[i].identifier === vpadTouchIdx) {
- event.clientX = event.changedTouches[i].clientX;
- event.clientY = event.changedTouches[i].clientY;
- }
- }
- if (event.clientX === undefined || event.clientY === undefined)
- return;
- }
-
- let xDiff = event.clientX - vpadTouchDrag.x;
- let yDiff = event.clientY - vpadTouchDrag.y;
- let angle = Math.atan2(yDiff, xDiff);
- let distance = Math.min(MAX_DIFF, Math.hypot(xDiff, yDiff));
- let xNew = distance * Math.cos(angle);
- let yNew = distance * Math.sin(angle);
-
- if (env.display().isLayoutSwitched) {
- let tmp = xNew;
- xNew = yNew;
- yNew = -tmp;
- }
-
- vpadCircle.style['transform'] = `translate(${xNew}px, ${yNew}px)`;
-
- let xRatio = xNew / MAX_DIFF;
- let yRatio = yNew / MAX_DIFF;
-
- if (dpadMode) {
- checkVpadState(KEY.LEFT, xRatio <= -0.5);
- checkVpadState(KEY.RIGHT, xRatio >= 0.5);
- checkVpadState(KEY.UP, yRatio <= -0.5);
- checkVpadState(KEY.DOWN, yRatio >= 0.5);
- } else {
- checkAnalogState(0, xRatio);
- checkAnalogState(1, yRatio);
- }
-}
-
-// right side - control buttons
-const _handleButton = (key, state) => checkVpadState(key, state)
-
-function handleButtonDown() {
- _handleButton(getKey(this), true);
-}
-
-function handleButtonUp() {
- _handleButton(getKey(this), false);
-}
-
-function handleButtonClick() {
- _handleButton(getKey(this), true);
- setTimeout(() => {
- _handleButton(getKey(this), false);
- }, 30);
-}
-
-function handlePlayerSlider() {
- pub(GAME_PLAYER_IDX, {index: this.value - 1});
-}
-
-// Touch menu
-let menuTouchIdx = null;
-let menuTouchDrag = null;
-let menuTouchTime = null;
-
-function handleMenuDown(event) {
- // Identify of touch point
- if (event.changedTouches) {
- menuTouchIdx = event.changedTouches[0].identifier;
- event.clientX = event.changedTouches[0].clientX;
- event.clientY = event.changedTouches[0].clientY;
- }
-
- menuTouchDrag = {x: event.clientX, y: event.clientY,};
- menuTouchTime = Date.now();
-}
-
-function handleMenuMove(evt) {
- if (menuTouchDrag === null) return;
-
- if (evt.changedTouches) {
- // check if moving source is from other touch?
- for (let i = 0; i < evt.changedTouches.length; i++) {
- if (evt.changedTouches[i].identifier === menuTouchIdx) {
- evt.clientX = evt.changedTouches[i].clientX;
- evt.clientY = evt.changedTouches[i].clientY;
- }
- }
- if (evt.clientX === undefined || evt.clientY === undefined)
- return;
- }
-
- const pos = env.display().isLayoutSwitched ? evt.clientX - menuTouchDrag.x : menuTouchDrag.y - evt.clientY;
- pub(MENU_PRESSED, pos);
-}
-
-function handleMenuUp(evt) {
- if (menuTouchDrag === null) return;
- if (evt.changedTouches) {
- if (evt.changedTouches[0].identifier !== menuTouchIdx)
- return;
- evt.clientX = evt.changedTouches[0].clientX;
- evt.clientY = evt.changedTouches[0].clientY;
- }
-
- let newY = env.display().isLayoutSwitched ? -menuTouchDrag.x + evt.clientX : menuTouchDrag.y - evt.clientY;
-
- let interval = Date.now() - menuTouchTime; // 100ms?
- if (interval < 200) {
- // calc velocity
- newY = newY / interval * 250;
- }
-
- // current item?
- pub(MENU_RELEASED, newY);
- menuTouchDrag = null;
-}
-
-// Common events
-function handleWindowMove(event) {
- if (!enabled) return
-
- event.preventDefault();
- handleVpadJoystickMove(event);
- handleMenuMove(event);
-
- // moving touch
- if (event.changedTouches) {
- for (let i = 0; i < event.changedTouches.length; i++) {
- if (event.changedTouches[i].identifier !== menuTouchIdx && event.changedTouches[i].identifier !== vpadTouchIdx) {
- // check class
-
- let elem = document.elementFromPoint(event.changedTouches[i].clientX, event.changedTouches[i].clientY);
-
- if (elem.classList.contains('btn')) {
- elem.dispatchEvent(new Event('touchstart'));
- } else {
- elem.dispatchEvent(new Event('touchend'));
- }
- }
- }
- }
-}
-
-function handleWindowUp(ev) {
- handleVpadJoystickUp(ev);
- handleMenuUp(ev);
- buttons.forEach((btn) => {
- btn.dispatchEvent(new Event('touchend'));
- });
-}
-
-// touch/mouse events for control buttons. mouseup events is bound to window.
-buttons.forEach((btn) => {
- btn.addEventListener('mousedown', handleButtonDown);
- btn.addEventListener('touchstart', handleButtonDown, {passive: true});
- btn.addEventListener('touchend', handleButtonUp);
-});
-
-// touch/mouse events for dpad. mouseup events is bound to window.
-vpadHolder.addEventListener('mousedown', handleVpadJoystickDown);
-vpadHolder.addEventListener('touchstart', handleVpadJoystickDown, {passive: true});
-vpadHolder.addEventListener('touchend', handleVpadJoystickUp);
-
-dpad.forEach((arrow) => {
- arrow.addEventListener('click', handleButtonClick);
-});
-
-// touch/mouse events for player slider.
-playerSlider.addEventListener('oninput', handlePlayerSlider);
-playerSlider.addEventListener('onchange', handlePlayerSlider);
-playerSlider.addEventListener('click', handlePlayerSlider);
-playerSlider.addEventListener('touchend', handlePlayerSlider);
-playerSlider.onkeydown = (e) => {
- e.preventDefault();
-}
-
/**
* Touch controls.
*
@@ -305,27 +7,300 @@ playerSlider.onkeydown = (e) => {
* @link https://jsfiddle.net/aa0et7tr/5/
* @version 1
*/
-export const touch = {
- init: () => {
- enabled = true
- // Bind events for menu
- // TODO change this flow
- pub(MENU_HANDLER_ATTACHED, {event: 'mousedown', handler: handleMenuDown});
- pub(MENU_HANDLER_ATTACHED, {event: 'touchstart', handler: handleMenuDown});
- pub(MENU_HANDLER_ATTACHED, {event: 'touchend', handler: handleMenuUp});
+const touch = (() => {
+ const MAX_DIFF = 20; // radius of circle boundary
- sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked));
+ // vpad state, use for mouse button down
+ let vpadState = {[KEY.UP]: false, [KEY.DOWN]: false, [KEY.LEFT]: false, [KEY.RIGHT]: false};
+ let analogState = [0, 0];
- // add buttons into the state 🤦
- Array.from(document.querySelectorAll('.btn,.btn-big')).forEach((el) => {
- vpadState[getKey(el)] = false;
+ let vpadTouchIdx = null;
+ let vpadTouchDrag = null;
+ let vpadHolder = document.getElementById('circle-pad-holder');
+ let vpadCircle = document.getElementById('circle-pad');
+
+ const buttons = Array.from(document.getElementsByClassName('btn'));
+ const playerSlider = document.getElementById('playeridx');
+ const dpad = Array.from(document.getElementsByClassName('dpad'));
+
+ const dpadToggle = document.getElementById('dpad-toggle')
+ dpadToggle.addEventListener('change', (e) => {
+ event.pub(DPAD_TOGGLE, {checked: e.target.checked});
+ });
+
+ let dpadMode = true;
+ const deadZone = 0.1;
+
+ function onDpadToggle(checked) {
+ if (dpadMode === checked) {
+ return //error?
+ }
+ if (dpadMode) {
+ dpadMode = false;
+ vpadHolder.classList.add('dpad-empty');
+ vpadCircle.classList.add('bong-full');
+ // reset dpad keys pressed before moving to analog stick mode
+ resetVpadState()
+ } else {
+ dpadMode = true;
+ vpadHolder.classList.remove('dpad-empty');
+ vpadCircle.classList.remove('bong-full');
+ }
+ }
+
+ function resetVpadState() {
+ if (dpadMode) {
+ // trigger up event?
+ checkVpadState(KEY.UP, false);
+ checkVpadState(KEY.DOWN, false);
+ checkVpadState(KEY.LEFT, false);
+ checkVpadState(KEY.RIGHT, false);
+ } else {
+ checkAnalogState(0, 0);
+ checkAnalogState(1, 0);
+ }
+
+ vpadTouchDrag = null;
+ vpadTouchIdx = null;
+
+ dpad.forEach(arrow => arrow.classList.remove('pressed'));
+ }
+
+ function checkVpadState(axis, state) {
+ if (state !== vpadState[axis]) {
+ vpadState[axis] = state;
+ event.pub(state ? KEY_PRESSED : KEY_RELEASED, {key: axis});
+ }
+ }
+
+ function checkAnalogState(axis, value) {
+ if (-deadZone < value && value < deadZone) value = 0;
+ if (analogState[axis] !== value) {
+ analogState[axis] = value;
+ event.pub(AXIS_CHANGED, {id: axis, value: value});
+ }
+ }
+
+ function handleVpadJoystickDown(event) {
+ vpadCircle.style['transition'] = '0s';
+
+ if (event.changedTouches) {
+ resetVpadState();
+ vpadTouchIdx = event.changedTouches[0].identifier;
+ event.clientX = event.changedTouches[0].clientX;
+ event.clientY = event.changedTouches[0].clientY;
+ }
+
+ vpadTouchDrag = {x: event.clientX, y: event.clientY};
+ }
+
+ function handleVpadJoystickUp() {
+ if (vpadTouchDrag === null) return;
+
+ vpadCircle.style['transition'] = '.2s';
+ vpadCircle.style['transform'] = 'translate3d(0px, 0px, 0px)';
+
+ resetVpadState();
+ }
+
+ function handleVpadJoystickMove(event) {
+ if (vpadTouchDrag === null) return;
+
+ if (event.changedTouches) {
+ // check if moving source is from other touch?
+ for (let i = 0; i < event.changedTouches.length; i++) {
+ if (event.changedTouches[i].identifier === vpadTouchIdx) {
+ event.clientX = event.changedTouches[i].clientX;
+ event.clientY = event.changedTouches[i].clientY;
+ }
+ }
+ if (event.clientX === undefined || event.clientY === undefined)
+ return;
+ }
+
+ let xDiff = event.clientX - vpadTouchDrag.x;
+ let yDiff = event.clientY - vpadTouchDrag.y;
+ let angle = Math.atan2(yDiff, xDiff);
+ let distance = Math.min(MAX_DIFF, Math.hypot(xDiff, yDiff));
+ let xNew = distance * Math.cos(angle);
+ let yNew = distance * Math.sin(angle);
+
+ if (env.display().isLayoutSwitched) {
+ let tmp = xNew;
+ xNew = yNew;
+ yNew = -tmp;
+ }
+
+ vpadCircle.style['transform'] = `translate(${xNew}px, ${yNew}px)`;
+
+ let xRatio = xNew / MAX_DIFF;
+ let yRatio = yNew / MAX_DIFF;
+
+ if (dpadMode) {
+ checkVpadState(KEY.LEFT, xRatio <= -0.5);
+ checkVpadState(KEY.RIGHT, xRatio >= 0.5);
+ checkVpadState(KEY.UP, yRatio <= -0.5);
+ checkVpadState(KEY.DOWN, yRatio >= 0.5);
+ } else {
+ checkAnalogState(0, xRatio);
+ checkAnalogState(1, yRatio);
+ }
+ }
+
+ // right side - control buttons
+ const _handleButton = (key, state) => checkVpadState(key, state)
+
+ function handleButtonDown() {
+ _handleButton(this.getAttribute('value'), true);
+ }
+
+ function handleButtonUp() {
+ _handleButton(this.getAttribute('value'), false);
+ }
+
+ function handleButtonClick() {
+ _handleButton(this.getAttribute('value'), true);
+ setTimeout(() => {
+ _handleButton(this.getAttribute('value'), false);
+ }, 30);
+ }
+
+ function handlePlayerSlider() {
+ event.pub(GAME_PLAYER_IDX, {index: this.value - 1});
+ }
+
+ // Touch menu
+ let menuTouchIdx = null;
+ let menuTouchDrag = null;
+ let menuTouchTime = null;
+
+ function handleMenuDown(event) {
+ // Identify of touch point
+ if (event.changedTouches) {
+ menuTouchIdx = event.changedTouches[0].identifier;
+ event.clientX = event.changedTouches[0].clientX;
+ event.clientY = event.changedTouches[0].clientY;
+ }
+
+ menuTouchDrag = {x: event.clientX, y: event.clientY,};
+ menuTouchTime = Date.now();
+ }
+
+ function handleMenuMove(evt) {
+ if (menuTouchDrag === null) return;
+
+ if (evt.changedTouches) {
+ // check if moving source is from other touch?
+ for (let i = 0; i < evt.changedTouches.length; i++) {
+ if (evt.changedTouches[i].identifier === menuTouchIdx) {
+ evt.clientX = evt.changedTouches[i].clientX;
+ evt.clientY = evt.changedTouches[i].clientY;
+ }
+ }
+ if (evt.clientX === undefined || evt.clientY === undefined)
+ return;
+ }
+
+ const pos = env.display().isLayoutSwitched ? evt.clientX - menuTouchDrag.x : menuTouchDrag.y - evt.clientY;
+ event.pub(MENU_PRESSED, pos);
+ }
+
+ function handleMenuUp(evt) {
+ if (menuTouchDrag === null) return;
+ if (evt.changedTouches) {
+ if (evt.changedTouches[0].identifier !== menuTouchIdx)
+ return;
+ evt.clientX = evt.changedTouches[0].clientX;
+ evt.clientY = evt.changedTouches[0].clientY;
+ }
+
+ let newY = env.display().isLayoutSwitched ? -menuTouchDrag.x + evt.clientX : menuTouchDrag.y - evt.clientY;
+
+ let interval = Date.now() - menuTouchTime; // 100ms?
+ if (interval < 200) {
+ // calc velo
+ newY = newY / interval * 250;
+ }
+
+ // current item?
+ event.pub(MENU_RELEASED, newY);
+ menuTouchDrag = null;
+ }
+
+ // Common events
+ function handleWindowMove(event) {
+ event.preventDefault();
+ handleVpadJoystickMove(event);
+ handleMenuMove(event);
+
+ // moving touch
+ if (event.changedTouches) {
+ for (let i = 0; i < event.changedTouches.length; i++) {
+ if (event.changedTouches[i].identifier !== menuTouchIdx && event.changedTouches[i].identifier !== vpadTouchIdx) {
+ // check class
+
+ let elem = document.elementFromPoint(event.changedTouches[i].clientX, event.changedTouches[i].clientY);
+
+ if (elem.classList.contains('btn')) {
+ elem.dispatchEvent(new Event('touchstart'));
+ } else {
+ elem.dispatchEvent(new Event('touchend'));
+ }
+ }
+ }
+ }
+ }
+
+ function handleWindowUp(ev) {
+ handleVpadJoystickUp(ev);
+ handleMenuUp(ev);
+ buttons.forEach((btn) => {
+ btn.dispatchEvent(new Event('touchend'));
});
+ }
- window.addEventListener('pointermove', handleWindowMove);
- window.addEventListener('touchmove', handleWindowMove, {passive: false});
- window.addEventListener('mouseup', handleWindowUp);
+ // touch/mouse events for control buttons. mouseup events is binded to window.
+ buttons.forEach((btn) => {
+ btn.addEventListener('mousedown', handleButtonDown);
+ btn.addEventListener('touchstart', handleButtonDown, {passive: true});
+ btn.addEventListener('touchend', handleButtonUp);
+ });
- log.info('[input] touch input has been initialized');
- },
- toggle: (v) => v === undefined ? (enabled = !enabled) : (enabled = v)
-}
+ // touch/mouse events for dpad. mouseup events is binded to window.
+ vpadHolder.addEventListener('mousedown', handleVpadJoystickDown);
+ vpadHolder.addEventListener('touchstart', handleVpadJoystickDown, {passive: true});
+ vpadHolder.addEventListener('touchend', handleVpadJoystickUp);
+
+ dpad.forEach((arrow) => {
+ arrow.addEventListener('click', handleButtonClick);
+ });
+
+ // touch/mouse events for player slider.
+ playerSlider.addEventListener('oninput', handlePlayerSlider);
+ playerSlider.addEventListener('onchange', handlePlayerSlider);
+ playerSlider.addEventListener('mouseup', handlePlayerSlider);
+ playerSlider.addEventListener('touchend', handlePlayerSlider);
+
+ // Bind events for menu
+ // TODO change this flow
+ event.pub(MENU_HANDLER_ATTACHED, {event: 'mousedown', handler: handleMenuDown});
+ event.pub(MENU_HANDLER_ATTACHED, {event: 'touchstart', handler: handleMenuDown});
+ event.pub(MENU_HANDLER_ATTACHED, {event: 'touchend', handler: handleMenuUp});
+
+ event.sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked));
+
+ return {
+ init: () => {
+ // add buttons into the state 🤦
+ Array.from(document.querySelectorAll('.btn,.btn-big')).forEach((el) => {
+ vpadState[el.getAttribute('value')] = false;
+ });
+
+ window.addEventListener('mousemove', handleWindowMove);
+ window.addEventListener('touchmove', handleWindowMove, {passive: false});
+ window.addEventListener('mouseup', handleWindowUp);
+
+ log.info('[input] touch input has been initialized');
+ }
+ }
+})(document, event, KEY, window);
diff --git a/web/js/log.js b/web/js/log.js
index 2c316225..af138188 100644
--- a/web/js/log.js
+++ b/web/js/log.js
@@ -1,31 +1,35 @@
-const noop = () => ({})
-
-const _log = {
- ASSERT: 1,
- ERROR: 2,
- WARN: 3,
- INFO: 4,
- DEBUG: 5,
- TRACE: 6,
-
- DEFAULT: 5,
-
- set level(level) {
- this.assert = level >= this.ASSERT ? console.assert.bind(window.console) : noop;
- this.error = level >= this.ERROR ? console.error.bind(window.console) : noop;
- this.warn = level >= this.WARN ? console.warn.bind(window.console) : noop;
- this.info = level >= this.INFO ? console.info.bind(window.console) : noop;
- this.debug = level >= this.DEBUG ? console.debug.bind(window.console) : noop;
- this.trace = level >= this.TRACE ? console.log.bind(window.console) : noop;
- this._level = level;
- },
- get level() {
- return this._level;
- }
-}
-_log.level = _log.DEFAULT;
-
/**
* Logging module.
+ *
+ * @version 2
*/
-export const log = _log
+const log = (() => {
+ const noop = () => ({})
+
+ const _log = {
+ ASSERT: 1,
+ ERROR: 2,
+ WARN: 3,
+ INFO: 4,
+ DEBUG: 5,
+ TRACE: 6,
+
+ DEFAULT: 5,
+
+ set level(level) {
+ this.assert = level >= this.ASSERT ? console.assert.bind(window.console) : noop;
+ this.error = level >= this.ERROR ? console.error.bind(window.console) : noop;
+ this.warn = level >= this.WARN ? console.warn.bind(window.console) : noop;
+ this.info = level >= this.INFO ? console.info.bind(window.console) : noop;
+ this.debug = level >= this.DEBUG ? console.debug.bind(window.console) : noop;
+ this.trace = level >= this.TRACE ? console.log.bind(window.console) : noop;
+ this._level = level;
+ },
+ get level() {
+ return this._level;
+ }
+ }
+ _log.level = _log.DEFAULT;
+
+ return _log
+})(console, window);
diff --git a/web/js/menu.js b/web/js/menu.js
deleted file mode 100644
index 721b87b1..00000000
--- a/web/js/menu.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import {gui} from 'gui';
-import {
- sub,
- MENU_HANDLER_ATTACHED,
-} from 'event';
-
-const rootEl = document.getElementById('menu-screen');
-
-// touch stuff
-sub(MENU_HANDLER_ATTACHED, (data) => {
- rootEl.addEventListener(data.event, data.handler, {passive: true});
-});
-
-export const menu = {
- toggle: (show) => show === undefined ? gui.toggle(rootEl) : gui.toggle(rootEl, show),
- noFullscreen: true,
-}
diff --git a/web/js/message.js b/web/js/message.js
deleted file mode 100644
index 41e8e66e..00000000
--- a/web/js/message.js
+++ /dev/null
@@ -1,44 +0,0 @@
-import {gui} from 'gui';
-
-const popupBox = document.getElementById('noti-box');
-
-// fifo queue
-let queue = [];
-const queueMaxSize = 5;
-
-let isScreenFree = true;
-
-const _popup = (time = 1000) => {
- // recursion edge case:
- // no messages in the queue or one on the screen
- if (!(queue.length > 0 && isScreenFree)) {
- return;
- }
-
- isScreenFree = false;
- popupBox.innerText = queue.shift();
- gui.anim.fadeInOut(popupBox, time, .05).finally(() => {
- isScreenFree = true;
- _popup();
- })
-}
-
-const _storeMessage = (text) => {
- if (queue.length <= queueMaxSize) {
- queue.push(text);
- }
-}
-
-const _proceed = (text, time) => {
- _storeMessage(text);
- _popup(time);
-}
-
-const show = (text, time = 1000) => _proceed(text, time)
-
-/**
- * App UI message module.
- */
-export const message = {
- show,
-}
diff --git a/web/js/network/ajax.js b/web/js/network/ajax.js
index c2f09ccd..6f8c69c2 100644
--- a/web/js/network/ajax.js
+++ b/web/js/network/ajax.js
@@ -1,26 +1,29 @@
-const defaultTimeout = 10000;
/**
* AJAX request module.
* @version 1
*/
-export const ajax = {
- fetch: (url, options, timeout = defaultTimeout) => new Promise((resolve, reject) => {
- const controller = new AbortController();
- const signal = controller.signal;
- const allOptions = Object.assign({}, options, signal);
+const ajax = (() => {
+ const defaultTimeout = 10000;
- // fetch(url, {...options, signal})
- fetch(url, allOptions)
- .then(resolve, () => {
+ return {
+ fetch: (url, options, timeout = defaultTimeout) => new Promise((resolve, reject) => {
+ const controller = new AbortController();
+ const signal = controller.signal;
+ const allOptions = Object.assign({}, options, signal);
+
+ // fetch(url, {...options, signal})
+ fetch(url, allOptions)
+ .then(resolve, () => {
+ controller.abort();
+ return reject
+ });
+
+ // auto abort when a timeout reached
+ setTimeout(() => {
controller.abort();
- return reject
- });
-
- // auto abort when a timeout reached
- setTimeout(() => {
- controller.abort();
- reject();
- }, timeout);
- }),
- defaultTimeoutMs: () => defaultTimeout
-}
+ reject();
+ }, timeout);
+ }),
+ defaultTimeoutMs: () => defaultTimeout
+ }
+})();
\ No newline at end of file
diff --git a/web/js/network/network.js b/web/js/network/network.js
deleted file mode 100644
index ca21be6a..00000000
--- a/web/js/network/network.js
+++ /dev/null
@@ -1,3 +0,0 @@
-export {ajax} from './ajax.js?v=3';
-export {socket} from './socket.js?v=3';
-export {webrtc} from './webrtc.js?v=3';
diff --git a/web/js/network/socket.js b/web/js/network/socket.js
index e153f441..06351246 100644
--- a/web/js/network/socket.js
+++ b/web/js/network/socket.js
@@ -1,51 +1,53 @@
-import {
- pub,
- MESSAGE
-} from 'event';
-import {log} from 'log';
-
-let conn;
-
-const buildUrl = (params = {}) => {
- const url = new URL(window.location);
- url.protocol = location.protocol !== 'https:' ? 'ws' : 'wss';
- url.pathname = "/ws";
- Object.keys(params).forEach(k => {
- if (!!params[k]) url.searchParams.set(k, params[k])
- })
- return url
-}
-
-const init = (roomId, wid, zone) => {
- let objParams = {room_id: roomId, zone: zone};
- if (wid) objParams.wid = wid;
- const url = buildUrl(objParams)
- log.info(`[ws] connecting to ${url}`);
- conn = new WebSocket(url.toString());
- conn.onopen = () => {
- log.info('[ws] <- open connection');
- };
- conn.onerror = () => log.error('[ws] some error!');
- conn.onclose = (event) => log.info(`[ws] closed (${event.code})`);
- conn.onmessage = response => {
- const data = JSON.parse(response.data);
- log.debug('[ws] <- ', data);
- pub(MESSAGE, data);
- };
-};
-
-const send = (data) => {
- if (conn.readyState === 1) {
- conn.send(JSON.stringify(data));
- }
-}
-
/**
* WebSocket connection module.
*
* Needs init() call.
+ *
+ * @version 1
+ *
+ * Events:
+ * @link MESSAGE
+ *
*/
-export const socket = {
- init,
- send
-}
+const socket = (() => {
+ let conn;
+
+ const buildUrl = (params = {}) => {
+ const url = new URL(window.location);
+ url.protocol = location.protocol !== 'https:' ? 'ws' : 'wss';
+ url.pathname = "/ws";
+ Object.keys(params).forEach(k => {
+ if (!!params[k]) url.searchParams.set(k, params[k])
+ })
+ return url
+ }
+
+ const init = (roomId, wid, zone) => {
+ let objParams = {room_id: roomId, zone: zone};
+ if (wid) objParams.wid = wid;
+ const url = buildUrl(objParams)
+ console.info(`[ws] connecting to ${url}`);
+ conn = new WebSocket(url.toString());
+ conn.onopen = () => {
+ log.info('[ws] <- open connection');
+ };
+ conn.onerror = () => log.error('[ws] some error!');
+ conn.onclose = (event) => log.info(`[ws] closed (${event.code})`);
+ conn.onmessage = response => {
+ const data = JSON.parse(response.data);
+ log.debug('[ws] <- ', data);
+ event.pub(MESSAGE, data);
+ };
+ };
+
+ const send = (data) => {
+ if (conn.readyState === 1) {
+ conn.send(JSON.stringify(data));
+ }
+ }
+
+ return {
+ init: init,
+ send: send,
+ }
+})(event, log);
diff --git a/web/js/network/webrtc.js b/web/js/network/webrtc.js
index 3bc5ff76..218e9e75 100644
--- a/web/js/network/webrtc.js
+++ b/web/js/network/webrtc.js
@@ -1,201 +1,180 @@
-import {
- pub,
- WEBRTC_CONNECTION_CLOSED,
- WEBRTC_CONNECTION_READY,
- WEBRTC_ICE_CANDIDATE_FOUND,
- WEBRTC_ICE_CANDIDATES_FLUSH,
- WEBRTC_SDP_ANSWER
-} from 'event';
-import {log} from 'log';
-
-let connection;
-let dataChannel
-let keyboardChannel
-let mouseChannel
-let mediaStream;
-let candidates = [];
-let isAnswered = false;
-let isFlushing = false;
-
-let connected = false;
-let inputReady = false;
-
-let onData;
-
-const start = (iceservers) => {
- log.info('[rtc] <- ICE servers', iceservers);
- const servers = iceservers || [];
- connection = new RTCPeerConnection({iceServers: servers});
- mediaStream = new MediaStream();
-
- connection.ondatachannel = e => {
- log.debug('[rtc] ondatachannel', e.channel.label)
- e.channel.binaryType = "arraybuffer";
-
- if (e.channel.label === 'keyboard') {
- keyboardChannel = e.channel
- return
- }
-
- if (e.channel.label === 'mouse') {
- mouseChannel = e.channel
- return
- }
-
- dataChannel = e.channel;
- dataChannel.onopen = () => {
- log.info('[rtc] the input channel has been opened');
- inputReady = true;
- pub(WEBRTC_CONNECTION_READY)
- };
- if (onData) {
- dataChannel.onmessage = onData;
- }
- dataChannel.onclose = () => {
- inputReady = false
- log.info('[rtc] the input channel has been closed')
- }
- }
- connection.oniceconnectionstatechange = ice.onIceConnectionStateChange;
- connection.onicegatheringstatechange = ice.onIceStateChange;
- connection.onicecandidate = ice.onIcecandidate;
- connection.ontrack = event => {
- mediaStream.addTrack(event.track);
- }
-};
-
-const stop = () => {
- if (mediaStream) {
- mediaStream.getTracks().forEach(t => {
- t.stop();
- mediaStream.removeTrack(t);
- });
- mediaStream = null;
- }
- if (connection) {
- connection.close();
- connection = null;
- }
- if (dataChannel) {
- dataChannel.close()
- dataChannel = null
- }
- if (keyboardChannel) {
- keyboardChannel?.close()
- keyboardChannel = null
- }
- if (mouseChannel) {
- mouseChannel?.close()
- mouseChannel = null
- }
- candidates = [];
- log.info('[rtc] WebRTC has been closed');
-}
-
-const ice = (() => {
- const ICE_TIMEOUT = 2000;
- let timeForIceGathering;
-
- return {
- onIcecandidate: data => {
- if (!data.candidate) return;
- log.info('[rtc] user candidate', data.candidate);
- pub(WEBRTC_ICE_CANDIDATE_FOUND, {candidate: data.candidate})
- },
- onIceStateChange: event => {
- switch (event.target.iceGatheringState) {
- case 'gathering':
- log.info('[rtc] ice gathering');
- timeForIceGathering = setTimeout(() => {
- log.warn(`[rtc] ice gathering was aborted due to timeout ${ICE_TIMEOUT}ms`);
- // sendCandidates();
- }, ICE_TIMEOUT);
- break;
- case 'complete':
- log.info('[rtc] ice gathering has been completed');
- if (timeForIceGathering) {
- clearTimeout(timeForIceGathering);
- }
- }
- },
- onIceConnectionStateChange: () => {
- log.info('[rtc] <- iceConnectionState', connection.iceConnectionState);
- switch (connection.iceConnectionState) {
- case 'connected':
- log.info('[rtc] connected...');
- connected = true;
- break;
- case 'disconnected':
- log.info(`[rtc] disconnected... ` +
- `connection: ${connection.connectionState}, ice: ${connection.iceConnectionState}, ` +
- `gathering: ${connection.iceGatheringState}, signalling: ${connection.signalingState}`)
- connected = false;
- pub(WEBRTC_CONNECTION_CLOSED);
- break;
- case 'failed':
- log.error('[rtc] failed establish connection, retry...');
- connected = false;
- connection.createOffer({iceRestart: true})
- .then(description => connection.setLocalDescription(description).catch(log.error))
- .catch(log.error);
- break;
- }
- }
- }
-})();
-
/**
* WebRTC connection module.
+ * @version 1
+ *
+ * Events:
+ * @link WEBRTC_CONNECTION_CLOSED
+ * @link WEBRTC_CONNECTION_READY
+ * @link WEBRTC_ICE_CANDIDATE_FOUND
+ * @link WEBRTC_ICE_CANDIDATES_FLUSH
+ * @link WEBRTC_SDP_ANSWER
+ *
*/
-export const webrtc = {
- start,
- setRemoteDescription: async (data, media) => {
- log.debug('[rtc] remote SDP', data)
- const offer = new RTCSessionDescription(JSON.parse(atob(data)));
- await connection.setRemoteDescription(offer);
+const webrtc = (() => {
+ let connection;
+ let inputChannel;
+ let mediaStream;
+ let candidates = Array();
+ let isAnswered = false;
+ let isFlushing = false;
- const answer = await connection.createAnswer();
- // Chrome bug https://bugs.chromium.org/p/chromium/issues/detail?id=818180 workaround
- // force stereo params for Opus tracks (a=fmtp:111 ...)
- answer.sdp = answer.sdp.replace(/(a=fmtp:111 .*)/g, '$1;stereo=1');
- await connection.setLocalDescription(answer);
- log.debug("[rtc] local SDP", answer)
+ let connected = false;
+ let inputReady = false;
- isAnswered = true;
- pub(WEBRTC_ICE_CANDIDATES_FLUSH);
- pub(WEBRTC_SDP_ANSWER, {sdp: answer});
- media.srcObject = mediaStream;
- },
- addCandidate: (data) => {
- if (data === '') {
- pub(WEBRTC_ICE_CANDIDATES_FLUSH);
- } else {
- candidates.push(data);
+ let onMessage;
+
+ const start = (iceservers) => {
+ log.info('[rtc] <- ICE servers', iceservers);
+ const servers = iceservers || [];
+ connection = new RTCPeerConnection({iceServers: servers});
+ mediaStream = new MediaStream();
+
+ connection.ondatachannel = e => {
+ log.debug('[rtc] ondatachannel', e.channel.label)
+ inputChannel = e.channel;
+ inputChannel.onopen = () => {
+ log.info('[rtc] the input channel has been opened');
+ inputReady = true;
+ event.pub(WEBRTC_CONNECTION_READY)
+ };
+ if (onMessage) {
+ inputChannel.onmessage = onMessage;
+ }
+ inputChannel.onclose = () => log.info('[rtc] the input channel has been closed');
}
- },
- flushCandidates: () => {
- if (isFlushing || !isAnswered) return;
- isFlushing = true;
- log.debug('[rtc] flushing candidates', candidates);
- candidates.forEach(data => {
- const candidate = new RTCIceCandidate(JSON.parse(atob(data)))
- connection.addIceCandidate(candidate).catch(e => {
- log.error('[rtc] candidate add failed', e.name);
+ connection.oniceconnectionstatechange = ice.onIceConnectionStateChange;
+ connection.onicegatheringstatechange = ice.onIceStateChange;
+ connection.onicecandidate = ice.onIcecandidate;
+ connection.ontrack = event => {
+ mediaStream.addTrack(event.track);
+ }
+ };
+
+ const stop = () => {
+ if (mediaStream) {
+ mediaStream.getTracks().forEach(t => {
+ t.stop();
+ mediaStream.removeTrack(t);
});
- });
- isFlushing = false;
- },
- keyboard: (data) => keyboardChannel?.send(data),
- mouse: (data) => mouseChannel?.send(data),
- input: (data) => inputReady && dataChannel.send(data),
- isConnected: () => connected,
- isInputReady: () => inputReady,
- stats: async () => {
- if (!connected) return Promise.resolve();
- return await connection.getStats()
- },
- stop,
- set onData(fn) {
- onData = fn
+ mediaStream = null;
+ }
+ if (connection) {
+ connection.close();
+ connection = null;
+ }
+ if (inputChannel) {
+ inputChannel.close();
+ inputChannel = null;
+ }
+ candidates = Array();
+ log.info('[rtc] WebRTC has been closed');
}
-}
+
+ const ice = (() => {
+ const ICE_TIMEOUT = 2000;
+ let timeForIceGathering;
+
+ return {
+ onIcecandidate: data => {
+ if (!data.candidate) return;
+ log.info('[rtc] user candidate', data.candidate);
+ event.pub(WEBRTC_ICE_CANDIDATE_FOUND, {candidate: data.candidate})
+ },
+ onIceStateChange: event => {
+ switch (event.target.iceGatheringState) {
+ case 'gathering':
+ log.info('[rtc] ice gathering');
+ timeForIceGathering = setTimeout(() => {
+ log.warning(`[rtc] ice gathering was aborted due to timeout ${ICE_TIMEOUT}ms`);
+ // sendCandidates();
+ }, ICE_TIMEOUT);
+ break;
+ case 'complete':
+ log.info('[rtc] ice gathering has been completed');
+ if (timeForIceGathering) {
+ clearTimeout(timeForIceGathering);
+ }
+ }
+ },
+ onIceConnectionStateChange: () => {
+ log.info('[rtc] <- iceConnectionState', connection.iceConnectionState);
+ switch (connection.iceConnectionState) {
+ case 'connected': {
+ log.info('[rtc] connected...');
+ connected = true;
+ break;
+ }
+ case 'disconnected': {
+ log.info('[rtc] disconnected...');
+ connected = false;
+ event.pub(WEBRTC_CONNECTION_CLOSED);
+ break;
+ }
+ case 'failed': {
+ log.error('[rtc] failed establish connection, retry...');
+ connected = false;
+ connection.createOffer({iceRestart: true})
+ .then(description => connection.setLocalDescription(description).catch(log.error))
+ .catch(log.error);
+ break;
+ }
+ }
+ }
+ }
+ })();
+
+ return {
+ start: start,
+ setRemoteDescription: async (data, media) => {
+ log.debug('[rtc] remote SDP', data)
+ const offer = new RTCSessionDescription(JSON.parse(atob(data)));
+ await connection.setRemoteDescription(offer);
+
+ const answer = await connection.createAnswer();
+ // Chrome bug https://bugs.chromium.org/p/chromium/issues/detail?id=818180 workaround
+ // force stereo params for Opus tracks (a=fmtp:111 ...)
+ answer.sdp = answer.sdp.replace(/(a=fmtp:111 .*)/g, '$1;stereo=1');
+ await connection.setLocalDescription(answer);
+ log.debug("[rtc] local SDP", answer)
+
+ isAnswered = true;
+ event.pub(WEBRTC_ICE_CANDIDATES_FLUSH);
+ event.pub(WEBRTC_SDP_ANSWER, {sdp: answer});
+ media.srcObject = mediaStream;
+ },
+ // setMessageHandler: (handler) => onMessage = handler,
+ addCandidate: (data) => {
+ if (data === '') {
+ event.pub(WEBRTC_ICE_CANDIDATES_FLUSH);
+ } else {
+ candidates.push(data);
+ }
+ },
+ flushCandidates: () => {
+ if (isFlushing || !isAnswered) return;
+ isFlushing = true;
+ log.debug('[rtc] flushing candidates', candidates);
+ candidates.forEach(data => {
+ const candidate = new RTCIceCandidate(JSON.parse(atob(data)))
+ connection.addIceCandidate(candidate).catch(e => {
+ log.error('[rtc] candidate add failed', e.name);
+ });
+ });
+ isFlushing = false;
+ },
+ // message: (mess = '') => {
+ // try {
+ // inputChannel.send(mess)
+ // return true
+ // } catch (error) {
+ // log.error('[rtc] input channel broken ' + error)
+ // return false
+ // }
+ // },
+ input: (data) => inputChannel.send(data),
+ isConnected: () => connected,
+ isInputReady: () => inputReady,
+ getConnection: () => connection,
+ stop,
+ }
+})(event, log);
diff --git a/web/js/recording.js b/web/js/recording.js
index 70f18ad0..b78cc01e 100644
--- a/web/js/recording.js
+++ b/web/js/recording.js
@@ -1,66 +1,64 @@
-import {
- pub,
- KEYBOARD_TOGGLE_FILTER_MODE,
- RECORDING_TOGGLED
-} from 'event';
-import {throttle} from 'utils';
+const RECORDING_ON = 1;
+const RECORDING_OFF = 0;
+const RECORDING_REC = 2;
-export const RECORDING_ON = 1;
-export const RECORDING_OFF = 0;
-export const RECORDING_REC = 2;
+/**
+ * Recording module.
+ * @version 1
+ */
+const recording = (() => {
+ const userName = document.getElementById('user-name'),
+ recButton = document.getElementById('btn-rec');
-const userName = document.getElementById('user-name'),
- recButton = document.getElementById('btn-rec');
-
-let state = {
- userName: '',
- state: RECORDING_OFF,
-};
-
-const restoreLastState = () => {
- const lastState = localStorage.getItem('recording');
- if (lastState) {
- const _last = JSON.parse(lastState);
- if (_last) {
- state = _last;
+ if (!userName || !recButton) {
+ return {
+ isActive: () => false,
+ getUser: () => '',
}
}
- userName.value = state.userName
-}
-const setRec = (val) => {
- recButton.classList.toggle('record', val);
-}
-const setIndicator = (val) => {
- recButton.classList.toggle('blink', val);
-};
+ let state = {
+ userName: '',
+ state: RECORDING_OFF,
+ };
-// persistence
-const saveLastState = () => {
- const _state = Object.keys(state)
- .filter(key => !key.startsWith('_'))
- .reduce((obj, key) => ({...obj, [key]: state[key]}), {});
- localStorage.setItem('recording', JSON.stringify(_state));
-}
-const saveUserName = throttle(() => {
- state.userName = userName.value;
- saveLastState();
-}, 500)
+ const restoreLastState = () => {
+ const lastState = localStorage.getItem('recording');
+ if (lastState) {
+ const _last = JSON.parse(lastState);
+ if (_last) {
+ state = _last;
+ }
+ }
+ userName.value = state.userName
+ }
-let _recording = {
- isActive: () => false,
- getUser: () => '',
- setIndicator: () => ({}),
-}
+ const setRec = (val) => {
+ recButton.classList.toggle('record', val);
+ }
+ const setIndicator = (val) => {
+ recButton.classList.toggle('blink', val);
+ };
+
+ // persistence
+ const saveLastState = () => {
+ const _state = Object.keys(state)
+ .filter(key => !key.startsWith('_'))
+ .reduce((obj, key) => ({...obj, [key]: state[key]}), {});
+ localStorage.setItem('recording', JSON.stringify(_state));
+ }
+ const saveUserName = utils.throttle(() => {
+ state.userName = userName.value;
+ saveLastState();
+ }, 500)
-if (userName && recButton) {
restoreLastState();
setIndicator(false);
setRec(state.state === RECORDING_ON)
// text
- userName.addEventListener('focus', () => pub(KEYBOARD_TOGGLE_FILTER_MODE))
- userName.addEventListener('blur', () => pub(KEYBOARD_TOGGLE_FILTER_MODE, {mode: true}))
+ userName.addEventListener('focus', () => event.pub(KEYBOARD_TOGGLE_FILTER_MODE))
+ userName.addEventListener('blur', () => event.pub(KEYBOARD_TOGGLE_FILTER_MODE, {mode: true}))
userName.addEventListener('keyup', ev => {
ev.stopPropagation();
saveUserName()
@@ -72,17 +70,11 @@ if (userName && recButton) {
const active = state.state === RECORDING_ON
setRec(active)
saveLastState()
- pub(RECORDING_TOGGLED, {userName: state.userName, recording: active})
+ event.pub(RECORDING_TOGGLED, {userName: state.userName, recording: active})
})
-
- _recording = {
+ return {
isActive: () => state.state > 0,
getUser: () => state.userName,
- setIndicator,
+ setIndicator: setIndicator,
}
-}
-
-/**
- * Recording module.
- */
-export const recording = _recording
+})(document, event, localStorage, utils);
diff --git a/web/js/room.js b/web/js/room.js
index 1321fc10..20a53a73 100644
--- a/web/js/room.js
+++ b/web/js/room.js
@@ -1,81 +1,76 @@
-import {
- sub,
- GAME_ROOM_AVAILABLE
-} from 'event';
-
-let id = '';
-
-// UI
-const roomLabel = document.getElementById('room-txt');
-
-// !to rewrite
-const parseURLForRoom = () => {
- let queryDict = {};
- let regex = /^\/?([A-Za-z]*)\/?/g;
- const zone = regex.exec(location.pathname)[1];
- let room = null;
-
- // get room from URL
- location.search.substr(1)
- .split('&')
- .forEach((item) => {
- queryDict[item.split('=')[0]] = item.split('=')[1]
- });
-
- if (typeof queryDict.id === 'string') {
- room = decodeURIComponent(queryDict.id);
- }
-
- return [room, zone];
-};
-
-sub(GAME_ROOM_AVAILABLE, data => {
- room.id = data.roomId
- room.save(data.roomId);
-}, 1);
-
/**
* Game room module.
+ * @version 1
*/
-export const room = {
- get id() {
- return id
- },
- set id(id_) {
- id = id_;
- roomLabel.value = id;
- },
- reset: () => {
- id = '';
- roomLabel.value = id;
- },
- save: (roomIndex) => {
- localStorage.setItem('roomID', roomIndex);
- },
- load: () => localStorage.getItem('roomID'),
- getLink: () => window.location.href.split('?')[0] + `?id=${encodeURIComponent(room.id)}`,
- loadMaybe: () => {
- // localStorage first
- //roomID = loadRoomID();
- let zone = '';
+const room = (() => {
+ let id = '';
- // Shared URL second
- const [parsedId, czone] = parseURLForRoom();
- if (parsedId !== null) {
- id = parsedId;
- }
- if (czone !== null) {
- zone = czone;
+ // UI
+ const roomLabel = document.getElementById('room-txt');
+
+ // !to rewrite
+ const parseURLForRoom = () => {
+ let queryDict = {};
+ let regex = /^\/?([A-Za-z]*)\/?/g;
+ const zone = regex.exec(location.pathname)[1];
+ let room = null;
+
+ // get room from URL
+ location.search.substr(1)
+ .split('&')
+ .forEach((item) => {
+ queryDict[item.split('=')[0]] = item.split('=')[1]
+ });
+
+ if (typeof queryDict.id === 'string') {
+ room = decodeURIComponent(queryDict.id);
}
- return [id, zone];
- },
- copyToClipboard: () => {
- const el = document.createElement('textarea');
- el.value = room.getLink();
- document.body.appendChild(el);
- el.select();
- document.execCommand('copy');
- document.body.removeChild(el);
+ return [room, zone];
+ };
+
+ event.sub(GAME_ROOM_AVAILABLE, data => {
+ room.setId(data.roomId);
+ room.save(data.roomId);
+ }, 1);
+
+ return {
+ getId: () => id,
+ setId: (id_) => {
+ id = id_;
+ roomLabel.value = id;
+ },
+ reset: () => {
+ id = '';
+ roomLabel.value = id;
+ },
+ save: (roomIndex) => {
+ localStorage.setItem('roomID', roomIndex);
+ },
+ load: () => localStorage.getItem('roomID'),
+ getLink: () => window.location.href.split('?')[0] + `?id=${encodeURIComponent(room.getId())}`,
+ loadMaybe: () => {
+ // localStorage first
+ //roomID = loadRoomID();
+
+ // Shared URL second
+ const [parsedId, czone] = parseURLForRoom();
+ if (parsedId !== null) {
+ id = parsedId;
+ }
+ if (czone !== null) {
+ zone = czone;
+ }
+
+ return [id, zone];
+ },
+ copyToClipboard: () => {
+ const el = document.createElement('textarea');
+ el.value = room.getLink();
+ document.body.appendChild(el);
+ el.select();
+ document.execCommand('copy');
+ document.body.removeChild(el);
+ }
}
-}
+})(document, event, location, localStorage, window);
diff --git a/web/js/screen.js b/web/js/screen.js
deleted file mode 100644
index b4342e3c..00000000
--- a/web/js/screen.js
+++ /dev/null
@@ -1,88 +0,0 @@
-import {
- sub,
- SETTINGS_CHANGED,
- REFRESH_INPUT,
-} from 'event';
-import {env} from 'env';
-import {input, pointer, keyboard} from 'input';
-import {opts, settings} from 'settings';
-import {gui} from 'gui';
-
-const rootEl = document.getElementById('screen')
-const footerEl = document.getElementsByClassName('screen__footer')[0]
-
-const state = {
- components: [],
- current: undefined,
- forceFullscreen: false,
-}
-
-const toggle = async (component, force) => {
- component && (state.current = component) // keep the last component
- state.components.forEach(c => c.toggle(false))
- state.current?.toggle(force)
- state.forceFullscreen && fullscreen(true)
-}
-
-const init = () => {
- state.forceFullscreen = settings.loadOr(opts.FORCE_FULLSCREEN, false)
- sub(SETTINGS_CHANGED, () => {
- state.forceFullscreen = settings.get()[opts.FORCE_FULLSCREEN]
- })
-}
-
-const cursor = pointer.autoHide(rootEl, 2000)
-
-const trackPointer = pointer.track(rootEl, () => {
- const display = state.current;
- return {...display.video.size, s: !!display?.hasDisplay}
-})
-
-const fullscreen = () => {
- if (state.current?.noFullscreen) return
-
- let h = parseFloat(getComputedStyle(rootEl, null).height.replace('px', ''))
- env.display().toggleFullscreen(h !== window.innerHeight, rootEl)
-}
-
-const controls = async (locked = false) => {
- if (!state.current?.hasDisplay) return
- if (env.isMobileDevice) return
- if (!input.kbm) return
-
- if (locked) {
- await pointer.lock(rootEl)
- }
-
- // oof, remove hover:hover when the pointer is forcibly locked,
- // leaving the element in the hovered state
- locked ? footerEl.classList.remove('hover') : footerEl.classList.add('hover')
-
- trackPointer(locked)
- await keyboard.lock(locked)
- input.retropad.toggle(!locked)
-}
-
-rootEl.addEventListener('fullscreenchange', async () => {
- const fs = document.fullscreenElement !== null
-
- cursor.autoHide(!fs)
- gui.toggle(footerEl, fs)
- await controls(fs)
- state.current?.onFullscreen?.(fs)
-})
-
-sub(REFRESH_INPUT, async () => {
- await controls(document.fullscreenElement !== null)
-})
-
-export const screen = {
- fullscreen,
- toggle,
- /**
- * Adds a component. It should have toggle(bool) method and
- * an optional noFullscreen (bool) property.
- */
- add: (...o) => state.components.push(...o),
- init,
-}
diff --git a/web/js/settings.js b/web/js/settings.js
deleted file mode 100644
index 7dc30b06..00000000
--- a/web/js/settings.js
+++ /dev/null
@@ -1,547 +0,0 @@
-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',
-}
-
-
-// internal structure version
-const revision = 1.6;
-
-// 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!`);
- }
-
- _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]
- 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;
- 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)
- }
-}
diff --git a/web/js/settings/opts.js b/web/js/settings/opts.js
new file mode 100644
index 00000000..bff7a098
--- /dev/null
+++ b/web/js/settings/opts.js
@@ -0,0 +1,14 @@
+/**
+ * Stores app wide option names.
+ *
+ * Use the following format:
+ * UPPERCASE_NAME: 'uppercase.name'
+ *
+ * @version 1
+ */
+const opts = Object.freeze({
+ _VERSION: '_version',
+ LOG_LEVEL: 'log.level',
+ INPUT_KEYBOARD_MAP: 'input.keyboard.map',
+ MIRROR_SCREEN: 'mirror.screen'
+});
diff --git a/web/js/settings/settings.js b/web/js/settings/settings.js
new file mode 100644
index 00000000..e3be8e81
--- /dev/null
+++ b/web/js/settings/settings.js
@@ -0,0 +1,475 @@
+/**
+ * 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.
+ *
+ * Uses ES8.
+ *
+ * @version 1
+ */
+const settings = (() => {
+ // internal structure version
+ const revision = 1.1;
+
+ // 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`;
+
+ // ui references
+ const ui = document.getElementById('app-settings'),
+ closeEl = document.getElementById('settings__controls__close'),
+ loadEl = document.getElementById('settings__controls__load'),
+ saveEl = document.getElementById('settings__controls__save'),
+ resetEl = document.getElementById('settings__controls__reset');
+
+ this._renderrer = this._renderrer || {
+ render: () => {
+ }
+ };
+
+ 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 = (key, value) => 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,
+ 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();
+ event.pub(SETTINGS_CHANGED);
+ } catch (e) {
+ log.error(`Your import file is broken!`);
+ }
+
+ _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);
+ el = undefined;
+ }
+
+ const init = () => {
+ provider = localStorageProvider(store) || voidProvider(store);
+ provider.loadSettings();
+
+ if (revision > store.settings._version) {
+ // !to handle this with migrations
+ log.warn(`Your settings are in older format (v${store.settings._version})`);
+ }
+ }
+
+ 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);
+ event.pub(SETTINGS_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();
+ event.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 = () => settings._renderrer.render()
+
+ /**
+ * Settings modal window toggle handler.
+ * @returns {boolean} True in case if it's opened.
+ */
+ const toggle = () => ui.classList.toggle('modal-visible') && !_render();
+
+ 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;
+ }
+
+ /**
+ * File reader submodule (FileReader API).
+ *
+ * @type {{read: read}} Tries to read a file.
+ * @private
+ */
+ 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);
+ }
+ }
+
+ event.sub(SETTINGS_CHANGED, _render);
+
+ // internal init section
+ closeEl.addEventListener('click', () => {
+ event.pub(SETTINGS_CLOSED);
+ // to make sure it's disabled, but it's a tad verbose
+ event.pub(KEYBOARD_TOGGLE_FILTER_MODE, {mode: true});
+ });
+ saveEl.addEventListener('click', () => _export());
+ loadEl.addEventListener('click', () => _fileReader.read(onFileLoad));
+ resetEl.addEventListener('click', () => {
+ if (window.confirm("Are you sure want to reset your settings?")) {
+ _reset();
+ event.pub(SETTINGS_CHANGED);
+ }
+ });
+
+ return {
+ init,
+ loadOr,
+ getStore,
+ get,
+ set,
+ remove,
+ import: _import,
+ export: _export,
+ ui: {
+ toggle,
+ }
+ }
+})(document, event, JSON, localStorage, log, window);
+
+// hardcoded ui stuff
+settings._renderrer = (() => {
+ // options to ignore (i.e. ignored = {'_version': 1})
+ const ignored = {};
+
+ // the main display data holder element
+ const data = document.getElementById('settings-data');
+
+ // 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 nameEl = document.createElement('div');
+ nameEl.classList.add('settings__option-name');
+ wrapperEl.append(nameEl);
+
+ const valueEl = document.createElement('div');
+ valueEl.classList.add('settings__option-value');
+ wrapperEl.append(valueEl);
+
+ return {
+ withName: function (name = '') {
+ nameEl.textContent = name;
+ return this;
+ },
+ withClass: function (name = '') {
+ wrapperEl.classList.add(name);
+ return this;
+ },
+ readOnly: function () {
+ // reserved
+ },
+ 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});
+ }
+ }
+
+ // !to check leaks
+ if (handler) {
+ handler.unsub();
+ handler = undefined;
+ }
+
+ event.pub(KEYBOARD_TOGGLE_FILTER_MODE);
+ event.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 = event.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.
+ * @param oldValue An old value to use somehow if needed.
+ */
+ const onChange = (key, newValue, oldValue) => settings.set(key, newValue);
+
+ const onKeyBindingChange = (key, oldValue) => {
+ clearData();
+ data.append(_keyChangeOverlay(key, oldValue));
+ event.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('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')
+ .add(Object.keys(value).map(k => gui.binding(value[k], k, onKeyBindingChange)))
+ .build();
+ break;
+ case opts.MIRROR_SCREEN:
+ _option(data).withName('Video mirroring without smooth')
+ .add(gui.select(k, onChange, {values: ['mirror']}, value))
+ .build();
+ break;
+ default:
+ _option(data).withName(k).add(value).build();
+ }
+ }
+ }
+
+ return {
+ render,
+ }
+})(document, log, opts, settings);
diff --git a/web/js/stats.js b/web/js/stats.js
deleted file mode 100644
index d8d28974..00000000
--- a/web/js/stats.js
+++ /dev/null
@@ -1,242 +0,0 @@
-import {
- sub,
- HELP_OVERLAY_TOGGLED
-} from 'event';
-
-const _modules = [];
-let tempHide = false;
-
-// internal rendering stuff
-const fps = 30;
-let time = 0;
-let active = false;
-
-// !to add connection drop notice
-
-const statsOverlayEl = document.getElementById('stats-overlay');
-
-/**
- * The graph element.
- */
-const graph = (parent, opts = {
- historySize: 60,
- width: 60 * 2 + 2,
- height: 20,
- pad: 4,
- scale: 1,
- style: {
- barColor: '#9bd914',
- barFallColor: '#c12604'
- }
-}) => {
- const _canvas = document.createElement('canvas');
- const _context = _canvas.getContext('2d');
-
- let data = [];
-
- _canvas.setAttribute('class', 'graph');
-
- _canvas.width = opts.width * opts.scale;
- _canvas.height = opts.height * opts.scale;
-
- _context.scale(opts.scale, opts.scale);
- _context.imageSmoothingEnabled = false;
- _context.fillStyle = opts.fillStyle;
-
- if (parent) parent.append(_canvas);
-
- // bar size
- const barWidth = Math.round(_canvas.width / opts.scale / opts.historySize);
- const barHeight = Math.round(_canvas.height / opts.scale);
-
- let maxN = 0,
- minN = 0;
-
- const max = () => maxN
-
- const get = () => _canvas
-
- const add = (value) => {
- if (data.length > opts.historySize) data.shift();
- data.push(value);
- render();
- }
-
- /**
- * Draws a bar graph on the canvas.
- *
- * @example
- * +-------+ +-------+ +---------+
- * | | |+---+ | |+---+ |
- * | | |||||| | ||||||+---+
- * | | |||||| | |||||||||||
- * +-------+ +----+--+ +---------+
- * [] [3] [3, 2]
- */
- const render = () => {
- _context.clearRect(0, 0, _canvas.width, _canvas.height);
-
- maxN = data[0] || 1;
- minN = 0;
- for (let k = 1; k < data.length; k++) {
- if (data[k] > maxN) maxN = data[k];
- if (data[k] < minN) minN = data[k];
- }
-
- for (let j = 0; j < data.length; j++) {
- let x = j * barWidth,
- y = (barHeight - opts.pad * 2) * (data[j] - minN) / (maxN - minN) + opts.pad;
-
- const color = j > 0 && data[j] > data[j - 1] ? opts.style.barFallColor : opts.style.barColor;
-
- drawRect(x, barHeight - Math.round(y), barWidth, barHeight, color);
- }
- }
-
- const drawRect = (x, y, w, h, color = opts.style.barColor) => {
- _context.fillStyle = color;
- _context.fillRect(x, y, w, h);
- }
-
- const clear = () => {
- data = [];
- render();
- }
-
- return {add, get, max, render, clear}
-}
-
-/**
- * Get cached module UI.
- *
- * HTML:
- * `
`
- *
- * @param label The name of the stat to show.
- * @param nan A value to show when zero.
- * @param withGraph True if to draw a graph.
- * @param postfix Supposed to be the name of the stat passed as a function.
- * @param cl Class of the UI div element.
- * @returns {{el: HTMLDivElement, update: function}}
- */
-const moduleUi = (label = '', nan = '', withGraph = false, postfix = () => 'ms', cl = '') => {
- const ui = document.createElement('div'),
- _label = document.createElement('div'),
- _value = document.createElement('span');
- ui.append(_label, _value);
-
- cl && ui.classList.add(cl)
-
- let postfix_ = postfix;
-
- let _graph;
- if (withGraph) {
- const _container = document.createElement('span');
- ui.append(_container);
- _graph = graph(_container);
- }
-
- _label.innerHTML = label;
-
- const withPostfix = (value) => (postfix_ = value);
-
- const update = (value) => {
- if (_graph) _graph.add(value);
- // 203 (333) ms
- _value.textContent = `${value < 1 ? nan : value}${_graph ? `(${_graph.max()}) ` : ''}${postfix_(value)}`;
- }
-
- const clear = () => {
- _graph && _graph.clear();
- }
-
- return {el: ui, update, withPostfix, clear}
-}
-
-const modules = (fn, force = true) => _modules.forEach(m => (force || m.get) && fn(m))
-
-const module = (mod) => {
- mod = {
- val: 0,
- enable: () => ({}),
- ...mod,
- _disable: function () {
- // mod.val = 0;
- mod.disable && mod.disable();
- mod.mui && mod.mui.clear();
- },
- ...(mod.mui && {
- get: () => mod.mui.el,
- render: () => mod.mui.update(mod.val)
- })
- }
- mod.init?.();
- _modules.push(mod);
- modules(m => m.get && statsOverlayEl.append(m.get()), false);
-}
-
-const enable = () => {
- active = true;
- modules(m => m.enable())
- render();
- draw();
- _show();
-};
-
-function draw(timestamp) {
- if (!active) return;
-
- const time_ = time + 1000 / fps;
-
- if (timestamp > time_) {
- time = timestamp;
- render();
- }
-
- requestAnimationFrame(draw);
-}
-
-const disable = () => {
- active = false;
- modules(m => m._disable());
- _hide();
-}
-
-const _show = () => (statsOverlayEl.style.visibility = 'visible');
-const _hide = () => (statsOverlayEl.style.visibility = 'hidden');
-
-/**
- * Handles help overlay toggle event.
- * Workaround for a not normal app layout layering.
- *
- * !to remove when app layering is fixed
- *
- * @param {Object} overlay Overlay data.
- * @param {boolean} overlay.shown A flag if the overlay is being currently showed.
- */
-const onHelpOverlayToggle = (overlay) => {
- if (statsOverlayEl.style.visibility === 'visible' && overlay.shown && !tempHide) {
- _hide();
- tempHide = true;
- } else {
- if (tempHide) {
- _show();
- tempHide = false;
- }
- }
-}
-
-const render = () => modules(m => m.render(), false);
-
-sub(HELP_OVERLAY_TOGGLED, onHelpOverlayToggle)
-
-/**
- * App statistics module.
- */
-export const stats = {
- toggle: () => active ? disable() : enable(),
- set modules(m) {
- m && m.forEach(mod => module(mod))
- },
- mui: moduleUi,
-}
diff --git a/web/js/stats/stats.js b/web/js/stats/stats.js
new file mode 100644
index 00000000..c71b96e7
--- /dev/null
+++ b/web/js/stats/stats.js
@@ -0,0 +1,433 @@
+/**
+ * App statistics module.
+ *
+ * Events:
+ * <- STATS_TOGGLE
+ * <- HELP_OVERLAY_TOGGLED
+ *
+ * @version 1
+ */
+const stats = (() => {
+ const _modules = [];
+ let tempHide = false;
+
+ // internal rendering stuff
+ const fps = 30;
+ let time = 0;
+ let active = false;
+
+ // !to add connection drop notice
+
+ const statsOverlayEl = document.getElementById('stats-overlay');
+
+ /**
+ * The graph element.
+ */
+ const graph = (parent, opts = {
+ historySize: 60,
+ width: 60 * 2 + 2,
+ height: 20,
+ pad: 4,
+ scale: 1,
+ style: {
+ barColor: '#9bd914',
+ barFallColor: '#c12604'
+ }
+ }) => {
+ const _canvas = document.createElement('canvas');
+ const _context = _canvas.getContext('2d');
+
+ let data = [];
+
+ _canvas.setAttribute('class', 'graph');
+
+ _canvas.width = opts.width * opts.scale;
+ _canvas.height = opts.height * opts.scale;
+
+ _context.scale(opts.scale, opts.scale);
+ _context.imageSmoothingEnabled = false;
+ _context.fillStyle = opts.fillStyle;
+
+ if (parent) parent.append(_canvas);
+
+ // bar size
+ const barWidth = Math.round(_canvas.width / opts.scale / opts.historySize);
+ const barHeight = Math.round(_canvas.height / opts.scale);
+
+ let maxN = 0,
+ minN = 0;
+
+ const max = () => maxN
+
+ const get = () => _canvas
+
+ const add = (value) => {
+ if (data.length > opts.historySize) data.shift();
+ data.push(value);
+ render();
+ }
+
+ /**
+ * Draws a bar graph on the canvas.
+ */
+ const render = () => {
+ // 0,0 w,0 0,0 w,0 0,0 w,0
+ // +-------+ +-------+ +---------+
+ // | | |+---+ | |+---+ |
+ // | | |||||| | ||||||+---+
+ // | | |||||| | |||||||||||
+ // +-------+ +----+--+ +---------+
+ // 0,h w,h 0,h w,h 0,h w,h
+ // [] [3] [3, 2]
+ //
+
+ _context.clearRect(0, 0, _canvas.width, _canvas.height);
+
+ maxN = data[0] || 1;
+ minN = 0;
+ for (let k = 1; k < data.length; k++) {
+ if (data[k] > maxN) maxN = data[k];
+ if (data[k] < minN) minN = data[k];
+ }
+
+ for (let j = 0; j < data.length; j++) {
+ let x = j * barWidth,
+ y = (barHeight - opts.pad * 2) * (data[j] - minN) / (maxN - minN) + opts.pad;
+
+ const color = j > 0 && data[j] > data[j - 1] ? opts.style.barFallColor : opts.style.barColor;
+
+ drawRect(x, barHeight - Math.round(y), barWidth, barHeight, color);
+ }
+ }
+
+ const drawRect = (x, y, w, h, color = opts.style.barColor) => {
+ _context.fillStyle = color;
+ _context.fillRect(x, y, w, h);
+ }
+
+ return {add, get, max, render}
+ }
+
+ /**
+ * Get cached module UI.
+ *
+ * HTML:
+ *
+ *
+ * @param label The name of the stat to show.
+ * @param withGraph True if to draw a graph.
+ * @param postfix Supposed to be the name of the stat passed as a function.
+ * @returns {{el: HTMLDivElement, update: function}}
+ */
+ const moduleUi = (label = '', withGraph = false, postfix = () => 'ms') => {
+ const ui = document.createElement('div'),
+ _label = document.createElement('div'),
+ _value = document.createElement('span');
+ ui.append(_label, _value);
+
+ let postfix_ = postfix;
+
+ let _graph;
+ if (withGraph) {
+ const _container = document.createElement('span');
+ ui.append(_container);
+ _graph = graph(_container);
+ }
+
+ _label.innerHTML = label;
+
+ const withPostfix = (value) => postfix_ = value;
+
+ const update = (value) => {
+ if (_graph) _graph.add(value);
+ // 203 (333) ms
+ _value.textContent = `${value < 1 ? '<1' : value} ${_graph ? `(${_graph.max()}) ` : ''}${postfix_(value)}`;
+ }
+
+ return {el: ui, update, withPostfix}
+ }
+
+ /**
+ * Latency stats submodule.
+ *
+ * Accumulates the simple rolling mean value
+ * between the next server request and following server response values.
+ *
+ * window
+ * _____________
+ * | |
+ * [1, 1, 3, 4, 1, 4, 3, 1, 2, 1, 1, 1, 2, ... n]
+ * |
+ * stats_snapshot_period
+ * mean = round(next - mean / length % window)
+ *
+ * Events:
+ * <- PING_RESPONSE
+ * <- PING_REQUEST
+ *
+ * ?Interface:
+ * HTMLElement get()
+ * void enable()
+ * void disable()
+ * void render()
+ *
+ * @version 1
+ */
+ const latency = (() => {
+ let listeners = [];
+
+ let mean = 0;
+ let length = 0;
+ let previous = 0;
+ const window = 5;
+
+ const ui = moduleUi('Ping(c)', true);
+
+ const onPingRequest = (data) => previous = data.time;
+
+ const onPingResponse = () => {
+ length++;
+ const delta = Date.now() - previous;
+ mean += Math.round((delta - mean) / length);
+
+ if (length % window === 0) {
+ length = 1;
+ mean = delta;
+ }
+ }
+
+ const enable = () => {
+ listeners.push(
+ event.sub(PING_RESPONSE, onPingResponse),
+ event.sub(PING_REQUEST, onPingRequest)
+ );
+ }
+
+ const disable = () => {
+ while (listeners.length) listeners.shift().unsub();
+ }
+
+ const render = () => ui.update(mean);
+
+ const get = () => ui.el;
+
+ return {get, enable, disable, render}
+ })(event, moduleUi);
+
+ /**
+ * User agent memory stats.
+ *
+ * ?Interface:
+ * HTMLElement get()
+ * void enable()
+ * void disable()
+ * void render()
+ *
+ * @version 1
+ */
+ const clientMemory = (() => {
+ let active = false;
+
+ const measures = ['B', 'KB', 'MB', 'GB'];
+ const precision = 1;
+ let mLog = 0;
+
+ const ui = moduleUi('Memory', false, (x) => (x > 0) ? measures[mLog] : '');
+
+ const get = () => ui.el;
+
+ const enable = () => {
+ active = true;
+ render();
+ }
+
+ const disable = () => active = false;
+
+ const render = () => {
+ if (!active) return;
+
+ const m = performance.memory.usedJSHeapSize;
+ let newValue = 'N/A';
+
+ if (m > 0) {
+ mLog = Math.floor(Math.log(m) / Math.log(1000));
+ newValue = Math.round(m * precision / Math.pow(1000, mLog)) / precision;
+ }
+
+ ui.update(newValue);
+ }
+
+ if (window.performance && !performance.memory) performance.memory = {usedJSHeapSize: 0, totalJSHeapSize: 0};
+
+ return {get, enable, disable, render}
+ })(moduleUi, performance, window);
+
+
+ const webRTCStats_ = (() => {
+ let interval = null
+
+ function getStats() {
+ if (!webrtc.isConnected()) return;
+ webrtc.getConnection().getStats(null).then(stats => {
+ let frameStatValue = '?';
+ stats.forEach(report => {
+ if (report["framesReceived"] !== undefined && report["framesDecoded"] !== undefined && report["framesDropped"] !== undefined) {
+ frameStatValue = report["framesReceived"] - report["framesDecoded"] - report["framesDropped"];
+ event.pub('STATS_WEBRTC_FRAME_STATS', frameStatValue)
+ } else if (report["framerateMean"] !== undefined) {
+ frameStatValue = Math.round(report["framerateMean"] * 100) / 100;
+ event.pub('STATS_WEBRTC_FRAME_STATS', frameStatValue)
+ }
+
+ if (report["nominated"] && report["currentRoundTripTime"] !== undefined) {
+ event.pub('STATS_WEBRTC_ICE_RTT', report["currentRoundTripTime"] * 1000);
+ }
+ });
+ });
+ }
+
+ const enable = () => {
+ interval = window.setInterval(getStats, 1000);
+ }
+
+ const disable = () => window.clearInterval(interval);
+
+ return {enable, disable, internal: true}
+ })(event, webrtc, window);
+
+ /**
+ * User agent frame stats.
+ *
+ * ?Interface:
+ * HTMLElement get()
+ * void enable()
+ * void disable()
+ * void render()
+ *
+ * @version 1
+ */
+ const webRTCFrameStats = (() => {
+ let value = 0;
+ let listener;
+
+ const label = env.getBrowser() === 'firefox' ? 'FramerateMean' : 'FrameDelay';
+ const ui = moduleUi(label, false, () => '');
+
+ const get = () => ui.el;
+
+ const enable = () => {
+ listener = event.sub('STATS_WEBRTC_FRAME_STATS', onStats);
+ }
+
+ const disable = () => {
+ value = 0;
+ if (listener) listener.unsub();
+ }
+
+ const render = () => ui.update(value);
+
+ function onStats(val) {
+ value = val;
+ }
+
+ return {get, enable, disable, render}
+ })(env, event, moduleUi);
+
+ const webRTCRttStats = (() => {
+ let value = 0;
+ let listener;
+
+ const ui = moduleUi('RTT', true, () => 'ms');
+
+ const get = () => ui.el;
+
+ const enable = () => {
+ listener = event.sub('STATS_WEBRTC_ICE_RTT', onStats);
+ }
+
+ const disable = () => {
+ value = 0;
+ if (listener) listener.unsub();
+ }
+
+ const render = () => ui.update(value);
+
+ function onStats(val) {
+ value = val;
+ }
+
+ return {get, enable, disable, render}
+ })(event, moduleUi);
+
+ const modules = (fn, force = true) => _modules.forEach(m => (force || !m.internal) && fn(m))
+
+ const enable = () => {
+ active = true;
+ modules(m => m.enable())
+ render();
+ draw();
+ _show();
+ };
+
+ function draw(timestamp) {
+ if (!active) return;
+
+ const time_ = time + 1000 / fps;
+
+ if (timestamp > time_) {
+ time = timestamp;
+ render();
+ }
+
+ requestAnimationFrame(draw);
+ }
+
+ const disable = () => {
+ active = false;
+ modules(m => m.disable());
+ _hide();
+ }
+
+ const _show = () => statsOverlayEl.style.visibility = 'visible';
+ const _hide = () => statsOverlayEl.style.visibility = 'hidden';
+
+ const onToggle = () => active ? disable() : enable();
+
+ /**
+ * Handles help overlay toggle event.
+ * Workaround for a not normal app layout layering.
+ *
+ * !to remove when app layering is fixed
+ *
+ * @param {Object} overlay Overlay data.
+ * @param {boolean} overlay.shown A flag if the overlay is being currently showed.
+ */
+ const onHelpOverlayToggle = (overlay) => {
+ if (statsOverlayEl.style.visibility === 'visible' && overlay.shown && !tempHide) {
+ _hide();
+ tempHide = true;
+ } else {
+ if (tempHide) {
+ _show();
+ tempHide = false;
+ }
+ }
+ }
+
+ const render = () => modules(m => m.render(), false);
+
+ // add submodules
+ _modules.push(
+ webRTCRttStats,
+ // latency,
+ clientMemory,
+ webRTCStats_,
+ webRTCFrameStats
+ );
+ modules(m => statsOverlayEl.append(m.get()), false);
+
+ event.sub(STATS_TOGGLE, onToggle);
+ event.sub(HELP_OVERLAY_TOGGLED, onHelpOverlayToggle)
+
+ return {enable, disable}
+})(document, env, event, log, webrtc, window);
diff --git a/web/js/stream.js b/web/js/stream.js
deleted file mode 100644
index 01718e2a..00000000
--- a/web/js/stream.js
+++ /dev/null
@@ -1,227 +0,0 @@
-import {
- sub,
- APP_VIDEO_CHANGED,
- SETTINGS_CHANGED,
- TRANSFORM_CHANGE
-} from 'event';
-import {log} from 'log';
-import {opts, settings} from 'settings';
-
-const videoEl = document.getElementById('stream')
-const mirrorEl = document.getElementById('mirror-stream')
-const playEl = document.getElementById('play-stream')
-
-const options = {
- volume: 0.5,
- poster: '/img/screen_loading.gif',
- mirrorMode: null,
- mirrorUpdateRate: 1 / 60,
-}
-
-const state = {
- screen: videoEl,
- timerId: null,
- w: 0,
- h: 0,
- aspect: 4 / 3,
- fit: 'contain',
- ready: false,
- autoplayWait: false
-}
-
-const mute = (mute) => (videoEl.muted = mute)
-
-const onPlay = () => {
- state.ready = true
- videoEl.poster = ''
- resize(state.w, state.h, state.aspect, state.fit)
- useCustomScreen(options.mirrorMode === 'mirror')
-}
-
-const play = () => {
- const promise = videoEl.play()
-
- if (promise === undefined) {
- log.error('oh no, the video is not a promise!')
- return
- }
-
- promise
- .then(onPlay)
- .catch(error => {
- if (error.name === 'NotAllowedError') {
- showPlayButton()
- } else {
- log.error('Playback fail', error)
- }
- })
-}
-
-const toggle = (show) => state.screen.toggleAttribute('hidden', show === undefined ? show : !show)
-
-const resize = (w, h, aspect, fit) => {
- if (!state.ready) return;
-
- state.screen.setAttribute('width', '' + w)
- state.screen.setAttribute('height', '' + h)
- aspect !== undefined && (state.screen.style.aspectRatio = '' + aspect)
- fit !== undefined && (state.screen.style['object-fit'] = fit)
-}
-
-const showPlayButton = () => {
- state.autoplayWait = true
- toggle()
- playEl.removeAttribute('hidden')
-}
-
-playEl.addEventListener('click', () => {
- playEl.setAttribute('hidden', "")
- state.autoplayWait = false
- play()
- toggle()
-})
-
-// Track resize even when the underlying media stream changes its video size
-videoEl.addEventListener('resize', () => {
- recalculateSize()
- if (state.screen === videoEl) return
- resize(videoEl.videoWidth, videoEl.videoHeight)
-})
-
-videoEl.addEventListener('loadstart', () => {
- videoEl.volume = options.volume / 100
- videoEl.poster = options.poster
-})
-
-videoEl.onfocus = () => videoEl.blur()
-videoEl.onerror = (e) => log.error('Playback error', e)
-
-const onFullscreen = (fullscreen) => {
- const el = document.fullscreenElement
-
- if (fullscreen) {
- // timeout is due to a chrome bug
- setTimeout(() => {
- // aspect ratio calc
- const w = window.screen.width ?? window.innerWidth
- const hh = el.innerHeight || el.clientHeight || 0
- const dw = (w - hh * state.aspect) / 2
- state.screen.style.padding = `0 ${dw}px`
- state.screen.classList.toggle('with-footer')
- }, 1)
- } else {
- state.screen.style.padding = '0'
- state.screen.classList.toggle('with-footer')
- }
-
- if (el === videoEl) {
- videoEl.classList.toggle('no-media-controls', !fullscreen)
- videoEl.blur()
- }
-}
-
-const vs = {w: 1, h: 1}
-
-const recalculateSize = () => {
- const fullscreen = document.fullscreenElement !== null
- const {aspect, screen} = state
-
- let width, height
- if (fullscreen) {
- // we can't get the real