diff --git a/web/index.html b/web/index.html
index 2446207e..61f24f13 100644
--- a/web/index.html
+++ b/web/index.html
@@ -15,7 +15,7 @@
-
+
Cloud Retro
@@ -49,7 +49,9 @@
- Arrows (move), ZXCVAS;'./ (game ABXYL1-L3R1-R3), 1/2 (1st/2nd player), Shift/Enter/K/L (select/start/save/load), F (fullscreen), share (copy the link to the clipboard)
+ Arrows (move), ZXCVAS;'./ (game ABXYL1-L3R1-R3), 1/2 (1st/2nd player),
+ Shift/Enter/K/L (select/start/save/load), F (fullscreen), share (copy the link to the
+ clipboard)
@@ -102,32 +104,23 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
+
{{if .Analytics.Inject}}
diff --git a/web/js/api.js b/web/js/api.js
new file mode 100644
index 00000000..6b93264b
--- /dev/null
+++ b/web/js/api.js
@@ -0,0 +1,70 @@
+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,
+
+ APP_VIDEO_CHANGE: 150,
+}
+
+/**
+ * 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: {
+ 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,
+ }),
+ toggleRecording: (active = false, userName = '') =>
+ packet(endpoints.GAME_RECORDING, {active: active, user: userName}),
+ quit: (roomId) => packet(endpoints.GAME_QUIT, {room_id: roomId}),
+ }
+}
+
+let transport = {
+ send: (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))
diff --git a/web/js/api/api.js b/web/js/api/api.js
deleted file mode 100644
index ad9c1322..00000000
--- a/web/js/api/api.js
+++ /dev/null
@@ -1,68 +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_RECORDING: 110,
- GET_WORKER_LIST: 111,
- GAME_ERROR_NO_FREE_SLOTS: 112,
-
- APP_VIDEO_CHANGE: 150,
- });
-
- const packet = (type, payload, id) => {
- const packet = {t: type};
- if (id !== undefined) packet.id = id;
- if (payload !== undefined) packet.p = payload;
-
- socket.send(packet);
- };
-
- const decodeBytes = (b) => String.fromCharCode.apply(null, new Uint8Array(b))
-
- return Object.freeze({
- 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:
- {
- 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,
- }),
- 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..5e951d26
--- /dev/null
+++ b/web/js/app.js
@@ -0,0 +1,541 @@
+import {api} from 'api';
+import {
+ pub,
+ sub,
+ APP_VIDEO_CHANGED,
+ AXIS_CHANGED,
+ CONTROLLER_UPDATED,
+ DPAD_TOGGLE,
+ GAME_ERROR_NO_FREE_SLOTS,
+ GAME_LOADED,
+ GAME_PLAYER_IDX,
+ GAME_PLAYER_IDX_SET,
+ GAME_ROOM_AVAILABLE,
+ GAME_SAVED,
+ GAMEPAD_CONNECTED,
+ GAMEPAD_DISCONNECTED,
+ HELP_OVERLAY_TOGGLED,
+ KEY_PRESSED,
+ KEY_RELEASED,
+ LATENCY_CHECK_REQUESTED,
+ MENU_HANDLER_ATTACHED,
+ MESSAGE,
+ RECORDING_STATUS_CHANGED,
+ RECORDING_TOGGLED,
+ SETTINGS_CHANGED,
+ STATS_TOGGLE,
+ 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
+} from 'event';
+import {gui} from 'gui';
+import {keyboard, KEY, joystick, retropad, touch} from 'input';
+import {log} from 'log';
+import {opts, settings} from 'settings';
+import {socket, webrtc} from 'network';
+import {debounce} from 'utils';
+
+import {gameList} from './gameList.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 {stats} from './stats.js?v=3';
+import {stream} from './stream.js?v=3';
+import {workerManager} from "./workerManager.js?v=3";
+
+// application state
+let state;
+let lastState;
+
+// first user interaction
+let interacted = false;
+
+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 = () => {
+ // room is ready
+};
+
+const onConnectionReady = () => {
+ // 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) 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()
+
+ api.game.start(
+ gameList.selected,
+ room.getId(),
+ recording.isActive(),
+ recording.getUser(),
+ +playerIndex.value - 1,
+ );
+
+ // clear menu screen
+ retropad.poll.disable();
+ gui.hide(menuScreen);
+ stream.toggle(true);
+ stream.forceFullscreenMaybe();
+ gui.show(keyButtons[KEY.SAVE]);
+ gui.show(keyButtons[KEY.LOAD]);
+ // end clear
+ retropad.poll.enable();
+};
+
+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:
+ if (payload.av) {
+ pub(APP_VIDEO_CHANGED, payload.av)
+ }
+ pub(GAME_ROOM_AVAILABLE, {roomId: payload.roomId});
+ break;
+ case api.endpoint.GAME_SAVE:
+ pub(GAME_SAVED);
+ break;
+ case api.endpoint.GAME_LOAD:
+ pub(GAME_LOADED);
+ 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);
+};
+
+// 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, 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 = () => {
+ const toggle = document.getElementById('dpad-toggle');
+ 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:
+ pub(STATS_TOGGLE);
+ break;
+ case KEY.SETTINGS:
+ break;
+ case KEY.DTOGGLE:
+ handleToggle();
+ break;
+ }
+ },
+ },
+
+ game: {
+ ..._default,
+ name: 'game',
+ axisChanged: (id, value) => retropad.setAxisChanged(id, value),
+ keyPress: key => retropad.setKeyState(key, true),
+ keyRelease: function (key) {
+ 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:
+ stream.video.toggleFullscreen();
+ 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:
+ retropad.poll.disable();
+ api.game.quit(room.getId());
+ room.reset();
+ window.location = window.location.pathname;
+ break;
+ case KEY.STATS:
+ pub(STATS_TOGGLE);
+ break;
+ case KEY.DTOGGLE:
+ handleToggle();
+ break;
+ }
+ },
+ }
+ }
+};
+
+// subscriptions
+sub(MESSAGE, onMessage);
+
+sub(GAME_ROOM_AVAILABLE, onGameRoomAvailable, 2);
+sub(GAME_SAVED, () => message.show('Saved'));
+sub(GAME_LOADED, () => message.show('Loaded'));
+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, () => {
+ retropad.poll.disable();
+ webrtc.stop();
+});
+sub(LATENCY_CHECK_REQUESTED, onLatencyCheck);
+sub(GAMEPAD_CONNECTED, () => message.show('Gamepad connected'));
+sub(GAMEPAD_DISCONNECTED, () => message.show('Gamepad disconnected'));
+// touch stuff
+sub(MENU_HANDLER_ATTACHED, (data) => {
+ menuScreen.addEventListener(data.event, data.handler, {passive: true});
+});
+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));
+// recording
+sub(RECORDING_TOGGLED, handleRecording);
+sub(RECORDING_STATUS_CHANGED, handleRecordingStatus);
+
+sub(SETTINGS_CHANGED, () => {
+ const newValue = settings.get()[opts.LOG_LEVEL];
+ if (newValue !== log.level) {
+ log.level = newValue;
+ }
+});
+
+// initial app state
+setState(app.state.eden);
+
+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();
+
+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 = socket;
diff --git a/web/js/controller.js b/web/js/controller.js
deleted file mode 100644
index d0e336e7..00000000
--- a/web/js/controller.js
+++ /dev/null
@@ -1,468 +0,0 @@
-/**
- * App controller module.
- * @version 1
- */
-(() => {
- // application state
- let state;
- let lastState;
-
- // first user interaction
- let interacted = false;
-
- 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 = () => {
- // room is ready
- };
-
- const onConnectionReady = () => {
- // 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()
-
- api.game.start(
- gameList.selected,
- room.getId(),
- recording.isActive(),
- recording.getUser(),
- +playerIndex.value - 1,
- );
-
- // clear menu screen
- input.poll.disable();
- gui.hide(menuScreen);
- stream.toggle(true);
- stream.forceFullscreenMaybe();
- 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 = (m) => {
- const {id, t, p: payload} = m;
- 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:
- if (payload.av) {
- event.pub(APP_VIDEO_CHANGED, payload.av)
- }
- event.pub(GAME_ROOM_AVAILABLE, {roomId: payload.roomId});
- 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;
- case api.endpoint.GAME_ERROR_NO_FREE_SLOTS:
- event.pub(GAME_ERROR_NO_FREE_SLOTS);
- break;
- case api.endpoint.APP_VIDEO_CHANGE:
- event.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);
- };
-
- // 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, 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 = () => {
- 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'}`)
- 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:
- 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;
- 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.poll.disable();
- api.game.quit(room.getId());
- room.reset();
- 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, state !== app.state.game);
- });
- event.sub(GAME_PLAYER_IDX_SET, idx => {
- if (!isNaN(+idx)) message.show(+idx + 1);
- });
- event.sub(GAME_ERROR_NO_FREE_SLOTS, () => message.show("No free slots :(", 2500));
- event.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);
- });
- 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(WEBRTC_CONNECTION_READY, onConnectionReady);
- event.sub(WEBRTC_CONNECTION_CLOSED, () => {
- input.poll.disable();
- 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(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 a8f79ebd..ef1ef3d5 100644
--- a/web/js/env.js
+++ b/web/js/env.js
@@ -1,119 +1,117 @@
-const env = (() => {
- // UI
- const page = document.getElementsByTagName('html')[0];
- const gameBoy = document.getElementById('gamebody');
- const sourceLink = document.getElementsByClassName('source')[0];
+// UI
+const page = document.getElementsByTagName('html')[0];
+const gameBoy = document.getElementById('gamebody');
+const sourceLink = document.getElementsByClassName('source')[0];
- let isLayoutSwitched = false;
+let isLayoutSwitched = false;
- // 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;
+// 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;
- // save page rotation
- isLayoutSwitched = isPortrait();
+ // save page rotation
+ isLayoutSwitched = isPortrait();
- rescaleGameBoy(targetWidth, targetHeight);
+ rescaleGameBoy(targetWidth, targetHeight);
- 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['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' : '';
+};
+
+const rescaleGameBoy = (targetWidth, targetHeight) => {
+ const transformations = ['translate(-50%, -50%)'];
+
+ if (isLayoutSwitched) {
+ transformations.push('rotate(90deg)');
+ [targetWidth, targetHeight] = [targetHeight, targetWidth]
+ }
+
+ // scale, fit to target size
+ const scale = Math.min(targetWidth / getWidth(gameBoy), targetHeight / getHeight(gameBoy));
+ transformations.push(`scale(${scale})`);
+
+ gameBoy.style['transform'] = transformations.join(' ');
+}
+
+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;
+};
+
+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;
+};
+
+const isPortrait = () => getWidth(page) < getHeight(page);
+
+const toggleFullscreen = (enable, element) => {
+ const el = enable ? element : document;
+
+ 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();
}
- sourceLink.style['transform'] = isLayoutSwitched ? 'rotate(-90deg)' : '';
- sourceLink.style['transform-origin'] = isLayoutSwitched ? 'left top' : '';
- };
-
- const rescaleGameBoy = (targetWidth, targetHeight) => {
- const transformations = ['translate(-50%, -50%)'];
-
- if (isLayoutSwitched) {
- transformations.push('rotate(90deg)');
- [targetWidth, targetHeight] = [targetHeight, targetWidth]
+ } 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();
}
-
- // scale, fit to target size
- const scale = Math.min(targetWidth / getWidth(gameBoy), targetHeight / getHeight(gameBoy));
- transformations.push(`scale(${scale})`);
-
- gameBoy.style['transform'] = transformations.join(' ');
}
+};
- 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;
- };
+function getHeight(el) {
+ return parseFloat(getComputedStyle(el, null).height.replace("px", ""));
+}
- 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;
- };
+function getWidth(el) {
+ return parseFloat(getComputedStyle(el, null).width.replace("px", ""));
+}
- const isPortrait = () => getWidth(page) < getHeight(page);
+window.addEventListener('resize', fixScreenLayout);
+window.addEventListener('orientationchange', fixScreenLayout);
+document.addEventListener('DOMContentLoaded', () => fixScreenLayout(), false);
- const toggleFullscreen = (enable, element) => {
- const el = enable ? element : document;
-
- 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();
- }
- }
- };
-
- function getHeight(el) {
- return parseFloat(getComputedStyle(el, null).height.replace("px", ""));
- }
-
- function getWidth(el) {
- return parseFloat(getComputedStyle(el, null).width.replace("px", ""));
- }
-
- window.addEventListener('resize', fixScreenLayout);
- window.addEventListener('orientationchange', fixScreenLayout);
- document.addEventListener('DOMContentLoaded', () => fixScreenLayout(), false);
-
- return {
- getOs: getOS,
- getBrowser: getBrowser,
- isMobileDevice: () => /Mobi|Android|iPhone/i.test(navigator.userAgent),
- display: () => ({
- isPortrait: isPortrait,
- toggleFullscreen: toggleFullscreen,
- fixScreenLayout: fixScreenLayout,
- isLayoutSwitched: isLayoutSwitched
- })
- }
-})(document, log, navigator, screen, window);
+export const env = {
+ getOs: getOS,
+ getBrowser: getBrowser,
+ isMobileDevice: () => /Mobi|Android|iPhone/i.test(navigator.userAgent),
+ display: () => ({
+ isPortrait: isPortrait,
+ toggleFullscreen: toggleFullscreen,
+ fixScreenLayout: fixScreenLayout,
+ isLayoutSwitched: isLayoutSwitched
+ })
+}
diff --git a/web/js/event.js b/web/js/event.js
new file mode 100644
index 00000000..df189044
--- /dev/null
+++ b/web/js/event.js
@@ -0,0 +1,100 @@
+/**
+ * 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 PING_REQUEST = 'pingRequest';
+export const PING_RESPONSE = 'pingResponse';
+
+export const WORKER_LIST_FETCHED = 'workerListFetched';
+
+export const GAME_ROOM_AVAILABLE = 'gameRoomAvailable';
+export const GAME_SAVED = 'gameSaved';
+export const GAME_LOADED = 'gameLoaded';
+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 AXIS_CHANGED = 'axisChanged';
+export const CONTROLLER_UPDATED = 'controllerUpdated';
+
+export const DPAD_TOGGLE = 'dpadToggle';
+export const STATS_TOGGLE = 'statsToggle';
+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'
diff --git a/web/js/event/event.js b/web/js/event/event.js
deleted file mode 100644
index 301e731b..00000000
--- a/web/js/event/event.js
+++ /dev/null
@@ -1,104 +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 GAME_ERROR_NO_FREE_SLOTS = 'gameNoFreeSlots'
-
-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 RECORDING_TOGGLED = 'recordingToggle'
-const RECORDING_STATUS_CHANGED = 'recordingStatusChanged'
-
-const APP_VIDEO_CHANGED = 'appVideoChanged'
diff --git a/web/js/gameList.js b/web/js/gameList.js
index 74338a3e..48ef73b6 100644
--- a/web/js/gameList.js
+++ b/web/js/gameList.js
@@ -1,235 +1,239 @@
-/**
- * Game list module.
- * @version 1
- */
-const gameList = (() => {
- const TOP_POSITION = 102
- const SELECT_THRESHOLD_MS = 160
+import {
+ sub,
+ MENU_PRESSED,
+ MENU_RELEASED
+} from 'event';
+import {gui} from 'gui';
- 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) {
- //-2 |
- //-1 | |
- // 0 < | <
- // 1 | |
- // 2 < < |
- //+1 | |
- //+2 |
- index = i < -1 ? i = 0 :
- i > list.length ? i = list.length - 1 :
- (i % list.length + list.length) % list.length
- },
- set: (data = []) => list = data.sort((a, b) => a.title.toLowerCase() > b.title.toLowerCase() ? 1 : -1),
- empty: () => list.length === 0
- }
- })()
-
- const scroll = ((DEFAULT_INTERVAL) => {
- const state = {
- IDLE: 0, UP: -1, DOWN: 1, DRAG: 3
- }
- let last = state.IDLE
- let _si
- let onShift, onStop
-
- const shift = (delta) => {
- if (scroll.scrolling) return
- onShift(delta)
- // velocity?
- // keep rolling the game list if the button is pressed
- _si = setInterval(() => onShift(delta), DEFAULT_INTERVAL)
- }
-
- const stop = () => {
- onStop()
- _si && (clearInterval(_si) && (_si = null))
- }
-
- const handle = {[state.IDLE]: stop, [state.UP]: shift, [state.DOWN]: shift, [state.DRAG]: null}
-
- return {
- scroll: (move = state.IDLE) => {
- handle[move] && handle[move](move)
- last = move
- },
- get scrolling() {
- return last !== state.IDLE
- },
- set onShift(fn) {
- onShift = fn
- },
- set onStop(fn) {
- onStop = fn
- },
- state,
- last: () => last
- }
- })(SELECT_THRESHOLD_MS)
-
- const ui = (() => {
- const rootEl = document.getElementById('menu-container')
- const choiceMarkerEl = document.getElementById('menu-item-choice')
-
- const TRANSITION_DEFAULT = `top ${SELECT_THRESHOLD_MS}ms`
- let listTopPos = TOP_POSITION
-
- rootEl.style.transition = TRANSITION_DEFAULT
-
- let onTransitionEnd = () => ({})
-
- //rootEl.addEventListener('transitionend', () => onTransitionEnd())
-
- let items = []
-
- 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 _title = {
- animate: () => title.classList.add('text-move'),
- pick: () => title.classList.add('pick'),
- reset: () => title.classList.remove('pick', 'text-move'),
- }
-
- const clear = () => {
- _title.reset()
- // _desc.hide()
- }
-
- 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)
-
- let hasTransition = true // needed for cases when MENU_RELEASE called instead MENU_PRESSED
-
- scroll.onStop = () => {
- const item = ui.selected
- if (item) {
- item.title.pick()
- item.title.animate()
- // hasTransition ? (ui.onTransitionEnd = item.description.show) : item.description.show()
- }
- }
-
- event.sub(MENU_PRESSED, (position) => {
- if (games.empty()) return
- ui.onTransitionEnd = ui.NO_TRANSITION
- hasTransition = false
- scroll.scroll(scroll.state.DRAG)
- ui.selected && ui.selected.clear()
- ui.drag.startPos(position)
- })
-
- event.sub(MENU_RELEASED, (position) => {
- if (games.empty()) return
- ui.drag.stopPos(position)
- select(ui.roundIndex)
- hasTransition = !hasTransition
- scroll.scroll(scroll.state.IDLE)
- hasTransition = true
- })
+const TOP_POSITION = 102
+const SELECT_THRESHOLD_MS = 160
+const games = (() => {
+ let list = [], index = 0
return {
- scroll: (x) => {
- if (games.empty()) return
- scroll.scroll(x)
+ get index() {
+ return index
+ },
+ get list() {
+ return list
},
get selected() {
- return games.selected
+ return list[index].title // selected by the game title, oof
},
- set: games.set,
- show: () => {
- if (games.empty()) return
- show()
+ set index(i) {
+ //-2 |
+ //-1 | |
+ // 0 < | <
+ // 1 | |
+ // 2 < < |
+ //+1 | |
+ //+2 |
+ 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
}
-})(document, event, gui)
+})()
+
+const scroll = ((DEFAULT_INTERVAL) => {
+ const state = {
+ IDLE: 0, UP: -1, DOWN: 1, DRAG: 3
+ }
+ let last = state.IDLE
+ let _si
+ let onShift, onStop
+
+ const shift = (delta) => {
+ if (scroll.scrolling) return
+ onShift(delta)
+ // velocity?
+ // keep rolling the game list if the button is pressed
+ _si = setInterval(() => onShift(delta), DEFAULT_INTERVAL)
+ }
+
+ const stop = () => {
+ onStop()
+ _si && (clearInterval(_si) && (_si = null))
+ }
+
+ const handle = {[state.IDLE]: stop, [state.UP]: shift, [state.DOWN]: shift, [state.DRAG]: null}
+
+ return {
+ scroll: (move = state.IDLE) => {
+ handle[move] && handle[move](move)
+ last = move
+ },
+ get scrolling() {
+ return last !== state.IDLE
+ },
+ set onShift(fn) {
+ onShift = fn
+ },
+ set onStop(fn) {
+ onStop = fn
+ },
+ state,
+ last: () => last
+ }
+})(SELECT_THRESHOLD_MS)
+
+const ui = (() => {
+ const rootEl = document.getElementById('menu-container')
+ const choiceMarkerEl = document.getElementById('menu-item-choice')
+
+ const TRANSITION_DEFAULT = `top ${SELECT_THRESHOLD_MS}ms`
+ let listTopPos = TOP_POSITION
+
+ rootEl.style.transition = TRANSITION_DEFAULT
+
+ let onTransitionEnd = () => ({})
+
+ //rootEl.addEventListener('transitionend', () => onTransitionEnd())
+
+ let items = []
+
+ 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 _title = {
+ animate: () => title.classList.add('text-move'),
+ pick: () => title.classList.add('pick'),
+ reset: () => title.classList.remove('pick', 'text-move'),
+ }
+
+ const clear = () => {
+ _title.reset()
+ // _desc.hide()
+ }
+
+ 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)
+
+let hasTransition = true // needed for cases when MENU_RELEASE called instead MENU_PRESSED
+
+scroll.onStop = () => {
+ const item = ui.selected
+ if (item) {
+ item.title.pick()
+ item.title.animate()
+ // hasTransition ? (ui.onTransitionEnd = item.description.show) : item.description.show()
+ }
+}
+
+sub(MENU_PRESSED, (position) => {
+ if (games.empty()) return
+ ui.onTransitionEnd = ui.NO_TRANSITION
+ hasTransition = false
+ 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)
+ hasTransition = !hasTransition
+ scroll.scroll(scroll.state.IDLE)
+ hasTransition = true
+})
+
+/**
+ * Game list module.
+ */
+export const gameList = {
+ 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..0d295ea1
--- /dev/null
+++ b/web/js/gui.js
@@ -0,0 +1,259 @@
+/**
+ * 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('none', current === ''));
+ 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 = (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) {
+ 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)
+}
+
+export const gui = {
+ anim: {
+ fadeIn,
+ fadeOut,
+ fadeInOut,
+ },
+ binding,
+ checkbox,
+ create: _create,
+ fragment,
+ hide,
+ inputN,
+ panel,
+ select,
+ show,
+ toggle,
+}
diff --git a/web/js/gui/gui.js b/web/js/gui/gui.js
deleted file mode 100644
index c63a4b89..00000000
--- a/web/js/gui/gui.js
+++ /dev/null
@@ -1,267 +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 = () => ({}), 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 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 = (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 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) {
- 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,
- checkbox,
- create: _create,
- fragment,
- hide,
- inputN,
- panel,
- select,
- show,
- toggle,
- }
-})(document);
diff --git a/web/js/gui/message.js b/web/js/gui/message.js
deleted file mode 100644
index 598d69e9..00000000
--- a/web/js/gui/message.js
+++ /dev/null
@@ -1,46 +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 = (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)
-
- 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..a6aa333d 100644
--- a/web/js/input/input.js
+++ b/web/js/input/input.js
@@ -1,114 +1,5 @@
-const input = (() => {
- const pollingIntervalMs = 4;
- let controllerChangedIndex = -1;
-
- // 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
- };
-
- 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;
- }
- }
- };
-
- const controllerEncoded = [0, 0, 0, 0, 0];
- const keys = Object.keys(controllerState);
-
- const compare = (a, b) => {
- if (a.length !== b.length) return false;
- for (let i = 0; i < a.length; i++) {
- if (a[i] !== b[i]) {
- return false;
- }
- }
- return true;
- };
-
-
- // let lastState = controllerEncoded;
-
- const sendControllerState = () => {
- if (controllerChangedIndex >= 0) {
- const state = _getState();
-
- // log.debug(state)
-
- // if (compare(lastState, state)) {
- // log.debug('!skip')
- // } else {
- event.pub(CONTROLLER_UPDATED, _encodeState(state));
- // }
- // lastState = state;
- controllerChangedIndex = -1;
- }
- };
-
- const setKeyState = (name, state) => {
- if (controllerState[name] !== undefined) {
- controllerState[name] = state;
- controllerChangedIndex = Math.max(controllerChangedIndex, 0);
- }
- };
-
- const setAxisChanged = (index, value) => {
- if (controllerEncoded[index + 1] !== undefined) {
- controllerEncoded[index + 1] = Math.floor(32767 * value);
- controllerChangedIndex = Math.max(controllerChangedIndex, index + 1);
- }
- };
-
- /**
- * Converts key state into a bitmap and prepends it to the axes state.
- *
- * @returns {Uint16Array} The controller state.
- * First uint16 is the controller state bitmap.
- * The other uint16 are the axes values.
- * Truncated to the last value changed.
- *
- * @private
- */
- const _encodeState = (state) => new Uint16Array(state)
-
- const _getState = () => {
- controllerEncoded[0] = 0;
- for (let i = 0, len = keys.length; i < len; i++) {
- controllerEncoded[0] += controllerState[keys[i]] ? 1 << i : 0;
- }
- return controllerEncoded.slice(0, controllerChangedIndex + 1);
- }
-
- return {
- poll: poll(pollingIntervalMs, sendControllerState),
- setKeyState,
- setAxisChanged,
- }
-})(event, KEY, log);
+export {joystick} from './joystick.js?v=3';
+export {KEY} from './keys.js?v=3';
+export {keyboard} from './keyboard.js?v=3'
+export {retropad} from './retropad.js?v=3';
+export {touch} from './touch.js?v=3';
diff --git a/web/js/input/joystick.js b/web/js/input/joystick.js
index c5ee2fdb..b7f9a54a 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} 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)
+ 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;
+ });
+
+ 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 7b46c2cb..1ccba499 100644
--- a/web/js/input/keyboard.js
+++ b/web/js/input/keyboard.js
@@ -1,131 +1,142 @@
+import {
+ pub,
+ sub,
+ KEYBOARD_TOGGLE_FILTER_MODE,
+ AXIS_CHANGED,
+ DPAD_TOGGLE,
+ KEY_PRESSED,
+ KEY_RELEASED,
+ KEYBOARD_KEY_PRESSED
+} 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
+});
+
+let keyMap = {};
+let isKeysFilteredMode = true;
+
+const remap = (map = {}) => {
+ settings.set(opts.INPUT_KEYBOARD_MAP, map);
+ log.info('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 onKey = (code, evt, state) => {
+ const key = keyMap[code]
+ if (key === undefined) return
+
+ 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})
+}
+
+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,
- 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
- });
-
- let keyMap = {};
- let isKeysFilteredMode = true;
-
- const remap = (map = {}) => {
- settings.set(opts.INPUT_KEYBOARD_MAP, map);
- log.info('Keyboard keys have been remapped')
- }
-
- event.sub(KEYBOARD_TOGGLE_FILTER_MODE, data => {
- isKeysFilteredMode = data.mode !== undefined ? data.mode : !isKeysFilteredMode;
- log.debug(`New keyboard filter mode: ${isKeysFilteredMode}`);
- });
-
- let dpadMode = true;
- let dpadState = {[KEY.LEFT]: false, [KEY.RIGHT]: false, [KEY.UP]: false, [KEY.DOWN]: false};
-
- function onDpadToggle(checked) {
- if (dpadMode === checked) {
- return //error?
- }
-
- dpadMode = !dpadMode
- if (dpadMode) {
- // reset dpad keys pressed before moving to analog stick mode
- for (const key in dpadState) {
- if (dpadState[key]) {
- dpadState[key] = false;
- event.pub(KEY_RELEASED, {key: key});
- }
+export const keyboard = {
+ 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 {
+ pub(KEYBOARD_KEY_PRESSED, {key: e.code});
}
- } 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});
+ }, false);
+
+ body.addEventListener('keydown', e => {
+ e.stopPropagation();
+ if (isKeysFilteredMode) {
+ onKey(e.code, KEY_PRESSED, true)
+ } else {
+ pub(KEYBOARD_KEY_PRESSED, {key: e.code});
}
- 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};
- }
+ });
+
+ log.info('[input] keyboard has been initialized');
+ },
+ settings: {
+ remap
}
-
- const onKey = (code, evt, state) => {
- const key = keyMap[code]
- if (key === undefined) return
-
- 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})
- }
-
- event.sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked));
-
- return {
- init: () => {
- keyMap = settings.loadOr(opts.INPUT_KEYBOARD_MAP, defaultMap);
- const body = document.body;
- // !to use prevent default as everyone
- body.addEventListener('keyup', e => {
- e.stopPropagation();
- if (isKeysFilteredMode) {
- onKey(e.code, KEY_RELEASED, false)
- } else {
- event.pub(KEYBOARD_KEY_PRESSED, {key: e.code});
- }
- }, false);
-
- body.addEventListener('keydown', e => {
- e.stopPropagation();
- if (isKeysFilteredMode) {
- onKey(e.code, KEY_PRESSED, true)
- } else {
- event.pub(KEYBOARD_KEY_PRESSED, {key: e.code});
- }
- });
-
- log.info('[input] keyboard has been initialized');
- }, settings: {
- remap
- }
- }
-})(event, document, KEY, log, opts, settings);
+}
diff --git a/web/js/input/keys.js b/web/js/input/keys.js
index 7b16777c..6f94c2ff 100644
--- a/web/js/input/keys.js
+++ b/web/js/input/keys.js
@@ -1,34 +1,32 @@
-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',
- 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',
+}
diff --git a/web/js/input/retropad.js b/web/js/input/retropad.js
new file mode 100644
index 00000000..e841dea6
--- /dev/null
+++ b/web/js/input/retropad.js
@@ -0,0 +1,98 @@
+import {
+ pub,
+ CONTROLLER_UPDATED
+} from 'event';
+import {KEY} from 'input'
+import {log} from 'log';
+
+const pollingIntervalMs = 4;
+let controllerChangedIndex = -1;
+
+// 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
+};
+
+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;
+ }
+ }
+};
+
+const controllerEncoded = [0, 0, 0, 0, 0];
+const keys = Object.keys(controllerState);
+
+const sendControllerState = () => {
+ if (controllerChangedIndex >= 0) {
+ const state = _getState();
+ pub(CONTROLLER_UPDATED, _encodeState(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);
+}
+
+export const retropad = {
+ poll: poll(pollingIntervalMs, sendControllerState),
+ setKeyState,
+ setAxisChanged,
+}
diff --git a/web/js/input/touch.js b/web/js/input/touch.js
index a0a8c32d..a246ef56 100644
--- a/web/js/input/touch.js
+++ b/web/js/input/touch.js
@@ -1,3 +1,300 @@
+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});
+});
+
+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;
+ 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(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() {
+ 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) {
+ 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);
+
+// 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});
+
+sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked));
+
/**
* Touch controls.
*
@@ -7,300 +304,17 @@
* @link https://jsfiddle.net/aa0et7tr/5/
* @version 1
*/
-const touch = (() => {
- 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) => {
- 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'));
+export const touch = {
+ 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');
}
-
- // 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);
- });
-
- // 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('click', handlePlayerSlider);
- playerSlider.addEventListener('touchend', handlePlayerSlider);
-
- // Bind events for menu
- // TODO change this flow
- event.pub(MENU_HANDLER_ATTACHED, {event: 'mousedown', handler: handleMenuDown});
- event.pub(MENU_HANDLER_ATTACHED, {event: 'touchstart', handler: handleMenuDown});
- event.pub(MENU_HANDLER_ATTACHED, {event: 'touchend', handler: handleMenuUp});
-
- event.sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked));
-
- return {
- init: () => {
- // add buttons into the state 🤦
- Array.from(document.querySelectorAll('.btn,.btn-big')).forEach((el) => {
- vpadState[el.getAttribute('value')] = false;
- });
-
- window.addEventListener('mousemove', handleWindowMove);
- window.addEventListener('touchmove', handleWindowMove, {passive: false});
- window.addEventListener('mouseup', handleWindowUp);
-
- log.info('[input] touch input has been initialized');
- }
- }
-})(document, event, KEY, window);
+}
diff --git a/web/js/log.js b/web/js/log.js
index 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/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..47314d9d 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)
+ 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);
+ 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 544a9370..5e8ae47d 100644
--- a/web/js/network/webrtc.js
+++ b/web/js/network/webrtc.js
@@ -1,177 +1,176 @@
-/**
- * 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 dataChannel;
- let mediaStream;
- let candidates = [];
- 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 mediaStream;
+let candidates = [];
+let isAnswered = false;
+let isFlushing = false;
- let onData;
+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)
- e.channel.binaryType = "arraybuffer";
+const start = (iceservers) => {
+ log.info('[rtc] <- ICE servers', iceservers);
+ const servers = iceservers || [];
+ connection = new RTCPeerConnection({iceServers: servers});
+ mediaStream = new MediaStream();
- dataChannel = e.channel;
- dataChannel.onopen = () => {
- log.info('[rtc] the input channel has been opened');
- inputReady = true;
- event.pub(WEBRTC_CONNECTION_READY)
- };
- if (onData) {
- dataChannel.onmessage = onData;
- }
- dataChannel.onclose = () => log.info('[rtc] the input channel has been closed');
+ connection.ondatachannel = e => {
+ log.debug('[rtc] ondatachannel', e.channel.label)
+ e.channel.binaryType = "arraybuffer";
+
+ 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;
}
- connection.oniceconnectionstatechange = ice.onIceConnectionStateChange;
- connection.onicegatheringstatechange = ice.onIceStateChange;
- connection.onicecandidate = ice.onIcecandidate;
- connection.ontrack = event => {
- mediaStream.addTrack(event.track);
- }
- };
-
- const stop = () => {
- if (mediaStream) {
- mediaStream.getTracks().forEach(t => {
- t.stop();
- mediaStream.removeTrack(t);
- });
- mediaStream = null;
- }
- if (connection) {
- connection.close();
- connection = null;
- }
- if (dataChannel) {
- dataChannel.close();
- dataChannel = null;
- }
- candidates = Array();
- log.info('[rtc] WebRTC has been closed');
+ dataChannel.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 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;
+ }
+ 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.warn(`[rtc] ice gathering was aborted due to timeout ${ICE_TIMEOUT}ms`);
- // sendCandidates();
- }, ICE_TIMEOUT);
- break;
- case 'complete':
- log.info('[rtc] ice gathering has been completed');
- if (timeForIceGathering) {
- clearTimeout(timeForIceGathering);
- }
- }
- },
- onIceConnectionStateChange: () => {
- log.info('[rtc] <- iceConnectionState', connection.iceConnectionState);
- switch (connection.iceConnectionState) {
- case 'connected': {
- log.info('[rtc] connected...');
- connected = true;
- break;
- }
- case 'disconnected': {
- log.info(`[rtc] disconnected... ` +
- `connection: ${connection.connectionState}, ice: ${connection.iceConnectionState}, ` +
- `gathering: ${connection.iceGatheringState}, signalling: ${connection.signalingState}`)
- connected = false;
- 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})
},
- 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;
- },
- input: (data) => dataChannel.send(data),
- isConnected: () => connected,
- isInputReady: () => inputReady,
- getConnection: () => connection,
- stop,
- set onData(fn) {
- onData = fn
+ 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;
+ },
+ input: (data) => dataChannel.send(data),
+ isConnected: () => connected,
+ isInputReady: () => inputReady,
+ getConnection: () => connection,
+ 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..f8f2e37f 100644
--- a/web/js/room.js
+++ b/web/js/room.js
@@ -1,76 +1,79 @@
+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.setId(data.roomId);
+ room.save(data.roomId);
+}, 1);
+
/**
* Game room module.
- * @version 1
*/
-const room = (() => {
- let id = '';
+export const room = {
+ 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();
+ 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/settings.js b/web/js/settings.js
new file mode 100644
index 00000000..e63b1394
--- /dev/null
+++ b/web/js/settings.js
@@ -0,0 +1,537 @@
+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.51;
+
+// 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 _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,
+ 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')
+ .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))
+ .restartNeeded()
+ .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 5e9fac03..00000000
--- a/web/js/settings/opts.js
+++ /dev/null
@@ -1,16 +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',
- VOLUME: 'volume',
- FORCE_FULLSCREEN: 'force.fullscreen'
-});
diff --git a/web/js/settings/settings.js b/web/js/settings/settings.js
deleted file mode 100644
index 76c20c33..00000000
--- a/web/js/settings/settings.js
+++ /dev/null
@@ -1,525 +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.51;
-
- // 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`;
-
- let _renderer = {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 = () => 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();
- 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);
- }
-
- 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);
- 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 = () => {
- _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();
- event.pub(SETTINGS_CHANGED);
- }
- },
- title: 'Reset',
- },
- {}
- ],
- (show) => {
- if (show) {
- _render();
- return;
- }
-
- // to make sure it's disabled, but it's a tad verbose
- event.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);
- }
- }
-
- event.sub(SETTINGS_CHANGED, _render);
-
- return {
- init,
- loadOr,
- getStore,
- get,
- set,
- remove,
- import: _import,
- export: _export,
- ui: {
- set onToggle(fn) {
- panel.onToggle(fn);
- },
- toggle: () => panel.toggle(),
- },
- set renderer(fn) {
- _renderer = fn;
- }
- }
-})(document, event, JSON, localStorage, log, window);
-
-// hardcoded ui stuff
-settings.renderer = (() => {
- // 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();
-
- 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.
- */
- const onChange = (key, newValue) => {
- settings.set(key, newValue);
- scrollState.restore(data);
- }
-
- 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('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')
- .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))
- .restartNeeded()
- .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}`;
- })
- );
- }
-
- return {
- render,
- set data(el) {
- data = el;
- scrollState.track(el)
- }
- }
-})(document, gui, log, opts, settings);
diff --git a/web/js/stats.js b/web/js/stats.js
new file mode 100644
index 00000000..126ca4a8
--- /dev/null
+++ b/web/js/stats.js
@@ -0,0 +1,440 @@
+import {env} from 'env';
+import {
+ pub,
+ sub,
+ STATS_TOGGLE,
+ HELP_OVERLAY_TOGGLED,
+ PING_REQUEST,
+ PING_RESPONSE
+} from 'event';
+import {log} from 'log';
+import {webrtc} from 'network';
+
+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(
+ sub(PING_RESPONSE, onPingResponse),
+ 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"];
+ pub('STATS_WEBRTC_FRAME_STATS', frameStatValue)
+ } else if (report["framerateMean"] !== undefined) {
+ frameStatValue = Math.round(report["framerateMean"] * 100) / 100;
+ pub('STATS_WEBRTC_FRAME_STATS', frameStatValue)
+ }
+
+ if (report["nominated"] && report["currentRoundTripTime"] !== undefined) {
+ 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 = 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 = 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);
+
+sub(STATS_TOGGLE, onToggle);
+sub(HELP_OVERLAY_TOGGLED, onHelpOverlayToggle)
+
+/**
+ * App statistics module.
+ */
+export const stats = {
+ enable,
+ disable
+}
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..cd213464
--- /dev/null
+++ b/web/js/stream.js
@@ -0,0 +1,222 @@
+import {env} from 'env';
+import {
+ sub,
+ APP_VIDEO_CHANGED,
+ SETTINGS_CHANGED
+} from 'event' ;
+import {gui} from 'gui';
+import {log} from 'log';
+import {opts, settings} from 'settings';
+
+const screen = document.getElementById('stream');
+
+let options = {
+ volume: 0.5,
+ poster: '/img/screen_loading.gif',
+ mirrorMode: null,
+ mirrorUpdateRate: 1 / 60,
+ forceFullscreen: true,
+ },
+ state = {
+ screen: screen,
+ fullscreen: false,
+ timerId: null,
+ w: 0,
+ h: 0,
+ aspect: 4 / 3
+ };
+
+const mute = (mute) => screen.muted = mute
+
+const _stream = () => {
+ screen.play()
+ .then(() => log.info('Media can autoplay'))
+ .catch(error => {
+ log.error('Media failed to play', error);
+ });
+}
+
+const toggle = (show) => {
+ state.screen.toggleAttribute('hidden', !show)
+}
+
+const toggleFullscreen = () => {
+ let h = parseFloat(getComputedStyle(state.screen, null)
+ .height
+ .replace('px', '')
+ )
+ env.display().toggleFullscreen(h !== window.innerHeight, state.screen);
+}
+
+const getVideoEl = () => screen
+
+screen.onerror = (e) => {
+ // video playback failed - show a message saying why
+ switch (e.target.error.code) {
+ case e.target.error.MEDIA_ERR_ABORTED:
+ log.error('You aborted the video playback.');
+ break;
+ case e.target.error.MEDIA_ERR_NETWORK:
+ log.error('A network error caused the video download to fail part-way.');
+ break;
+ case e.target.error.MEDIA_ERR_DECODE:
+ log.error('The video playback was aborted due to a corruption problem or because the video used features your browser did not support.');
+ break;
+ case e.target.error.MEDIA_ERR_SRC_NOT_SUPPORTED:
+ log.error('The video could not be loaded, either because the server or network failed or because the format is not supported.');
+ break;
+ default:
+ log.error('An unknown video error occurred.');
+ break;
+ }
+};
+
+screen.addEventListener('loadedmetadata', () => {
+ if (state.screen !== screen) {
+ state.screen.setAttribute('width', screen.videoWidth);
+ state.screen.setAttribute('height', screen.videoHeight);
+ }
+}, false);
+screen.addEventListener('loadstart', () => {
+ screen.volume = options.volume;
+ screen.poster = options.poster;
+}, false);
+screen.addEventListener('canplay', () => {
+ screen.poster = '';
+ useCustomScreen(options.mirrorMode === 'mirror');
+}, false);
+
+screen.addEventListener('fullscreenchange', () => {
+ state.fullscreen = !!document.fullscreenElement;
+
+ const w = window.screen.width ?? window.innerWidth;
+ const h = window.screen.height ?? window.innerHeight;
+
+ const ww = document.documentElement.innerWidth;
+ const hh = document.documentElement.innerHeight;
+
+ screen.style.padding = '0'
+ if (state.fullscreen) {
+ const dw = (w - ww * state.aspect) / 2
+ screen.style.padding = `0 ${dw}px`
+ // chrome bug
+ setTimeout(() => {
+ const dw = (h - hh * state.aspect) / 2
+ screen.style.padding = `0 ${dw}px`
+ }, 1)
+ makeFullscreen(true);
+ } else {
+ makeFullscreen(false);
+ }
+
+ // !to flipped
+})
+
+const makeFullscreen = (make = false) => {
+ screen.classList.toggle('no-media-controls', make)
+}
+
+const forceFullscreenMaybe = () => {
+ const touchMode = env.isMobileDevice();
+ log.debug('touch check', touchMode)
+ !touchMode && options.forceFullscreen && toggleFullscreen();
+}
+
+const useCustomScreen = (use) => {
+ if (use) {
+ if (screen.paused || screen.ended) return;
+
+ let id = state.screen.getAttribute('id');
+ if (id === 'canvas-mirror') return;
+
+ const canvas = gui.create('canvas');
+ canvas.setAttribute('id', 'canvas-mirror');
+ canvas.setAttribute('hidden', '');
+ canvas.setAttribute('width', screen.videoWidth);
+ canvas.setAttribute('height', screen.videoHeight);
+ canvas.style['image-rendering'] = 'pixelated';
+ canvas.style.width = '100%'
+ canvas.style.height = '100%'
+ canvas.classList.add('game-screen');
+
+ // stretch depending on the video orientation
+ // portrait -- vertically, landscape -- horizontally
+ const isPortrait = screen.videoWidth < screen.videoHeight;
+ canvas.style.width = isPortrait ? 'auto' : canvas.style.width;
+ // canvas.style.height = isPortrait ? canvas.style.height : 'auto';
+
+ let surface = canvas.getContext('2d');
+ screen.parentNode.insertBefore(canvas, screen.nextSibling);
+ toggle(false)
+ state.screen = canvas
+ toggle(true)
+ state.timerId = setInterval(function () {
+ if (screen.paused || screen.ended || !surface) return;
+ surface.drawImage(screen, 0, 0);
+ }, options.mirrorUpdateRate);
+ } else {
+ clearInterval(state.timerId);
+ let mirror = state.screen;
+ state.screen = screen;
+ toggle(true);
+ if (mirror !== screen) {
+ mirror.parentNode.removeChild(mirror);
+ }
+ }
+}
+
+const init = () => {
+ options.mirrorMode = settings.loadOr(opts.MIRROR_SCREEN, 'none');
+ options.volume = settings.loadOr(opts.VOLUME, 50) / 100;
+ options.forceFullscreen = settings.loadOr(opts.FORCE_FULLSCREEN, false);
+}
+
+sub(SETTINGS_CHANGED, () => {
+ const s = settings.get();
+ const newValue = s[opts.MIRROR_SCREEN];
+ if (newValue !== options.mirrorMode) {
+ useCustomScreen(newValue === 'mirror');
+ options.mirrorMode = newValue;
+ }
+ const newValue2 = s[opts.FORCE_FULLSCREEN];
+ if (newValue2 !== options.forceFullscreen) {
+ options.forceFullscreen = newValue2;
+ }
+});
+
+const fit = 'contain'
+
+sub(APP_VIDEO_CHANGED, (payload) => {
+ const {w, h, a, s} = payload
+
+ const scale = !s ? 1 : s;
+ const ww = w * scale;
+ const hh = h * scale;
+
+ state.aspect = a
+
+ const a2 = (ww / hh).toFixed(6)
+
+ state.screen.style['object-fit'] = a > 1 && a.toFixed(6) !== a2 ? 'fill' : fit
+ state.h = hh
+ state.w = Math.floor(hh * a)
+ state.screen.setAttribute('width', '' + ww)
+ state.screen.setAttribute('height', '' + hh)
+ state.screen.style.aspectRatio = '' + state.aspect
+})
+
+/**
+ * Game streaming module.
+ * Contains HTML5 AV media elements.
+ *
+ * @version 1
+ */
+export const stream = {
+ audio: {mute},
+ video: {toggleFullscreen, el: getVideoEl},
+ play: _stream,
+ toggle,
+ useCustomScreen,
+ forceFullscreenMaybe,
+ init
+}
diff --git a/web/js/stream/stream.js b/web/js/stream/stream.js
deleted file mode 100644
index b0fb730d..00000000
--- a/web/js/stream/stream.js
+++ /dev/null
@@ -1,216 +0,0 @@
-/**
- * Game streaming module.
- * Contains HTML5 AV media elements.
- *
- * @version 1
- */
-const stream = (() => {
- const screen = document.getElementById('stream');
-
- let options = {
- volume: 0.5,
- poster: '/img/screen_loading.gif',
- mirrorMode: null,
- mirrorUpdateRate: 1 / 60,
- forceFullscreen: true,
- },
- state = {
- screen: screen,
- fullscreen: false,
- timerId: null,
- w: 0,
- h: 0,
- aspect: 4 / 3
- };
-
- const mute = (mute) => screen.muted = mute
-
- const stream = () => {
- screen.play()
- .then(() => log.info('Media can autoplay'))
- .catch(error => {
- log.error('Media failed to play', error);
- });
- }
-
- const toggle = (show) => {
- state.screen.toggleAttribute('hidden', !show)
- }
-
- const toggleFullscreen = () => {
- let h = parseFloat(getComputedStyle(state.screen, null)
- .height
- .replace('px', '')
- )
- env.display().toggleFullscreen(h !== window.innerHeight, state.screen);
- }
-
- const getVideoEl = () => screen
-
- screen.onerror = (e) => {
- // video playback failed - show a message saying why
- switch (e.target.error.code) {
- case e.target.error.MEDIA_ERR_ABORTED:
- log.error('You aborted the video playback.');
- break;
- case e.target.error.MEDIA_ERR_NETWORK:
- log.error('A network error caused the video download to fail part-way.');
- break;
- case e.target.error.MEDIA_ERR_DECODE:
- log.error('The video playback was aborted due to a corruption problem or because the video used features your browser did not support.');
- break;
- case e.target.error.MEDIA_ERR_SRC_NOT_SUPPORTED:
- log.error('The video could not be loaded, either because the server or network failed or because the format is not supported.');
- break;
- default:
- log.error('An unknown video error occurred.');
- break;
- }
- };
-
- screen.addEventListener('loadedmetadata', () => {
- if (state.screen !== screen) {
- state.screen.setAttribute('width', screen.videoWidth);
- state.screen.setAttribute('height', screen.videoHeight);
- }
- }, false);
- screen.addEventListener('loadstart', () => {
- screen.volume = options.volume;
- screen.poster = options.poster;
- }, false);
- screen.addEventListener('canplay', () => {
- screen.poster = '';
- useCustomScreen(options.mirrorMode === 'mirror');
- }, false);
-
- screen.addEventListener('fullscreenchange', () => {
- state.fullscreen = !!document.fullscreenElement;
-
- const w = window.screen.width ?? window.innerWidth;
- const h = window.screen.height ?? window.innerHeight;
-
- const ww = document.documentElement.innerWidth;
- const hh = document.documentElement.innerHeight;
-
- screen.style.padding = '0'
- if (state.fullscreen) {
- const dw = (w - ww * state.aspect) / 2
- screen.style.padding = `0 ${dw}px`
- // chrome bug
- setTimeout(() => {
- const dw = (h - hh * state.aspect) / 2
- screen.style.padding = `0 ${dw}px`
- }, 1)
- makeFullscreen(true);
- } else {
- makeFullscreen(false);
- }
-
- // !to flipped
- })
-
- const makeFullscreen = (make = false) => {
- screen.classList.toggle('no-media-controls', make)
- }
-
- const forceFullscreenMaybe = () => {
- const touchMode = env.isMobileDevice();
- log.debug('touch check', touchMode)
- !touchMode && options.forceFullscreen && toggleFullscreen();
- }
-
- const useCustomScreen = (use) => {
- if (use) {
- if (screen.paused || screen.ended) return;
-
- let id = state.screen.getAttribute('id');
- if (id === 'canvas-mirror') return;
-
- const canvas = gui.create('canvas');
- canvas.setAttribute('id', 'canvas-mirror');
- canvas.setAttribute('hidden', '');
- canvas.setAttribute('width', screen.videoWidth);
- canvas.setAttribute('height', screen.videoHeight);
- canvas.style['image-rendering'] = 'pixelated';
- canvas.style.width = '100%'
- canvas.style.height = '100%'
- canvas.classList.add('game-screen');
-
- // stretch depending on the video orientation
- // portrait -- vertically, landscape -- horizontally
- const isPortrait = screen.videoWidth < screen.videoHeight;
- canvas.style.width = isPortrait ? 'auto' : canvas.style.width;
- // canvas.style.height = isPortrait ? canvas.style.height : 'auto';
-
- let surface = canvas.getContext('2d');
- screen.parentNode.insertBefore(canvas, screen.nextSibling);
- toggle(false)
- state.screen = canvas
- toggle(true)
- state.timerId = setInterval(function () {
- if (screen.paused || screen.ended || !surface) return;
- surface.drawImage(screen, 0, 0);
- }, options.mirrorUpdateRate);
- } else {
- clearInterval(state.timerId);
- let mirror = state.screen;
- state.screen = screen;
- toggle(true);
- if (mirror !== screen) {
- mirror.parentNode.removeChild(mirror);
- }
- }
- }
-
- const init = () => {
- options.mirrorMode = settings.loadOr(opts.MIRROR_SCREEN, 'none');
- options.volume = settings.loadOr(opts.VOLUME, 50) / 100;
- options.forceFullscreen = settings.loadOr(opts.FORCE_FULLSCREEN, false);
- }
-
- event.sub(SETTINGS_CHANGED, () => {
- const s = settings.get();
- const newValue = s[opts.MIRROR_SCREEN];
- if (newValue !== options.mirrorMode) {
- useCustomScreen(newValue === 'mirror');
- options.mirrorMode = newValue;
- }
- const newValue2 = s[opts.FORCE_FULLSCREEN];
- if (newValue2 !== options.forceFullscreen) {
- options.forceFullscreen = newValue2;
- }
- });
-
-
- const fit = 'contain'
-
- event.sub(APP_VIDEO_CHANGED, (payload) => {
- const {w, h, a, s} = payload
-
- const scale = !s ? 1 : s;
- const ww = w * scale;
- const hh = h * scale;
-
- state.aspect = a
-
- const a2 = (ww / hh).toFixed(6)
-
- state.screen.style['object-fit'] = a > 1 && a.toFixed(6) !== a2 ? 'fill' : fit
- state.h = hh
- state.w = Math.floor(hh * a)
- state.screen.setAttribute('width', '' + ww)
- state.screen.setAttribute('height', '' + hh)
- state.screen.style.aspectRatio = '' + state.aspect
- })
-
- return {
- audio: {mute},
- video: {toggleFullscreen, el: getVideoEl},
- play: stream,
- toggle,
- useCustomScreen,
- forceFullscreenMaybe,
- init
- }
- }
-)(env, event, gui, log, opts, settings);
diff --git a/web/js/utils.js b/web/js/utils.js
index 28f557ab..5725c6cb 100644
--- a/web/js/utils.js
+++ b/web/js/utils.js
@@ -1,58 +1,50 @@
/**
- * Utility module.
- * @version 1
+ * A decorator that passes the call to function at maximum once per specified milliseconds.
+ * @param f The function to call.
+ * @param ms The amount of time in milliseconds to ignore the function calls.
+ * @returns {Function}
+ * @example
+ * const showMessage = () => { alert('00001'); }
+ * const showOnlyOnceASecond = debounce(showMessage, 1000);
*/
-const utils = (() => {
- return {
- /**
- * A decorator that passes the call to function at maximum once per specified milliseconds.
- * @param f The function to call.
- * @param ms The amount of time in milliseconds to ignore the function calls.
- * @returns {Function}
- * @example
- * const showMessage = () => { alert('00001'); }
- * const showOnlyOnceASecond = debounce(showMessage, 1000);
- */
- debounce: (f, ms) => {
- let wait = false;
+export const debounce = (f, ms) => {
+ let wait = false;
- return function () {
- if (wait) return;
+ return function () {
+ if (wait) return;
- f.apply(this, arguments);
- wait = true;
- setTimeout(() => wait = false, ms);
- };
- },
+ f.apply(this, arguments);
+ wait = true;
+ setTimeout(() => wait = false, ms);
+ };
+}
- /**
- * A decorator that blocks and calls the last function until the specified amount of milliseconds.
- * @param f The function to call.
- * @param ms The amount of time in milliseconds to ignore the function calls.
- * @returns {Function}
- */
- throttle: (f, ms) => {
- let lastCall;
- let lastTime;
+/**
+ * A decorator that blocks and calls the last function until the specified amount of milliseconds.
+ * @param f The function to call.
+ * @param ms The amount of time in milliseconds to ignore the function calls.
+ * @returns {Function}
+ */
+export const throttle = (f, ms) => {
+ let lastCall;
+ let lastTime;
- return function () {
- // could be a stack
- const lastContext = this;
- const lastArguments = arguments;
+ return function () {
+ // could be a stack
+ const lastContext = this;
+ const lastArguments = arguments;
- if (!lastTime) {
+ if (!lastTime) {
+ f.apply(lastContext, lastArguments);
+ lastTime = Date.now()
+ } else {
+ clearTimeout(lastCall);
+ lastCall = setTimeout(() => {
+ if (Date.now() - lastTime >= ms) {
f.apply(lastContext, lastArguments);
lastTime = Date.now()
- } else {
- clearTimeout(lastCall);
- lastCall = setTimeout(() => {
- if (Date.now() - lastTime >= ms) {
- f.apply(lastContext, lastArguments);
- lastTime = Date.now()
- }
- }, ms - (Date.now() - lastTime))
}
- }
+ }, ms - (Date.now() - lastTime))
}
}
-})();
+}
diff --git a/web/js/workerManager.js b/web/js/workerManager.js
index 3d119b2a..4afba4ca 100644
--- a/web/js/workerManager.js
+++ b/web/js/workerManager.js
@@ -1,151 +1,158 @@
-/**
- * Worker manager module.
- * @version 1
- */
-const workerManager = (() => {
- const id = 'servers',
- _class = 'server-list',
- trigger = document.getElementById('w'),
- panel = gui.panel(document.getElementById(id), 'WORKERS', 'server-list', null, [
- {
- caption: '⟳',
- cl: ['bold'],
- handler: utils.debounce(handleReload, 1000),
- title: 'Reload server data',
- }
- ]),
- index = ((i = 1) => ({v: () => i++, r: () => i = 1}))(),
- // caption -- the field caption
- // renderer -- an arbitrary DOM output for the field
- list = {
- 'n': {
- renderer: renderIdEl
- },
- 'id': {
- caption: 'ID',
- renderer: (data) => data?.in_group ? `${data.id} x ${data.replicas}` : data.id
- },
- 'addr': {
- caption: 'Address',
- renderer: (data) => data?.port ? `${data.addr}:${data.port}` : data.addr
- },
- 'is_busy': {
- caption: 'State',
- renderer: renderStateEl
- },
- 'use': {
- caption: 'Use',
- renderer: renderServerChangeEl
- }
+import {api} from 'api';
+import {
+ sub,
+ WORKER_LIST_FETCHED
+} from 'event'
+import {gui} from 'gui';
+import {log} from 'log';
+import {ajax} from 'network';
+import {debounce} from 'utils';
+
+const id = 'servers',
+ _class = 'server-list',
+ trigger = document.getElementById('w'),
+ panel = gui.panel(document.getElementById(id), 'WORKERS', 'server-list', null, [
+ {
+ caption: '⟳',
+ cl: ['bold'],
+ handler: debounce(handleReload, 1000),
+ title: 'Reload server data',
+ }
+ ]),
+ index = ((i = 1) => ({v: () => i++, r: () => i = 1}))(),
+ // caption -- the field caption
+ // renderer -- an arbitrary DOM output for the field
+ list = {
+ 'n': {
+ renderer: renderIdEl
},
- fields = Object.keys(list);
-
- let state = {
- lastId: null,
- workers: [],
- }
-
- const onNewData = (dat = {servers: []}) => {
- panel.setLoad(false);
- index.r();
- state.workers = dat?.servers || [];
- _render(state.workers);
- }
-
- function _render(servers = []) {
- if (panel.isHidden()) return;
-
- const content = gui.fragment();
-
- if (servers.length === 0) {
- content.append(gui.create('span', (el) => el.innerText = 'No data :('));
- panel.setContent(content);
- return;
+ 'id': {
+ caption: 'ID',
+ renderer: (data) => data?.in_group ? `${data.id} x ${data.replicas}` : data.id
+ },
+ 'addr': {
+ caption: 'Address',
+ renderer: (data) => data?.port ? `${data.addr}:${data.port}` : data.addr
+ },
+ 'is_busy': {
+ caption: 'State',
+ renderer: renderStateEl
+ },
+ 'use': {
+ caption: 'Use',
+ renderer: renderServerChangeEl
}
+ },
+ fields = Object.keys(list);
- const header = gui.create('div', (el) => {
- el.classList.add(`${_class}__header`);
- fields.forEach(field => el.append(gui.create('span', (f) => f.innerHTML = list[field]?.caption || '')))
- });
- content.append(header)
+let state = {
+ lastId: null,
+ workers: [],
+}
- const renderRow = (server) => (row) => {
- if (server?.id && state.lastId && state.lastId === server?.id) {
- row.classList.add('active');
- }
- return fields.forEach(field => {
- const val = server.hasOwnProperty(field) ? server[field] : '';
- const renderer = list[field]?.renderer;
- row.append(gui.create('span', (f) => f.append(renderer ? renderer(server) : val)));
- })
- }
- servers.forEach(server => content.append(gui.create('div', renderRow(server))))
+const onNewData = (dat = {servers: []}) => {
+ panel.setLoad(false);
+ index.r();
+ state.workers = dat?.servers || [];
+ _render(state.workers);
+}
+
+function _render(servers = []) {
+ if (panel.isHidden()) return;
+
+ const content = gui.fragment();
+
+ if (servers.length === 0) {
+ content.append(gui.create('span', (el) => el.innerText = 'No data :('));
panel.setContent(content);
+ return;
}
- function handleReload() {
- panel.setLoad(true);
- api.server.getWorkerList();
- }
+ const header = gui.create('div', (el) => {
+ el.classList.add(`${_class}__header`);
+ fields.forEach(field => el.append(gui.create('span', (f) => f.innerHTML = list[field]?.caption || '')))
+ });
+ content.append(header)
- function renderIdEl(server) {
- const id = String(index.v()).padStart(2, '0');
- const isActive = server?.id && state.lastId && state.lastId === server?.id
- return `${(isActive ? '>' : '')}${id}`
- }
-
- function renderServerChangeEl(server) {
- const handleServerChange = (e) => {
- e.preventDefault();
- window.location.search = `wid=${server.id}`
+ const renderRow = (server) => (row) => {
+ if (server?.id && state.lastId && state.lastId === server?.id) {
+ row.classList.add('active');
}
- return gui.create('a', (el) => {
- el.innerText = '>>';
- el.href = "#";
- el.addEventListener('click', handleServerChange);
+ return fields.forEach(field => {
+ const val = server.hasOwnProperty(field) ? server[field] : '';
+ const renderer = list[field]?.renderer;
+ row.append(gui.create('span', (f) => f.append(renderer ? renderer(server) : val)));
})
}
+ servers.forEach(server => content.append(gui.create('div', renderRow(server))))
+ panel.setContent(content);
+}
- function renderStateEl(server) {
- const state = server?.is_busy === true ? 'R' : ''
- if (server.room) {
- return gui.create('a', (el) => {
- el.innerText = state;
- el.href = "/?id=" + server.room;
- })
- }
- return state
+function handleReload() {
+ panel.setLoad(true);
+ api.server.getWorkerList();
+}
+
+function renderIdEl(server) {
+ const id = String(index.v()).padStart(2, '0');
+ const isActive = server?.id && state.lastId && state.lastId === server?.id
+ return `${(isActive ? '>' : '')}${id}`
+}
+
+function renderServerChangeEl(server) {
+ const handleServerChange = (e) => {
+ e.preventDefault();
+ window.location.search = `wid=${server.id}`
}
-
- panel.toggle(false);
-
- trigger.addEventListener('click', () => {
- handleReload();
- panel.toggle(true);
+ return gui.create('a', (el) => {
+ el.innerText = '>>';
+ el.href = "#";
+ el.addEventListener('click', handleServerChange);
})
+}
- const checkLatencies = (data) => {
- const timeoutMs = 1111;
- // deduplicate
- const addresses = [...new Set(data.addresses || [])];
-
- return Promise.all(addresses.map(address => {
- const start = Date.now();
- return ajax.fetch(`${address}?_=${start}`, {method: "GET", redirect: "follow"}, timeoutMs)
- .then(() => ({[address]: Date.now() - start}))
- .catch(() => ({[address]: 9999}));
- }))
- };
-
- const whoami = (id) => {
- state.lastId = id;
- _render(state.workers);
+function renderStateEl(server) {
+ const state = server?.is_busy === true ? 'R' : ''
+ if (server.room) {
+ return gui.create('a', (el) => {
+ el.innerText = state;
+ el.href = "/?id=" + server.room;
+ })
}
+ return state
+}
- event.sub(WORKER_LIST_FETCHED, onNewData);
+panel.toggle(false);
- return {
- checkLatencies,
- whoami,
- }
-})(ajax, api, document, event, gui, log, utils);
+trigger.addEventListener('click', () => {
+ handleReload();
+ panel.toggle(true);
+})
+
+const checkLatencies = (data) => {
+ const timeoutMs = 1111;
+ // deduplicate
+ const addresses = [...new Set(data.addresses || [])];
+
+ return Promise.all(addresses.map(address => {
+ const start = Date.now();
+ return ajax.fetch(`${address}?_=${start}`, {method: "GET", redirect: "follow"}, timeoutMs)
+ .then(() => ({[address]: Date.now() - start}))
+ .catch(() => ({[address]: 9999}));
+ }))
+};
+
+const whoami = (id) => {
+ state.lastId = id;
+ _render(state.workers);
+}
+
+sub(WORKER_LIST_FETCHED, onNewData);
+
+/**
+ * Worker manager module.
+ */
+export const workerManager = {
+ checkLatencies,
+ whoami,
+}