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: + *
LABEL
VALUE[]
+ * + * @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: - *
LABEL
VALUE[]
- * - * @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, +}