update to newer version

master
Thomas 2 years ago
parent 5d839e157b
commit 5c7a30c7f8
  1. 15
      README.md
  2. 7
      backend/.eslintrc.js
  3. 14
      backend/Dockerfile
  4. 1455
      backend/package-lock.json
  5. 15
      backend/package.json
  6. 299
      backend/src/Room.ts
  7. 223
      backend/src/Service.ts
  8. 193
      backend/src/User.ts
  9. 4896
      backend/src/data/timelines.ts
  10. 230
      backend/src/index.ts
  11. 61
      backend/src/timeline.ts
  12. 10
      backend/src/util.ts
  13. 6
      backend/tsconfig.json
  14. 739
      backend/yarn.lock
  15. 23
      frontend/.eslintrc.js
  16. 12
      frontend/Dockerfile
  17. 25
      frontend/config-overrides.js
  18. 21
      frontend/index.html
  19. 5282
      frontend/package-lock.json
  20. 64
      frontend/package.json
  21. 6
      frontend/postcss.config.js
  22. BIN
      frontend/public/favicon.ico
  23. 39
      frontend/public/index.html
  24. BIN
      frontend/public/logo192.png
  25. BIN
      frontend/public/logo512.png
  26. 8
      frontend/public/manifest.json
  27. 1
      frontend/public/robots.txt
  28. 11
      frontend/src/components/App.tsx
  29. 56
      frontend/src/components/Centurion.tsx
  30. 232
      frontend/src/components/Feed.tsx
  31. 47
      frontend/src/components/FeedItem.tsx
  32. 95
      frontend/src/components/FeedTicker.tsx
  33. 502
      frontend/src/components/Lobby.tsx
  34. 80
      frontend/src/components/NextShot.tsx
  35. 261
      frontend/src/components/Player.tsx
  36. 57
      frontend/src/components/ShotsTaken.tsx
  37. 133
      frontend/src/css/feed.css
  38. 49
      frontend/src/css/feed.sass
  39. 59
      frontend/src/css/index.css
  40. 57
      frontend/src/css/index.sass
  41. 76
      frontend/src/css/lobby.css
  42. 62
      frontend/src/css/lobby.sass
  43. 16
      frontend/src/css/player.css
  44. 4
      frontend/src/css/player.sass
  45. 9
      frontend/src/index.tsx
  46. 518
      frontend/src/lib/Connection.ts
  47. 1
      frontend/src/react-app-env.d.ts
  48. 153
      frontend/src/types/types.ts
  49. 39
      frontend/src/util/hooks.ts
  50. 33
      frontend/src/util/socket.ts
  51. 67
      frontend/src/util/sub.ts
  52. 18
      frontend/tsconfig.json
  53. 46
      frontend/vite.config.ts
  54. 12344
      frontend/yarn.lock
  55. 11772
      package-lock.json
  56. 44
      package.json

@ -1,13 +1,20 @@
CENTVRION CENTURION
========= =========
## Introduction ## Introduction
The projects consists of two projects, a frontend written in React, and a backend written in javascript. Both communicate with a websocket. The projects consists of two projects, a frontend written in React, and a backend written in Typescript (Node). Both communicate with a websocket.
## Setup ## Setup
Download centurion.m4a and place it in frontend/public/centurion.m4a 1. Download the various song files and place them in `frontend/public/songs/`. Easiest way to do this is to get them from production (E.g. `https://centurion.svia.nl/songs/centurion.m4a`)
2. Run `npm i` in the root directory, frontend, and backend.
3. Prepare the pre-commit hooks by running `npm run prepare`.
Start the backend with `npm run app` and the frontend with `npm run start`. ESLint is used for linting, Prettier for formatting. Currently, there are no tests. Tread carefully.
## Starting
* Start the frontend by running `npm run start` in the frontend directory.
* Idem for the backend.

@ -0,0 +1,7 @@
module.exports = {
"ignorePatterns": [".eslintrc.js"],
"parserOptions": {
"tsconfigRootDir": __dirname,
"project": ["./tsconfig.json"]
}
};

@ -1,11 +1,13 @@
FROM node:13-alpine FROM node:16-alpine
WORKDIR /app WORKDIR /app
COPY package.json yarn.lock ./ # Install TS manually since it's only included in the parent's package.json
RUN yarn install RUN npm install --global typescript@^4.5.2
COPY package.json package-lock.json ./
RUN npm ci --no-progress --no-optional
COPY tsconfig.json ./ COPY tsconfig.json ./
COPY src src/ COPY src src/
COPY data data/ RUN npm run build
CMD ["npm", "run", "app"]
CMD ["npm", "run", "start-compiled"]

File diff suppressed because it is too large Load Diff

@ -5,18 +5,21 @@
"main": "./src/index.js", "main": "./src/index.js",
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"app": "ts-node src/index.ts" "start-compiled": "node build/index.js",
"start": "ts-node src/index.ts",
"check": "tsc --noEmit",
"lint": "eslint 'src/**/*.{ts,tsx}'",
"fix": "eslint --fix 'src/**/*.{ts,tsx}'"
}, },
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"express": "^4.17.1", "express": "^4.17.1",
"socket.io": "^2.3.0" "socket.io": "^4.4.0"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.6", "@types/express": "^4.17.13",
"@types/socket.io": "^2.1.4", "@types/node": "^16.11.12",
"ts-node": "^8.8.2", "ts-node": "^10.4.0"
"typescript": "^3.8.3"
} }
} }

@ -1,210 +1,167 @@
import {Socket} from "socket.io";
import User from "./User"; import User from "./User";
import {getIndex, getNextShot, getTimeline, getTimelineNames, indexForTime} from "./timeline"; import { getTimeline, getTimelineNames } from "./timeline";
import {getCurrentTime} from "./util"; import { getCurrentTime } from "./util";
export interface RoomOptions { export interface RoomOptions {
seekTime: number seekTime: number;
timelineName: string timelineName: string;
}
export interface TickerMessage {
user: User;
message: string;
}
// FIXME: dedupe with frontend
export interface SerializedRoom {
id: number;
userCount: number;
isLeader: boolean;
running: boolean;
startTime?: number;
seekTime: number;
timelineName: string;
readyToParticipate: boolean;
speedFactor: number;
ticker: string[];
users?: { id: string; readyToParticipate: boolean }[];
} }
export default class Room { export default class Room {
id: number = 0; id = 0;
users: User[] = []; users: User[] = [];
leader: User | null = null; leader: User | null = null;
running = false; ticker: TickerMessage[] = [];
startTime = 0;
currentSeconds = 0;
timelineIndex: number = 0;
seekTime: number = 0; running = false;
timelineName: string = 'Centurion'; startTime: number | undefined = undefined;
// For debugging purposes seekTime = 0;
speedFactor = 1; timelineName = "Centurion";
autoStart = false;
constructor(name: number) { // For debugging purposes
this.id = name; speedFactor = 1;
}
serialize(user: User) { constructor(name: number) {
return { this.id = name;
'id': this.id, }
'userCount': this.users.length,
'isLeader': this.leader == user,
'running': this.running,
'startTime': this.startTime,
'timelineName': this.timelineName,
'seekTime': this.seekTime,
'readyToParticipate': user.readyToParticipate || this.leader == user,
'speedFactor': this.speedFactor
}
}
serializeTimeline(user: User) { serialize(user?: User) {
return getTimeline(this.timelineName); const obj: SerializedRoom = {
} id: this.id,
userCount: this.users.length,
isLeader: this.leader === user,
running: this.running,
startTime: this.startTime,
timelineName: this.timelineName,
seekTime: this.seekTime,
readyToParticipate: this.getLeader()?.readyToParticipate || false,
speedFactor: this.speedFactor,
ticker: this.ticker.map((i) => i.message),
};
sync() { if (typeof user === "undefined" || this.leader === user) {
this.users.forEach(u => u.sync()); obj["users"] = this.users.map((u) => u.serialize());
} }
join(user: User) { return obj;
this.users.push(user); }
user.setRoom(this);
if (!this.hasLeader()) { serializeTimeline() {
this.setLeader(user); return getTimeline(this.timelineName);
} }
if (this.autoStart) { sync() {
this.seekTime = 2500000; this.users.forEach((u) => u.sync());
this.running = true; }
this.start();
}
this.sync(); async join(user: User) {
this.users.push(user);
await user.setRoom(this);
if (!this.hasLeader()) {
this.setLeader(user);
} }
leave(user: User) { this.sync();
this.users.splice(this.users.indexOf(user), 1); }
user.setRoom(null);
if (this.leader == user) { async leave(user: User) {
this.setRandomLeader(); this.removeTickerMessageForUser(user);
} this.users.splice(this.users.indexOf(user), 1);
await user.setRoom(null);
this.sync(); if (this.leader == user) {
this.setRandomLeader();
} }
onBeforeDelete() { this.sync();
} }
setOptions(options: any) { setOptions(options: { seekTime: number; timelineName: string }) {
this.seekTime = Math.max(0, Math.min(options.seekTime, 250 * 60 * 1000)) this.seekTime = Math.max(0, Math.min(options.seekTime, 250 * 60 * 1000));
if (getTimelineNames().indexOf(options.timelineName) >= 0) { if (getTimelineNames().indexOf(options.timelineName) >= 0) {
this.timelineName = options.timelineName; this.timelineName = options.timelineName;
}
this.sync()
} }
this.sync();
}
start() { start() {
this.running = true; this.running = true;
this.startTime = getCurrentTime() - this.seekTime this.startTime = getCurrentTime() - this.seekTime;
this.sync();
}
this.sync(); /**
} *
* @returns {boolean}
*/
hasUsers() {
return this.users.length !== 0;
}
run(io: Socket) { setRandomLeader() {
this.running = true; if (this.hasUsers()) {
this.startTime = Date.now(); this.leader = this.users[0];
// io.to(this.id.toString()).emit('timeline', {
// 'timeline': {
// }
// });
const doTick = () => {
if (this.users.length === 0) {
// this room is over.
return;
}
const timestamp = getIndex(this.timelineIndex);
const nextShot = getNextShot(this.timelineIndex);
if (!timestamp) {
// We are done.
io.to(this.id.toString()).emit('tick_event', {
tick: {
current: this.currentSeconds
}
});
console.log("Done");
this.running = false;
return;
}
console.log("ticking", this.currentSeconds);
io.to(this.id.toString()).emit('tick_event', {
tick: {
current: this.currentSeconds,
next: timestamp,
nextShot: nextShot
}
});
if (this.currentSeconds >= timestamp.timestamp) {
this.timelineIndex += 1;
}
this.currentSeconds += 1;
// We spend some time processing, wait a bit less than 1000ms
const nextTickTime = this.startTime + (1000 * this.currentSeconds / this.speedFactor);
const waitTime = nextTickTime - Date.now();
console.log("waiting", waitTime);
setTimeout(doTick, Math.floor(waitTime / this.speedFactor));
};
doTick();
} }
}
/** hasLeader(): boolean {
* return this.leader != null;
* @param io }
* @param {number} time
*/
seek(io: Socket, time: number) {
this.currentSeconds = time;
this.startTime = Date.now() - time * 1000;
this.timelineIndex = indexForTime(this.currentSeconds);
io.to(this.id.toString()).emit('seek', time);
}
/** setLeader(user: User) {
* this.leader = user;
* @returns {boolean} }
*/
hasUsers() {
return this.users.length !== 0;
}
setRandomLeader() { getLeader(): User | null {
if (this.hasUsers()) { return this.leader;
this.leader = this.users[0]; }
}
}
/** submitTickerMessage(user: User, message: string) {
* message = message.replace("\n", "");
* @param id
* @returns {User|undefined}
*/
getUser(id: string) {
return this.users.find(u => u.id === id);
}
/** this.removeTickerMessageForUser(user);
*
* @param {string} id
*/
removeUser(id: string) {
this.users = this.users.filter(u => u.id !== id);
}
hasLeader(): boolean { this.ticker.push({
return this.leader != null; user: user,
} message: message,
});
setLeader(user: User) { this.sync();
this.leader = user; }
}
getLeader(): User | null { removeTickerMessageForUser(user: User) {
return this.leader; let existing = -1;
for (let i = 0; i < this.ticker.length; i++) {
if (this.ticker[i].user === user) {
existing = i;
break;
}
} }
}; if (existing >= 0) {
this.ticker.splice(existing, 1);
}
}
}

@ -1,140 +1,163 @@
import {Socket} from "socket.io"; import { Socket } from "socket.io";
import User from './User' import User from "./User";
import Room, {RoomOptions} from './Room' import Room, { RoomOptions } from "./Room";
import {getCurrentTime, randomInt} from "./util"; import { getCurrentTime } from "./util";
export default class Service { export default class Service {
private roomIdToRooms = new Map<number, Room>(); private roomIdToRooms = new Map<number, Room>();
private socketsToUsers = new Map<string, User>(); private socketsToUsers = new Map<string, User>();
onSocketConnect(socket: Socket) { get rooms(): Room[] {
let user = new User(socket); const rooms = [];
this.socketsToUsers.set(socket.id, user); for (const [, room] of this.roomIdToRooms) {
user.sync(); rooms.push(room);
} }
onSocketDisconnect(socket: Socket) { return rooms;
let user = this.getUser(socket); }
if (user.room != null) { onSocketConnect(socket: Socket) {
user.room.leave(user); const user = new User(socket);
} this.socketsToUsers.set(socket.id, user);
user.sync();
}
this.deleteEmptyRooms(); async onSocketDisconnect(socket: Socket) {
const user = this.getUser(socket);
user.onDisconnect(); if (user.room != null) {
await user.room.leave(user);
} }
onTimeSync(socket: Socket, requestId: number, clientTime: number) { this.deleteEmptyRooms();
let user = this.getUser(socket); }
let now = getCurrentTime(); onTimeSync(socket: Socket, requestId: number, clientTime: number) {
user.emit('time_sync', { const user = this.getUser(socket);
'requestId': requestId,
'clientDiff': now - clientTime,
'serverTime': now
})
}
onSetRoomOptions(socket: Socket, options: RoomOptions) {
let user = this.getUser(socket);
if (user.room?.getLeader() == user) { const now = getCurrentTime();
user.room!!.setOptions(options) user.emit("time_sync", {
} requestId: requestId,
} clientDiff: now - clientTime,
serverTime: now,
});
}
onRequestStart(socket: Socket) { onSetRoomOptions(socket: Socket, options: RoomOptions) {
let user = this.getUser(socket); const user = this.getUser(socket);
if (user.room?.getLeader() == user) { if (user.room?.getLeader() == user) {
user.room!!.start(); user.room.setOptions(options);
}
} }
}
onRequestJoin(socket: Socket, roomId: number): boolean { onRequestStart(socket: Socket) {
let user = this.getUser(socket); const user = this.getUser(socket);
if (user.room && user.room.id == roomId) return false;
if (user.room) { if (user.room?.getLeader() === user) {
user.room.leave(user); user.room.start();
this.deleteEmptyRooms(); user.room.sync();
} }
}
if (!this.roomIdToRooms.has(roomId)) { async onRequestJoin(socket: Socket, roomId: number) {
this.createRoomWithId(roomId); const user = this.getUser(socket);
} if (user.room && user.room.id == roomId) return false;
let room = this.roomIdToRooms.get(roomId)!!; if (user.room) {
room.join(user); await user.room.leave(user);
this.deleteEmptyRooms();
}
return true; if (!this.roomIdToRooms.has(roomId)) {
this.createRoomWithId(roomId);
} }
onRequestReady(socket: Socket) { const room = this.roomIdToRooms.get(roomId);
let user = this.getUser(socket); if (!room) {
if (!user.room || user.readyToParticipate) return; return false;
user.readyToParticipate = true;
user.sync();
} }
onRequestJoinRandom(socket: Socket) { await room.join(user);
let user = this.getUser(socket);
if (user.room) { return true;
user.room.leave(user); }
this.deleteEmptyRooms();
}
const room = this.createRandomRoom(); onRequestSetReady(socket: Socket) {
if (!room) throw Error('Too many rooms active'); const user = this.getUser(socket);
room.join(user); if (!user.room || user.readyToParticipate) return;
} user.readyToParticipate = true;
user.room.sync();
}
hasRoomId(roomId: number): boolean { async onRequestJoinRandom(socket: Socket) {
return this.roomIdToRooms.has(roomId); const user = this.getUser(socket);
}
private getUser(socket: Socket): User { if (user.room) {
let user = this.socketsToUsers.get(socket.id); await user.room.leave(user);
if (!user) { this.deleteEmptyRooms();
throw new Error('User not found');
}
return user;
} }
private deleteEmptyRooms() { const room = this.createRandomRoom();
for (let room of this.roomIdToRooms.values()) { if (!room) throw Error("Too many rooms active");
if (room.users.length == 0) { await room.join(user);
this.deleteRoom(room); }
}
} hasRoomId(roomId: number): boolean {
} return this.roomIdToRooms.has(roomId);
}
private createRandomRoom(): Room | null { submitTickerMessage(socket: Socket, message: string) {
let tries = 0; const user = this.getUser(socket);
while (tries++ < 1000) {
const randomId = randomInt(100, Math.max(1000, this.roomIdToRooms.size * 2));
if (this.roomIdToRooms.has(randomId)) continue;
return this.createRoomWithId(randomId); if (!user.room) {
} throw new Error("User has no room");
return null;
} }
private createRoomWithId(roomId: number): Room { user.room.submitTickerMessage(user, message);
if (this.roomIdToRooms.has(roomId)) { }
throw new Error('A room with the given id already exists');
}
let room = new Room(roomId); private getUser(socket: Socket): User {
this.roomIdToRooms.set(roomId, room); const user = this.socketsToUsers.get(socket.id);
return room; if (!user) {
throw new Error("User not found");
}
return user;
}
private deleteEmptyRooms() {
for (const room of this.roomIdToRooms.values()) {
if (room.users.length == 0) {
this.deleteRoom(room);
}
} }
}
private createRandomRoom(): Room | null {
let tries = 0;
let i = 1;
while (tries++ < 1000) {
if (this.roomIdToRooms.has(i)) {
i++;
} else {
return this.createRoomWithId(i);
}
}
return null;
}
private deleteRoom(room: Room) { private createRoomWithId(roomId: number): Room {
this.roomIdToRooms.get(room.id)!!.onBeforeDelete(); if (this.roomIdToRooms.has(roomId)) {
this.roomIdToRooms.delete(room.id) throw new Error("A room with the given id already exists");
} }
const room = new Room(roomId);
this.roomIdToRooms.set(roomId, room);
return room;
}
private deleteRoom(room: Room) {
this.roomIdToRooms.delete(room.id);
}
} }

