-
69ff8ae
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
+
{{if .Analytics.Inject}}
diff --git a/web/js/api.js b/web/js/api.js
new file mode 100644
index 00000000..906342b0
--- /dev/null
+++ b/web/js/api.js
@@ -0,0 +1,337 @@
+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
deleted file mode 100644
index 7fafe225..00000000
--- a/web/js/api/api.js
+++ /dev/null
@@ -1,64 +0,0 @@
-/**
- * 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
new file mode 100644
index 00000000..3d58dc89
--- /dev/null
+++ b/web/js/app.js
@@ -0,0 +1,627 @@
+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
deleted file mode 100644
index ab0ad129..00000000
--- a/web/js/controller.js
+++ /dev/null
@@ -1,537 +0,0 @@
-/**
- * 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 0a6ddb80..a725c87d 100644
--- a/web/js/env.js
+++ b/web/js/env.js
@@ -1,120 +1,113 @@
-const env = (() => {
- // UI
- const page = document.getElementsByTagName('html')[0];
- const gameBoy = document.getElementById('gamebody');
- const sourceLink = document.getElementsByClassName('source')[0];
+import {
+ pub,
+ TRANSFORM_CHANGE
+} from 'event';
- let isLayoutSwitched = false;
+// UI
+const page = document.getElementsByTagName('html')[0];
+const gameBoy = document.getElementById('gamebody');
+const sourceLink = document.getElementsByClassName('source')[0];
- // 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;
+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,}
- // save page rotation
- isLayoutSwitched = isPortrait();
+let isLayoutSwitched = false;
- rescaleGameBoy(targetWidth, targetHeight);
+// 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;
- 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' : '';
- };
+ // save page rotation
+ isLayoutSwitched = isPortrait();
- const rescaleGameBoy = (targetWidth, targetHeight) => {
- const transformations = ['translate(-50%, -50%)'];
+ rescaleGameBoy(targetWidth, targetHeight);
- if (isLayoutSwitched) {
- transformations.push('rotate(90deg)');
- [targetWidth, targetHeight] = [targetHeight, targetWidth]
- }
+ 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' : '';
+};
- // scale, fit to target size
- const scale = Math.min(targetWidth / getWidth(gameBoy), targetHeight / getHeight(gameBoy));
- transformations.push(`scale(${scale})`);
+const rescaleGameBoy = (targetWidth, targetHeight) => {
+ const transformations = ['translate(-50%, -50%)'];
- gameBoy.style['transform'] = transformations.join(' ');
+ if (isLayoutSwitched) {
+ transformations.push('rotate(90deg)');
+ [targetWidth, targetHeight] = [targetHeight, targetWidth]
}
- 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;
- };
+ // scale, fit to target size
+ const scale = Math.min(targetWidth / getWidth(gameBoy), targetHeight / getHeight(gameBoy));
+ transformations.push(`scale(${scale})`);
- 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;
- };
+ gameBoy.style['transform'] = transformations.join(' ');
+}
- const isPortrait = () => getWidth(page) < getHeight(page);
+new MutationObserver(() => pub(TRANSFORM_CHANGE)).observe(gameBoy, {attributeFilter: ['style']})
- const toggleFullscreen = (enable, element) => {
- const el = enable ? element : document;
+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
+}
- 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 _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;
+}
- function getHeight(el) {
- return parseFloat(getComputedStyle(el, null).height.replace("px", ""));
+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
}
+ el.exitFullscreen?.().then().catch();
+}
- function getWidth(el) {
- return parseFloat(getComputedStyle(el, null).width.replace("px", ""));
- }
+function getHeight(el) {
+ return parseFloat(getComputedStyle(el, null).height.replace("px", ""));
+}
- window.addEventListener('resize', fixScreenLayout);
- window.addEventListener('orientationchange', fixScreenLayout);
- document.addEventListener('DOMContentLoaded', () => fixScreenLayout(), false);
+function getWidth(el) {
+ return parseFloat(getComputedStyle(el, null).width.replace("px", ""));
+}
- 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);
+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
+ })
+}
diff --git a/web/js/event.js b/web/js/event.js
new file mode 100644
index 00000000..8ade9024
--- /dev/null
+++ b/web/js/event.js
@@ -0,0 +1,109 @@
+/**
+ * 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
deleted file mode 100644
index 922d468f..00000000
--- a/web/js/event/event.js
+++ /dev/null
@@ -1,102 +0,0 @@
-/**
- * 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 f309295d..cae64220 100644
--- a/web/js/gameList.js
+++ b/web/js/gameList.js
@@ -1,100 +1,255 @@
-/**
- * Game list module.
- * @version 1
- */
-const gameList = (() => {
- // state
- let games = [];
- let gameIndex = 1;
- let gamePickTimer = null;
+import {MENU_PRESSED, MENU_RELEASED, sub} from 'event';
+import {gui} from 'gui';
- // UI
- const listBox = document.getElementById('menu-container');
- const menuItemChoice = document.getElementById('menu-item-choice');
+const TOP_POSITION = 102
+const SELECT_THRESHOLD_MS = 160
- const MENU_TOP_POSITION = 102;
- let menuTop = MENU_TOP_POSITION;
+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 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);
+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
- gamePickTimer = setInterval(() => {
- pickGame(gameIndex + shift);
- }, 200);
- };
+ _si = setInterval(() => onShift(delta), DEFAULT_INTERVAL)
+ }
- const stopGamePickerTimer = () => {
- if (gamePickTimer === null) return;
- clearInterval(gamePickTimer);
- gamePickTimer = null;
- };
+ const stop = () => {
+ onStop()
+ _si && (clearInterval(_si) && (_si = 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);
+ const handle = {[state.IDLE]: stop, [state.UP]: shift, [state.DOWN]: shift, [state.DRAG]: null}
return {
- startGamePickerTimer: startGamePickerTimer,
- stopGamePickerTimer: stopGamePickerTimer,
- pickGame: pickGame,
- show: show,
- set: setGames,
- getCurrentGame: () => games[gameIndex]
+ 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
}
-})(document, event, log);
+})(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.
+ */
+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()
+ },
+}
diff --git a/web/js/gui.js b/web/js/gui.js
new file mode 100644
index 00000000..b6eb9d94
--- /dev/null
+++ b/web/js/gui.js
@@ -0,0 +1,277 @@
+/**
+ * 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
deleted file mode 100644
index 361fe72b..00000000
--- a/web/js/gui/gui.js
+++ /dev/null
@@ -1,213 +0,0 @@
-/**
- * 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
deleted file mode 100644
index d4bf668c..00000000
--- a/web/js/gui/message.js
+++ /dev/null
@@ -1,48 +0,0 @@
-/**
- * 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
deleted file mode 100644
index d421bddb..00000000
--- a/web/js/init.js
+++ /dev/null
@@ -1,26 +0,0 @@
-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 0ccf5b48..2c3fffa4 100644
--- a/web/js/input/input.js
+++ b/web/js/input/input.js
@@ -1,114 +1,56 @@
-const input = (() => {
- const pollingIntervalMs = 4;
- let controllerChangedIndex = -1;
+import {
+ REFRESH_INPUT,
+ KB_MOUSE_FLAG,
+ pub,
+ sub
+} from 'event';
- // 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
- };
+export {KEY, JOYPAD_KEYS} from './keys.js?v=3';
- 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;
- }
+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 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);
+ },
+ set kbm(v) {
+ input_state.kbm = v
+ },
+ get kbm() {
+ return input_state.kbm
}
-
- 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 c5ee2fdb..51e22e2a 100644
--- a/web/js/input/joystick.js
+++ b/web/js/input/joystick.js
@@ -1,3 +1,260 @@
+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.
*
@@ -16,263 +273,18 @@
*
* @version 1
*/
-const joystick = (() => {
- const deadZone = 0.1;
- let joystickMap;
- let joystickState = {};
- let joystickAxes = [];
- let joystickIdx;
- let joystickTimer = null;
- let dpadMode = true;
+export const joystick = {
+ init: () => {
+ // we only capture the last plugged joystick
+ window.addEventListener('gamepadconnected', onGamepadConnected);
- 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;
+ // disconnected event is triggered
+ window.addEventListener('gamepaddisconnected', (event) => {
+ clearInterval(joystickTimer);
+ log.info(`Gamepad disconnected at index ${event.gamepad.index}`);
+ pub(GAMEPAD_DISCONNECTED);
});
- 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');
- }
+ 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 91b9f548..4c26e9db 100644
--- a/web/js/input/keyboard.js
+++ b/web/js/input/keyboard.js
@@ -1,128 +1,164 @@
+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
*/
-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
- });
+export const keyboard = {
+ init: () => {
+ keyMap = settings.loadOr(opts.INPUT_KEYBOARD_MAP, defaultMap);
+ const body = document.body;
- let keyMap = {};
- let isKeysFilteredMode = true;
+ body.addEventListener('keyup', e => {
+ e.stopPropagation()
+ !hasKeyboardLock && locked && e.preventDefault()
- 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});
- }
+ let lock = locked
+ // hack with Esc up when outside of lock
+ if (e.code === 'Escape') {
+ lock = true
}
- } 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};
- }
- }
- const onKey = (code, evt, state) => {
- const key = keyMap[code]
- if (key === undefined) return
+ isKeysFilteredMode ?
+ (lock ? pub(KEYBOARD_KEY_UP, e) : onKey(e.code, KEY_RELEASED, false))
+ : pub(KEYBOARD_KEY_PRESSED, {key: e.code})
+ }, false)
- 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})
- }
+ body.addEventListener('keydown', e => {
+ e.stopPropagation()
+ !hasKeyboardLock && locked && e.preventDefault()
- event.sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked));
+ isKeysFilteredMode ?
+ (locked ? pub(KEYBOARD_KEY_DOWN, e) : onKey(e.code, KEY_PRESSED, true)) :
+ pub(KEYBOARD_KEY_PRESSED, {key: e.code})
+ })
- 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);
+ log.info('[input] keyboard has been initialized')
+ },
+ settings: {
+ remap
+ },
+ lock,
+}
diff --git a/web/js/input/keys.js b/web/js/input/keys.js
index 1f798b95..60e45e3e 100644
--- a/web/js/input/keys.js
+++ b/web/js/input/keys.js
@@ -1,35 +1,41 @@
-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',
- }
-})();
+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
+]
diff --git a/web/js/input/pointer.js b/web/js/input/pointer.js
new file mode 100644
index 00000000..e0fab075
--- /dev/null
+++ b/web/js/input/pointer.js
@@ -0,0 +1,153 @@
+// 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
new file mode 100644
index 00000000..0e7026ee
--- /dev/null
+++ b/web/js/input/retropad.js
@@ -0,0 +1,64 @@
+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 97b45a9a..f98359fc 100644
--- a/web/js/input/touch.js
+++ b/web/js/input/touch.js
@@ -1,3 +1,301 @@
+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.
*
@@ -7,300 +305,27 @@
* @link https://jsfiddle.net/aa0et7tr/5/
* @version 1
*/
-const touch = (() => {
- const MAX_DIFF = 20; // radius of circle boundary
+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});
- // 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];
+ sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked));
- 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'));
+ // add buttons into the state 🤦
+ Array.from(document.querySelectorAll('.btn,.btn-big')).forEach((el) => {
+ vpadState[getKey(el)] = false;
});
- }
- // 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);
- });
+ window.addEventListener('pointermove', handleWindowMove);
+ window.addEventListener('touchmove', handleWindowMove, {passive: false});
+ window.addEventListener('mouseup', handleWindowUp);
- // 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);
+ log.info('[input] touch input has been initialized');
+ },
+ toggle: (v) => v === undefined ? (enabled = !enabled) : (enabled = v)
+}
diff --git a/web/js/log.js b/web/js/log.js
index af138188..2c316225 100644
--- a/web/js/log.js
+++ b/web/js/log.js
@@ -1,35 +1,31 @@
+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
*/
-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);
+export const log = _log
diff --git a/web/js/menu.js b/web/js/menu.js
new file mode 100644
index 00000000..721b87b1
--- /dev/null
+++ b/web/js/menu.js
@@ -0,0 +1,17 @@
+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
new file mode 100644
index 00000000..41e8e66e
--- /dev/null
+++ b/web/js/message.js
@@ -0,0 +1,44 @@
+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 6f8c69c2..c2f09ccd 100644
--- a/web/js/network/ajax.js
+++ b/web/js/network/ajax.js
@@ -1,29 +1,26 @@
+const defaultTimeout = 10000;
/**
* AJAX request module.
* @version 1
*/
-const ajax = (() => {
- const defaultTimeout = 10000;
+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);
- 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(() => {
+ // fetch(url, {...options, signal})
+ fetch(url, allOptions)
+ .then(resolve, () => {
controller.abort();
- reject();
- }, timeout);
- }),
- defaultTimeoutMs: () => defaultTimeout
- }
-})();
\ No newline at end of file
+ return reject
+ });
+
+ // auto abort when a timeout reached
+ setTimeout(() => {
+ controller.abort();
+ reject();
+ }, timeout);
+ }),
+ defaultTimeoutMs: () => defaultTimeout
+}
diff --git a/web/js/network/network.js b/web/js/network/network.js
new file mode 100644
index 00000000..ca21be6a
--- /dev/null
+++ b/web/js/network/network.js
@@ -0,0 +1,3 @@
+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 06351246..e153f441 100644
--- a/web/js/network/socket.js
+++ b/web/js/network/socket.js
@@ -1,53 +1,51 @@
+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
- *
*/
-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);
+export const socket = {
+ init,
+ send
+}
diff --git a/web/js/network/webrtc.js b/web/js/network/webrtc.js
index 218e9e75..3bc5ff76 100644
--- a/web/js/network/webrtc.js
+++ b/web/js/network/webrtc.js
@@ -1,180 +1,201 @@
-/**
- * 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
- *
- */
-const webrtc = (() => {
- let connection;
- let inputChannel;
- let mediaStream;
- let candidates = Array();
- let isAnswered = false;
- let isFlushing = false;
+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 connected = false;
- let inputReady = false;
+let connection;
+let dataChannel
+let keyboardChannel
+let mouseChannel
+let mediaStream;
+let candidates = [];
+let isAnswered = false;
+let isFlushing = false;
- let onMessage;
+let connected = false;
+let inputReady = false;
- const start = (iceservers) => {
- log.info('[rtc] <- ICE servers', iceservers);
- const servers = iceservers || [];
- connection = new RTCPeerConnection({iceServers: servers});
- mediaStream = new MediaStream();
+let onData;
- 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');
- }
- connection.oniceconnectionstatechange = ice.onIceConnectionStateChange;
- connection.onicegatheringstatechange = ice.onIceStateChange;
- connection.onicecandidate = ice.onIcecandidate;
- connection.ontrack = event => {
- mediaStream.addTrack(event.track);
- }
- };
+const start = (iceservers) => {
+ log.info('[rtc] <- ICE servers', iceservers);
+ const servers = iceservers || [];
+ connection = new RTCPeerConnection({iceServers: servers});
+ mediaStream = new MediaStream();
- const stop = () => {
- if (mediaStream) {
- mediaStream.getTracks().forEach(t => {
- t.stop();
- mediaStream.removeTrack(t);
- });
- mediaStream = null;
+ connection.ondatachannel = e => {
+ log.debug('[rtc] ondatachannel', e.channel.label)
+ e.channel.binaryType = "arraybuffer";
+
+ if (e.channel.label === 'keyboard') {
+ keyboardChannel = e.channel
+ return
}
- if (connection) {
- connection.close();
- connection = null;
+
+ if (e.channel.label === 'mouse') {
+ mouseChannel = e.channel
+ return
}
- if (inputChannel) {
- inputChannel.close();
- inputChannel = null;
+
+ 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')
}
- candidates = Array();
- log.info('[rtc] WebRTC has been closed');
}
+ connection.oniceconnectionstatechange = ice.onIceConnectionStateChange;
+ connection.onicegatheringstatechange = ice.onIceStateChange;
+ connection.onicecandidate = ice.onIcecandidate;
+ connection.ontrack = event => {
+ mediaStream.addTrack(event.track);
+ }
+};
- const ice = (() => {
- const ICE_TIMEOUT = 2000;
- let timeForIceGathering;
+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');
+}
- 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;
- }
- }
- }
- }
- })();
+const ice = (() => {
+ const ICE_TIMEOUT = 2000;
+ let timeForIceGathering;
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;
+ onIcecandidate: data => {
+ if (!data.candidate) return;
+ log.info('[rtc] user candidate', data.candidate);
+ pub(WEBRTC_ICE_CANDIDATE_FOUND, {candidate: data.candidate})
},
- // setMessageHandler: (handler) => onMessage = handler,
- addCandidate: (data) => {
- if (data === '') {
- event.pub(WEBRTC_ICE_CANDIDATES_FLUSH);
- } else {
- candidates.push(data);
+ 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);
+ }
}
},
- 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,
+ 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;
+ }
+ }
}
-})(event, log);
+})();
+
+/**
+ * WebRTC connection module.
+ */
+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 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;
+ 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);
+ }
+ },
+ 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;
+ },
+ 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
+ }
+}
diff --git a/web/js/recording.js b/web/js/recording.js
index b78cc01e..70f18ad0 100644
--- a/web/js/recording.js
+++ b/web/js/recording.js
@@ -1,64 +1,66 @@
-const RECORDING_ON = 1;
-const RECORDING_OFF = 0;
-const RECORDING_REC = 2;
+import {
+ pub,
+ KEYBOARD_TOGGLE_FILTER_MODE,
+ RECORDING_TOGGLED
+} from 'event';
+import {throttle} from 'utils';
-/**
- * Recording module.
- * @version 1
- */
-const recording = (() => {
- const userName = document.getElementById('user-name'),
- recButton = document.getElementById('btn-rec');
+export const RECORDING_ON = 1;
+export const RECORDING_OFF = 0;
+export const RECORDING_REC = 2;
- if (!userName || !recButton) {
- return {
- isActive: () => false,
- getUser: () => '',
+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;
}
}
+ userName.value = state.userName
+}
- let state = {
- userName: '',
- state: RECORDING_OFF,
- };
+const setRec = (val) => {
+ recButton.classList.toggle('record', val);
+}
+const setIndicator = (val) => {
+ recButton.classList.toggle('blink', val);
+};
- const restoreLastState = () => {
- const lastState = localStorage.getItem('recording');
- if (lastState) {
- const _last = JSON.parse(lastState);
- if (_last) {
- state = _last;
- }
- }
- userName.value = state.userName
- }
+// 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 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)
+let _recording = {
+ isActive: () => false,
+ getUser: () => '',
+ setIndicator: () => ({}),
+}
+if (userName && recButton) {
restoreLastState();
setIndicator(false);
setRec(state.state === RECORDING_ON)
// text
- userName.addEventListener('focus', () => event.pub(KEYBOARD_TOGGLE_FILTER_MODE))
- userName.addEventListener('blur', () => event.pub(KEYBOARD_TOGGLE_FILTER_MODE, {mode: true}))
+ userName.addEventListener('focus', () => pub(KEYBOARD_TOGGLE_FILTER_MODE))
+ userName.addEventListener('blur', () => pub(KEYBOARD_TOGGLE_FILTER_MODE, {mode: true}))
userName.addEventListener('keyup', ev => {
ev.stopPropagation();
saveUserName()
@@ -70,11 +72,17 @@ const recording = (() => {
const active = state.state === RECORDING_ON
setRec(active)
saveLastState()
- event.pub(RECORDING_TOGGLED, {userName: state.userName, recording: active})
+ pub(RECORDING_TOGGLED, {userName: state.userName, recording: active})
})
- return {
+
+ _recording = {
isActive: () => state.state > 0,
getUser: () => state.userName,
- setIndicator: setIndicator,
+ setIndicator,
}
-})(document, event, localStorage, utils);
+}
+
+/**
+ * Recording module.
+ */
+export const recording = _recording
diff --git a/web/js/room.js b/web/js/room.js
index 20a53a73..1321fc10 100644
--- a/web/js/room.js
+++ b/web/js/room.js
@@ -1,76 +1,81 @@
+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
*/
-const room = (() => {
- let id = '';
+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 = '';
- // 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);
+ // Shared URL second
+ const [parsedId, czone] = parseURLForRoom();
+ if (parsedId !== null) {
+ id = parsedId;
+ }
+ if (czone !== null) {
+ zone = czone;
}
- 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);
- }
+ 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
new file mode 100644
index 00000000..b4342e3c
--- /dev/null
+++ b/web/js/screen.js
@@ -0,0 +1,88 @@
+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
new file mode 100644
index 00000000..7dc30b06
--- /dev/null
+++ b/web/js/settings.js
@@ -0,0 +1,547 @@
+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
deleted file mode 100644
index bff7a098..00000000
--- a/web/js/settings/opts.js
+++ /dev/null
@@ -1,14 +0,0 @@
-/**
- * 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
deleted file mode 100644
index e3be8e81..00000000
--- a/web/js/settings/settings.js
+++ /dev/null
@@ -1,475 +0,0 @@
-/**
- * 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
new file mode 100644
index 00000000..d8d28974
--- /dev/null
+++ b/web/js/stats.js
@@ -0,0 +1,242 @@
+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
deleted file mode 100644
index c71b96e7..00000000
--- a/web/js/stats/stats.js
+++ /dev/null
@@ -1,433 +0,0 @@
-/**
- * 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
new file mode 100644
index 00000000..01718e2a
--- /dev/null
+++ b/web/js/stream.js
@@ -0,0 +1,227 @@
+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