From 2bc64a3be827993b9be4b9c6b23faedcb7fab7da Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Sun, 17 Mar 2024 17:24:00 +0300 Subject: [PATCH] Migrate from IIFE to modern ES modules These modules should be supported by all contemporary browsers, and this transition should resolve most issues related to the explicit import order of the .js files. --- web/index.html | 47 ++- web/js/api.js | 70 +++++ web/js/api/api.js | 68 ---- web/js/app.js | 541 ++++++++++++++++++++++++++++++++ web/js/controller.js | 468 ---------------------------- web/js/env.js | 212 +++++++------ web/js/event.js | 100 ++++++ web/js/event/event.js | 104 ------- web/js/gameList.js | 458 +++++++++++++-------------- web/js/gui.js | 259 ++++++++++++++++ web/js/gui/gui.js | 267 ---------------- web/js/gui/message.js | 46 --- web/js/init.js | 26 -- web/js/input/input.js | 119 +------ web/js/input/joystick.js | 524 ++++++++++++++++--------------- web/js/input/keyboard.js | 261 ++++++++-------- web/js/input/keys.js | 66 ++-- web/js/input/retropad.js | 98 ++++++ web/js/input/touch.js | 604 ++++++++++++++++++------------------ web/js/log.js | 60 ++-- web/js/message.js | 44 +++ web/js/network/ajax.js | 43 ++- web/js/network/network.js | 3 + web/js/network/socket.js | 94 +++--- web/js/network/webrtc.js | 321 ++++++++++--------- web/js/recording.js | 112 +++---- web/js/room.js | 141 +++++---- web/js/settings.js | 537 ++++++++++++++++++++++++++++++++ web/js/settings/opts.js | 16 - web/js/settings/settings.js | 525 ------------------------------- web/js/stats.js | 440 ++++++++++++++++++++++++++ web/js/stats/stats.js | 433 -------------------------- web/js/stream.js | 222 +++++++++++++ web/js/stream/stream.js | 216 ------------- web/js/utils.js | 84 +++-- web/js/workerManager.js | 273 ++++++++-------- 36 files changed, 3984 insertions(+), 3918 deletions(-) create mode 100644 web/js/api.js delete mode 100644 web/js/api/api.js create mode 100644 web/js/app.js delete mode 100644 web/js/controller.js create mode 100644 web/js/event.js delete mode 100644 web/js/event/event.js create mode 100644 web/js/gui.js delete mode 100644 web/js/gui/gui.js delete mode 100644 web/js/gui/message.js delete mode 100644 web/js/init.js create mode 100644 web/js/input/retropad.js create mode 100644 web/js/message.js create mode 100644 web/js/network/network.js create mode 100644 web/js/settings.js delete mode 100644 web/js/settings/opts.js delete mode 100644 web/js/settings/settings.js create mode 100644 web/js/stats.js delete mode 100644 web/js/stats/stats.js create mode 100644 web/js/stream.js delete mode 100644 web/js/stream/stream.js 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, +}