@ -1,108 +1,131 @@
import {Socket} from "socket.io"; import { Socket } from "socket.io";
import Room from "./Room"; import Room, { SerializedRoom } from "./Room";
import {getTimelineNames} from "./timeline"; import { getTimelineNames } from "./timeline";
export default class User { export interface Config {
socket: Socket; availableTimelines: string[];
id: string; }
room: Room | null = null;
readyToParticipate: boolean = false;
constructor(socket: Socket) { export default class User {
this.socket = socket; socket: Socket;
this.id = socket.id; id: string;
room: Room | null = null;
readyToParticipate = false;
constructor(socket: Socket) {
this.socket = socket;
this.id = socket.id;
}
serialize() {
return {
id: this.id,
readyToParticipate: this.readyToParticipate,
};
}
async setRoom(room: Room | null) {
if (this.room === room) return;
if (this.room !== null) {
await this.socket.leave(this.room.id.toString());
this.readyToParticipate = false;
} }
onDisconnect() { this.room = room;
if (this.room != null) {
} if (this.room !== null) {
await this.socket.join(this.room.id.toString());
} }
setRoom(room: Room | null) { this.sync();
if (this.room === room) return; }
getConfig() {
return {
availableTimelines: getTimelineNames(),
};
}
sentConfig: Config | null = null;
sentRoom: SerializedRoom | null = null;
sentTimelineName: string | null = null;
sync() {
// Config
const config = this.getConfig();
if (!this.syncEquals(this.sentConfig, config)) {
this.sentConfig = Object.assign({}, config);
this.emit("config", {
config: this.sentConfig,
});
}
if (this.room != null) { // Room
this.socket.leave(this.room.id.toString()); if (!this.syncEquals(this.sentRoom, this.room?.serialize(this))) {
this.readyToParticipate = false; this.sentRoom = this.room
} ? (JSON.parse(
JSON.stringify(this.room.serialize(this))
) as SerializedRoom)
: null;
this.emit("room", {
room: this.sentRoom,
});
}
this.room = room; // Timeline
if (!this.syncEquals(this.sentTimelineName, this.room?.timelineName)) {
this.sentTimelineName = this.room?.timelineName || null;
this.emit("timeline", {
timeline:
this.sentTimelineName == null ? null : this.room?.serializeTimeline(),
});
}
}
if (this.room != null) { emit(eventName: string, obj: unknown) {
this.socket.join(this.room.id.toString()); this.socket.emit(eventName, obj);
} }
this.sync(); syncEquals(obj1: unknown, obj2: unknown): boolean {
if (typeof obj1 !== typeof obj2) {
return false;
} }
getConfig() { if (typeof obj1 !== "object") {
return { // Both are not 'object'
'availableTimelines': getTimelineNames() return Object.is(obj1, obj2);
}
} }
sentConfig: any = null; if (obj1 === null && obj2 === null) {
sentRoom: any = null; return true;
sentTimelineName: string | null = null;
sync() {
// Config
let config = this.getConfig();
if (!this.syncEquals(this.sentConfig, config)) {
this.sentConfig = config;
this.emit('config', {
'config': this.sentConfig
});
}
// Room
if (!this.syncEquals(this.sentRoom, this.room?.serialize(this))) {
this.sentRoom = this.room?.serialize(this);
this.emit('room', {
'room': this.sentRoom
})
}
// Timeline
if (!this.syncEquals(this.sentTimelineName, this.room?.timelineName)) {
this.sentTimelineName = this.room?.timelineName || null;
this.emit('timeline', {
'timeline': this.sentTimelineName == null ? null : this.room!!.serializeTimeline(this)
})
}
} }
emit(eventName: string, obj: any) { if (obj1 === null || obj2 === null) {
this.socket.emit(eventName, obj); return false;
} }
syncEquals(obj1: any, obj2: any): boolean { if (typeof obj2 !== "object") {
if (obj1 === undefined && obj2 === undefined) // This can not happen ;)
return true; throw new TypeError("Obj2 is not object while obj1 is.");
}
if ((obj1 === undefined && obj2 !== undefined) || (obj1 !== undefined && obj2 === undefined))
return false;
if (obj1 === null && obj2 === null)
return true;
if ((obj1 === null && obj2 !== null) || (obj1 !== null && obj2 === null))
return false;
if (typeof (obj1) !== typeof (obj2))
return false;
if (typeof (obj1) === 'string' || typeof (obj1) === 'number' || typeof (obj1) === 'boolean') {
return obj1 === obj2;
}
if (Object.keys(obj1).length !== Object.keys(obj2).length) return false
return Object.keys(obj1).every(key => if (Object.keys(obj1).length !== Object.keys(obj2).length) {
obj2.hasOwnProperty(key) && this.syncEquals(obj1[key], obj2[key]) return false;
);
} }
return Object.keys(obj1).every((key: string) => {
if (!(key in obj1) || !(key in obj2)) {
return false;
}
return this.syncEquals(
obj1[key as keyof object],
obj2[key as keyof object]
);
});
}
} }

File diff suppressed because it is too large Load Diff

@ -1,105 +1,133 @@
import express from "express"; import express from "express";
import SocketIO, {Socket} from "socket.io"; import { Server } from "socket.io";
import path from "path"; import path from "path";
import Service from './Service' import Service from "./Service";
import { RoomOptions } from "./Room";
// process.on('SIGINT', () => process.exit()); // process.on('SIGINT', () => process.exit());
// process.on('SIGTERM', () => process.exit()); // process.on('SIGTERM', () => process.exit());
const HOST = '0.0.0.0'; const HOST = "0.0.0.0";
const PORT = 3001; const PORT = 3001;
const app = express(); const app = express();
const server = app.listen(PORT, HOST, () => console.log(`Centurion listening on port ${PORT}!`)); const httpServer = app.listen(PORT, HOST, () =>
app.use(express.static(path.join(__dirname, '../public'))); console.log(`Centurion listening on port ${PORT}!`)
);
app.use(express.static(path.join(__dirname, "../public")));
const io = SocketIO(server); const io = new Server(httpServer);
const service = new Service(); const service = new Service();
io.on('connection', socket => { app.get("/state", (req, res) => {
socket.on('disconnect', (reason) => { return res.json(service.rooms.map((r) => r.serialize()));
service.onSocketDisconnect(socket); });
});
socket.on('ping', () => {
socket.emit('pong');
})
socket.on('time_sync', (requestId: number, clientTime: number) => {
if (!Number.isSafeInteger(requestId)) return;
if (!Number.isSafeInteger(clientTime)) return;
service.onTimeSync(socket, requestId, clientTime);
})
socket.on('room_options', (options) => {
if (!options) return;
if (!options.timelineName || typeof (options.timelineName) !== 'string') return;
if (!Number.isSafeInteger(options.seekTime)) return;
service.onSetRoomOptions(socket, options);
});
socket.on('request_start', (options) => {
service.onRequestStart(socket);
});
socket.on('request_join', (roomId: number) => {
if (!Number.isSafeInteger(roomId)) return;
service.onRequestJoin(socket, roomId);
});
socket.on('request_ready', () => {
service.onRequestReady(socket);
})
socket.on('request_join_random', () => {
service.onRequestJoinRandom(socket);
})
socket.on('call', (id: number, name: string, params: any) => {
if (!Number.isSafeInteger(id)) return;
// noinspection SuspiciousTypeOfGuard
if (!name || typeof (name) !== 'string') return;
// if (!params) return;
let call = new Call(socket, id, name, params);
if (name == 'room_exists') {
let roomId = params && params['roomId'];
if (!Number.isSafeInteger(roomId)) {
call.error('Invalid room id');
return;
}
call.respond(service.hasRoomId(roomId)); io.on("connection", (socket) => {
return; socket.on("disconnect", async () => {
} await service.onSocketDisconnect(socket);
// });
// if (name == 'request_join') {
// let roomId = params && params['roomId']; socket.on("ping", () => {
// if (!Number.isSafeInteger(roomId)) { socket.emit("pong");
// call.error('Invalid room id'); });
// return;
// } socket.on("time_sync", (requestId: number, clientTime: number) => {
// if (!service.hasRoomId(roomId)) { if (!Number.isSafeInteger(requestId)) return;
// call.respond(false); if (!Number.isSafeInteger(clientTime)) return;
// return;
// } service.onTimeSync(socket, requestId, clientTime);
// if (service.onRequestJoin(socket, roomId)) { });
// call.respond(true);
// } else { socket.on("room_options", (options: RoomOptions) => {
// call.respond(false); if (!options) return;
// } if (!options.timelineName || typeof options.timelineName !== "string")
// } return;
}) if (!Number.isSafeInteger(options.seekTime)) return;
service.onSocketConnect(socket); service.onSetRoomOptions(socket, options);
});
/*socket.on('join_room', (roomId, callback) => {
socket.on("request_start", () => {
service.onRequestStart(socket);
});
socket.on(
"request_join",
async (roomId: number, callback: (err?: string, res?: boolean) => void) => {
if (!Number.isSafeInteger(roomId)) {
return callback("Invalid roomId.");
}
if (!service.hasRoomId(roomId)) {
// cannot join a room that does not exist.
return callback(undefined, false);
}
try {
const didJoinRoom = await service.onRequestJoin(socket, roomId);
callback(undefined, didJoinRoom);
} catch (e) {
callback(e instanceof Error ? e.message : "Unknown error.");
}
}
);
socket.on("request_set_ready", () => {
service.onRequestSetReady(socket);
});
socket.on("request_join_random", async () => {
await service.onRequestJoinRandom(socket);
});
socket.on(
"submit_ticker_message",
(message?: unknown, callback?: (res?: null, err?: string) => void) => {
if (typeof message !== "string") {
return callback && callback(null, "Invalid message.");
}
if (message.length > 192) {
// perfect voor het Wilhelmus
return callback && callback(null, "Message too long.");
}
try {
service.submitTickerMessage(socket, message);
return callback && callback();
} catch (e) {
console.error(e);
return (
callback &&
callback(null, e instanceof Error ? e.message : "Unknown error")
);
}
}
);
service.onSocketConnect(socket);
// if (name == 'request_join') {
// let roomId = params && params['roomId'];
// if (!Number.isSafeInteger(roomId)) {
// call.error('Invalid room id');
// return;
// }
// if (!service.hasRoomId(roomId)) {
// call.respond(false);
// return;
// }
// if (service.onRequestJoin(socket, roomId)) {
// call.respond(true);
// } else {
// call.respond(false);
// }
// }
/*socket.on('join_room', (roomId, callback) => {
if (!callback || typeof callback !== 'function') { if (!callback || typeof callback !== 'function') {
console.error("Join: Callback not a function."); console.error("Join: Callback not a function.");
return return
@ -157,31 +185,3 @@ io.on('connection', socket => {
} }
});*/ });*/
}); });
class Call {
private socket: Socket;
private id: number;
private name: string;
private params: any;
constructor(socket: Socket, id: number, name: string, params: any) {
this.socket = socket;
this.id = id;
this.name = name;
this.params = params;
}
error(reason: string) {
this.socket.emit('call_response', {
'id': this.id,
'error': reason
})
}
respond(data: any) {
this.socket.emit('call_response', {
'id': this.id,
'response': data
});
}
}

@ -1,62 +1,11 @@
// @ts-ignore import timeline from "./data/timelines";
import timeline from '../data/timelines.js';
export function getTimelineNames(): string[] { export function getTimelineNames(): string[] {
return timeline.timelines.map((i: any) => i.name) return timeline.timelines.map((timeline) => timeline.name);
} }
export function getTimeline(name: string) { export function getTimeline(name: string) {
let t = timeline.timelines.find((i: any) => i.name == name); const t = timeline.timelines.find((t) => t.name == name);
if (!t) return null; if (!t) return null;
return t; return t;
}
/**
*
* @param i
* @returns {*}
*/
export function getIndex(i: number): any {
if (i >= timeline.length) {
return;
}
return timeline[i];
}
/**
* @param {number} i - the index.
* @returns {{count: number, timestamp: number}|undefined}
*/
export function getNextShot(i: number) {
for (; i < timeline.length; i++) {
const time = getIndex(i);
for (let event of time.events) {
if (event.type === 'shot') {
return {
timestamp: time.timestamp,
count: event.shotCount
}
}
}
}
return undefined;
}
export function indexForTime(seconds: number): number {
let lastIndex = 0;
for (let i = 0; i < timeline.length; i++) {
const time = timeline[i];
if (time.timestamp >= seconds) {
return lastIndex;
}
lastIndex = i;
}
return -1;
} }

@ -5,16 +5,14 @@
* @returns {number} * @returns {number}
*/ */
export function randomInt(min: number, max: number): number { export function randomInt(min: number, max: number): number {
min = Math.ceil(min); min = Math.ceil(min);
max = Math.floor(max); max = Math.floor(max);
return Math.floor(Math.random() * (max - min)) + min; return Math.floor(Math.random() * (max - min)) + min;
} }
let _randomTimeOffsetForDebug = randomInt(-10000, 10000); let _randomTimeOffsetForDebug = randomInt(-10000, 10000);
_randomTimeOffsetForDebug = 0; _randomTimeOffsetForDebug = 0;
console.log('random time offset', _randomTimeOffsetForDebug);
export function getCurrentTime() { export function getCurrentTime() {
return Date.now() + _randomTimeOffsetForDebug; return Date.now() + _randomTimeOffsetForDebug;
} }

