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.
This commit is contained in:
Sergey Stepanov 2024-03-17 17:24:00 +03:00 committed by sergystepanov
parent 2aaf37b766
commit 2bc64a3be8
36 changed files with 3984 additions and 3918 deletions

View file

@ -15,7 +15,7 @@
<link rel="icon" href="data:,">
<link href="css/main.css?v=6" rel="stylesheet">
<link href="css/main.css?v=3" rel="stylesheet">
<link href="css/ui.css?v=3" rel="stylesheet">
<title>Cloud Retro</title>
</head>
@ -49,7 +49,9 @@
<div id="settings"></div>
<div id="guide-txt">
<b>Arrows</b> (move), <b>ZXCVAS;'./</b> (game ABXYL1-L3R1-R3), <b>1/2</b> (1st/2nd player), <b>Shift/Enter/K/L</b> (select/start/save/load), <b>F</b> (fullscreen), <b>share</b> (copy the link to the clipboard)
<b>Arrows</b> (move), <b>ZXCVAS;'./</b> (game ABXYL1-L3R1-R3), <b>1/2</b> (1st/2nd player),
<b>Shift/Enter/K/L</b> (select/start/save/load), <b>F</b> (fullscreen), <b>share</b> (copy the link to the
clipboard)
</div>
<div id="btn-join" class="btn big" value="join"></div>
<div id="slider-playeridx" class="slidecontainer">
@ -102,32 +104,23 @@
</a>
</div>
<script src="js/gui/gui.js?v=3"></script>
<script src="js/utils.js?v1"></script>
<script src="js/gui/message.js?v=4"></script>
<script src="js/log.js?v=5"></script>
<script src="js/event/event.js?v=6"></script>
<script src="js/input/keys.js?v=3"></script>
<script src="js/settings/opts.js?v=3"></script>
<script src="js/settings/settings.js?v=8"></script>
<script src="js/env.js?v=6"></script>
<script src="js/input/input.js?v=3"></script>
<script src="js/gameList.js?v=5"></script>
<script src="js/stream/stream.js?v=8"></script>
<script src="js/room.js?v=3"></script>
<script src="js/network/ajax.js?v=3"></script>
<script src="js/network/socket.js?v=4"></script>
<script src="js/network/webrtc.js?v=4"></script>
<script src="js/recording.js?v=1"></script>
<script src="js/api/api.js?v=3"></script>
<script src="js/workerManager.js?v=1"></script>
<script src="js/stats/stats.js?v=1"></script>
<script src="js/controller.js?v=13"></script>
<script src="js/input/keyboard.js?v=6"></script>
<script src="js/input/touch.js?v=3"></script>
<script src="js/input/joystick.js?v=3"></script>
<script type="importmap">
{
"imports": {
"api": "./js/api.js?v=3",
"env": "./js/env.js?v=3",
"event": "./js/event.js?v=3",
"gui": "./js/gui.js?v=3",
"input": "./js/input/input.js?v=3",
"log": "./js/log.js?v=3",
"network": "./js/network/network.js?v=3",
"settings": "./js/settings.js?v=3",
"utils": "./js/utils.js?v=3"
}
}
</script>
<script src="js/init.js?v=5"></script>
<script type="module" src="js/app.js?v=3"></script>
{{if .Analytics.Inject}}
<script async src="https://www.googletagmanager.com/gtag/js?id={{.Analytics.Gtag}}"></script>

70
web/js/api.js Normal file
View file

@ -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))

View file

@ -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);

541
web/js/app.js Normal file
View file

@ -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;

View file

@ -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);

View file

@ -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
})
}

100
web/js/event.js Normal file
View file

@ -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'

View file

@ -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'

View file

@ -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 =>
`<div class="menu-item">` +
`<div><span>${game.title}</span></div>` +
//`<div class="menu-item__info">${game.system}</div>` +
`</div>`)
.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 =>
`<div class="menu-item">` +
`<div><span>${game.title}</span></div>` +
//`<div class="menu-item__info">${game.system}</div>` +
`</div>`)
.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()
},
}

259
web/js/gui.js Normal file
View file

@ -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,
}

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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';

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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',
}

98
web/js/input/retropad.js Normal file
View file

@ -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,
}

View file

@ -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);
}

View file

@ -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

44
web/js/message.js Normal file
View file

@ -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,
}

View file

@ -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
}
})();
return reject
});
// auto abort when a timeout reached
setTimeout(() => {
controller.abort();
reject();
}, timeout);
}),
defaultTimeoutMs: () => defaultTimeout
}

View file

@ -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';

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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

View file

@ -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);
}

537
web/js/settings.js Normal file
View file

@ -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)
}
}

View file

@ -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'
});

View file

@ -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);

440
web/js/stats.js Normal file
View file

@ -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:
* <div><div>LABEL</div><span>VALUE</span>[<span><canvas/><span>]</div>
*
* @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
}

View file

@ -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:
* <div><div>LABEL</div><span>VALUE</span>[<span><canvas/><span>]</div>
*
* @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);

222
web/js/stream.js Normal file
View file

@ -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
}

View file

@ -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);

View file

@ -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))
}
}
})();
}

View file

@ -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,
}