@ -1,8 +1,12 @@
{ {
"compilerOptions": { "compilerOptions": {
"outDir": "build",
"target": "es6", "target": "es6",
"module": "commonjs", "module": "commonjs",
"strict": true, "strict": true,
"esModuleInterop": true "esModuleInterop": true
} },
"include": [
"src"
]
} }

@ -1,739 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@types/body-parser@*":
version "1.19.0"
resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.0.tgz#0685b3c47eb3006ffed117cdd55164b61f80538f"
integrity sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ==
dependencies:
"@types/connect" "*"
"@types/node" "*"
"@types/connect@*":
version "3.4.33"
resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.33.tgz#31610c901eca573b8713c3330abc6e6b9f588546"
integrity sha512-2+FrkXY4zllzTNfJth7jOqEHC+enpLeGslEhpnTAkg21GkRrWV4SsAtqchtT4YS9/nODBU2/ZfsBY2X4J/dX7A==
dependencies:
"@types/node" "*"
"@types/express-serve-static-core@*":
version "4.17.5"
resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.5.tgz#a00ac7dadd746ae82477443e4d480a6a93ea083c"
integrity sha512-578YH5Lt88AKoADy0b2jQGwJtrBxezXtVe/MBqWXKZpqx91SnC0pVkVCcxcytz3lWW+cHBYDi3Ysh0WXc+rAYw==
dependencies:
"@types/node" "*"
"@types/range-parser" "*"
"@types/express@^4.17.6":
version "4.17.6"
resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.6.tgz#6bce49e49570507b86ea1b07b806f04697fac45e"
integrity sha512-n/mr9tZI83kd4azlPG5y997C/M4DNABK9yErhFM6hKdym4kkmd9j0vtsJyjFIwfRBxtrxZtAfGZCNRIBMFLK5w==
dependencies:
"@types/body-parser" "*"
"@types/express-serve-static-core" "*"
"@types/qs" "*"
"@types/serve-static" "*"
"@types/mime@*":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.1.tgz#dc488842312a7f075149312905b5e3c0b054c79d"
integrity sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw==
"@types/node@*":
version "13.9.2"
resolved "https://registry.yarnpkg.com/@types/node/-/node-13.9.2.tgz#ace1880c03594cc3e80206d96847157d8e7fa349"
integrity sha512-bnoqK579sAYrQbp73wwglccjJ4sfRdKU7WNEZ5FW4K2U6Kc0/eZ5kvXG0JKsEKFB50zrFmfFt52/cvBbZa7eXg==
"@types/qs@*":
version "6.9.1"
resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.1.tgz#937fab3194766256ee09fcd40b781740758617e7"
integrity sha512-lhbQXx9HKZAPgBkISrBcmAcMpZsmpe/Cd/hY7LGZS5OfkySUBItnPZHgQPssWYUET8elF+yCFBbP1Q0RZPTdaw==
"@types/range-parser@*":
version "1.2.3"
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c"
integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==
"@types/serve-static@*":
version "1.13.3"
resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.3.tgz#eb7e1c41c4468272557e897e9171ded5e2ded9d1"
integrity sha512-oprSwp094zOglVrXdlo/4bAHtKTAxX6VT8FOZlBKrmyLbNvE1zxZyJ6yikMVtHIvwP45+ZQGJn+FdXGKTozq0g==
dependencies:
"@types/express-serve-static-core" "*"
"@types/mime" "*"
"@types/socket.io@^2.1.4":
version "2.1.4"
resolved "https://registry.yarnpkg.com/@types/socket.io/-/socket.io-2.1.4.tgz#674e7bc193c5ccdadd4433f79f3660d31759e9ac"
integrity sha512-cI98INy7tYnweTsUlp8ocveVdAxENUThO0JsLSCs51cjOP2yV5Mqo5QszMDPckyRRA+PO6+wBgKvGvHUCc23TQ==
dependencies:
"@types/node" "*"
accepts@~1.3.4, accepts@~1.3.7:
version "1.3.7"
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd"
integrity sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==
dependencies:
mime-types "~2.1.24"
negotiator "0.6.2"
after@0.8.2:
version "0.8.2"
resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f"
integrity sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8=
arg@^4.1.0:
version "4.1.3"
resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089"
integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==
array-flatten@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2"
integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=
arraybuffer.slice@~0.0.7:
version "0.0.7"
resolved "https://registry.yarnpkg.com/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz#3bbc4275dd584cc1b10809b89d4e8b63a69e7675"
integrity sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog==
async-limiter@~1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd"
integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==
backo2@1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947"
integrity sha1-MasayLEpNjRj41s+u2n038+6eUc=
base64-arraybuffer@0.1.5:
version "0.1.5"
resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz#73926771923b5a19747ad666aa5cd4bf9c6e9ce8"
integrity sha1-c5JncZI7Whl0etZmqlzUv5xunOg=
base64id@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/base64id/-/base64id-2.0.0.tgz#2770ac6bc47d312af97a8bf9a634342e0cd25cb6"
integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==
better-assert@~1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/better-assert/-/better-assert-1.0.2.tgz#40866b9e1b9e0b55b481894311e68faffaebc522"
integrity sha1-QIZrnhueC1W0gYlDEeaPr/rrxSI=
dependencies:
callsite "1.0.0"
blob@0.0.5:
version "0.0.5"
resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.5.tgz#d680eeef25f8cd91ad533f5b01eed48e64caf683"
integrity sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==
body-parser@1.19.0:
version "1.19.0"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"
integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==
dependencies:
bytes "3.1.0"
content-type "~1.0.4"
debug "2.6.9"
depd "~1.1.2"
http-errors "1.7.2"
iconv-lite "0.4.24"
on-finished "~2.3.0"
qs "6.7.0"
raw-body "2.4.0"
type-is "~1.6.17"
buffer-from@^1.0.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==
bytes@3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==
callsite@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/callsite/-/callsite-1.0.0.tgz#280398e5d664bd74038b6f0905153e6e8af1bc20"
integrity sha1-KAOY5dZkvXQDi28JBRU+borxvCA=
component-bind@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/component-bind/-/component-bind-1.0.0.tgz#00c608ab7dcd93897c0009651b1d3a8e1e73bbd1"
integrity sha1-AMYIq33Nk4l8AAllGx06jh5zu9E=
component-emitter@1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6"
integrity sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=
component-inherit@0.0.3:
version "0.0.3"
resolved "https://registry.yarnpkg.com/component-inherit/-/component-inherit-0.0.3.tgz#645fc4adf58b72b649d5cae65135619db26ff143"
integrity sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM=
content-disposition@0.5.3:
version "0.5.3"
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd"
integrity sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==
dependencies:
safe-buffer "5.1.2"
content-type@~1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
cookie-signature@1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw=
cookie@0.3.1:
version "0.3.1"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb"
integrity sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=
cookie@0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba"
integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==
debug@2.6.9:
version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
dependencies:
ms "2.0.0"
debug@~3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==
dependencies:
ms "2.0.0"
debug@~4.1.0:
version "4.1.1"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791"
integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==
dependencies:
ms "^2.1.1"
depd@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=
destroy@~1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80"
integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=
diff@^4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
ee-first@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
encodeurl@~1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=
engine.io-client@~3.4.0:
version "3.4.0"
resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.4.0.tgz#82a642b42862a9b3f7a188f41776b2deab643700"
integrity sha512-a4J5QO2k99CM2a0b12IznnyQndoEvtA4UAldhGzKqnHf42I3Qs2W5SPnDvatZRcMaNZs4IevVicBPayxYt6FwA==
dependencies:
component-emitter "1.2.1"
component-inherit "0.0.3"
debug "~4.1.0"
engine.io-parser "~2.2.0"
has-cors "1.1.0"
indexof "0.0.1"
parseqs "0.0.5"
parseuri "0.0.5"
ws "~6.1.0"
xmlhttprequest-ssl "~1.5.4"
yeast "0.1.2"
engine.io-parser@~2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.2.0.tgz#312c4894f57d52a02b420868da7b5c1c84af80ed"
integrity sha512-6I3qD9iUxotsC5HEMuuGsKA0cXerGz+4uGcXQEkfBidgKf0amsjrrtwcbwK/nzpZBxclXlV7gGl9dgWvu4LF6w==
dependencies:
after "0.8.2"
arraybuffer.slice "~0.0.7"
base64-arraybuffer "0.1.5"
blob "0.0.5"
has-binary2 "~1.0.2"
engine.io@~3.4.0:
version "3.4.0"
resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-3.4.0.tgz#3a962cc4535928c252759a00f98519cb46c53ff3"
integrity sha512-XCyYVWzcHnK5cMz7G4VTu2W7zJS7SM1QkcelghyIk/FmobWBtXE7fwhBusEKvCSqc3bMh8fNFMlUkCKTFRxH2w==
dependencies:
accepts "~1.3.4"
base64id "2.0.0"
cookie "0.3.1"
debug "~4.1.0"
engine.io-parser "~2.2.0"
ws "^7.1.2"
escape-html@~1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=
etag@~1.8.1:
version "1.8.1"
resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=
express@^4.17.1:
version "4.17.1"
resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134"
integrity sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==
dependencies:
accepts "~1.3.7"
array-flatten "1.1.1"
body-parser "1.19.0"
content-disposition "0.5.3"
content-type "~1.0.4"
cookie "0.4.0"
cookie-signature "1.0.6"
debug "2.6.9"
depd "~1.1.2"
encodeurl "~1.0.2"
escape-html "~1.0.3"
etag "~1.8.1"
finalhandler "~1.1.2"
fresh "0.5.2"
merge-descriptors "1.0.1"
methods "~1.1.2"
on-finished "~2.3.0"
parseurl "~1.3.3"
path-to-regexp "0.1.7"
proxy-addr "~2.0.5"
qs "6.7.0"
range-parser "~1.2.1"
safe-buffer "5.1.2"
send "0.17.1"
serve-static "1.14.1"
setprototypeof "1.1.1"
statuses "~1.5.0"
type-is "~1.6.18"
utils-merge "1.0.1"
vary "~1.1.2"
finalhandler@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d"
integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==
dependencies:
debug "2.6.9"
encodeurl "~1.0.2"
escape-html "~1.0.3"
on-finished "~2.3.0"
parseurl "~1.3.3"
statuses "~1.5.0"
unpipe "~1.0.0"
forwarded@~0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84"
integrity sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=
fresh@0.5.2:
version "0.5.2"
resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=
has-binary2@~1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/has-binary2/-/has-binary2-1.0.3.tgz#7776ac627f3ea77250cfc332dab7ddf5e4f5d11d"
integrity sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw==
dependencies:
isarray "2.0.1"
has-cors@1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/has-cors/-/has-cors-1.1.0.tgz#5e474793f7ea9843d1bb99c23eef49ff126fff39"
integrity sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk=
http-errors@1.7.2:
version "1.7.2"
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.2.tgz#4f5029cf13239f31036e5b2e55292bcfbcc85c8f"
integrity sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==
dependencies:
depd "~1.1.2"
inherits "2.0.3"
setprototypeof "1.1.1"
statuses ">= 1.5.0 < 2"
toidentifier "1.0.0"
http-errors@~1.7.2:
version "1.7.3"
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06"
integrity sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==
dependencies:
depd "~1.1.2"
inherits "2.0.4"
setprototypeof "1.1.1"
statuses ">= 1.5.0 < 2"
toidentifier "1.0.0"
iconv-lite@0.4.24:
version "0.4.24"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
dependencies:
safer-buffer ">= 2.1.2 < 3"
indexof@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d"
integrity sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=
inherits@2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
inherits@2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
ipaddr.js@1.9.1:
version "1.9.1"
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==
isarray@2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.1.tgz#a37d94ed9cda2d59865c9f76fe596ee1f338741e"
integrity sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4=
make-error@^1.1.1:
version "1.3.6"
resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2"
integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==
media-typer@0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=
merge-descriptors@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=
methods@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=
mime-db@1.43.0:
version "1.43.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.43.0.tgz#0a12e0502650e473d735535050e7c8f4eb4fae58"
integrity sha512-+5dsGEEovYbT8UY9yD7eE4XTc4UwJ1jBYlgaQQF38ENsKR3wj/8q8RFZrF9WIZpB2V1ArTVFUva8sAul1NzRzQ==
mime-types@~2.1.24:
version "2.1.26"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.26.tgz#9c921fc09b7e149a65dfdc0da4d20997200b0a06"
integrity sha512-01paPWYgLrkqAyrlDorC1uDwl2p3qZT7yl806vW7DvDoxwXi46jsjFbg+WdwotBIk6/MbEhO/dh5aZ5sNj/dWQ==
dependencies:
mime-db "1.43.0"
mime@1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
ms@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=
ms@2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a"
integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==
ms@^2.1.1:
version "2.1.2"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
negotiator@0.6.2:
version "0.6.2"
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
object-component@0.0.3:
version "0.0.3"
resolved "https://registry.yarnpkg.com/object-component/-/object-component-0.0.3.tgz#f0c69aa50efc95b866c186f400a33769cb2f1291"
integrity sha1-8MaapQ78lbhmwYb0AKM3acsvEpE=
on-finished@~2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=
dependencies:
ee-first "1.1.1"
parseqs@0.0.5:
version "0.0.5"
resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d"
integrity sha1-1SCKNzjkZ2bikbouoXNoSSGouJ0=
dependencies:
better-assert "~1.0.0"
parseuri@0.0.5:
version "0.0.5"
resolved "https://registry.yarnpkg.com/parseuri/-/parseuri-0.0.5.tgz#80204a50d4dbb779bfdc6ebe2778d90e4bce320a"
integrity sha1-gCBKUNTbt3m/3G6+J3jZDkvOMgo=
dependencies:
better-assert "~1.0.0"
parseurl@~1.3.3:
version "1.3.3"
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
path-to-regexp@0.1.7:
version "0.1.7"
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c"
integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=
proxy-addr@~2.0.5:
version "2.0.6"
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.6.tgz#fdc2336505447d3f2f2c638ed272caf614bbb2bf"
integrity sha512-dh/frvCBVmSsDYzw6n926jv974gddhkFPfiN8hPOi30Wax25QZyZEGveluCgliBnqmuM+UJmBErbAUFIoDbjOw==
dependencies:
forwarded "~0.1.2"
ipaddr.js "1.9.1"
qs@6.7.0:
version "6.7.0"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"
integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==
range-parser@~1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
raw-body@2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.0.tgz#a1ce6fb9c9bc356ca52e89256ab59059e13d0332"
integrity sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==
dependencies:
bytes "3.1.0"
http-errors "1.7.2"
iconv-lite "0.4.24"
unpipe "1.0.0"
safe-buffer@5.1.2:
version "5.1.2"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
"safer-buffer@>= 2.1.2 < 3":
version "2.1.2"
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
send@0.17.1:
version "0.17.1"
resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8"
integrity sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==
dependencies:
debug "2.6.9"
depd "~1.1.2"
destroy "~1.0.4"
encodeurl "~1.0.2"
escape-html "~1.0.3"
etag "~1.8.1"
fresh "0.5.2"
http-errors "~1.7.2"
mime "1.6.0"
ms "2.1.1"
on-finished "~2.3.0"
range-parser "~1.2.1"
statuses "~1.5.0"
serve-static@1.14.1:
version "1.14.1"
resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.1.tgz#666e636dc4f010f7ef29970a88a674320898b2f9"
integrity sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==
dependencies:
encodeurl "~1.0.2"
escape-html "~1.0.3"
parseurl "~1.3.3"
send "0.17.1"
setprototypeof@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683"
integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==
socket.io-adapter@~1.1.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-1.1.2.tgz#ab3f0d6f66b8fc7fca3959ab5991f82221789be9"
integrity sha512-WzZRUj1kUjrTIrUKpZLEzFZ1OLj5FwLlAFQs9kuZJzJi5DKdU7FsWc36SNmA8iDOtwBQyT8FkrriRM8vXLYz8g==
socket.io-client@2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.3.0.tgz#14d5ba2e00b9bcd145ae443ab96b3f86cbcc1bb4"
integrity sha512-cEQQf24gET3rfhxZ2jJ5xzAOo/xhZwK+mOqtGRg5IowZsMgwvHwnf/mCRapAAkadhM26y+iydgwsXGObBB5ZdA==
dependencies:
backo2 "1.0.2"
base64-arraybuffer "0.1.5"
component-bind "1.0.0"
component-emitter "1.2.1"
debug "~4.1.0"
engine.io-client "~3.4.0"
has-binary2 "~1.0.2"
has-cors "1.1.0"
indexof "0.0.1"
object-component "0.0.3"
parseqs "0.0.5"
parseuri "0.0.5"
socket.io-parser "~3.3.0"
to-array "0.1.4"
socket.io-parser@~3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.3.0.tgz#2b52a96a509fdf31440ba40fed6094c7d4f1262f"
integrity sha512-hczmV6bDgdaEbVqhAeVMM/jfUfzuEZHsQg6eOmLgJht6G3mPKMxYm75w2+qhAQZ+4X+1+ATZ+QFKeOZD5riHng==
dependencies:
component-emitter "1.2.1"
debug "~3.1.0"
isarray "2.0.1"
socket.io-parser@~3.4.0:
version "3.4.0"
resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.4.0.tgz#370bb4a151df2f77ce3345ff55a7072cc6e9565a"
integrity sha512-/G/VOI+3DBp0+DJKW4KesGnQkQPFmUCbA/oO2QGT6CWxU7hLGWqU3tyuzeSK/dqcyeHsQg1vTe9jiZI8GU9SCQ==
dependencies:
component-emitter "1.2.1"
debug "~4.1.0"
isarray "2.0.1"
socket.io@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.3.0.tgz#cd762ed6a4faeca59bc1f3e243c0969311eb73fb"
integrity sha512-2A892lrj0GcgR/9Qk81EaY2gYhCBxurV0PfmmESO6p27QPrUK1J3zdns+5QPqvUYK2q657nSj0guoIil9+7eFg==
dependencies:
debug "~4.1.0"
engine.io "~3.4.0"
has-binary2 "~1.0.2"
socket.io-adapter "~1.1.0"
socket.io-client "2.3.0"
socket.io-parser "~3.4.0"
source-map-support@^0.5.17:
version "0.5.19"
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61"
integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==
dependencies:
buffer-from "^1.0.0"
source-map "^0.6.0"
source-map@^0.6.0:
version "0.6.1"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
"statuses@>= 1.5.0 < 2", statuses@~1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=
to-array@0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/to-array/-/to-array-0.1.4.tgz#17e6c11f73dd4f3d74cda7a4ff3238e9ad9bf890"
integrity sha1-F+bBH3PdTz10zaek/zI46a2b+JA=
toidentifier@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
ts-node@^8.8.2:
version "8.9.0"
resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-8.9.0.tgz#d7bf7272dcbecd3a2aa18bd0b96c7d2f270c15d4"
integrity sha512-rwkXfOs9zmoHrV8xE++dmNd6ZIS+nmHHCxcV53ekGJrxFLMbp+pizpPS07ARvhwneCIECPppOwbZHvw9sQtU4w==
dependencies:
arg "^4.1.0"
diff "^4.0.1"
make-error "^1.1.1"
source-map-support "^0.5.17"
yn "3.1.1"
type-is@~1.6.17, type-is@~1.6.18:
version "1.6.18"
resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
dependencies:
media-typer "0.3.0"
mime-types "~2.1.24"
typescript@^3.8.3:
version "3.8.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.8.3.tgz#409eb8544ea0335711205869ec458ab109ee1061"
integrity sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w==
unpipe@1.0.0, unpipe@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=
utils-merge@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
vary@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=
ws@^7.1.2:
version "7.2.3"
resolved "https://registry.yarnpkg.com/ws/-/ws-7.2.3.tgz#a5411e1fb04d5ed0efee76d26d5c46d830c39b46"
integrity sha512-HTDl9G9hbkNDk98naoR/cHDws7+EyYMOdL1BmjsZXRUjf7d+MficC4B7HLUPlSiho0vg+CWKrGIt/VJBd1xunQ==
ws@~6.1.0:
version "6.1.4"
resolved "https://registry.yarnpkg.com/ws/-/ws-6.1.4.tgz#5b5c8800afab925e94ccb29d153c8d02c1776ef9"
integrity sha512-eqZfL+NE/YQc1/ZynhojeV8q+H050oR8AZ2uIev7RU10svA9ZnJUddHcOUZTJLinZ9yEfdA2kSATS2qZK5fhJA==
dependencies:
async-limiter "~1.0.0"
xmlhttprequest-ssl@~1.5.4:
version "1.5.5"
resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz#c2876b06168aadc40e57d97e81191ac8f4398b3e"
integrity sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4=
yeast@0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419"
integrity sha1-AI4G2AlDIMNy28L47XagymyKxBk=
yn@3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50"
integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==

@ -0,0 +1,23 @@
module.exports = {
"ignorePatterns": [".eslintrc.js", "vite.config.ts", "postcss.config.js"],
"settings": {
"react": {
"version": "detect"
}
},
"parserOptions": {
"tsconfigRootDir": __dirname,
"project": ["./tsconfig.json"]
},
"extends": [
"plugin:react/recommended"
],
"rules": {
// suppress errors for missing 'import React' in files
"react/react-in-jsx-scope": "off",
// At some point these must be set to "error"
"@typescript-eslint/no-unsafe-member-access": "warn",
"@typescript-eslint/no-unsafe-assignment": "warn",
"@typescript-eslint/no-unsafe-argument": "warn"
}
};

@ -1,14 +1,16 @@
FROM node:13-alpine AS build FROM node:16-alpine AS build
WORKDIR /app WORKDIR /app
COPY package.json yarn.lock config-overrides.js ./ # Install TS manually since it's only included in the parent's package.json
RUN yarn install RUN npm install --global typescript@^4.5.2
COPY package.json package-lock.json vite.config.ts ./
RUN npm ci --no-progress --no-optional
COPY tsconfig.json ./ COPY tsconfig.json postcss.config.js index.html ./
COPY public public/ COPY public public/
COPY src src/ COPY src src/
RUN yarn build RUN npm run build
FROM nginx:alpine FROM nginx:alpine
WORKDIR /app WORKDIR /app

@ -1,25 +0,0 @@
module.exports = {
webpack: function (config, env) {
return config;
},
devServer: function (configFunction) {
// Return the replacement function for create-react-app to use to generate the Webpack
// Development Server config. "configFunction" is the function that would normally have
// been used to generate the Webpack Development server config - you can use it to create
// a starting configuration to then modify instead of having to create a config from scratch.
return function (proxy, allowedHost) {
// Create the default config by calling configFunction with the proxy/allowedHost parameters
const config = configFunction(proxy, allowedHost);
config.proxy = {
"/socket.io": {
target: "http://localhost:3001",
ws: true
}
}
// Return your customised Webpack Development Server config.
return config;
};
}
}

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/harambee.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#304ba3" />
<meta
name="description"
content="Centurion: Honderd minuten... Honderd shots... Kan jij het aan?"
/>
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" />
<title>Centurion</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

@ -3,35 +3,38 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@testing-library/jest-dom": "^4.2.4", "antd": "^4.17.2",
"@testing-library/react": "^9.4.0", "react": "^17.0.2",
"@testing-library/user-event": "^7.2.1", "react-dom": "^17.0.2",
"@types/jest": "^24.9.1", "react-ticker": "^1.3.0",
"@types/node": "^12.12.27", "react-transition-group": "^4.4.2",
"@types/react": "^16.9.19", "socket.io-client": "^4.4.0"
"@types/react-dom": "^16.9.5",
"@types/react-transition-group": "^4.2.3",
"antd": "^4.1.4",
"node-sass": "^4.13.1",
"query-string": "^6.12.1",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-scripts": "^3.4.0",
"react-transition-group": "^4.3.0",
"socket.io-client": "^2.3.0",
"sscaffold-css": "^0.1.0",
"typescript": "^3.7.5",
"use-query-params": "^0.6.0",
"use-socketio": "^2.0.0"
}, },
"scripts": { "devDependencies": {
"start": "react-app-rewired start", "@testing-library/jest-dom": "^5.15.1",
"build": "react-app-rewired build", "@testing-library/react": "^12.1.2",
"test": "react-app-rewired test", "@testing-library/user-event": "^13.5.0",
"eject": "react-scripts eject" "@types/jest": "^27.0.3",
"@types/react": "^17.0.37",
"@types/react-dom": "^17.0.11",
"@types/react-transition-group": "^4.4.4",
"@types/socket.io-client": "^1.4.36",
"@vitejs/plugin-react": "^1.1.1",
"autoprefixer": "^10.4.0",
"less": "^4.1.2",
"postcss": "^8.4.4",
"postcss-nested": "^5.0.6",
"vite": "^2.7.1",
"vite-plugin-imp": "^2.0.10",
"vite-plugin-svgr": "^0.6.0"
}, },
"eslintConfig": { "scripts": {
"extends": "react-app" "start": "vite",
"build": "tsc && vite build",
"serve": "vite preview",
"check": "tsc --noEmit",
"lint": "eslint 'src/**/*.{ts,tsx}'",
"fix": "eslint --fix 'src/**/*.{ts,tsx}'"
}, },
"browserslist": { "browserslist": {
"production": [ "production": [
@ -44,12 +47,5 @@
"last 1 firefox version", "last 1 firefox version",
"last 1 safari version" "last 1 safari version"
] ]
},
"devDependencies": {
"@types/socket.io-client": "^1.4.32",
"customize-cra": "^0.9.1",
"eslint-plugin-react-hooks": "^2.3.0",
"react-app-rewired": "^2.1.5",
"workerize-loader": "^1.1.0"
} }
} }

@ -0,0 +1,6 @@
module.exports = {
plugins: [
require('autoprefixer'),
require('postcss-nested'),
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@ -1,39 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/harambee.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Centurion</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 229 KiB

@ -1,9 +1,9 @@
{ {
"short_name": "React App", "short_name": "Centurion",
"name": "Create React App Sample", "name": "Centurion: honderd minuten...",
"icons": [ "icons": [
{ {
"src": "harambee.ico", "src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16", "sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon" "type": "image/x-icon"
}, },
@ -20,6 +20,6 @@
], ],
"start_url": ".", "start_url": ".",
"display": "standalone", "display": "standalone",
"theme_color": "#000000", "theme_color": "#304ba3",
"background_color": "#ffffff" "background_color": "#ffffff"
} }

@ -1,2 +1,3 @@
# https://www.robotstxt.org/robotstxt.html # https://www.robotstxt.org/robotstxt.html
User-agent: * User-agent: *
Disallow: /

@ -1,11 +0,0 @@
import React from 'react';
import Centurion from "./Centurion";
const App = () => {
return (
<Centurion/>
);
};
export default App;

@ -1,46 +1,26 @@
import React from "react"; import React, { useState } from "react";
import {Row} from "antd";
import {useRoomRunningAndReadyChanged} from "../lib/Connection"; import { useRoomRunningAndReadyChanged } from "../lib/Connection";
import NextShot from "./NextShot";
import Feed from "./Feed"; import Feed from "./Feed";
import ShotsTaken from "./ShotsTaken";
import Lobby from "./Lobby"; import Lobby from "./Lobby";
import logo from "../img/via-logo.svg";
import haramlogo from "../img/harambee_logo.png";
import Player from "./Player";
const Centurion = () => { const Centurion = () => {
const room = useRoomRunningAndReadyChanged(); const [currentUserReady, setCurrentUserReady] = useState(false);
const showFeed = (room?.running && room.readyToParticipate) || false; const room = useRoomRunningAndReadyChanged();
const showFeed = (room?.readyToParticipate && currentUserReady) || false;
const feedContent = (
<React.Fragment>
<Row>
<NextShot/>
<Feed/>
<ShotsTaken/>
</Row>
<Player/>
</React.Fragment>
);
const lobbyContent = (
<Lobby/>
);
return ( return (
<> <section className="content">
<section className="content"> {showFeed ? (
{showFeed ? feedContent : lobbyContent} <Feed />
</section> ) : (
<footer> <Lobby
<img src={haramlogo} className="haram-logo" alt="haramlogo"/> currentUserReady={currentUserReady}
<img src={logo} className="via-logo" alt="logo"/> onCurrentUserReadyChange={(b: boolean) => setCurrentUserReady(b)}
</footer> />
</> )}
); </section>
);
}; };
export default Centurion; export default Centurion;

@ -1,40 +1,220 @@
import React from 'react'; import React, { useRef, useState } from "react";
import {Col} from "antd" import { Col, Row } from "antd";
import {TimelineItem} from "../types/types"; import {
EVENT_PRIORITY,
Timeline,
TimelineItem,
TimestampEvent,
} from "../types/types";
import FeedItem from "./FeedItem" import FeedItem from "./FeedItem";
import {roomTime, useTimeline} from "../lib/Connection"; import connection, {
import {useUpdateAfterDelay} from "../util/hooks"; calculateRoomTime,
useRoomRunningAndReadyChanged,
useRoomTime,
useTimeline,
} from "../lib/Connection";
import { useResize, useUpdateAfterDelay } from "../util/hooks";
import CSSTransition from "react-transition-group/CSSTransition"; import CSSTransition from "react-transition-group/CSSTransition";
import TransitionGroup from "react-transition-group/TransitionGroup"; import TransitionGroup from "react-transition-group/TransitionGroup";
import NextShot from "./NextShot";
import ShotsTaken from "./ShotsTaken";
import Player from "./Player";
const Feed = (props: any) => { import "../css/feed.css";
const timeline = useTimeline(); import FeedTicker from "./FeedTicker";
useUpdateAfterDelay(500) declare global {
interface Window {
__feedShakeDebug?: boolean;
}
}
let liveFeed: TimelineItem[] = []; function getNextItemDelay(timeline: Timeline | null, defaultDelay = 500) {
if (!timeline) {
return defaultDelay;
}
if (timeline != null) { const time = calculateRoomTime();
liveFeed = timeline.feed.filter(item => { const nextItem = timeline.itemAfterTime(time);
return item.timestamp * 1000 <= roomTime()
}); if (!nextItem) {
return defaultDelay;
}
const speedFactor = connection.room.get()?.speedFactor || 1;
return (nextItem.timestamp * 1000 - time) / speedFactor;
}
/**
* This map relates back an event to the index of the item displayed from
* top to bottom. We can use this to calculate opacity.
*/
function calculateKeyToIndexMap(feed: TimelineItem[]) {
const keyToIndex: Map<string, number> = new Map();
let totalEvents = 0;
for (const item of feed) {
totalEvents += item.events.length;
}
let totalIdx = 0;
for (const item of feed) {
for (let eventIdx = 0; eventIdx < item.events.length; eventIdx++) {
keyToIndex.set(`${item.timestamp}.${eventIdx}`, totalEvents - ++totalIdx);
} }
}
return keyToIndex;
}
/**
* Makes sure 'shot' is always top-most within an timeline item
*/
function sortEvents(events: TimestampEvent[]) {
return events.sort((a, b) => {
return EVENT_PRIORITY.indexOf(b.type) - EVENT_PRIORITY.indexOf(a.type);
});
}
const Feed = () => {
const timeline = useTimeline();
useRoomRunningAndReadyChanged();
useResize();
useRoomTime();
return ( const feedElement = useRef<HTMLDivElement>(null);
<Col className="time-feed" span={24} md={16}> const [lastShotId, setLastShotId] = useState<string | null>(null);
<TransitionGroup className="feed-reverse">
{liveFeed.map((item, i) => useUpdateAfterDelay(getNextItemDelay(timeline));
item.events.map((event, j) =>
<CSSTransition timeout={500} classNames="fade" key={`${item.timestamp}.${j}`}> if (!timeline) {
<FeedItem item={event} key={`${item.timestamp}.${j}f`}/> throw new TypeError("Feed without timeline.");
</CSSTransition> }
)
)} const time = calculateRoomTime();
</TransitionGroup>
const liveFeed = timeline.feed.filter((item) => {
return item.timestamp * 1000 <= time;
});
const keyToIndex: Map<string, number> = calculateKeyToIndexMap(liveFeed);
const lastEvent = timeline.eventBeforeTime(time, "shot");
if (lastEvent && lastShotId !== lastEvent?.id) {
setLastShotId(lastEvent.id);
if (feedElement.current) {
// Let the browser first do the heavy dom stuff to avoid lagging our
// animation.
setTimeout(doSingleShake, 100);
}
}
function doSingleShake() {
if (!feedElement.current) return;
if (feedElement.current.getAnimations().length > 0) return;
if (feedElement.current.classList.contains("feed-shot-shake")) {
feedElement.current.getAnimations().forEach((i) => i.finish());
feedElement.current.classList.remove("feed-shot-shake");
setTimeout(doSingleShake, 0);
return;
}
feedElement.current.classList.add("feed-shot-shake");
const el = feedElement.current;
const listener = function () {
el.classList.remove("feed-shot-shake");
el.removeEventListener("animationend", listener);
};
el.addEventListener("animationend", listener);
}
function itemOpacity(itemKey: string) {
// This is a bit of a hack but works better than trying to do this in css.
// This fades out the elements the lower the element is on the screen.
// Ticker is only visible on large screens, the ticker obstructs some of
// the items. If it is not visible we simply take body height, otherwise
// we use the height where the ticker starts.
const tickerHeight = document
.querySelector(".ticker-container")
?.getBoundingClientRect().y;
const bodyHeight = document.body.clientHeight;
const totalHeight = tickerHeight ? tickerHeight : bodyHeight;
// Start at top most item, figure out which index is the first
// that is NOT visible.
const items = document.querySelectorAll(".feed-item-container");
let i = items.length - 1;
for (; i > 0; i--) {
const rect = items[i].getBoundingClientRect();
// If the bottom of this element is below the screen, we declare it
// not-visible.
if (rect.y + rect.height >= totalHeight) {
break;
}
}
const totalItemsOnScreen = items.length - i;
const index = keyToIndex.get(itemKey) ?? 0;
let x = index / (totalItemsOnScreen - 1.8);
x = Math.min(0.9, Math.max(0, x));
return 1 - Math.pow(x, 4);
}
const debug = true;
if (debug) {
if (!window["__feedShakeDebug"]) {
window["__feedShakeDebug"] = true;
window.document.documentElement.addEventListener("keydown", (e) => {
if (e.keyCode === 67) {
// c
doSingleShake();
}
});
}
}
return (
<div className="feed" ref={feedElement}>
<Row className="feed-items">
<Col span={12} md={4} className="sider">
<NextShot />
<Player />
</Col>
<Col span={12} md={{ span: 4, push: 16 }} className="sider">
<ShotsTaken />
</Col>
<Col span={24} md={{ span: 16, pull: 4 }}>
<TransitionGroup className="feed-reverse">
{liveFeed.map((item) =>
sortEvents(item.events).map((event, j) => (
<CSSTransition
timeout={300}
classNames="fade"
key={`${item.timestamp}.${j}`}
>
<div
className="feed-item-container"
style={{ opacity: itemOpacity(`${item.timestamp}.${j}`) }}
>
<FeedItem item={event} />
</div>
</CSSTransition>
))
)}
</TransitionGroup>
</Col> </Col>
); </Row>
<Row className="ticker">
<FeedTicker />
</Row>
</div>
);
}; };
export default Feed; export default Feed;

@ -1,33 +1,38 @@
import React, {PureComponent} from 'react'; import { Col, Row } from "antd";
import {TimestampEvent} from "../types/types"; import type { TimestampEvent } from "../types/types";
import '../css/feed.sass' import "../css/feed.css";
import shot from "../img/shot.png"; import shot from "../img/shot.png";
import song from "../img/song.png"; import song from "../img/song.png";
import talk from "../img/talk.png"; import talk from "../img/talk.png";
import time from "../img/time.png"; import time from "../img/time.png";
export interface FeedItemProps {
item: TimestampEvent;
}
const images = { const images = {
shot, song, talk, time shot,
song,
talk,
time,
}; };
class FeedItem extends PureComponent<{item: TimestampEvent}> { const FeedItem = ({ item }: FeedItemProps) => {
render() { return (
return ( <Row align="middle" className="feed-item">
<div className="feed-item"> <Col span={11} className="feed-item__title">
<div className="feed-item__title"> {item.text[0]}
{this.props.item.text[0]} </Col>
</div> <Col span={2} className="feed-item__emoji">
<div className="feed-item__emoji"> <img alt={item.type} src={images[item.type]} />
<img src={images[this.props.item.type]}/> </Col>
</div> <Col span={11} className="feed-item__desc">
<div className="feed-item__desc"> {item.text[1]}
{this.props.item.text[1]} </Col>
</div> </Row>
</div> );
); };
}
}
export default FeedItem; export default FeedItem;

@ -0,0 +1,95 @@
import React, { MouseEvent, useRef, useState } from "react";
import "../css/feed.css";
import connection, { useRoom } from "../lib/Connection";
import { Button, Input, Modal } from "antd";
import Ticker from "react-ticker";
const FeedTicker = (props: any) => {
const room = useRoom();
const [showTickerMessageModal, setShowTickerMessageModal] = useState(false);
const [tickerMessage, setTickerMessage] = useState("");
const [blockMessageInput, setBlockMessageInput] = useState(false);
const messageInput = useRef<Input>(null);
function handleTickerMessageButton(e: MouseEvent) {
if (blockMessageInput) return;
setShowTickerMessageModal(true);
setTimeout(function () {
messageInput.current?.focus();
}, 100);
}
function cancelTickerMessageModal() {
if (blockMessageInput) return;
setShowTickerMessageModal(false);
setTickerMessage("");
}
async function okTickerMessageModal() {
if (blockMessageInput) return;
if (tickerMessage) {
setBlockMessageInput(true);
try {
await connection.submitTickerMessage(tickerMessage);
setBlockMessageInput(false);
setShowTickerMessageModal(false);
setTickerMessage("");
if (messageInput.current) {
messageInput.current.input.value = "";
}
} catch {
setBlockMessageInput(false);
}
}
}
function getForIndex(index: number) {
return room?.ticker[index % room.ticker.length];
}
return (
<div className="ticker-container">
<Modal
title="Stuur berichtje naar de ticker"
onCancel={cancelTickerMessageModal}
onOk={okTickerMessageModal}
visible={showTickerMessageModal}
>
<Input
value={tickerMessage}
ref={messageInput}
placeholder="Bericht"
onChange={(e) => setTickerMessage(e.target.value)}
onKeyPress={(e) => {
e.key === "Enter" && okTickerMessageModal();
}}
/>
</Modal>
<div className="ticker-outer">
<Ticker>
{({ index }) => (
<>
{room?.ticker && (
<span className="ticker-item">{getForIndex(index)}</span>
)}
</>
)}
</Ticker>
<Button
className="ticker-message-button"
type="ghost"
onClick={handleTickerMessageButton}
>
+
</Button>
</div>
</div>
);
};
export default FeedTicker;

@ -1,219 +1,313 @@
import React, {MouseEvent, useState} from 'react'; import { useState } from "react";
import {Button, Card, Col, Divider, Form, Input, InputNumber, Row, Select} from "antd" import {
import {red} from '@ant-design/colors'; Button,
Card,
import connection, {useConfig, useIsConnected, useRoom} from "../lib/Connection"; Col,
Divider,
import "../css/lobby.sass"; Form,
import beer from "../img/beer.png" Input,
import {RoomOptions} from "../types/types"; InputNumber,
Row,
const {Option} = Select; Select,
Badge,
const Lobby = (props: any) => { } from "antd";
// Form/control states. import { red } from "@ant-design/colors";
const [selectedRoomId, setSelectedRoomId] = useState(1);
const [seekTime, setSeekTime] = useState(0); import connection, {
const [timelineName, setTimelineName] = useState(null); useConfig,
const [joiningLobby, setJoiningLobby] = useState(false); useIsConnected,
const [joinLobbyError, setJoinLobbyError] = useState(false); useRoom,
useTimelineSongFileChanged,
// Room and logic states. } from "../lib/Connection";
const isConnected = useIsConnected();
const room = useRoom(); import "../css/lobby.css";
const config = useConfig(); import logo from "../img/via-logo.svg";
import haramlogo from "../img/harambee_logo.png";
// @ts-ignore import beer from "../img/beer.png";
const connectionType = connection.socket.io.engine.transport.name; import { RoomOptions } from "../types/types";
let isLeader = room?.isLeader || false; const { Option } = Select;
let userCount = room?.userCount || 0;
export interface PropType {
function handleRequestStartClicked(e: MouseEvent) { currentUserReady: boolean;
connection.requestStart(seekTime * 1000); onCurrentUserReadyChange?: (ready: boolean) => void;
} }
function handleJoin(e: MouseEvent) { const Lobby = (props: PropType) => {
connection.requestReady(); // Form/control states.
} const [selectedRoomId, setSelectedRoomId] = useState(1);
const [seekTime, setSeekTime] = useState(0);
function applyRoomId(v: number) { const [timelineName, setTimelineName] = useState(null);
connection.requestJoin(v).then(v => { const [joiningLobby, setJoiningLobby] = useState(false);
setJoiningLobby(false); const [joinLobbyError, setJoinLobbyError] = useState(false);
setJoinLobbyError(!v); const [isPreloading, setIsPreloading] = useState(false);
}) const timeline = useTimelineSongFileChanged();
setJoiningLobby(true)
} // Room and logic states.
const isConnected = useIsConnected();
const room = useRoom();
const config = useConfig();
const isLeader = room?.isLeader || false;
const userCount = room?.userCount || 0;
async function handleJoin() {
await preloadAudio();
connection.requestSetReady();
props.onCurrentUserReadyChange?.(true);
}
async function applyRoomId(v: number) {
setJoiningLobby(true);
await connection.requestJoin(v);
setJoiningLobby(false);
setJoinLobbyError(!v);
}
function handleJoinRandomLobby() {
connection.requestJoinRandom();
setJoinLobbyError(false);
}
function handleTimelineNameSet(timelineName: any) {
setTimelineName(timelineName);
connection.setRoomOptions(
new RoomOptions(
seekTime * 1000 || 0,
timelineName || room?.timelineName || ""
)
);
}
function handleSetSeekTime(seekTime: number) {
setSeekTime(seekTime);
connection.setRoomOptions(
new RoomOptions(
seekTime * 1000 || 0,
timelineName || room?.timelineName || ""
)
);
}
function handleJoinRandomLobby() { function preloadAudio(): Promise<boolean> {
connection.requestJoinRandom() setIsPreloading(true);
setJoinLobbyError(false); const songFile = timeline?.songFile;
}
function handleTimelineNameSet(timelineName: any) { if (!songFile) {
setTimelineName(timelineName); return Promise.resolve(false);
connection.setRoomOptions(new RoomOptions(
seekTime || 0,
timelineName || room?.timelineName || ''))
} }
function handleSetSeekTime(seekTime: number) { return new Promise<boolean>((resolve) => {
setSeekTime(seekTime); const audioElement = new Audio();
connection.setRoomOptions(new RoomOptions( audioElement.addEventListener("canplaythrough", () => {
seekTime * 1000 || 0, // 'canplaythrough' means the browser thinks it has buffered enough to play
timelineName || room?.timelineName || '')) // until the end.
} setIsPreloading(false);
resolve(true);
let leaderConfig = ( });
audioElement.src = songFile;
});
}
const leaderConfig = (
<Row justify="center">
<Col>
<Form
layout="horizontal"
labelCol={{ span: 8 }}
wrapperCol={{ span: 24 }}
>
<Form.Item label="Starttijd">
<Input
type="number"
suffix="sec"
value={seekTime}
onChange={(v) => handleSetSeekTime(parseInt(v.target.value) || 0)}
/>
</Form.Item>
<Form.Item label="Nummer">
<Select
defaultValue={(room && room.timelineName) || ""}
onChange={(e) => handleTimelineNameSet(e)}
>
{config &&
config.availableTimelines.map((item, i) => (
<Option key={item} value={item}>
{item}
</Option>
))}
</Select>
</Form.Item>
</Form>
<Button
block
type="primary"
loading={isPreloading}
onClick={handleJoin}
>
Start
</Button>
</Col>
</Row>
);
const nonLeaderConfig = (
<Row justify="center">
<Col>
<p>
{room?.running ? "We luisteren naar" : "We gaan luisteren naar"}{" "}
<b>{room && room.timelineName}</b> en
{room?.running && <span> zijn al gestart!</span>}
{!room?.running && (
<span> starten op {(room?.seekTime || 0) / 1000} seconden</span>
)}
</p>
<Button
block
type="primary"
disabled={!room || props.currentUserReady}
loading={isPreloading}
onClick={handleJoin}
>
{room && props.currentUserReady
? "Wachten op het startsein"
: "Kom erbij"}
</Button>
</Col>
</Row>
);
return (
<div className="lobby">
<Row className="centurion-title" justify="center">
<Col span={4} md={4}>
<img
src={beer}
className={`beer beer-flipped ${
isConnected ? "connected" : "connecting"
}`}
alt="beer"
/>
</Col>
<Col span={12} md={6}>
Centurion!
</Col>
<Col span={4} md={4}>
<img
src={beer}
className={`beer ${isConnected ? "connected" : "connecting"}`}
alt="beer"
/>
</Col>
</Row>
<Row>
<Col className="hints" span={24}>
<div>Honderd minuten...</div>
<div>Honderd shots...</div>
<div>Kun jij het aan?</div>
</Col>
</Row>
<br />
{!isConnected && (
<Row justify="center"> <Row justify="center">
<Col> <Col className="lobby-connecting">
<Form <h2>Verbinden...</h2>
layout='horizontal' </Col>
labelCol={{span: 8}}
wrapperCol={{span: 24}}
>
<Form.Item label="Starttijd">
<Input
type="number"
suffix="sec"
value={seekTime}
onChange={v => handleSetSeekTime(parseInt(v.target.value) || 0)}/>
</Form.Item>
<Form.Item label="Nummer">
<Select defaultValue={(room && room.timelineName) || ''}
onChange={e => handleTimelineNameSet(e)}>
{config && config.availableTimelines.map((item, i) =>
<Option key={item} value={item}>{item}</Option>
)}
</Select>
</Form.Item>
</Form>
<Button
block
type="primary"
onClick={handleRequestStartClicked}>Start</Button>
</Col>
</Row> </Row>
) )}
let nonLeaderConfig = ( {isConnected && (
<Row justify="center"> <Row justify="center">
<Col> <Col xs={24} sm={16} md={12} xl={10}>
<p> <Card>
We gaan luisteren naar <b>{room && room.timelineName}</b> en <h3>
{room?.running && <span> zijn al gestart!</span>} Huidige lobby: <b>{room ? `#${room.id}` : "Geen lobby"}</b>
{!room?.running && <span> starten op {(room?.seekTime || 0) / 1000} seconden</span>} </h3>
</p>
{room && (
<Button <Row>
block {userCount === 1 ? (
<span>Er is één gebruiker aanwezig.</span>
) : (
<span>Er zijn {userCount} gebruikers aanwezig.</span>
)}
</Row>
)}
<Row justify="center">
{room?.users?.map((u) => (
<Badge
key={u.id}
status={u.readyToParticipate ? "success" : "error"}
/>
))}
</Row>
{room && <Row>Deel de link met je vrienden om mee te doen!</Row>}
<Divider />
{room && (isLeader ? leaderConfig : nonLeaderConfig)}
<Divider />
<Row justify="center">
<Col>
<InputNumber
style={{ width: "calc(100% - 150px)" }}
min={1}
max={100000}
value={selectedRoomId || room?.id || 0}
onChange={(v) => setSelectedRoomId(v || 0)}
/>
<Button
style={{ width: "150px" }}
type="primary" type="primary"
disabled={!room || room.readyToParticipate} loading={joiningLobby}
onClick={handleJoin}>{room && room.readyToParticipate ? 'Wachten op het startsein' : 'Kom erbij'}</Button> onClick={async () => {
</Col> await applyRoomId(selectedRoomId);
</Row> }}
) >
Ga naar die lobby
// @ts-ignore </Button>
return (
<div className="lobby"> {joinLobbyError && (
<Row> <span style={{ color: red[4] }}>
<Col className="centurion-title" span={24}> Die lobby bestaat niet
<div className="beer-flipped"> </span>
<img src={beer} className={`beer ${isConnected ? 'connected' : 'connecting'}`} alt="beer"/> )}
</div>
<span className="text">Centurion!</span>
<img src={beer} className={`beer ${isConnected ? 'connected' : 'connecting'}`} alt="beer"/>
</Col> </Col>
</Row> </Row>
<Row>
<Col className="hints" span={24}>
<div>Honderd minuten...</div>
<div>Honderd shots...</div>
<div>Kun jij het aan?</div>
</Col>
</Row>
<br/>
{!isConnected && <Row justify="center">
<Row justify="center"> <span className={"lobby-options-or"}>of</span>
<Col className="lobby-connecting"> </Row>
<h2>Verbinden...</h2>
</Col> <Row justify="center">
</Row> <Col>
} <Button
type="primary"
{isConnected && onClick={() => {
<Row justify="center"> handleJoinRandomLobby();
<Col xs={24} sm={16} md={12} xl={10} className="lobby-info"> }}
<Card> >
<h3>Huidige lobby: <b>{room?.id || 'Geen lobby'}</b></h3> Join een nieuwe lobby
</Button>
{/*<span>Verbonden met {connectionType}</span>*/}
{room &&
<span>
{userCount === 1 ?
<p>Er is één gebruiker aanwezig.</p>
:
<p>Er zijn {userCount} gebruikers aanwezig.</p>
}
</span>
}
{room &&
<span>Deel de link met je vrienden om mee te doen!</span>
}
<br/>
<br/>
{room && (isLeader ? leaderConfig : nonLeaderConfig)}
<Divider/>
<Row justify="center">
<Col>
<InputNumber
style={{'width': 'calc(100% - 150px)'}}
min={1}
max={100000}
value={selectedRoomId || room?.id || 0}
onChange={(v) => setSelectedRoomId(v || 0)}/>
<Button
style={{'width': '150px'}}
type="primary"
loading={joiningLobby}
onClick={() => {
applyRoomId(selectedRoomId)
}}>Verander van lobby</Button>
{joinLobbyError &&
<span style={{color: red[4]}}>Die lobby bestaat niet</span>
}
</Col>
</Row>
<Row justify="center">
<span className={'lobby-options-or'}>of</span>
</Row>
<Row justify="center">
<Col>
<Button type="primary"
onClick={() => {
handleJoinRandomLobby()
}}>Join een willekeurige lobby</Button>
</Col>
</Row>
</Card>
</Col> </Col>
</Row> </Row>
} </Card>
</div> </Col>
); </Row>
)}
<img src={haramlogo} className="haram-logo" alt="haramlogo"/>
<img src={logo} className="via-logo" alt="logo" />
</div>
);
}; };
export default Lobby; export default Lobby;

@ -1,42 +1,48 @@
import React from 'react'; import { Progress } from "antd";
import {Col, Progress} from "antd" import { calculateRoomTime, useTimeline } from "../lib/Connection";
import {roomTime, useTimeline} from "../lib/Connection"; import { useUpdateAfterDelay } from "../util/hooks";
import {useUpdateAfterDelay} from "../util/hooks";
const NextShot = () => { const NextShot = () => {
const timeline = useTimeline() const timeline = useTimeline();
useUpdateAfterDelay(1000) useUpdateAfterDelay(1000);
let remainingTime = 0; if (!timeline) {
let remainingPercentage = 0; throw new TypeError("NextShot without timeline");
}
if (timeline) {
const time = roomTime(); let remainingTime = 0;
const [current, next] = timeline.itemAtTime(time, 'shot'); let remainingPercentage = 0;
if (current && next) { const currentRoomTime = calculateRoomTime();
let currentTime = time - current.timestamp * 1000
let nextTime = next.timestamp * 1000 - current.timestamp * 1000; const nextItem = timeline.itemAfterTime(currentRoomTime, "shot");
remainingTime = Math.round((nextTime - currentTime) / 1000) if (nextItem) {
remainingPercentage = 100 - (currentTime / (nextTime || 1)) * 100; const prevShotRoomTime =
} (timeline.itemBeforeTime(currentRoomTime, "shot")?.timestamp || 0) * 1000;
} const nextShotRoomTime = nextItem?.timestamp * 1000;
const totalRoomTimeBetweenShots = nextShotRoomTime - prevShotRoomTime;
return ( const roomTimeSinceLastShot = currentRoomTime - prevShotRoomTime;
<Col className="sider" span={24} md={4}>
<h1>Tijd tot volgende shot:</h1> remainingTime = Math.round((nextShotRoomTime - currentRoomTime) / 1000);
<Progress type="circle" remainingPercentage =
percent={remainingPercentage} 100 - (roomTimeSinceLastShot / totalRoomTimeBetweenShots) * 100;
format={_ => remainingTime + ' sec.'} }
strokeColor={"#304ba3"}
strokeWidth={10} return (
status="normal"/> <>
</Col> <h1>Tijd tot volgende shot:</h1>
<Progress
); type="circle"
percent={remainingPercentage}
format={() => `${remainingTime} sec.`}
strokeColor={"#304ba3"}
strokeWidth={10}
status="normal"
/>
</>
);
}; };
export default NextShot; export default NextShot;

@ -1,106 +1,211 @@
import {roomTime, useRoomRunningAndReadyChanged, useRoomTime, useTimelineSongFileChanged} from "../lib/Connection"; import connection, {
import React, {createRef, SyntheticEvent, useRef, useState} from "react"; calculateRoomTime,
useRoomRunningAndReadyChanged,
useTimelineSongFileChanged,
} from "../lib/Connection";
import { SyntheticEvent, useEffect, useRef, useState } from "react";
import '../css/player.sass' import { Button, Slider } from "antd";
import {Room} from "../types/types"; import { SoundFilled, SoundOutlined } from "@ant-design/icons";
import {parse as parseQueryString} from "query-string";
import "../css/player.css";
import { Room } from "../types/types";
const Player = () => { const Player = () => {
const room = useRoomRunningAndReadyChanged(); const room = useRoomRunningAndReadyChanged();
const _ = useRoomTime() const timeline = useTimelineSongFileChanged();
const timeline = useTimelineSongFileChanged();
const player = useRef<HTMLAudioElement>(null);
const defaultVolume = parseInt(localStorage.getItem("volume") ?? "100");
const [volume, setVolume] = useState(defaultVolume);
const [muted, setMuted] = useState(false);
const [finishedLoading, setFinishedLoading] = useState(false);
const [timesSeeked, setTimesSeeked] = useState(0);
const [hadError, setHadError] = useState(false);
// If our time synchronisation algorithm thing thinks the time is off by more
// than this value, we seek the running player to correct it.
const diffSecondsRequiredToSeekRunningPlayer = 0.2;
// Hard cap we are allowed to seek this player. Some browsers are slow or inaccurate
// and will always be off. To avoid endless skipping of the song this cap stops seeking the
// player.
const maxTimesSeekAllow = 25;
useEffect(() => {
// Need to use an effect since 'player' will only contain a reference after first render.
if (!timeline) {
throw new Error("Player without active timeline.");
}
if (!player.current) {
throw new Error("No player after mount.");
}
let player = useRef<HTMLAudioElement>(null) player.current.src = timeline.songFile;
}, [timeline]);
const [timesSeeked, setTimesSeeked] = useState(0); function handlePlayerOnPlay(e: SyntheticEvent) {
const [hadError, setHadError] = useState(false); e.preventDefault();
// For when the user manually started the player for when autoplay is off.
setHadError(false);
}
// If our time synchronisation algorithm thing thinks the time is off by more async function handlePlayerPause(e: SyntheticEvent) {
// than this value, we seek the running player to correct it. if (!shouldPlay()) {
const diffSecondsRequiredToSeekRunningPlayer = 0.20; // We should not be playing, pausing is fine.
console.log("should not play, paused");
return;
}
// Hard cap we are allowed to seek this player. Some browsers are slow or inaccurate e.preventDefault();
// and will always be off. To avoid endless skipping of the song this cap stops seeking the
// player.
const maxTimesSeekAllow = 25;
const query = parseQueryString(window.location.search); if (room) {
if (query.nosound) { setPlayerTime(room, true);
return null;
} }
await player.current?.play();
}
if (player.current && player.current.dataset.src != timeline!!.songFile) { function handlePlayerCanPlayThrough() {
player.current.dataset.src = timeline!!.songFile; if (!finishedLoading) {
player.current.src = timeline!!.songFile; setFinishedLoading(true);
connection.requestStart();
}
}
function shouldPlay() {
return (
player.current &&
timeline &&
room &&
room.running &&
room.readyToParticipate &&
!player.current.ended
);
}
async function startPlaying(manual: boolean) {
if (!player.current) {
return;
} }
function handlePlayerOnPlay(e: SyntheticEvent) { if (player.current.paused && !hadError) {
e.preventDefault(); setPlayerVolume(volume);
// For when the user manually started the player for when autoplay is off. try {
await player.current.play();
setHadError(false); setHadError(false);
if (shouldPlay()) { } catch (e) {
startPlaying(true) console.error("Error playing", e);
} setHadError(true);
}
} }
function shouldPlay() { if (!hadError && room) {
return player.current && timeline && room && room.running && room.readyToParticipate setPlayerTime(room, manual);
} }
}
function startPlaying(manual: boolean) { function setPlayerTime(room: Room, manualAdjustment: boolean) {
if (!player.current) return; if (!player.current) {
return;
if (player.current.paused && !hadError) {
player.current.play().then(() => {
setHadError(false);
}).catch(e => {
console.error('Error playing', e);
setHadError(true);
})
}
if (!hadError) {
setPlayerTime(room!!, manual);
}
} }
function setPlayerTime(room: Room, manualAdjustment: boolean) { // Player's currentTime is in seconds, not ms.
if (!player.current) return; const targetTime = calculateRoomTime() / 1000;
const diff = Math.abs(player.current.currentTime - targetTime);
let targetTime = roomTime() / 1000;
let diff = player.current.currentTime - targetTime; // console.log('PLAYER DIFF', diff,
// 'min req to seek: ', diffSecondsRequiredToSeekRunningPlayer,
if (player.current && Math.abs(diff) > diffSecondsRequiredToSeekRunningPlayer) { // `(${timesSeeked} / ${maxTimesSeekAllow})`);
if (room.speedFactor != 1 || manualAdjustment || timesSeeked < maxTimesSeekAllow) {
player.current.currentTime = targetTime; if (diff <= diffSecondsRequiredToSeekRunningPlayer) {
player.current.playbackRate = Math.max(Math.min(4.0, room.speedFactor), 0.25); return;
if (!manualAdjustment) {
setTimesSeeked(timesSeeked + 1);
}
} else {
console.warn('The running player is off, but we\'ve changed the time ' +
'too often, skipping synchronizing the player.');
}
}
} }
if (shouldPlay()) { if (timesSeeked >= maxTimesSeekAllow && !manualAdjustment) {
startPlaying(false) // If we are adjusting manually we always allow a seek.
} else { console.warn(
if (player.current) { "The running player is off, but we've changed the time " +
player.current.pause(); "too often, skipping synchronizing the player."
} );
return;
} }
function render() { player.current.currentTime = targetTime;
return ( player.current.playbackRate = Math.min(room.speedFactor, 5);
<audio ref={player} className='player' hidden={!hadError} controls={true} onPlay={handlePlayerOnPlay}/>
) if (!manualAdjustment) {
setTimesSeeked(timesSeeked + 1);
} }
return render(); console.log(
} `Player seeked: diff: ${diff}, target: ${targetTime}, (${timesSeeked} / ${maxTimesSeekAllow})`
);
}
function toggleMute() {
if (player.current != null) {
player.current.muted = !player.current.muted;
setMuted(!muted);
}
}
function changeVolume(sliderValue: number) {
setVolume(sliderValue);
localStorage["volume"] = sliderValue;
setPlayerVolume(sliderValue);
}
function setPlayerVolume(value: number) {
if (player.current) {
player.current.volume =
value === 0.0 ? 0.0 : Math.pow(10, (value / 100 - 1) * 2);
}
}
if (shouldPlay()) {
startPlaying(false)
.then(() => {
//
})
.catch((e) => {
console.error(e);
});
} else {
if (player.current) {
player.current.pause();
}
}
return (
<div id="audio">
<audio
ref={player}
className="player"
controls={true}
loop={false}
hidden={!hadError}
onPause={handlePlayerPause}
onPlay={handlePlayerOnPlay}
onCanPlayThrough={handlePlayerCanPlayThrough}
/>
<div id="volume-control">
<Button onClick={toggleMute} shape="circle">
{muted ? <SoundOutlined /> : <SoundFilled />}
</Button>
<Slider
id="volume-slider"
defaultValue={volume}
onChange={changeVolume}
trackStyle={{ backgroundColor: "var(--secondary-color)" }}
/>
</div>
</div>
);
};
export default Player; export default Player;

@ -1,34 +1,41 @@
import React from 'react'; import { Progress } from "antd";
import {Col, Progress} from "antd" import { calculateRoomTime, useTimeline } from "../lib/Connection";
import {roomTime, useTimeline} from "../lib/Connection"; import { useUpdateAfterDelay } from "../util/hooks";
import {useUpdateAfterDelay} from "../util/hooks";
const ShotsTaken = () => { const ShotsTaken = () => {
let timeline = useTimeline(); const timeline = useTimeline();
useUpdateAfterDelay(1000);
if (!timeline) {
throw new TypeError("ShotsTaken without timeline");
}
const totalShots = timeline.getTotalShotCount();
useUpdateAfterDelay(1000); let taken = 0;
let taken = 0; const time = calculateRoomTime();
const prevShot = timeline.eventBeforeTime(time, "shot");
if (timeline) { if (prevShot) {
let [current, _] = timeline.eventAtTime(roomTime(), 'shot'); taken = prevShot.shotCount;
if (current) { } else {
taken = current.shotCount!!; const nextShot = timeline.eventAfterTime(time, "shot");
} taken = nextShot ? nextShot.shotCount - 1 : taken;
} }
return ( return (
<Col className="sider" span={24} md={4}> <>
<h1>Shots genomen:</h1> <h1>Shots genomen:</h1>
<Progress type="circle" <Progress
percent={taken} type="circle"
format={_ => taken + ' / 100'} percent={(taken / totalShots) * 100}
status="normal" format={() => `${taken} / ${totalShots}`}
strokeColor={"#304ba3"} status="normal"
strokeWidth={10}/> strokeColor={"#304ba3"}
</Col> strokeWidth={10}
); />
</>
);
}; };
export default ShotsTaken; export default ShotsTaken;

@ -0,0 +1,133 @@
.feed {
#audio {
padding-top: 15px;
padding-bottom: 15px;
padding: 15px auto;
}
&.feed-shot-shake {
animation: shake-out 0.5s ease-out;
}
@keyframes shake-out {
0%,
20% {
transform: translate3d(-4px, 0, 0);
}
10%,
30% {
transform: translate3d(4px, 0, 0);
}
40% {
transform: translate3d(-3px, 0, 0);
}
50% {
transform: translate3d(3px, 0, 0);
}
60% {
transform: translate3d(-2px, 0, 0);
}
70% {
transform: translate3d(2px, 0, 0);
}
80% {
transform: translate3d(-1px, 0, 0);
}
90% {
transform: translate3d(1px, 0, 0);
}
100% {
transform: translate3d(0px, 0, 0);
}
}
.fade-enter {
opacity: 1;
transform: translateY(-30%);
}
.fade-enter-active {
transition: opacity 0.3s ease-out, transform 0.3s ease-out;
opacity: 1;
transform: translateY(0);
}
.fade-leave {
opacity: 1;
}
.fade-leave.fade-leave-active {
transition: opacity 0.3s ease-out;
opacity: 0;
}
}
.feed-reverse {
display: flex;
flex-direction: column-reverse;
}
.feed-item {
border: 1px solid #efefef;
border-radius: 2px;
padding: 0.35em;
margin: 0.25em;
text-align: center;
font-size: 1.1rem;
font-weight: bold;
color: rgba(0, 0, 0, 0.85);
box-shadow: 0 0 10px -4px rgba(48, 75, 163, 0.2);
@media (min-width: 992px) {
font-size: 1.5rem;
}
}
.feed-item__emoji > img {
max-width: 100%;
max-height: 40px;
}
:root {
--ticker-height: 3em;
--feed-height: calc(100% - var(--ticker-height));
}
.feed-items {
overflow: hidden;
@media (min-width: 992px) {
height: var(--feed-height);
padding-bottom: var(--ticker-height);
}
}
.ticker-container {
display: none;
width: 100%;
height: var(--ticker-height);
overflow: hidden;
@media (min-width: 992px) {
display: block;
}
.ticker-outer {
width: 100%;
height: 100%;
position: relative;
}
.ticker-message-button {
position: absolute;
left: 0;
bottom: 0;
background: white;
}
.ticker-item {
font-size: 2em;
margin-right: 2em;
}
}

@ -1,49 +0,0 @@
.feed-item
display: flex
border: 1px solid #efefef
border-radius: 4px
padding: 10px
margin: 10px
text-align: center
font-size: 16pt
font-weight: bold
color: rgba(0, 0, 0, 0.85)
box-shadow: 0 0 10px -4px rgba(48, 75, 163, 0.2)
.feed-item__title
width: 45%
.feed-item__desc
width: 45%
.feed-item__emoji
width: 10%
img
width: 60%
max-width: 100%
max-height: 100%
.feed-reverse
display: flex
flex-direction: column-reverse
// Animations
.fade-enter
opacity: 0.01
max-height: 0
.fade-enter.fade-enter-active
opacity: 1
max-height: 1000px
transition: opacity 500ms ease-in, max-height 500ms ease-in
.fade-leave
opacity: 1
max-height: 1000px
.fade-leave.fade-leave-active
opacity: 0.01
max-height: 0
transition: opacity 300ms ease-in, max-height 300ms ease-in

@ -0,0 +1,59 @@
@import 'antd/es/style/index';
@font-face {
font-family: 'SourceSansPro';
src: local('SourceSansPro'), url('./SourceSansPro.otf') format('opentype');
}
:root {
--primary-color: #304ba3;
--secondary-color: #ae2573;
}
body {
font-family: "SourceSansPro", sans-serif !important;
background-color: white;
}
#root {
height: 100%;
}
.feed {
height: 100%;
overflow: hidden;
padding: 0.5em;
}
.content {
padding: 1rem;
height: 100%;
}
.via-logo {
z-index: -10000;
position: fixed;
right: 0;
bottom: 0;
width: auto;
height: 5em;
padding: 10px;
}
.haram-logo {
z-index: -10000
position: fixed;
left: 0;
bottom: 0;
width: auto;
height: 10em;
padding: 10px;
}
.sider {
text-align: center;
}
h1 {
min-height: 3em;
}

@ -1,57 +0,0 @@
@import '~antd/dist/antd.css'
@font-face
font-family: 'SourceSansPro'
src: local('SourceSansPro'), url('./SourceSansPro.otf') format('opentype')
$footer-height: 4.5em
$content-height: calc(100vh - #{$footer-height})
body
font-family: 'SourceSansPro', sans-serif
background-color: white
.feed
max-height: $content-height
overflow: hidden
padding: 0.5em
.content
padding: 1rem
height: $content-height
.via-logo
z-index: -10000
position: fixed
right: 0
bottom: 0
width: auto
height: 5em
padding: 10px
.haram-logo
z-index: -10000
position: fixed
left: 0
bottom: 0
width: auto
height: 10em
padding: 10px
.sider
text-align: center
h1
min-height: 3em
footer
padding: 0.25em 0.5em
height: 4.5em

@ -0,0 +1,76 @@
.lobby {
.centurion-title {
text-align: center;
font-size: 2.5rem;
min-height: inherit;
.text {
padding: 0 2rem;
}
.beer {
animation-fill-mode: forwards;
max-width: 120%;
max-height: 120%;
&.connecting {
animation: spin 0.4s ease-in-out 25;
}
@keyframes spin {
100% {
transform: rotate(-360deg);
}
}
}
}
.beer-flipped {
transform: scaleX(-1);
}
.lobby-connecting {
margin: 2em 0 0 0;
text-align: center;
}
.hints {
margin: 1rem 0 0 0;
font-size: 1.5rem;
text-align: center;
}
.control {
font-size: 1.3rem;
margin: 2em 1em 2em 1em;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
}
.lobby-options-or {
display: inline-block;
position: relative;
margin: 16px 0;
color: #888;
&:before,
&:after {
content: "";
width: 1px;
height: 6px;
position: absolute;
left: 50%;
margin-left: 0.1px;
background: #bbb;
}
&:before {
bottom: 100%;
}
&:after {
top: 100%;
}
}
}

@ -1,62 +0,0 @@
.lobby
.centurion-title
text-align: center
font-size: 2.5rem
min-height: inherit
.text
padding: 0 2rem
.beer
animation-fill-mode: forwards
&.connecting
animation: spin 0.4s ease-in-out 25
@keyframes spin
100%
transform: rotate(-360deg)
.beer-flipped
transform: scaleX(-1)
display: inline-block
.lobby-connecting
margin: 2em 0 0 0
text-align: center
.hints
margin: 1rem 0 0 0
font-size: 1.5rem
text-align: center
.lobby-info
text-align: center
.control
font-size: 1.3rem
margin: 2em 1em 2em 1em
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24)
.lobby-options-or
display: inline-block
position: relative
margin: 16px 0
color: #888
&:before, &:after
content: ''
width: 1px
height: 6px
position: absolute
left: 50%
margin-left: 0.1px
background: #bbb
&:before
bottom: 100%
&:after
top: 100%

@ -0,0 +1,16 @@
#volume-control {
display: flex;
flex-direction: row;
justify-content: center;
}
#volume-control .ant-slider {
width: 65%;
padding-left: 10px;
}
.player {
position: fixed;
left: 0;
bottom: 0;
}

@ -1,4 +0,0 @@
.player
position: fixed
left: 0
bottom: 0

@ -1,7 +1,6 @@
import React from 'react'; import ReactDOM from "react-dom";
import ReactDOM from 'react-dom'; import "./css/index.css";
import './css/index.sass';
import App from './components/App'; import Centurion from "./components/Centurion";
ReactDOM.render(<App/>, document.getElementById('root')); ReactDOM.render(<Centurion />, document.getElementById("root"));

@ -1,335 +1,311 @@
import io from "socket.io-client"; import io, { Socket } from "socket.io-client";
import {useEffect, useState} from "react";
import {parse as parseQueryString, stringify as stringifyQueryString} from 'query-string';
import {Config, Room, RoomOptions, Timeline} from "../types/types"; import { Config, Room, RoomOptions, Timeline } from "../types/types";
import {Sub, useSub} from "../util/sub"; import { Sub, useSub } from "../util/sub";
class Connection { class Connection {
url = '/'; url = "/";
socket: SocketIOClient.Socket; socket: Socket;
isConnected = new Sub<boolean>(); isConnected = new Sub<boolean>();
config = new Sub<Config | null>(); config = new Sub<Config | null>();
room = new Sub<Room | null>(); room = new Sub<Room | null>();
timeline = new Sub<Timeline | null>(); timeline = new Sub<Timeline | null>();
timeSyncIntervals = [500, 1000, 3000, 5000, 10000, 30000]; timeSyncIntervals = [500, 1000, 3000, 5000, 10000, 30000];
timeSyncs: { [requestId: number]: TimeSyncRequest } = {}; timeSyncs: { [requestId: number]: TimeSyncRequest } = {};
timeSyncTimeoutIds: number[] = []; timeSyncTimeoutIds: number[] = [];
timeSyncTooOld = 120000; timeSyncTooOld = 120000;
roomTime = new Sub<number>(); roomTime = new Sub<number>();
calls: { [id: number]: Call } = {}; constructor() {
this.isConnected.set(false);
constructor() {
this.isConnected.set(false); this.socket = io(this.url, {
autoConnect: false,
this.socket = io(this.url, { transports: ["websocket"],
autoConnect: false, });
transports: ['websocket']
}); this.setupSocketListeners();
this.connect();
this.roomTime.set(0);
}
connect() {
this.socket.connect();
}
setupSocketListeners() {
this.socket.on("connect", async () => {
this.isConnected.set(true);
await this.onConnect();
});
this.socket.on("disconnect", () => {
this.isConnected.set(false);
this.onDisconnect();
});
this.socket.on("config", (data: any) => {
this.config.set(data.config);
});
this.socket.on("time_sync", (data: any) => {
this.timeSyncResponse(data.requestId, data.clientDiff, data.serverTime);
});
this.socket.on("room", (data: any) => {
if (data.room) {
this.setQueryLobbyId(data.room.id);
}
this.room.set(data.room);
});
this.socket.on("timeline", (data: any) => {
if (data.timeline) {
this.timeline.set(new Timeline(data.timeline));
} else {
this.timeline.set(null);
}
});
}
async onConnect() {
this.startTimeSync();
const lobbyId = this.getQueryLobbyId();
if (lobbyId) {
const exists = await this.requestJoin(lobbyId);
if (!exists) {
this.setQueryLobbyId();
this.requestJoinRandom();
}
} else {
this.requestJoinRandom();
}
}
this.setupSocketListeners(); onDisconnect() {
this.stopTimeSync();
}
this.connect(); private getQueryLobbyId(): number | null {
const query = new URLSearchParams(window.location.search);
const lobby = query.get("lobby");
this.roomTime.set(0); if (!lobby) {
return null;
} }
connect() { const lobbyId = Number.parseInt(query.get("lobby")!.toString());
this.socket.connect();
}
setupSocketListeners() { if (!Number.isSafeInteger(lobbyId) || lobbyId < 1) {
this.socket.on('connect', () => { return null;
this.isConnected.set(true);
this.onConnect();
})
this.socket.on('disconnect', () => {
this.isConnected.set(false);
this.onDisconnect();
})
this.socket.on('config', (data: any) => {
this.config.set(data.config);
})
this.socket.on('time_sync', (data: any) => {
this.timeSyncResponse(data.requestId, data.clientDiff, data.serverTime);
})
this.socket.on('room', (data: any) => {
if (data.room) {
this.setQueryLobbyId(data.room.id);
}
this.room.set(data.room);
});
this.socket.on('timeline', (data: any) => {
if (data.timeline) {
this.timeline.set(new Timeline(data.timeline));
} else {
this.timeline.set(null);
}
});
this.socket.on('call_response', (data: any) => {
let call = this.calls[data.id];
if (!call) return;
if (data.error) {
call.callback(data.error, null);
} else {
call.callback(null, data.response);
}
delete this.calls[data.id];
});
} }
onConnect() { return lobbyId;
this.startTimeSync(); }
let lobbyId = this.getQueryLobbyId();
if (lobbyId) {
this.requestJoin(lobbyId).then(v => {
if (!v) {
this.setQueryLobbyId(null);
this.requestJoinRandom();
}
})
} else {
this.requestJoinRandom();
}
}
onDisconnect() { private setQueryLobbyId(lobbyId?: number) {
this.stopTimeSync(); const newUrl = new URL(window.location.href);
}
autoStart() : boolean { if (lobbyId) {
let query = parseQueryString(window.location.search); newUrl.searchParams.set("lobby", String(lobbyId));
return !!query.autostart; } else {
newUrl.searchParams.delete("lobby");
} }
private getQueryLobbyId(): number | null { window.history.pushState({}, "", newUrl.toString());
let query = parseQueryString(window.location.search); }
if (query.lobby) {
let lobbyId = Number.parseInt(query.lobby.toString()); setRoomOptions(roomOptions: RoomOptions) {
if (Number.isSafeInteger(lobbyId) && lobbyId > 0) { this.socket.emit("room_options", roomOptions);
return lobbyId }
}
requestStart() {
this.socket.emit("request_start");
}
submitTickerMessage(message: string) {
return new Promise<void>((resolve, reject) => {
this.socket.emit(
"submit_ticker_message",
message,
(_: null, err?: string) => {
if (err) {
reject(err);
} else {
resolve();
}
} }
return null; );
} });
}
private setQueryLobbyId(lobbyId: number | null) {
let query = parseQueryString(window.location.search); async requestJoin(roomId: number): Promise<boolean> {
if (lobbyId) { return new Promise<boolean>((resolve, reject) => {
query.lobby = lobbyId.toString(); this.socket.emit(
} else { "request_join",
delete query.lobby; roomId,
(err?: string, didJoinRoom?: boolean) => {
if (err) {
return reject(err);
}
return resolve(!!didJoinRoom);
} }
let newUrl = window.location.protocol + "//" + window.location.host + );
window.location.pathname + (Object.keys(query).length ? ('?' + stringifyQueryString(query)) : ''); });
window.history.pushState({}, '', newUrl); }
}
requestSetReady() {
async call(name: string, params: any) { this.socket.emit("request_set_ready");
return new Promise<any>((resolve, reject) => { }
let callback = (err: any, res: any) => {
if (err) { requestJoinRandom() {
return reject(err); this.socket.emit("request_join_random");
} }
resolve(res); startTimeSync() {
}; for (let i = 0; i < this.timeSyncIntervals.length; i++) {
const timeoutId = setTimeout(() => {
let call = new Call(name, params, callback); // Only reschedule the last sync interval (i.e. every 30 seconds)
this.calls[call.id] = call; const shouldReschedule = i === this.timeSyncIntervals.length - 1;
this.socket.emit('call', call.id, name, params); this.sendTimeSync(shouldReschedule);
}); }, this.timeSyncIntervals[i]);
}
this.timeSyncTimeoutIds.push(timeoutId);
setRoomOptions(roomOptions: RoomOptions) {
this.socket.emit('room_options', roomOptions)
}
requestStart(seekTime: number) {
this.socket.emit('request_start', {
seekTime: seekTime
});
}
async requestJoin(roomId: number): Promise<boolean> {
return this.call('room_exists', {roomId: roomId}).then(v => {
if (v) {
this.socket.emit('request_join', roomId, this.autoStart());
if (this.autoStart()) {
this.requestReady();
}
return true;
} else {
return false;
}
})
} }
}
requestReady() { stopTimeSync() {
this.socket.emit('request_ready'); for (let i = 0; i < this.timeSyncTimeoutIds.length; i++) {
clearTimeout(this.timeSyncTimeoutIds[i]);
} }
}
requestJoinRandom() { sendTimeSync(alsoSchedule: boolean) {
this.socket.emit('request_join_random'); const sync = new TimeSyncRequest();
} this.socket.emit("time_sync", sync.requestId, Date.now());
this.timeSyncs[sync.requestId] = sync;
startTimeSync() { if (alsoSchedule) {
for (let i = 0; i < this.timeSyncIntervals.length; i++) { setTimeout(() => {
let timeoutId = setTimeout(() => { this.sendTimeSync(true);
this.sendTimeSync(i === this.timeSyncIntervals.length - 1); }, this.timeSyncIntervals[this.timeSyncIntervals.length - 1]);
}, this.timeSyncIntervals[i]);
// @ts-ignore
this.timeSyncTimeoutIds.push(timeoutId);
}
} }
}
stopTimeSync() { timeSyncResponse(requestId: number, clientDiff: number, serverTime: number) {
for (let i = 0; i < this.timeSyncTimeoutIds.length; i++) { const syncReq = this.timeSyncs[requestId];
clearTimeout(this.timeSyncTimeoutIds[i]); if (!syncReq) {
} return;
} }
sendTimeSync(alsoSchedule: boolean) { syncReq.response(clientDiff, serverTime);
let sync = new TimeSyncRequest();
this.socket.emit('time_sync', sync.requestId, Date.now());
this.timeSyncs[sync.requestId] = sync;
if (alsoSchedule) { for (const i in this.timeSyncs) {
setTimeout(() => { if (this.timeSyncs[i].start < Date.now() - this.timeSyncTooOld) {
this.sendTimeSync(true); delete this.timeSyncs[i];
}, this.timeSyncIntervals[this.timeSyncIntervals.length - 1]); break;
} }
} }
timeSyncResponse(requestId: number, clientDiff: number, serverTime: number) { this.roomTime.set(calculateRoomTime());
let syncReq = this.timeSyncs[requestId]; }
if (!syncReq) return
delete this.timeSyncs[requestId]; serverTime(): number {
syncReq.response(clientDiff, serverTime); return Date.now() + this.serverTimeOffset();
}
for (let i in this.timeSyncs) {
if (this.timeSyncs[i].start < Date.now() - this.timeSyncTooOld) { serverTimeOffset(): number {
delete this.timeSyncs[i]; let num = 0;
break; let sum = 0;
} for (const i in this.timeSyncs) {
} const sync = this.timeSyncs[i];
// console.log(this.timeSyncs); if (!sync.ready) continue;
// console.log('SERVER TIME', this.serverTimeOffset()); sum += sync.offset;
num += 1;
this.roomTime.set(roomTime());
} }
serverTime(): number { if (num === 0) {
return Date.now() + this.serverTimeOffset(); return 0;
} }
serverTimeOffset(): number { return Math.round(sum / num);
let num = 0; }
let sum = 0;
for (let i in this.timeSyncs) {
let sync = this.timeSyncs[i];
if (!sync.ready) continue;
sum += sync.offset;
num += 1;
}
if (num === 0) {
return 0;
}
return Math.round(sum / num);
}
}
let _callId = 0;
class Call {
id: number;
name: string;
params: any;
callback: (err: any, res: any) => any;
constructor(name: string, params: any, callback: (err: any, res: any) => void) {
this.name = name;
this.params = params;
this.id = _callId++;
this.callback = callback;
}
} }
let _timeSyncId = 0; let _timeSyncId = 0;
class TimeSyncRequest { class TimeSyncRequest {
requestId: number; requestId: number;
start: number; start: number;
offset: number = 0; offset = 0;
ready = false; ready = false;
constructor() { constructor() {
this.requestId = _timeSyncId++; this.requestId = _timeSyncId++;
this.start = Date.now(); this.start = Date.now();
} }
response(clientDiff: number, serverTime: number) { response(clientDiff: number, serverTime: number) {
this.ready = true; this.ready = true;
let now = Date.now(); const now = Date.now();
let lag = now - this.start; const lag = now - this.start;
this.offset = serverTime - now + lag / 2; this.offset = serverTime - now + lag / 2;
// console.log('TIME SYNC', 'cdiff:', clientDiff, 'lag:', // console.log('TIME SYNC', 'cdiff:', clientDiff, 'lag:',
// lag, 'diff:', serverTime - now, 'offset:', this.offset); // lag, 'diff:', serverTime - now, 'offset:', this.offset);
} }
} }
let connection: Connection = new Connection(); const connection: Connection = new Connection();
// @ts-ignore
window['connection'] = connection;
export default connection; export default connection;
export function useRoom(): Room | null { export function useRoom(): Room | null {
return useSub(connection.room); return useSub(connection.room);
} }
export function useConfig(): Config | null { export function useConfig(): Config | null {
return useSub(connection.config); return useSub(connection.config);
} }
export function useRoomRunningAndReadyChanged(): Room | null { export function useRoomRunningAndReadyChanged(): Room | null {
return useSub(connection.room, (v) => [v && v.running && v.readyToParticipate]); return useSub(connection.room, (v) => [v, v?.running, v?.readyToParticipate]);
} }
export function useTimeline(): Timeline | null { export function useTimeline(): Timeline | null {
return useSub(connection.timeline); return useSub(connection.timeline);
} }
export function useTimelineSongFileChanged(): Timeline | null { export function useTimelineSongFileChanged(): Timeline | null {
return useSub(connection.timeline, (v) => [v && v.songFile]); return useSub(connection.timeline, (v) => [v?.songFile]);
} }
export function useRoomTime(): number { export function useRoomTime(): number {
return useSub(connection.roomTime); return useSub(connection.roomTime) || 0;
} }
export function roomTime(): number { /**
let room = connection.room.get(); * Calculates the current room time, adjusted for any possible server time
if (!room) return 0; * offset and lag.
return (connection.serverTime() - room.startTime) * room.speedFactor; */
export function calculateRoomTime(): number {
const room = connection.room.get();
if (!room || typeof room.startTime === "undefined") {
return 0;
}
return (connection.serverTime() - room.startTime) * room.speedFactor;
} }
export function useIsConnected(): boolean { export function useIsConnected(): boolean {
return useSub(connection.isConnected); return useSub(connection.isConnected) || false;
} }

@ -1 +0,0 @@
/// <reference types="react-scripts" />

@ -1,78 +1,125 @@
export interface Tick {
current: number,
next?: {
timestamp: number,
events: TimestampEvent[]
},
nextShot?: {
timestamp: number,
count: number
}
}
export interface Config { export interface Config {
availableTimelines: string[] availableTimelines: string[];
} }
export interface Room { export interface Room {
id: number, id: number;
userCount: number, userCount: number;
isLeader: boolean, isLeader: boolean;
running: boolean, running: boolean;
startTime: number, startTime?: number;
seekTime: number, seekTime: number;
timelineName: string, timelineName: string;
readyToParticipate: boolean, readyToParticipate: boolean;
speedFactor: number speedFactor: number;
ticker: string[];
users?: { id: string; readyToParticipate: boolean }[];
} }
export class RoomOptions { export class RoomOptions {
seekTime: number seekTime: number;
timelineName: string timelineName: string;
constructor(seekTime: number, timelineName: string) { constructor(seekTime: number, timelineName: string) {
this.seekTime = seekTime; this.seekTime = seekTime;
this.timelineName = timelineName; this.timelineName = timelineName;
} }
} }
export class Timeline { export class Timeline {
name: string name: string;
songFile: string songFile: string;
feed: TimelineItem[] feed: TimelineItem[];
constructor(obj: any) { constructor(obj: any) {
this.name = obj.name; this.name = obj.name;
this.songFile = obj.songFile; this.songFile = obj.songFile;
this.feed = obj.feed; this.feed = obj.feed;
this.feed = this.feed.sort((a, b) => a.timestamp - b.timestamp);
// Add string ids to the feed to uniquely identify them in the various
// reactive components.
for (let i = 0; i < this.feed.length; i++) {
this.feed[i].id = i.toString();
for (let j = 0; j < this.feed[i].events.length; j++) {
this.feed[i].events[j].id = `${i}:${j}`;
}
} }
}
itemAtTime(time: number, type: string = ''): [TimelineItem | null, TimelineItem | null] { getTotalShotCount(): number {
let feedToSearch = type ? this.feed.filter(i => i.events.some(j => j.type == type)) : this.feed; let maxShot = 0;
for (let i = 1; i < feedToSearch.length; i++) { for (const item of this.feed) {
if (feedToSearch[i].timestamp * 1000 >= time) { for (const event of item.events) {
return [feedToSearch[i - 1], feedToSearch[i]]; if (event.type === "shot") {
} maxShot = Math.max(
maxShot,
(event as TimestampEventShot).shotCount || 0
);
} }
}
}
return maxShot;
}
itemAfterTime(time: number, type?: EventType): TimelineItem | undefined {
const feedToSearch = type
? this.feed.filter((i) => i.events.some((j) => j.type === type))
: this.feed;
return feedToSearch.find((item) => item.timestamp * 1000 > time);
}
return [feedToSearch[feedToSearch.length - 1], null]; itemBeforeTime(time: number, type?: EventType): TimelineItem | undefined {
const feedToSearch = type
? this.feed.filter((i) => i.events.some((j) => j.type === type))
: this.feed;
return feedToSearch.reverse().find((item) => item.timestamp * 1000 < time);
}
eventAfterTime(time: number, type: "shot"): TimestampEventShot | undefined;
eventAfterTime(time: number, type: EventType): TimestampEvent | undefined {
const item = this.itemAfterTime(time, type);
if (!item || !item.events.length) {
return undefined;
} }
eventAtTime(time: number, type: string = ''): [TimestampEvent | null, TimestampEvent | null] { return item.events.find((ev) => ev.type === type);
let [current, next] = this.itemAtTime(time, type) }
return [current ? (current.events.find(i => i.type == type) || null) : null,
next ? (next.events.find(i => i.type == type) || null) : null] eventBeforeTime(time: number, type: "shot"): TimestampEventShot | undefined;
eventBeforeTime(time: number, type: EventType): TimestampEvent | undefined {
const item = this.itemBeforeTime(time, type);
if (!item || !item.events.length) {
return undefined;
} }
return item.events.find((ev) => ev.type === type);
}
} }
export interface TimelineItem { export interface TimelineItem {
timestamp: number, id: string;
events: TimestampEvent[] timestamp: number;
events: TimestampEvent[];
} }
export interface TimestampEvent { export const EVENT_PRIORITY: EventType[] = ["shot", "talk", "time", "song"];
type: 'talk' | 'shot' | 'song' | 'time', export type EventType = "talk" | "shot" | "song" | "time";
text: string[],
shotCount?: number interface TimestampEventBase {
id: string;
type: EventType;
text: string[];
}
interface TimestampEventShot extends TimestampEventBase {
type: "shot";
shotCount: number;
} }
export type TimestampEvent = TimestampEventBase | TimestampEventShot;

@ -1,11 +1,34 @@
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
export function useUpdateAfterDelay(delay: number) { export function useUpdateAfterDelay(delay: number) {
const [_, timedUpdateSet] = useState(0); const [, timedUpdateSet] = useState(0);
useEffect(() => { useEffect(() => {
let timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
timedUpdateSet(v => v + 1); timedUpdateSet((v) => v + 1);
}, delay); }, delay);
return () => clearTimeout(timeoutId) return () => clearTimeout(timeoutId);
}) });
}
export function useResize() {
const [dimensions, setDimensions] = useState({
height: window.innerHeight,
width: window.innerWidth,
});
useEffect(() => {
const listener = (ev: UIEvent) => {
setDimensions({
height: window.innerHeight,
width: window.innerWidth,
});
};
window.addEventListener("resize", listener);
return () => {
window.removeEventListener("resize", listener);
};
});
return dimensions;
} }

@ -1,23 +1,24 @@
import { Socket } from "socket.io-client";
/** /**
* Promisify emit. * Promisify emit.
* @param event * @param event
* @param arg * @param arg
*/ */
export function emit(socket: SocketIOClient.Socket, event: string, arg: any = null) { export function emit(socket: Socket, event: string, arg: any = null) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const cb = (err: any, res: any) => { const cb = (err: any, res: any) => {
if (err) { if (err) {
return reject(err); return reject(err);
} }
resolve(res);
};
if (arg === null || typeof arg === 'undefined') { resolve(res);
socket.emit(event, cb); };
} else {
socket.emit(event, arg, cb);
}
}) if (arg === null || typeof arg === "undefined") {
} socket.emit(event, cb);
} else {
socket.emit(event, arg, cb);
}
});
}

@ -1,40 +1,49 @@
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
export class Sub<T> { export class Sub<T> {
_listeners: ((obj: T) => void)[] = []; _listeners: ((obj: T) => void)[] = [];
_current: any = null; _current: T | null = null;
subscribe(listener: any) { subscribe(listener: any) {
if (this._listeners.indexOf(listener) < 0) { if (this._listeners.indexOf(listener) < 0) {
this._listeners.push(listener); this._listeners.push(listener);
}
} }
}
unsubscribe(listener: any) { unsubscribe(listener: any) {
let index = this._listeners.indexOf(listener); const index = this._listeners.indexOf(listener);
if (index >= 0) { if (index >= 0) {
this._listeners.splice(index, 1); this._listeners.splice(index, 1);
}
} }
}
get() { get(): T | null {
return this._current; return this._current;
} }
set(obj: T) { set(obj: T) {
this._current = obj; this._current = obj;
this._listeners.forEach(cb => cb(obj)); this._listeners.forEach((cb) => cb(obj));
} }
} }
export function useSub<T>(sub: Sub<T>, effectChanges: ((v: T) => any[]) | null = null): T { export function useSub<T>(
const [currentState, stateSetter] = useState(sub.get()); sub: Sub<T>,
effectChanges: ((v: T | null) => any[]) | null = null
useEffect(() => { ): T | null {
let listener = (obj: T) => stateSetter(obj); const [currentState, stateSetter] = useState(sub.get());
sub.subscribe(listener);
return () => sub.unsubscribe(listener); useEffect(
}, effectChanges ? effectChanges(currentState) : []); () => {
const listener = (obj: T) => stateSetter(obj);
return currentState; sub.subscribe(listener);
return () => sub.unsubscribe(listener);
// This effect uses "sub" from outside of its scope, should it be a dependency?
// Ignore the lint warning for now.
// eslint-disable-next-line
},
effectChanges ? effectChanges(currentState) : []
);
return currentState;
} }

@ -1,23 +1,21 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "ESNext",
"lib": [ "lib": ["dom", "dom.iterable", "esnext"],
"dom", "types": ["vite/client", "vite-plugin-svgr/client"],
"dom.iterable", "allowJs": false,
"esnext" "skipLibCheck": false,
], "esModuleInterop": false,
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"strict": true, "strict": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext", "module": "esnext",
"moduleResolution": "node", "moduleResolution": "node",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"jsx": "react" "jsx": "react-jsx"
}, },
"include": [ "include": [
"src" "src"

@ -0,0 +1,46 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import svgr from "vite-plugin-svgr";
import vitePluginImp from "vite-plugin-imp";
// https://vitejs.dev/config/
export default defineConfig({
// This changes the out put dir from dist to build
// comment this out if that isn't relevant for your project
build: {
outDir: "build",
},
plugins: [
react(),
svgr(),
vitePluginImp({
libList: [
{
libName: "antd",
style: (name) => `antd/es/${name}/style`,
},
],
}),
],
server: {
proxy: {
"/socket.io": {
target: "http://localhost:3001",
ws: true,
},
},
},
resolve: {
alias: [{ find: /^~/, replacement: "" }],
},
css: {
preprocessorOptions: {
less: {
modifyVars: {
"primary-color": "#304ba3",
},
javascriptEnabled: true,
},
},
},
});

File diff suppressed because it is too large Load Diff

11772
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,44 @@
{
"name": "centurion",
"version": "1.0.0",
"description": "Centurion: Honderd minuten... Honderd shots... Kan jij het aan?",
"engines": {
"node": ">=16",
"npm": ">=7"
},
"workspaces": [
"frontend",
"backend"
],
"scripts": {
"fmt": "prettier -w '**/*.{ts,tsx}'",
"check-fmt": "prettier --check '**/*.{ts,tsx}'",
"prepare": "husky install"
},
"repository": {
"type": "git",
"url": "git@gitlab.com:studieverenigingvia/ict/centurion.git"
},
"author": "Vereniging Informatiewetenschappen Amsterdam (V.I.A.)",
"license": "MIT",
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^5.6.0",
"@typescript-eslint/parser": "^5.6.0",
"eslint": "^8.4.1",
"eslint-config-react-app": "^6.0.0",
"eslint-plugin-import": "^2.25.3",
"eslint-plugin-react": "^7.27.1",
"eslint-plugin-react-hooks": "^4.3.0",
"husky": "^7.0.4",
"lint-staged": "^12.1.2",
"prettier": "^2.5.1",
"typescript": "^4.5.2"
},
"lint-staged": {
"backend/**/*.{ts,tsx}": "eslint --fix",
"frontend/**/*.{ts,tsx}": "eslint --fix",
"*.{ts,tsx}": [
"prettier -w"
]
}
}
Loading…
Cancel
Save