├── public ├── icon.png ├── avatar.png ├── buddy-in.mp3 ├── audio-logo.png ├── icons │ ├── chat.png │ ├── list.png │ ├── look.png │ ├── menu.png │ ├── rbw.png │ ├── shk.png │ ├── skip.png │ ├── spn.png │ ├── wvy.png │ ├── avatar.png │ ├── blocks.png │ ├── cancel.png │ ├── close.png │ ├── emotes.png │ ├── music.png │ ├── popout.png │ ├── resync.png │ ├── search.png │ ├── users.png │ ├── rotate-l.png │ └── rotate-r.png ├── zone-logo.png ├── mockup-small.png ├── zone-logo-small.png └── ascii_small_simple │ ├── ascii_small_simple.ttf │ ├── ascii_small_simple.woff │ ├── ascii_small_simple.woff2 │ └── ascii_small_simple.css ├── .prettierrc ├── .gitignore ├── src ├── server │ ├── tsconfig.json │ ├── libraries.ts │ ├── run.ts │ ├── zone.ts │ ├── __tests__ │ │ └── playback.test.ts │ ├── playback.ts │ └── server.ts ├── common │ ├── tsconfig.json │ ├── __tests__ │ │ ├── media.data.ts │ │ ├── utility.test.ts │ │ ├── messaging.test.ts │ │ ├── utilities.ts │ │ └── server-client.test.ts │ ├── zone.ts │ ├── messaging.ts │ ├── utility.ts │ └── client.ts └── client │ ├── tsconfig.json │ ├── webpack.config.js │ ├── webpack.config.dev.js │ ├── menus.ts │ ├── utility.ts │ ├── html-ui.ts │ ├── chat.ts │ ├── menus.css │ ├── __tests__ │ └── text.test.ts │ ├── player.ts │ ├── style.css │ ├── index.pug │ ├── scene.ts │ ├── text.ts │ └── main.ts ├── jestconfig.json ├── tslint.json ├── .github └── workflows │ └── nodejs.yml ├── README.md ├── LICENSE └── package.json /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ragzouken/zone/HEAD/public/icon.png -------------------------------------------------------------------------------- /public/avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ragzouken/zone/HEAD/public/avatar.png -------------------------------------------------------------------------------- /public/buddy-in.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ragzouken/zone/HEAD/public/buddy-in.mp3 -------------------------------------------------------------------------------- /public/audio-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ragzouken/zone/HEAD/public/audio-logo.png -------------------------------------------------------------------------------- /public/icons/chat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ragzouken/zone/HEAD/public/icons/chat.png -------------------------------------------------------------------------------- /public/icons/list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ragzouken/zone/HEAD/public/icons/list.png -------------------------------------------------------------------------------- /public/icons/look.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ragzouken/zone/HEAD/public/icons/look.png -------------------------------------------------------------------------------- /public/icons/menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ragzouken/zone/HEAD/public/icons/menu.png -------------------------------------------------------------------------------- /public/icons/rbw.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ragzouken/zone/HEAD/public/icons/rbw.png -------------------------------------------------------------------------------- /public/icons/shk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ragzouken/zone/HEAD/public/icons/shk.png -------------------------------------------------------------------------------- /public/icons/skip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ragzouken/zone/HEAD/public/icons/skip.png -------------------------------------------------------------------------------- /public/icons/spn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ragzouken/zone/HEAD/public/icons/spn.png -------------------------------------------------------------------------------- /public/icons/wvy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ragzouken/zone/HEAD/public/icons/wvy.png -------------------------------------------------------------------------------- /public/zone-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ragzouken/zone/HEAD/public/zone-logo.png -------------------------------------------------------------------------------- /public/icons/avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ragzouken/zone/HEAD/public/icons/avatar.png -------------------------------------------------------------------------------- /public/icons/blocks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ragzouken/zone/HEAD/public/icons/blocks.png -------------------------------------------------------------------------------- /public/icons/cancel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ragzouken/zone/HEAD/public/icons/cancel.png -------------------------------------------------------------------------------- /public/icons/close.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ragzouken/zone/HEAD/public/icons/close.png -------------------------------------------------------------------------------- /public/icons/emotes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ragzouken/zone/HEAD/public/icons/emotes.png -------------------------------------------------------------------------------- /public/icons/music.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ragzouken/zone/HEAD/public/icons/music.png -------------------------------------------------------------------------------- /public/icons/popout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ragzouken/zone/HEAD/public/icons/popout.png -------------------------------------------------------------------------------- /public/icons/resync.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ragzouken/zone/HEAD/public/icons/resync.png -------------------------------------------------------------------------------- /public/icons/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ragzouken/zone/HEAD/public/icons/search.png -------------------------------------------------------------------------------- /public/icons/users.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ragzouken/zone/HEAD/public/icons/users.png -------------------------------------------------------------------------------- /public/mockup-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ragzouken/zone/HEAD/public/mockup-small.png -------------------------------------------------------------------------------- /public/icons/rotate-l.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ragzouken/zone/HEAD/public/icons/rotate-l.png -------------------------------------------------------------------------------- /public/icons/rotate-r.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ragzouken/zone/HEAD/public/icons/rotate-r.png -------------------------------------------------------------------------------- /public/zone-logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ragzouken/zone/HEAD/public/zone-logo-small.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 4, 4 | "trailingComma": "all", 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /public/ascii_small_simple/ascii_small_simple.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ragzouken/zone/HEAD/public/ascii_small_simple/ascii_small_simple.ttf -------------------------------------------------------------------------------- /public/ascii_small_simple/ascii_small_simple.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ragzouken/zone/HEAD/public/ascii_small_simple/ascii_small_simple.woff -------------------------------------------------------------------------------- /public/ascii_small_simple/ascii_small_simple.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ragzouken/zone/HEAD/public/ascii_small_simple/ascii_small_simple.woff2 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | coverage 4 | .data 5 | media 6 | 7 | public/index.html 8 | public/*.css 9 | public/script.js 10 | public/script.js.map 11 | -------------------------------------------------------------------------------- /src/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "module": "commonjs", 5 | "lib": ["ES2017"], 6 | "declaration": true, 7 | "outDir": "../../lib/", 8 | "strict": true 9 | }, 10 | "include": ["."] 11 | } 12 | -------------------------------------------------------------------------------- /src/common/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "module": "commonjs", 5 | "lib": ["ES2017", "DOM"], 6 | "declaration": true, 7 | "outDir": "../../lib/common", 8 | "strict": true 9 | }, 10 | "include": [".", "../server/__tests__/playback.test.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /src/client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "module": "commonjs", 5 | "lib": ["ES2017", "DOM"], 6 | "declaration": true, 7 | "outDir": "../../lib/client", 8 | "strict": true, 9 | "sourceMap": true 10 | }, 11 | "include": [".", "../common/*"] 12 | } 13 | -------------------------------------------------------------------------------- /src/common/__tests__/media.data.ts: -------------------------------------------------------------------------------- 1 | import { Media } from '../zone'; 2 | 3 | export const TINY_MEDIA: Media = { mediaId: 'TINY_MEDIA', title: 'tiny', duration: 100, src: 'local/TINY_MEDIA' }; 4 | export const DAY_MEDIA: Media = { mediaId: 'DAY_MEDIA', title: 'day', duration: 24 * 60 * 60 * 1000, src: 'local/DAY_MEDIA' }; 5 | 6 | export const MEDIA: Media[] = [TINY_MEDIA, DAY_MEDIA]; 7 | -------------------------------------------------------------------------------- /jestconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "transform": { 3 | "^.+\\.(t|j)sx?$": "ts-jest" 4 | }, 5 | "testRegex": "(\\.|/)(test|spec)\\.(jsx?|tsx?)$", 6 | "coveragePathIgnorePatterns": ["__tests__"], 7 | "testPathIgnorePatterns": ["/node_modules/", "/lib/"], 8 | "moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"], 9 | "setupFiles": ["jest-canvas-mock"], 10 | "collectCoverage": true 11 | } 12 | -------------------------------------------------------------------------------- /public/ascii_small_simple/ascii_small_simple.css: -------------------------------------------------------------------------------- 1 | /*! Generated by Font Squirrel (https://www.fontsquirrel.com) on May 19, 2020 */ 2 | 3 | @font-face { 4 | font-family: 'ascii_small_simple'; 5 | src: url('ascii_small_simple.woff2') format('woff2'), 6 | url('ascii_small_simple.woff') format('woff'), 7 | url('ascii_small_simple.ttf') format('ttf'); 8 | font-weight: normal; 9 | font-style: normal; 10 | } 11 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended", "tslint-config-prettier"], 3 | "rules": { 4 | "object-literal-sort-keys": false, 5 | "interface-over-type-literal": false, 6 | "no-console": false, 7 | "no-shadowed-variable": false, 8 | "no-empty": [true, "allow-empty-functions", "allow-empty-catch"], 9 | "no-string-literal": false, 10 | "no-bitwise": false, 11 | "max-classes-per-file": false, 12 | "no-var-requires": false 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/common/__tests__/utility.test.ts: -------------------------------------------------------------------------------- 1 | import { performance } from 'perf_hooks'; 2 | import { copy, sleep } from '../utility'; 3 | 4 | test('sleep', async () => { 5 | const target = 1; 6 | const begin = performance.now(); 7 | await sleep(target * 1000); 8 | const duration = (performance.now() - begin) / 1000; 9 | expect(duration).toBeCloseTo(target, 0); 10 | }); 11 | 12 | test('copy', () => { 13 | const obj = { hello: 'hello', count: 0 }; 14 | const copied = copy(obj); 15 | expect(copied).toEqual(obj); 16 | copied.hello = 'goodbye'; 17 | expect(copied).not.toEqual(obj); 18 | }); 19 | -------------------------------------------------------------------------------- /src/client/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | mode: 'production', 5 | devtool: "source-map", 6 | entry: './src/client/main.ts', 7 | output: { 8 | filename: 'script.js', 9 | path: path.resolve(__dirname, '../../public'), 10 | library: 'zone', 11 | libraryTarget: 'window', 12 | }, 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.tsx?$/, 17 | use: [ 18 | { 19 | loader: 'ts-loader', 20 | options: { 21 | transpileOnly: true, 22 | }, 23 | } 24 | ], 25 | exclude: /node_modules/, 26 | }, 27 | ], 28 | }, 29 | resolve: { 30 | extensions: [ '.tsx', '.ts', '.js' ], 31 | fallback: { "url": require.resolve("url/") }, 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [12.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: npm ci 28 | - run: npm run build --if-present 29 | - run: npm test 30 | - run: npm run lint --if-present 31 | env: 32 | CI: true 33 | -------------------------------------------------------------------------------- /src/client/webpack.config.dev.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | 4 | module.exports = { 5 | watch: true, 6 | mode: 'development', 7 | devtool: "inline-source-map", 8 | entry: './src/client/main.ts', 9 | output: { 10 | filename: 'script.js', 11 | path: path.resolve(__dirname, '../../public'), 12 | library: 'zone', 13 | libraryTarget: 'window', 14 | }, 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.tsx?$/, 19 | use: [ 20 | { 21 | loader: 'ts-loader', 22 | options: { 23 | transpileOnly: true, 24 | }, 25 | } 26 | ], 27 | exclude: /node_modules/, 28 | }, 29 | ], 30 | }, 31 | resolve: { 32 | extensions: [ '.tsx', '.ts', '.js' ], 33 | fallback: { "url": require.resolve("url/") }, 34 | }, 35 | plugins: [ 36 | new webpack.WatchIgnorePlugin([ 37 | /\.js$/, 38 | /\.d\.ts$/ 39 | ]) 40 | ], 41 | }; 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # zone server 2 | 3 | web socket server for hosting a zone 4 | 5 | 6 | ## hints for running your own zone 7 | 8 | rebuild zone after updating: 9 | ``` 10 | npm install 11 | npm run build 12 | ``` 13 | 14 | run zone; 15 | ``` 16 | npm start 17 | ``` 18 | 19 | zone responds to interrupt by saving the current state (playlist, bans, blocks) and shutting down 20 | ``` 21 | pkill -INT -f "zone server" 22 | ``` 23 | 24 | zone currently takes config via environmental variables: 25 | ``` 26 | export PORT=443; 27 | export AUTH_PASSWORD=scooter; 28 | export LIBRARY_ENDPOINT=http://127.0.0.1:4000/library; 29 | export YOUTUBE_ENDPOINT=http://127.0.0.1:4001/youtube; 30 | export YOUTUBE_AUTHORIZATION="Bearer some_token"; 31 | ``` 32 | 33 | zone relies on zone-library and zone-youtube repos for media 34 | 35 | # zone libraries api 36 | 37 | ## search for items 38 | ``` 39 | GET /?q=search%20terms 40 | ``` 41 | list/search/filter library 42 | 43 | ## item metadata 44 | ``` 45 | GET /:id 46 | ``` 47 | json metadata for a particular library item e.g `{ mediaId: "some_id" title: "demo song", duration: 30000, src: "https://example.com/demo-song.mp3" }` 48 | 49 | ## item availability 50 | ``` 51 | GET /:id/status 52 | ``` 53 | 54 | json string for availability of a particular library item e.g `"available"` `"none"` `"failed"` `"requested"` 55 | 56 | ## request item 57 | ``` 58 | POST /:id/request 59 | ``` 60 | request a particular library item be made available for playback 61 | -------------------------------------------------------------------------------- /src/server/libraries.ts: -------------------------------------------------------------------------------- 1 | import fetch, { HeadersInit } from "node-fetch"; 2 | 3 | export class Library { 4 | private headers?: HeadersInit; 5 | 6 | constructor( 7 | readonly prefix: string, 8 | readonly endpoint: string, 9 | readonly auth?: string, 10 | ) { 11 | this.headers = this.auth ? { "Authorization": this.auth } : undefined; 12 | } 13 | 14 | async search(query: string) { 15 | return fetch(`${this.endpoint}${query}`, { headers: this.headers }).then((r) => r.json()); 16 | } 17 | 18 | async getMeta(mediaId: string) { 19 | return fetch(`${this.endpoint}/${mediaId}`, { headers: this.headers }).then((r) => r.json()); 20 | } 21 | 22 | async getStatus(mediaId: string) { 23 | return fetch(`${this.endpoint}/${mediaId}/status`, { headers: this.headers }).then((r) => r.json()); 24 | } 25 | 26 | async getProgress(mediaId: string): Promise { 27 | return fetch(`${this.endpoint}/${mediaId}/progress`, { headers: this.headers }).then((r) => r.json()); 28 | } 29 | 30 | async request(mediaId: string) { 31 | return fetch(`${this.endpoint}/${mediaId}/request`, { method: "POST", headers: this.headers }); 32 | } 33 | }; 34 | 35 | export async function libraryToQueueableMedia(library: Library, mediaId: string) { 36 | const media = await library.getMeta(mediaId); 37 | media.library = library.prefix; 38 | await library.request(mediaId); 39 | return media; 40 | } 41 | -------------------------------------------------------------------------------- /src/common/zone.ts: -------------------------------------------------------------------------------- 1 | import { getDefault, Grid } from './utility'; 2 | 3 | export type UserId = string; 4 | 5 | export type UserState = { 6 | userId: UserId; 7 | name?: string; 8 | position?: number[]; 9 | avatar?: string; 10 | emotes: string[]; 11 | tags: string[]; 12 | }; 13 | 14 | export type Media = { 15 | mediaId: string; 16 | title: string; 17 | duration: number; 18 | src: string; 19 | subtitle?: string; 20 | thumbnail?: string; 21 | path?: string; 22 | library?: string; 23 | }; 24 | 25 | export type UserEcho = UserState & { 26 | text: string; 27 | }; 28 | 29 | export type QueueInfo = { userId?: UserId; ip?: unknown; banger?: boolean }; 30 | export type QueueItem = { media: Media; info: QueueInfo; itemId: number }; 31 | 32 | export class ZoneState { 33 | public readonly users = new Map(); 34 | readonly queue: QueueItem[] = []; 35 | lastPlayedItem?: QueueItem; 36 | 37 | public readonly echoes = new Grid(); 38 | 39 | public clear() { 40 | this.users.clear(); 41 | this.echoes.clear(); 42 | this.queue.length = 0; 43 | this.lastPlayedItem = undefined; 44 | } 45 | 46 | public addUser(userId: UserId): UserState { 47 | const user = { userId, emotes: [], tags: [] }; 48 | this.users.set(userId, user); 49 | return user; 50 | } 51 | 52 | public getUser(userId: UserId): UserState { 53 | return getDefault(this.users, userId, () => ({ userId, emotes: [], tags: [] })); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ANTI-CAPITALIST SOFTWARE LICENSE (v 1.4) 2 | 3 | Copyright © 2020 mark wonnacott 4 | 5 | This is anti-capitalist software, released for free use by individuals and organizations that do not operate by capitalist principles. 6 | 7 | Permission is hereby granted, free of charge, to any person or organization (the "User") obtaining a copy of this software and associated documentation files (the "Software"), to use, copy, modify, merge, distribute, and/or sell copies of the Software, subject to the following conditions: 8 | 9 | 1. The above copyright notice and this permission notice shall be included in all copies or modified versions of the Software. 10 | 11 | 2. The User is one of the following: 12 | a. An individual person, laboring for themselves 13 | b. A non-profit organization 14 | c. An educational institution 15 | d. An organization that seeks shared profit for all of its members, and allows non-members to set the cost of their labor 16 | 17 | 3. If the User is an organization with owners, then all owners are workers and all workers are owners with equal equity and/or equal vote. 18 | 19 | 4. If the User is an organization, then the User is not law enforcement or military, or working for or under either. 20 | 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT EXPRESS OR IMPLIED WARRANTY OF ANY KIND, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /src/server/run.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | import * as expressWs from 'express-ws'; 3 | import { promises as fs } from 'fs'; 4 | import { host } from './server'; 5 | import FileSync = require('lowdb/adapters/FileSync'); 6 | import path = require('path'); 7 | import { Library } from './libraries'; 8 | 9 | process.on('uncaughtException', (err) => console.log('uncaught exception:', err, err.stack)); 10 | process.on('unhandledRejection', (err) => console.log('uncaught reject:', err)); 11 | 12 | async function run() { 13 | process.title = "zone server"; 14 | 15 | const app = express(); 16 | const xws = expressWs(app); 17 | const port = process.env.PORT || 4000; 18 | const server = app.listen(port, () => console.log(`listening on http://localhost:${port}...`)); 19 | server.on('error', (error) => console.log('server error', error)); 20 | 21 | const dataPath = process.env.ZONE_DATA_PATH || '.data/db.json'; 22 | fs.mkdir(path.dirname(dataPath)).catch(() => {}); 23 | const adapter = new FileSync(dataPath, { serialize: JSON.stringify, deserialize: JSON.parse }); 24 | 25 | const libraries: Map = new Map(); 26 | if (process.env.YOUTUBE_ENDPOINT) libraries.set("youtube", new Library("youtube", process.env.YOUTUBE_ENDPOINT, process.env.YOUTUBE_AUTHORIZATION)); 27 | if (process.env.LIBRARY_ENDPOINT) libraries.set("library", new Library("library", process.env.LIBRARY_ENDPOINT)); 28 | 29 | const { save, sendAll } = host(xws, adapter, { 30 | authPassword: process.env.AUTH_PASSWORD || 'riverdale', 31 | libraries, 32 | }); 33 | 34 | // trust glitch's proxy to give us socket ips 35 | app.set('trust proxy', true); 36 | app.use('/', express.static('public')); 37 | 38 | process.on('SIGINT', () => { 39 | console.log('exiting due to SIGINT'); 40 | save(); 41 | sendAll('status', { text: 'manual shutdown' }); 42 | process.exit(); 43 | }); 44 | } 45 | 46 | run(); 47 | -------------------------------------------------------------------------------- /src/common/messaging.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter, once } from 'events'; 2 | 3 | export type Message = { type: string; [key: string]: any }; 4 | export type MessageEvent = { data: any }; 5 | 6 | export interface Socket { 7 | readyState: number; 8 | send(data: string): void; 9 | close(code?: number, reason?: string): void; 10 | 11 | addEventListener(event: string, listener: (event: any) => void): void; 12 | removeEventListener(event: string, listener: (event: any) => void): void; 13 | } 14 | 15 | export interface Messaging { 16 | on(event: 'close', callback: (code: number) => void): this; 17 | on(event: 'error', callback: (error: any) => void): this; 18 | } 19 | 20 | export class Messaging extends EventEmitter { 21 | readonly messages = new EventEmitter(); 22 | private socket?: Socket; 23 | private closeListener = (event: any) => this.emit('close', event.code || event); 24 | 25 | setSocket(socket: Socket) { 26 | if (this.socket) { 27 | this.socket.removeEventListener('close', this.closeListener); 28 | this.socket.close(); 29 | } 30 | 31 | this.socket = socket; 32 | this.socket.addEventListener('close', this.closeListener); 33 | this.socket.addEventListener('message', (event: MessageEvent) => { 34 | const { type, ...message } = JSON.parse(event.data) as Message; 35 | this.messages.emit(type, message); 36 | }); 37 | } 38 | 39 | async close(code = 1000) { 40 | if (!this.socket || this.socket.readyState === 3) return; 41 | const waiter = once(this, 'close'); 42 | this.socket.close(code); 43 | await waiter; 44 | } 45 | 46 | send(type: string, message: object = {}) { 47 | if (!this.socket) { 48 | this.emit('error', new Error('no socket')); 49 | return; 50 | } else if (this.socket.readyState !== 1) { 51 | this.emit('error', new Error('socket not open')); 52 | return; 53 | } 54 | 55 | const data = JSON.stringify({ type, ...message }); 56 | 57 | try { 58 | this.socket.send(data); 59 | } catch (e) { 60 | this.emit('error', e); 61 | } 62 | } 63 | } 64 | 65 | export default Messaging; 66 | -------------------------------------------------------------------------------- /src/server/zone.ts: -------------------------------------------------------------------------------- 1 | export type UserId = string; 2 | 3 | export type UserState = { 4 | userId: UserId; 5 | name?: string; 6 | position?: number[]; 7 | avatar?: string; 8 | emotes: string[]; 9 | tags: string[]; 10 | }; 11 | 12 | export type Media = { 13 | mediaId: string; 14 | title: string; 15 | duration: number; 16 | src: string; 17 | subtitle?: string; 18 | thumbnail?: string; 19 | path?: string; 20 | library?: string; 21 | }; 22 | 23 | export type UserEcho = UserState & { 24 | text: string; 25 | }; 26 | 27 | export type QueueInfo = { userId?: UserId; ip?: unknown; banger?: boolean }; 28 | export type QueueItem = { media: Media; info: QueueInfo; itemId: number }; 29 | 30 | export class ZoneState { 31 | public readonly users = new Map(); 32 | readonly queue: QueueItem[] = []; 33 | lastPlayedItem?: QueueItem; 34 | 35 | public readonly echoes = new Grid(); 36 | 37 | public clear() { 38 | this.users.clear(); 39 | this.echoes.clear(); 40 | this.queue.length = 0; 41 | this.lastPlayedItem = undefined; 42 | } 43 | 44 | public addUser(userId: UserId): UserState { 45 | const user = { userId, emotes: [], tags: [] }; 46 | this.users.set(userId, user); 47 | return user; 48 | } 49 | } 50 | 51 | export function coordsToKey(coords: number[]): string { 52 | return coords.join(','); 53 | } 54 | 55 | export class Grid { 56 | private readonly cells = new Map(); 57 | 58 | get size() { 59 | return this.cells.size; 60 | } 61 | 62 | clear() { 63 | return this.cells.clear(); 64 | } 65 | 66 | has(coords: number[]) { 67 | return this.cells.has(coordsToKey(coords)); 68 | } 69 | 70 | get(coords: number[]) { 71 | const [, value] = this.cells.get(coordsToKey(coords)) || [undefined, undefined]; 72 | return value; 73 | } 74 | 75 | set(coords: number[], value: T) { 76 | return this.cells.set(coordsToKey(coords), [coords, value]); 77 | } 78 | 79 | delete(coords: number[]) { 80 | return this.cells.delete(coordsToKey(coords)); 81 | } 82 | 83 | forEach(callbackfn: (value: T, coords: number[], grid: Grid) => void) { 84 | return this.cells.forEach(([coords, value]) => callbackfn(value, coords, this)); 85 | } 86 | 87 | [Symbol.iterator]() { 88 | return this.cells.values(); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zone", 3 | "version": "0.0.1", 4 | "description": "a server for zones", 5 | "scripts": { 6 | "start": "node lib/server/run.js", 7 | "test": "jest --config jestconfig.json", 8 | "build:client:script": "webpack ./src/client/main.ts --config ./src/client/webpack.config.js", 9 | "watch:client:script": "webpack ./src/client/main.ts --config ./src/client/webpack.config.dev.js", 10 | "build:client:html": "pug3 --basedir src/client/ --pretty src/client/ -o public", 11 | "build:client:css": "copyfiles -u 2 \"src/client/*.css\" public", 12 | "build:client": "npm run build:client:css && npm run build:client:html && npm run build:client:script", 13 | "build:server": "tsc -p src/server/tsconfig.json", 14 | "build": "npm run build:server && npm run build:client", 15 | "format": "prettier --write \"src/**/*.ts\"", 16 | "lint:server": "tslint -p src/server/tsconfig.json", 17 | "lint:client": "tslint -p src/client/tsconfig.json", 18 | "lint": "npm run lint:server && npm run lint:client" 19 | }, 20 | "engines": { 21 | "node": "10.x" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/Ragzouken/zone.git" 26 | }, 27 | "license": "MIT", 28 | "keywords": [ 29 | "node", 30 | "express", 31 | "zone" 32 | ], 33 | "dependencies": { 34 | "express": "^4.17.1", 35 | "express-ws": "^4.0.0", 36 | "joi": "^17.4.0", 37 | "lowdb": "^1.0.0", 38 | "nanoid": "^3.1.3", 39 | "node-fetch": "^2.6.1", 40 | "request": "^2.88.2", 41 | "tmp": "^0.2.1" 42 | }, 43 | "devDependencies": { 44 | "@anduh/pug-cli": "^1.0.0-alpha8", 45 | "@types/express": "^4.17.6", 46 | "@types/express-ws": "^3.0.0", 47 | "@types/hapi__joi": "^16.0.12", 48 | "@types/jest": "^25.2.1", 49 | "@types/lowdb": "^1.0.9", 50 | "@types/nanoid": "^2.1.0", 51 | "@types/node": "^13.11.1", 52 | "@types/node-fetch": "^2.5.6", 53 | "@types/request": "^2.48.4", 54 | "@types/tmp": "^0.2.0", 55 | "@types/ws": "^7.2.4", 56 | "blitsy": "^0.2.1", 57 | "copyfiles": "^2.4.1", 58 | "jest": "^26.6.3", 59 | "jest-canvas-mock": "^2.2.0", 60 | "prettier": "^2.0.4", 61 | "ts-jest": "^26.5.1", 62 | "ts-loader": "^7.0.5", 63 | "tslint": "^6.1.1", 64 | "tslint-config-prettier": "^1.18.0", 65 | "typescript": "^4.1.5", 66 | "url": "^0.11.0", 67 | "webpack": "^5.39.1", 68 | "webpack-cli": "^4.7.2", 69 | "ws": "^7.2.5" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/common/utility.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | export const randomInt = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min; 4 | export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 5 | export const copy = (object: T) => JSON.parse(JSON.stringify(object)) as T; 6 | 7 | export function getDefault(map: Map, key: K, factory: (key: K) => V): V { 8 | let value = map.get(key); 9 | if (!value) { 10 | value = factory(key); 11 | map.set(key, value); 12 | } 13 | return value!; 14 | } 15 | 16 | export type EventMap = { [event: string]: (...args: any[]) => void }; 17 | 18 | export interface TypedEventEmitter { 19 | on(event: K, callback: TEventMap[K]): this; 20 | off(event: K, callback: TEventMap[K]): this; 21 | once(event: K, callback: TEventMap[K]): this; 22 | emit(event: K, ...args: Parameters): boolean; 23 | } 24 | 25 | export function specifically( 26 | emitter: EventEmitter, 27 | event: string, 28 | predicate: (...args: T) => boolean, 29 | callback: (...args: T) => void, 30 | ) { 31 | const handler = (...args: T) => { 32 | if (predicate(...args)) { 33 | emitter.removeListener(event, handler as any); 34 | callback(...args); 35 | } 36 | }; 37 | 38 | emitter.on(event, handler as any); 39 | } 40 | 41 | export function coordsToKey(coords: number[]): string { 42 | return coords.join(','); 43 | } 44 | 45 | export class Grid { 46 | private readonly cells = new Map(); 47 | 48 | get size() { 49 | return this.cells.size; 50 | } 51 | 52 | clear() { 53 | return this.cells.clear(); 54 | } 55 | 56 | has(coords: number[]) { 57 | return this.cells.has(coordsToKey(coords)); 58 | } 59 | 60 | get(coords: number[]) { 61 | const [, value] = this.cells.get(coordsToKey(coords)) || [undefined, undefined]; 62 | return value; 63 | } 64 | 65 | set(coords: number[], value: T) { 66 | return this.cells.set(coordsToKey(coords), [coords, value]); 67 | } 68 | 69 | delete(coords: number[]) { 70 | return this.cells.delete(coordsToKey(coords)); 71 | } 72 | 73 | forEach(callbackfn: (value: T, coords: number[], grid: Grid) => void) { 74 | return this.cells.forEach(([coords, value]) => callbackfn(value, coords, this)); 75 | } 76 | 77 | [Symbol.iterator]() { 78 | return this.cells.values(); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/server/__tests__/playback.test.ts: -------------------------------------------------------------------------------- 1 | import Playback from '../playback'; 2 | import { once } from 'events'; 3 | import { MEDIA, TINY_MEDIA, DAY_MEDIA } from '../../common/__tests__/media.data'; 4 | 5 | it('plays the first item queued', () => { 6 | const playback = new Playback(); 7 | MEDIA.forEach((media) => playback.queueMedia(media)); 8 | expect(playback.currentItem?.media).toEqual(MEDIA[0]); 9 | }); 10 | 11 | it('removes the playing item from the queue', () => { 12 | const playback = new Playback(); 13 | MEDIA.forEach((media) => playback.queueMedia(media)); 14 | expect(playback.queue.map((item) => item.media)).toEqual(MEDIA.slice(1)); 15 | }); 16 | 17 | test('loading copied state', () => { 18 | const playback = new Playback(); 19 | const other = new Playback(); 20 | MEDIA.forEach((media) => playback.queueMedia(media)); 21 | other.loadState(playback.copyState()); 22 | expect(other.currentItem).toEqual(playback.currentItem); 23 | expect(other.queue).toEqual(playback.queue); 24 | }); 25 | 26 | it('can copy empty state', () => { 27 | const playback = new Playback(); 28 | const other = new Playback(); 29 | other.loadState(playback.copyState()); 30 | expect(other.currentItem).toEqual(playback.currentItem); 31 | expect(other.queue).toEqual(playback.queue); 32 | }); 33 | 34 | it('continues the queue when a video ends', async () => { 35 | const playback = new Playback(); 36 | playback.queueMedia(TINY_MEDIA); 37 | playback.queueMedia(DAY_MEDIA); 38 | expect(playback.currentItem?.media).toBe(TINY_MEDIA); 39 | await once(playback, 'play'); 40 | expect(playback.currentItem?.media).toBe(DAY_MEDIA); 41 | }); 42 | 43 | it('stops when last item ends', async () => { 44 | const playback = new Playback(); 45 | const stopped = once(playback, 'stop'); 46 | playback.queueMedia(TINY_MEDIA); 47 | await stopped; 48 | }); 49 | 50 | it('stops when last item skipped', async () => { 51 | const playback = new Playback(); 52 | const stopped = once(playback, 'stop'); 53 | playback.queueMedia(DAY_MEDIA); 54 | playback.skip(); 55 | await stopped; 56 | }); 57 | 58 | it('continues the queue when an item is skipped', async () => { 59 | const playback = new Playback(); 60 | playback.queueMedia(TINY_MEDIA); 61 | MEDIA.forEach((video) => playback.queueMedia(video)); 62 | expect(playback.currentItem?.media).toBe(TINY_MEDIA); 63 | playback.skip(); 64 | expect(playback.currentItem?.media).toBe(MEDIA[0]); 65 | }); 66 | 67 | test('queue proceeds normally after loading state', async () => { 68 | const playback = new Playback(); 69 | const other = new Playback(); 70 | playback.queueMedia(TINY_MEDIA); 71 | playback.queueMedia(DAY_MEDIA); 72 | other.loadState(playback.copyState()); 73 | await once(other, 'play'); 74 | expect(other.currentItem?.media).toBe(DAY_MEDIA); 75 | }); 76 | -------------------------------------------------------------------------------- /src/common/__tests__/messaging.test.ts: -------------------------------------------------------------------------------- 1 | import { echoServer, timeout } from './utilities'; 2 | import { once } from 'events'; 3 | import Messaging from '../messaging'; 4 | 5 | describe('messaging', () => { 6 | it('emits close event when closed', async () => { 7 | await echoServer({}, async (server) => { 8 | const messaging = await server.messaging(); 9 | const waiter = once(messaging, 'close'); 10 | await messaging.close(); 11 | await waiter; 12 | }); 13 | }); 14 | 15 | it('emits only one close event', async () => { 16 | await echoServer({}, async (server) => { 17 | const messaging = await server.messaging(); 18 | 19 | const waiter1 = once(messaging, 'close'); 20 | await messaging.close(); 21 | await waiter1; 22 | 23 | const waiter2 = timeout(messaging, 'close', 100); 24 | await messaging.close(); 25 | await waiter2; 26 | }); 27 | }); 28 | 29 | it('does not emit close when replacing open socket', async () => { 30 | await echoServer({}, async (server) => { 31 | const socket = await server.socket(); 32 | const messaging = await server.messaging(); 33 | 34 | const noclose = timeout(messaging, 'close', 100); 35 | messaging.setSocket(socket); 36 | await noclose; 37 | }); 38 | }); 39 | 40 | it('emits second close event when second socket closed', async () => { 41 | await echoServer({}, async (server) => { 42 | const socket = await server.socket(); 43 | const messaging = await server.messaging(); 44 | 45 | const waiter1 = once(messaging, 'close'); 46 | await messaging.close(); 47 | await waiter1; 48 | 49 | messaging.setSocket(socket); 50 | 51 | const waiter2 = once(messaging, 'close'); 52 | await messaging.close(); 53 | await waiter2; 54 | }); 55 | }); 56 | 57 | it('emits error when sending after close', async () => { 58 | await echoServer({}, async (server) => { 59 | const messaging = await server.messaging(); 60 | await messaging.close(3000); 61 | 62 | const error = once(messaging, 'error'); 63 | messaging.send('test', {}); 64 | await error; 65 | }); 66 | }); 67 | 68 | it('emits error when sending without socket', async () => { 69 | const messaging = new Messaging(); 70 | const error = once(messaging, 'error'); 71 | messaging.send('test'); 72 | await error; 73 | }); 74 | 75 | it('echoes after reconnect', async () => { 76 | await echoServer({}, async (server) => { 77 | const socket = await server.socket(); 78 | const messaging = await server.messaging(); 79 | messaging.on('close', () => messaging.setSocket(socket)); 80 | 81 | await messaging.close(3000); 82 | const waiter = once(messaging.messages, 'test'); 83 | messaging.send('test', {}); 84 | await waiter; 85 | }); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /src/client/menus.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | export class Menu extends EventEmitter { 4 | paths = new Set(); 5 | tabToggles = new Map(); 6 | tabBodies = new Map(); 7 | 8 | readonly visible = new Map(); 9 | 10 | constructor() { 11 | super(); 12 | } 13 | 14 | private setShown(path: string, shown: boolean) { 15 | const tab = this.tabToggles.get(path); 16 | const body = this.tabBodies.get(path); 17 | 18 | if (tab) tab.classList.toggle('active', shown); 19 | if (body) body.hidden = !shown; 20 | } 21 | 22 | isVisible(path: string): boolean { 23 | const ancestors = !path.includes('/') || this.isVisible(getPathParent(path)); 24 | return ancestors && !this.tabBodies.get(path)?.hidden; 25 | } 26 | 27 | open(path: string) { 28 | if (path.includes('/')) this.open(getPathParent(path)); 29 | 30 | this.paths.forEach((other) => { 31 | if (arePathsSiblings(path, other)) this.setShown(other, other === path); 32 | }); 33 | 34 | this.refresh(); 35 | this.emit(`open:${path}`); 36 | } 37 | 38 | close(path: string) { 39 | const tab = this.tabToggles.get(path); 40 | const body = this.tabBodies.get(path); 41 | 42 | if (tab) tab.classList.toggle('active', false); 43 | if (body) body.hidden = true; 44 | 45 | this.refresh(); 46 | this.emit(`close:${path}`); 47 | } 48 | 49 | closeChildren(path: string) { 50 | this.tabToggles.forEach((toggle, togglePath) => { 51 | if (getPathParent(togglePath) === path) this.close(togglePath); 52 | }); 53 | } 54 | 55 | refresh() { 56 | this.paths.forEach((path) => { 57 | const prev = this.visible.get(path) === true; 58 | const next = this.isVisible(path); 59 | 60 | if (prev !== next) this.emit(`${next ? 'show' : 'hide'}:${path}`); 61 | 62 | this.visible.set(path, next); 63 | }); 64 | } 65 | } 66 | 67 | export function menusFromDataAttributes(root: HTMLElement) { 68 | const menu = new Menu(); 69 | menu.tabToggles = indexByDataAttribute(root, 'data-tab-toggle'); 70 | menu.tabBodies = indexByDataAttribute(root, 'data-tab-body'); 71 | menu.tabToggles.forEach((_, path) => menu.paths.add(path)); 72 | menu.tabBodies.forEach((_, path) => menu.paths.add(path)); 73 | 74 | menu.tabToggles.forEach((toggle, path) => { 75 | toggle.addEventListener('click', (event) => { 76 | event.stopPropagation(); 77 | 78 | if (toggle.classList.contains('active')) { 79 | menu.close(path); 80 | } else { 81 | menu.open(path); 82 | } 83 | }); 84 | }); 85 | 86 | return menu; 87 | } 88 | 89 | function arePathsSiblings(idA: string, idB: string) { 90 | return getPathParent(idA) === getPathParent(idB); 91 | } 92 | 93 | function getPathParent(id: string) { 94 | const components = id.split('/'); 95 | return components.slice(0, -1).join('/'); 96 | } 97 | 98 | export function indexByDataAttribute(root: HTMLElement, attribute: string) { 99 | const index = new Map(); 100 | root.querySelectorAll(`[${attribute}]`).forEach((element) => { 101 | const value = element.getAttribute(attribute); 102 | if (value !== null) index.set(value, element as HTMLElement); 103 | }); 104 | return index; 105 | } 106 | -------------------------------------------------------------------------------- /src/client/utility.ts: -------------------------------------------------------------------------------- 1 | export function fakedownToTag(text: string, fd: string, tag: string) { 2 | const pattern = new RegExp(`${fd}([^${fd}]+)${fd}`, 'g'); 3 | return text.replace(pattern, `{+${tag}}$1{-${tag}}`); 4 | } 5 | 6 | const pad2 = (part: number) => (part.toString().length >= 2 ? part.toString() : '0' + part.toString()); 7 | export function secondsToTime(seconds: number) { 8 | if (isNaN(seconds)) return '??:??'; 9 | 10 | const s = Math.floor(seconds % 60); 11 | const m = Math.floor(seconds / 60) % 60; 12 | const h = Math.floor(seconds / 3600); 13 | 14 | return h > 0 ? `${pad2(h)}:${pad2(m)}:${pad2(s)}` : `${pad2(m)}:${pad2(s)}`; 15 | } 16 | 17 | // source : https://gist.github.com/mjackson/5311256 18 | export function hue2rgb(p: number, q: number, t: number) { 19 | if (t < 0) t += 1; 20 | if (t > 1) t -= 1; 21 | if (t < 1 / 6) return p + (q - p) * 6 * t; 22 | if (t < 1 / 2) return q; 23 | if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; 24 | return p; 25 | } 26 | 27 | export function hslToRgb(h: number, s: number, l: number) { 28 | let r; 29 | let g; 30 | let b; 31 | 32 | if (s === 0) { 33 | r = g = b = l; // achromatic 34 | } else { 35 | const q = l < 0.5 ? l * (1 + s) : l + s - l * s; 36 | const p = 2 * l - q; 37 | 38 | r = hue2rgb(p, q, h + 1 / 3); 39 | g = hue2rgb(p, q, h); 40 | b = hue2rgb(p, q, h - 1 / 3); 41 | } 42 | 43 | return [(r * 255)|0, (g * 255)|0, (b * 255)|0]; 44 | } 45 | 46 | export function withPixels(context: CanvasRenderingContext2D, action: (pixels: Uint32Array) => void) { 47 | const image = context.getImageData(0, 0, context.canvas.width, context.canvas.height); 48 | action(new Uint32Array(image.data.buffer)); 49 | context.putImageData(image, 0, 0); 50 | } 51 | 52 | export function num2hex(value: number): string { 53 | return rgb2hex(num2rgb(value)); 54 | } 55 | 56 | export function rgb2num(r: number, g: number, b: number, a: number = 255) { 57 | return ((a << 24) | (b << 16) | (g << 8) | r) >>> 0; 58 | } 59 | 60 | export function num2rgb(value: number): [number, number, number] { 61 | const r = (value >> 0) & 0xff; 62 | const g = (value >> 8) & 0xff; 63 | const b = (value >> 16) & 0xff; 64 | 65 | return [r, g, b]; 66 | } 67 | 68 | export function rgb2hex(color: [number, number, number]): string { 69 | const [r, g, b] = color; 70 | let rs = r.toString(16); 71 | let gs = g.toString(16); 72 | let bs = b.toString(16); 73 | 74 | if (rs.length < 2) { 75 | rs = '0' + rs; 76 | } 77 | if (gs.length < 2) { 78 | gs = '0' + gs; 79 | } 80 | if (bs.length < 2) { 81 | bs = '0' + bs; 82 | } 83 | 84 | return `#${rs}${gs}${bs}`; 85 | } 86 | 87 | export function hex2rgb(color: string): [number, number, number] { 88 | const matches = color.match(/^#([0-9a-f]{6})$/i); 89 | 90 | if (matches) { 91 | const match = matches[1]; 92 | 93 | return [parseInt(match.substr(0, 2), 16), parseInt(match.substr(2, 2), 16), parseInt(match.substr(4, 2), 16)]; 94 | } 95 | 96 | return [0, 0, 0]; 97 | } 98 | 99 | export function eventToElementPixel(event: MouseEvent | Touch, element: HTMLElement) { 100 | const rect = element.getBoundingClientRect(); 101 | return [event.clientX - rect.x, event.clientY - rect.y]; 102 | } 103 | 104 | export function escapeHtml(unsafe: string) { 105 | return unsafe 106 | .replace(/&/g, "&") 107 | .replace(//g, ">") 109 | .replace(/"/g, """) 110 | .replace(/'/g, "'"); 111 | } 112 | -------------------------------------------------------------------------------- /src/common/__tests__/utilities.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter, once } from 'events'; 2 | import { AddressInfo } from 'net'; 3 | import * as WebSocket from 'ws'; 4 | import Messaging from '../messaging'; 5 | import { Server } from 'http'; 6 | 7 | import * as express from 'express'; 8 | import * as expressWs from 'express-ws'; 9 | 10 | import * as Memory from 'lowdb/adapters/Memory'; 11 | import { host, HostOptions } from '../../server/server'; 12 | import ZoneClient, { ClientOptions } from '../../common/client'; 13 | import Playback from '../../server/playback'; 14 | 15 | export const TEST_CLIENT_OPTIONS: Partial = { 16 | quickResponseTimeout: 50, 17 | slowResponseTimeout: 2000, 18 | joinName: 'baby yoda', 19 | }; 20 | 21 | export function timeout(emitter: EventEmitter, event: string, ms: number) { 22 | return new Promise((resolve, reject) => { 23 | setTimeout(resolve, ms); 24 | emitter.once(event, reject); 25 | }); 26 | } 27 | 28 | export async function echoServer(options: Partial<{}>, callback: (server: EchoServer) => Promise) { 29 | const server = new EchoServer(options); 30 | try { 31 | await once(server.server, 'listening'); 32 | await callback(server); 33 | } finally { 34 | server.dispose(); 35 | } 36 | } 37 | 38 | export async function zoneServer(options: Partial, callback: (server: ZoneServer) => Promise) { 39 | const server = new ZoneServer(options); 40 | try { 41 | await once(server.hosting.server, 'listening'); 42 | await callback(server); 43 | } finally { 44 | server.dispose(); 45 | } 46 | } 47 | 48 | export class ZoneServer { 49 | public hosting: { server: Server; playback: Playback; }; 50 | private readonly sockets: WebSocket[] = []; 51 | 52 | public get host() { 53 | const address = this.hosting.server.address() as AddressInfo; 54 | return `localhost:${address.port}`; 55 | } 56 | 57 | constructor(options?: Partial) { 58 | const xws = expressWs(express()); 59 | const server = xws.app.listen(0); 60 | this.hosting = { ...host(xws, new Memory(''), options), server }; 61 | } 62 | 63 | public async socket(ticket: string) { 64 | const socket = new WebSocket(`ws://${this.host}/zone/${ticket}`); 65 | this.sockets.push(socket); 66 | return socket; 67 | } 68 | 69 | public async client(options: Partial = {}) { 70 | options = Object.assign({ 71 | urlRoot: 'http://' + this.host, 72 | createSocket: (ticket: string) => this.socket(ticket), 73 | }, TEST_CLIENT_OPTIONS, options); 74 | const client = new ZoneClient(options); 75 | return client; 76 | } 77 | 78 | public dispose() { 79 | this.sockets.forEach((socket) => socket.close()); 80 | this.hosting.server.close(); 81 | } 82 | } 83 | 84 | export class EchoServer { 85 | public readonly server: WebSocket.Server; 86 | private readonly sockets: WebSocket[] = []; 87 | 88 | constructor(options?: {}) { 89 | this.server = new WebSocket.Server({ port: 0 }); 90 | this.server.on('connection', (socket) => socket.on('message', socket.send)); 91 | } 92 | 93 | public async socket() { 94 | const address = this.server.address() as AddressInfo; 95 | const socket = new WebSocket(`ws://localhost:${address.port}/zone`); 96 | this.sockets.push(socket); 97 | await once(socket, 'open'); 98 | return socket; 99 | } 100 | 101 | public async messaging() { 102 | const messaging = new Messaging(); 103 | messaging.setSocket(await this.socket()); 104 | return messaging; 105 | } 106 | 107 | public dispose() { 108 | this.sockets.forEach((socket) => socket.close()); 109 | this.server.close(); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/client/html-ui.ts: -------------------------------------------------------------------------------- 1 | export class HTMLUI { 2 | readonly windowElements = new Set(); 3 | readonly idToWindowElement = new Map(); 4 | 5 | hideAllWindows() { 6 | this.windowElements.forEach((element) => (element.hidden = true)); 7 | } 8 | 9 | showWindowById(id: string) { 10 | const windowElement = this.idToWindowElement.get(id); 11 | if (windowElement) windowElement.hidden = false; 12 | } 13 | 14 | hideWindowById(id: string) { 15 | const windowElement = this.idToWindowElement.get(id); 16 | if (windowElement) windowElement.hidden = true; 17 | } 18 | 19 | hideWindowByElement(windowElement: HTMLElement) { 20 | windowElement.hidden = true; 21 | } 22 | 23 | addElementsInRoot(root: HTMLElement) { 24 | const windowElements = root.querySelectorAll('[data-window]'); 25 | 26 | windowElements.forEach((windowElement) => { 27 | // prettier-ignore 28 | const windowId = 29 | getAttributeOrUndefined(windowElement, 'data-window') 30 | || getAttributeOrUndefined(windowElement, 'id') 31 | || ''; 32 | 33 | this.windowElements.add(windowElement); 34 | this.idToWindowElement.set(windowId, windowElement); 35 | 36 | const dragElements = windowElement.querySelectorAll('[data-window-drag]'); 37 | dragElements.forEach((handleElement) => dragHandle(handleElement, windowElement)); 38 | }); 39 | 40 | const closeElement = root.querySelectorAll('[data-window-close]'); 41 | 42 | closeElement.forEach((closeElement) => { 43 | const targetId = getAttributeOrUndefined(closeElement, 'data-window-close'); 44 | 45 | closeElement.addEventListener('click', (event) => { 46 | killEvent(event); 47 | 48 | if (targetId) { 49 | this.hideWindowById(targetId); 50 | } else { 51 | const parentWindowElement = closeElement.closest('[data-window]'); 52 | if (parentWindowElement) this.hideWindowByElement(parentWindowElement); 53 | } 54 | }); 55 | }); 56 | } 57 | } 58 | 59 | export function getAttributeOrUndefined(element: HTMLElement, qualifiedName: string) { 60 | const value = element.getAttribute(qualifiedName) || ''; 61 | return value.length > 0 ? value : undefined; 62 | } 63 | 64 | export function killEvent(event: Event) { 65 | event.stopPropagation(); 66 | event.preventDefault(); 67 | } 68 | 69 | export function dragHandle(handleElement: HTMLElement, draggedElement: HTMLElement) { 70 | let offset: number[] | undefined; 71 | 72 | handleElement.addEventListener('click', killEvent); 73 | handleElement.addEventListener('pointerdown', (event) => { 74 | killEvent(event); 75 | 76 | const dx = draggedElement.offsetLeft - event.clientX; 77 | const dy = draggedElement.offsetTop - event.clientY; 78 | offset = [dx, dy]; 79 | }); 80 | 81 | window.addEventListener('pointerup', (event) => { 82 | offset = undefined; 83 | }); 84 | 85 | window.addEventListener('pointermove', (event) => { 86 | if (!offset) return; 87 | 88 | killEvent(event); 89 | 90 | const [dx, dy] = offset; 91 | const tx = event.clientX + dx; 92 | const ty = event.clientY + dy; 93 | 94 | let minX = tx; 95 | let minY = ty; 96 | 97 | const maxX = minX + draggedElement.clientWidth; 98 | const maxY = minY + draggedElement.clientHeight; 99 | 100 | const shiftX = Math.min(0, window.innerWidth - maxX); 101 | const shiftY = Math.min(0, window.innerHeight - maxY); 102 | 103 | minX = Math.max(0, minX + shiftX); 104 | minY = Math.max(0, minY + shiftY); 105 | 106 | draggedElement.style.left = minX + 'px'; 107 | draggedElement.style.top = minY + 'px'; 108 | }); 109 | } 110 | -------------------------------------------------------------------------------- /src/client/chat.ts: -------------------------------------------------------------------------------- 1 | import { createContext2D, decodeFont, fonts, makeVector2, imageToContext } from 'blitsy'; 2 | import { Page, scriptToPages, getPageHeight, PageRenderer } from './text'; 3 | import { hex2rgb, rgb2num, hslToRgb } from './utility'; 4 | import { randomInt } from '../common/utility'; 5 | 6 | const font = decodeFont(fonts['ascii-small']); 7 | const layout = { font, lineWidth: 240, lineCount: 9999 }; 8 | 9 | export function filterDrawable(text: string) { 10 | return [...text].map((char) => ((char.codePointAt(0) || 0) < 256 ? char : '?')).join(''); 11 | } 12 | 13 | export class ChatPanel { 14 | public readonly context = createContext2D(256, 256); 15 | public chatPages: Page[] = []; 16 | 17 | private pageRenderer = new PageRenderer(256, 256); 18 | private cached = new Map(); 19 | private timers = new Map(); 20 | 21 | constructor(public previewTime = 5000) {} 22 | 23 | get height() { 24 | return this.context.canvas.height; 25 | } 26 | 27 | set height(height: number) { 28 | this.context.canvas.height = height; 29 | } 30 | 31 | public error(text: string) { 32 | this.log('{clr=#FF0000}ERROR: ' + text); 33 | } 34 | 35 | public status(text: string) { 36 | this.log('{clr=#FF00FF}! ' + text); 37 | } 38 | 39 | public log(text: string) { 40 | text = filterDrawable(text); 41 | const page = scriptToPages(text, layout)[0]; 42 | this.timers.set(page, performance.now() + this.previewTime); 43 | 44 | const pageLimit = 48; 45 | this.chatPages.push(page); 46 | this.chatPages.slice(0, -pageLimit).forEach((page) => this.cached.delete(page)); 47 | this.chatPages = this.chatPages.slice(-pageLimit); 48 | } 49 | 50 | public render(full: boolean) { 51 | const width = this.context.canvas.width; 52 | const height = this.context.canvas.height; 53 | 54 | this.context.clearRect(0, 0, width, height); 55 | 56 | if (full) { 57 | /* 58 | this.context.globalAlpha = 0.65; 59 | this.context.fillStyle = 'rgb(0 0 0)'; 60 | this.context.fillRect(0, 0, 256, 256); 61 | */ 62 | this.context.globalAlpha = 1; 63 | } else { 64 | this.context.globalAlpha = 0.65; 65 | } 66 | 67 | const now = performance.now(); 68 | let bottom = height; 69 | for (let i = this.chatPages.length - 1; i >= 0 && bottom >= 0; --i) { 70 | const page = this.chatPages[i]; 71 | const messageHeight = getPageHeight(page, font); 72 | const y = bottom - messageHeight; 73 | 74 | if (!full) { 75 | const expiry = this.timers.get(page) || 0; 76 | if (expiry < now) break; 77 | } 78 | 79 | let render = this.cached.get(page); 80 | if (!render) { 81 | const animated = animatePage(page); 82 | this.pageRenderer.renderPage(page, 4, 8); 83 | render = this.pageRenderer.pageImage; 84 | if (!animated) this.cached.set(page, imageToContext(render as any).canvas); 85 | } 86 | 87 | this.context.drawImage(render, 0, y - 8); 88 | bottom = y; 89 | } 90 | } 91 | } 92 | 93 | export function animatePage(page: Page) { 94 | let animated = false; 95 | page.forEach((glyph, i) => { 96 | glyph.hidden = false; 97 | if (glyph.styles.has('r')) glyph.hidden = false; 98 | if (glyph.styles.has('clr')) { 99 | const hex = glyph.styles.get('clr') as string; 100 | const rgb = hex2rgb(hex); 101 | glyph.color = rgb2num(...rgb); 102 | } 103 | if (glyph.styles.has('shk')) { 104 | animated = true; 105 | glyph.offset = makeVector2(randomInt(-1, 1), randomInt(-1, 1)); 106 | } 107 | if (glyph.styles.has('wvy')) { 108 | animated = true; 109 | glyph.offset.y = (Math.sin(i + (performance.now() * 5) / 1000) * 3) | 0; 110 | } 111 | if (glyph.styles.has('rbw')) { 112 | animated = true; 113 | const h = Math.abs(Math.sin(performance.now() / 600 - i / 8)); 114 | const [r, g, b] = hslToRgb(h, 1, 0.5); 115 | glyph.color = rgb2num(r, g, b); 116 | } 117 | }); 118 | return animated; 119 | } 120 | -------------------------------------------------------------------------------- /src/server/playback.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import { performance } from 'perf_hooks'; 3 | import { Media, QueueItem, QueueInfo } from './zone'; 4 | import { Library } from './libraries'; 5 | 6 | export const copy = (object: T) => JSON.parse(JSON.stringify(object)) as T; 7 | 8 | export type PlaybackState = { 9 | current?: QueueItem; 10 | queue: QueueItem[]; 11 | time: number; 12 | nextId: number; 13 | }; 14 | 15 | export interface Playback { 16 | on(event: 'play' | 'queue' | 'unqueue' | 'finish' | 'failed' | 'waiting', callback: (media: QueueItem) => void): this; 17 | on(event: 'stop', callback: () => void): this; 18 | } 19 | 20 | export class Playback extends EventEmitter { 21 | public currentItem?: QueueItem; 22 | public queue: QueueItem[] = []; 23 | 24 | private currentBeginTime: number = 0; 25 | private currentEndTime: number = 0; 26 | private checkTimeout: NodeJS.Timeout | undefined; 27 | 28 | private nextId = 0; 29 | 30 | constructor(private readonly libraries = new Map()) { 31 | super(); 32 | this.clearMedia(); 33 | } 34 | 35 | copyState(): PlaybackState { 36 | return { 37 | current: this.currentItem, 38 | queue: this.queue, 39 | time: this.currentTime, 40 | nextId: this.nextId, 41 | }; 42 | } 43 | 44 | loadState(data: PlaybackState) { 45 | if (data.current) this.playMedia(data.current, data.time); 46 | data.queue.forEach((item) => this.queueMedia(item.media, item.info, item.itemId)); 47 | this.nextId = data.nextId || 0; 48 | } 49 | 50 | queueMedia(media: Media, info: QueueInfo = {}, itemId?: number) { 51 | if (itemId === undefined) { 52 | itemId = this.nextId; 53 | this.nextId += 1; 54 | } 55 | 56 | const queued = { media, info, itemId }; 57 | this.queue.push(queued); 58 | this.emit('queue', queued); 59 | this.check(); 60 | } 61 | 62 | unqueue(item: QueueItem) { 63 | const index = this.queue.indexOf(item); 64 | if (index >= 0) { 65 | this.queue.splice(index, 1); 66 | this.emit('unqueue', item); 67 | } 68 | } 69 | 70 | clear() { 71 | while (this.queue.length > 0) { 72 | this.unqueue(this.queue[0]); 73 | } 74 | this.clearMedia(); 75 | } 76 | 77 | jump(time: number) { 78 | if (!this.currentItem) return; 79 | this.playMedia(this.currentItem, time); 80 | } 81 | 82 | get playing() { 83 | return this.remainingTime > 0; 84 | } 85 | 86 | get currentTime() { 87 | return performance.now() - this.currentBeginTime; 88 | } 89 | 90 | get remainingTime() { 91 | return Math.max(0, this.currentEndTime - performance.now()); 92 | } 93 | 94 | async skip() { 95 | if (this.currentItem) this.emit('finish', this.currentItem); 96 | 97 | if (this.queue.length === 0) { 98 | this.clearMedia(); 99 | } else { 100 | const next = this.queue[0]; 101 | const library = this.libraries.get(next.media.library || ""); 102 | const status = library ? await library.getStatus(next.media.mediaId) : 'available'; 103 | if (status === 'available') { 104 | this.queue.shift(); 105 | this.playMedia(next); 106 | } else if (status === 'requested') { 107 | this.clearMedia(); 108 | if (this.checkTimeout) clearTimeout(this.checkTimeout); 109 | this.checkTimeout = setTimeout(() => this.check(), 500); 110 | this.emit('waiting', next); 111 | } else if (library && status === 'none') { 112 | library.request(next.media.mediaId); 113 | } else { 114 | this.queue.shift(); 115 | this.playMedia(next); 116 | this.emit('failed', next); 117 | } 118 | } 119 | } 120 | 121 | private clearMedia() { 122 | if (this.currentItem) this.emit('stop'); 123 | this.setTime(0); 124 | this.currentItem = undefined; 125 | } 126 | 127 | private check() { 128 | if (this.playing) { 129 | if (this.checkTimeout) clearTimeout(this.checkTimeout); 130 | this.checkTimeout = setTimeout(() => this.check(), this.remainingTime); 131 | } else { 132 | this.skip(); 133 | } 134 | } 135 | 136 | private playMedia(item: QueueItem, time = 0) { 137 | this.currentItem = item; 138 | this.setTime(item.media.duration, time); 139 | this.emit('play', copy(item)); 140 | } 141 | 142 | private setTime(duration: number, time = 0) { 143 | this.currentBeginTime = performance.now() - time; 144 | this.currentEndTime = this.currentBeginTime + duration; 145 | if (duration > 0) this.check(); 146 | } 147 | } 148 | 149 | export default Playback; 150 | -------------------------------------------------------------------------------- /src/client/menus.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --menu-background: #202020; 3 | --tab-toggle-background: #000000; 4 | } 5 | 6 | .menu-panel { 7 | display: flex; 8 | flex-direction: column; 9 | justify-content: flex-end; 10 | 11 | max-height: 100%; 12 | } 13 | 14 | .menu-panel-tab-toggle-container { 15 | width: 100%; 16 | height: 32px; 17 | 18 | display: flex; 19 | 20 | background: var(--menu-background); 21 | } 22 | 23 | .menu-panel-tab-toggle { 24 | flex: 1; 25 | 26 | background: var(--tab-toggle-background); 27 | cursor: pointer; 28 | border: none; 29 | color: unset; 30 | } 31 | 32 | .menu-panel-tab-toggle.active { 33 | background: unset; 34 | } 35 | 36 | .menu-panel-tab-body-container { 37 | width: 100%; 38 | flex: 1; 39 | 40 | display: flex; 41 | flex-direction: column; 42 | 43 | background: var(--menu-background); 44 | 45 | max-height: 512px; 46 | overflow-y: auto; 47 | } 48 | 49 | .menu-panel-tab-body { 50 | flex: 1; 51 | display: flex; 52 | flex-direction: column; 53 | align-items: stretch; 54 | justify-content: stretch; 55 | 56 | background: var(--menu-background); 57 | } 58 | 59 | #playback-panel { 60 | min-height: 320px; 61 | } 62 | 63 | #chat-panel { 64 | display: flex; 65 | flex-direction: column; 66 | flex: 1; 67 | } 68 | 69 | #chat-canvas2 { 70 | width: 512px; 71 | max-width: 100%; 72 | flex: 1; 73 | } 74 | 75 | #chat-input { 76 | margin: 8px; 77 | flex: none; 78 | pointer-events: auto; 79 | } 80 | 81 | 82 | .user-container { 83 | display: flex; 84 | flex-wrap: wrap; 85 | flex: 1; 86 | 87 | align-items: center; 88 | align-content: center; 89 | justify-content: space-evenly; 90 | gap: 16px 32px; 91 | padding: 16px; 92 | } 93 | 94 | .user-container > div { 95 | display: flex; 96 | flex-direction: row; 97 | } 98 | 99 | .user-container > div > canvas { 100 | width: 24px; 101 | height: 24px; 102 | margin-right: 9px; 103 | } 104 | 105 | .user-container > div > div { 106 | white-space: nowrap; 107 | overflow-x: hidden; 108 | } 109 | 110 | #auth-row { 111 | display: flex; 112 | padding: 8px; 113 | gap: 8px; 114 | } 115 | 116 | #auth-content { 117 | display: flex; 118 | flex-direction: column; 119 | padding: 8px; 120 | gap: 8px; 121 | } 122 | 123 | #avatar-panel .button-row { 124 | padding: 8px; 125 | } 126 | 127 | #avatar-paint-container { 128 | display: flex; 129 | flex-direction: row; 130 | padding: 8px; 131 | gap: 8px; 132 | align-self: center; 133 | } 134 | 135 | #avatar-paint { 136 | width: 320px; height: 320px; 137 | background-color: var(--bitsy-blue); 138 | } 139 | 140 | #avatar-slot-container { 141 | flex: 1; 142 | display: flex; 143 | flex-direction: column; 144 | justify-content: space-between; 145 | } 146 | 147 | .avatar-slot { 148 | width: 72px; height: 72px; 149 | background-color: var(--bitsy-blue); 150 | } 151 | 152 | .avatar-slot.active { 153 | filter: invert(); 154 | } 155 | 156 | #avatar-panel { 157 | align-items: center; 158 | } 159 | 160 | #avatar-panel > .controls { 161 | display: flex; 162 | flex: 1; 163 | } 164 | 165 | .button-row { 166 | display: flex; 167 | align-content: stretch; 168 | gap: 8px; 169 | } 170 | 171 | .button-rows { 172 | display: flex; 173 | flex-direction: column; 174 | padding: 8px; 175 | gap: 8px; 176 | } 177 | 178 | #queue-title { 179 | padding: 8px; 180 | } 181 | 182 | #queue-items { 183 | flex: 1; 184 | display: flex; 185 | flex-direction: column; 186 | } 187 | 188 | .queue-item { 189 | display: flex; 190 | flex-direction: row; 191 | align-items: stretch; 192 | 193 | padding: 8px; 194 | padding-top: 0; 195 | } 196 | 197 | .queue-item > *:not(:first-child) { 198 | margin-left: 8px; 199 | } 200 | 201 | .queue-item-title { 202 | flex: 1; 203 | overflow-wrap: anywhere; 204 | } 205 | 206 | .queue-item-time { 207 | width: 128px; 208 | flex: 0; 209 | 210 | color: #00ffff; 211 | } 212 | 213 | .queue-item-cancel { 214 | width: 24px; 215 | height: 24px; 216 | 217 | border: none; 218 | padding: 4px; 219 | background-color: var(--button-color); 220 | border-color: var(--button-color); 221 | } 222 | 223 | .queue-item-cancel:disabled { 224 | background-color: var(--disable-color); 225 | border-color: var(--disable-color); 226 | cursor: not-allowed; 227 | } 228 | 229 | .queue-item-cancel > img { 230 | width: 16px; height: 16px; 231 | } 232 | 233 | #search-form { 234 | width: 100%; 235 | display: flex; 236 | padding: 8px; 237 | gap: 8px; 238 | } 239 | 240 | #search-library { 241 | flex: 1; 242 | } 243 | 244 | #search-input { 245 | flex: 1; 246 | min-width: 0; 247 | flex-basis: 100%; 248 | } 249 | 250 | #search-results { 251 | overflow-y: auto; 252 | flex: 1; 253 | } 254 | 255 | .search-result { 256 | cursor: pointer; 257 | display: flex; 258 | flex-direction: row; 259 | align-items: stretch; 260 | } 261 | 262 | .search-result:hover { 263 | background: orange; 264 | } 265 | 266 | .search-result > div { 267 | flex: 1; 268 | padding: 8px; 269 | } 270 | 271 | .search-result > img { 272 | flex: 0; 273 | width: 128px; 274 | min-height: 64px; 275 | object-fit: cover; 276 | } 277 | -------------------------------------------------------------------------------- /src/client/__tests__/text.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | tokeniseScript, 3 | Token, 4 | tokensToCommands, 5 | GlyphCommand, 6 | commandsToPages, 7 | commandsBreakLongSpans, 8 | filterToSpans, 9 | } from '../text'; 10 | import { makeVector2, MAGENTA_SPRITE_4X4, Font, FontCharacter } from 'blitsy'; 11 | 12 | const font = makeTestFont(); 13 | const tinyPage = { font, lineWidth: 12, lineCount: 2 }; 14 | const smallPage = { font, lineWidth: 40, lineCount: 2 }; 15 | const normalPage = { font, lineWidth: 192, lineCount: 2 }; 16 | 17 | const filterToSpansTests: [string, RegExp, string[]][] = [ 18 | ['test span split', / /, ['test', 'span', 'split']], 19 | ['test span split', / /, ['test', 'span', 'split']], 20 | ['test123span444split', /\d/, ['test', 'span', 'split']], 21 | ]; 22 | 23 | test.each(filterToSpansTests)('filterToSpans', (input: string, pattern: RegExp, expectedSpans: string[]) => { 24 | const predicate = (char: string) => pattern.test(char); 25 | const spans = filterToSpans(input.split(''), predicate).map((span) => span.join('')); 26 | expect(spans).toEqual(expectedSpans); 27 | }); 28 | 29 | type TokeniseTestCase = [string, Token[]]; 30 | 31 | const tokeniseTests: TokeniseTestCase[] = [ 32 | ['hello', [['text', 'hello']]], 33 | [ 34 | 'hello{el}joe', 35 | [ 36 | ['text', 'hello'], 37 | ['markup', 'el'], 38 | ['text', 'joe'], 39 | ], 40 | ], 41 | [ 42 | '{el}hello{ep}robert', 43 | [ 44 | ['markup', 'el'], 45 | ['text', 'hello'], 46 | ['markup', 'ep'], 47 | ['text', 'robert'], 48 | ], 49 | ], 50 | [ 51 | 'hello{ep}my name is mark{el}', 52 | [ 53 | ['text', 'hello'], 54 | ['markup', 'ep'], 55 | ['text', 'my name is mark'], 56 | ['markup', 'el'], 57 | ], 58 | ], 59 | [ 60 | 'hello{test}joe', 61 | [ 62 | ['text', 'hello'], 63 | ['markup', 'test'], 64 | ['text', 'joe'], 65 | ], 66 | ], 67 | ]; 68 | 69 | test.each(tokeniseTests)('tokenise as expected', (script, expectedTokens) => { 70 | expect(tokeniseScript(script)).toEqual(expectedTokens); 71 | }); 72 | 73 | describe('commands from tokens', () => { 74 | const tokens = tokeniseScript("hello what's new"); 75 | const commands = tokensToCommands(tokens); 76 | 77 | const glyphs = commands.filter(({ type }) => type === 'glyph') as GlyphCommand[]; 78 | 79 | test('only spaces breakable', () => { 80 | glyphs.forEach(({ char, breakable }) => { 81 | expect((char !== ' ') === !breakable).toBeTruthy(); 82 | }); 83 | }); 84 | }); 85 | 86 | function scriptToIntermediates(script: string) { 87 | const tokens = tokeniseScript(script); 88 | const commands = tokensToCommands(tokens); 89 | const glyphs = commands.filter(({ type }) => type === 'glyph') as GlyphCommand[]; 90 | return { tokens, commands, glyphs }; 91 | } 92 | 93 | describe('commands to layout', () => { 94 | const breaks: [string, number][] = [ 95 | ["damn that's a long line", 4], 96 | ['woahlongword', 12], 97 | ['hello woahlongword', 13], 98 | ['woahlo{el}ngword', 0], 99 | ]; 100 | 101 | test.each(breaks)('breaking spans', (line, expectedBreakables) => { 102 | const intermediates = scriptToIntermediates(line); 103 | commandsBreakLongSpans(intermediates.commands, smallPage); 104 | const actualBreakables = intermediates.glyphs.filter(({ breakable }) => breakable).length; 105 | expect(actualBreakables).toBe(expectedBreakables); 106 | }); 107 | }); 108 | 109 | describe('layout glyphs', () => { 110 | { 111 | const tokens = tokeniseScript('hel{el}lo'); 112 | const [page] = commandsToPages(tokensToCommands(tokens), normalPage); 113 | 114 | test('first glyph 0,0', () => expect(page[0].position).toEqual(makeVector2(0, 0))); 115 | test('next line height', () => expect(page[3].position).toEqual(makeVector2(0, font.lineHeight + 4))); 116 | } 117 | 118 | test('wordwrap on spaces', () => { 119 | const tokens = tokeniseScript('h lo'); 120 | const [page] = commandsToPages(tokensToCommands(tokens), tinyPage); 121 | 122 | expect(page[1].position).toEqual(makeVector2(0, font.lineHeight + 4)); 123 | }); 124 | 125 | test('preserve unwrapped spaces', () => { 126 | const tokens = tokeniseScript('h o'); 127 | const [page] = commandsToPages(tokensToCommands(tokens), normalPage); 128 | 129 | expect(page.length).toEqual(5); 130 | }); 131 | 132 | test('strip markup', () => { 133 | const tokens = tokeniseScript('h{el}e{bla}la{el}'); 134 | const [page] = commandsToPages(tokensToCommands(tokens), normalPage); 135 | 136 | expect(page.length).toEqual(4); 137 | }); 138 | 139 | test('markup break up words', () => { 140 | const tokens = tokeniseScript('hello he{bla}llo'); 141 | const commands = tokensToCommands(tokens); 142 | const [page] = commandsToPages(commands, smallPage); 143 | 144 | expect(page[5].position).toEqual(makeVector2(0, font.lineHeight + 4)); 145 | }); 146 | }); 147 | 148 | function makeTestFont(): Font { 149 | const font: Font = { 150 | name: 'test font', 151 | lineHeight: 4, 152 | characters: new Map(), 153 | }; 154 | 155 | for (let i = 0; i < 256; ++i) { 156 | font.characters.set(i, { 157 | codepoint: i, 158 | sprite: MAGENTA_SPRITE_4X4, 159 | offset: makeVector2(0, 0), 160 | spacing: 4, 161 | }); 162 | } 163 | return font; 164 | } 165 | -------------------------------------------------------------------------------- /src/common/__tests__/server-client.test.ts: -------------------------------------------------------------------------------- 1 | import { once } from 'events'; 2 | import { sleep } from '../utility'; 3 | import { zoneServer } from './utilities'; 4 | 5 | describe('join server', () => { 6 | test('assigns id', async () => { 7 | await zoneServer({}, async (server) => { 8 | const client = await server.client(); 9 | await client.join(); 10 | 11 | expect(client.localUserId).not.toBeUndefined(); 12 | }); 13 | }); 14 | 15 | it('sends user list', async () => { 16 | await zoneServer({}, async (server) => { 17 | const name = "baby yoda"; 18 | const client1 = await server.client(); 19 | const client2 = await server.client(); 20 | await client1.join({ name }); 21 | 22 | const waitUsers = client2.expect('users'); 23 | await client2.join(); 24 | const { users } = await waitUsers; 25 | 26 | expect(users[0]).toMatchObject({ name }); 27 | }); 28 | }); 29 | 30 | test('server sends name on join', async () => { 31 | await zoneServer({}, async (server) => { 32 | const name = 'baby yoda 2'; 33 | const client1 = await server.client(); 34 | const client2 = await server.client(); 35 | 36 | await client1.join(); 37 | const waiter = client1.expect('user'); 38 | await client2.join({ name }); 39 | const message = await waiter; 40 | 41 | expect(message.name).toEqual(name); 42 | }); 43 | }); 44 | }); 45 | 46 | describe('user presence', () => { 47 | test('client chat', async () => { 48 | const message = 'hello baby yoda'; 49 | await zoneServer({}, async (server) => { 50 | const client = await server.client(); 51 | await client.join(); 52 | const waiter = once(client, 'chat'); 53 | client.chat(message); 54 | const chat = (await waiter)[0]; 55 | expect(chat.text).toEqual(message); 56 | }); 57 | }); 58 | 59 | test('client rename', async () => { 60 | const newName = 'adult yoda'; 61 | await zoneServer({}, async (server) => { 62 | const client = await server.client(); 63 | await client.join(); 64 | const { name, userId } = await client.rename(newName); 65 | expect(name).toEqual(newName); 66 | expect(userId).toEqual(client.localUserId); 67 | }); 68 | }); 69 | 70 | test('client avatar', async () => { 71 | const avatar = 'AGb/w+f/WmY='; 72 | await zoneServer({}, async (server) => { 73 | const client = await server.client(); 74 | await client.join(); 75 | const waiter = client.expect('user'); 76 | client.avatar(avatar); 77 | await waiter; 78 | expect(client.localUser?.avatar).toEqual(avatar); 79 | }); 80 | }); 81 | 82 | test('client move', async () => { 83 | const position = [6, 9, 0]; 84 | await zoneServer({}, async (server) => { 85 | const client = await server.client(); 86 | await client.join(); 87 | const waiter = client.expect('user'); 88 | client.move(position); 89 | await waiter; 90 | expect(client.localUser?.position).toEqual(position); 91 | }); 92 | }); 93 | 94 | test('client emotes', async () => { 95 | const emotes = ['shk', 'wvy']; 96 | await zoneServer({}, async (server) => { 97 | const client = await server.client(); 98 | await client.join(); 99 | const waiter = client.expect('user'); 100 | client.emotes(emotes); 101 | await waiter; 102 | expect(client.localUser?.emotes).toEqual(emotes); 103 | }); 104 | }); 105 | }); 106 | 107 | describe('echoes', () => { 108 | const position = [6, 9, 0]; 109 | const message = 'hello'; 110 | 111 | it('can add an echo', async () => { 112 | await zoneServer({}, async (server) => { 113 | const client = await server.client(); 114 | const initial = client.expect('echoes'); 115 | await client.join(); 116 | await initial; 117 | 118 | const waiter = client.expect('echoes'); 119 | client.echo(position, message); 120 | 121 | const { added, removed } = await waiter; 122 | expect(added).not.toBe(undefined); 123 | expect(removed).toBe(undefined); 124 | 125 | const first = added![0]; 126 | expect(first.position).toEqual(position); 127 | expect(first.text).toEqual(message); 128 | }); 129 | }); 130 | 131 | it('receives existing echoes', async () => { 132 | await zoneServer({}, async (server) => { 133 | const client1 = await server.client(); 134 | await client1.join(); 135 | client1.echo(position, message); 136 | 137 | await sleep(100); 138 | 139 | const client2 = await server.client(); 140 | const initial2 = client2.expect('echoes'); 141 | await client2.join(); 142 | const { added, removed } = await initial2; 143 | 144 | expect(added).not.toBe(undefined); 145 | expect(removed).toBe(undefined); 146 | 147 | const first = added![0]; 148 | expect(first.position).toEqual(position); 149 | expect(first.text).toEqual(message); 150 | }); 151 | }); 152 | }); 153 | 154 | test('server sends leave on clean quit', async () => { 155 | await zoneServer({}, async (server) => { 156 | const client1 = await server.client(); 157 | const client2 = await server.client(); 158 | 159 | await client1.join(); 160 | await client2.join(); 161 | 162 | const waiter = client2.expect('leave'); 163 | await client1.messaging.close(); 164 | const { userId: leftId } = await waiter; 165 | 166 | expect(client1.localUserId).toEqual(leftId); 167 | }); 168 | }); 169 | -------------------------------------------------------------------------------- /src/client/player.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import { sleep } from '../common/utility'; 3 | import { QueueItem } from '../common/zone'; 4 | 5 | export const NETWORK = ['NETWORK_EMPTY', 'NETWORK_IDLE', 'NETWORK_LOADING', 'NETWORK_NO_SOURCE']; 6 | export const READY = ['HAVE_NOTHING', 'HAVE_METADATA', 'HAVE_CURRENT_DATA', 'HAVE_FUTURE_DATA', 'HAVE_ENOUGH_DATA']; 7 | 8 | export async function expectMetadata(element: HTMLMediaElement) { 9 | return new Promise((resolve, reject) => { 10 | setTimeout(() => reject('timeout'), 30 * 1000); 11 | element.addEventListener('loadedmetadata', resolve, { once: true }); 12 | element.addEventListener('error', reject, { once: true }); 13 | }); 14 | } 15 | 16 | export interface Player { 17 | on(type: 'subtitles', callback: (lines: string[]) => void): this; 18 | } 19 | 20 | const STALL_TIMEOUT = 2500; 21 | 22 | export class Player extends EventEmitter { 23 | private item?: QueueItem; 24 | private itemPlayStart = 0; 25 | private reloading?: object; 26 | private startedPlaying = false; 27 | 28 | private source: HTMLSourceElement; 29 | private subtrack: HTMLTrackElement; 30 | 31 | constructor(private readonly element: HTMLVideoElement) { 32 | super(); 33 | 34 | this.source = document.createElement('source'); 35 | element.appendChild(this.source); 36 | this.subtrack = document.createElement('track'); 37 | 38 | let lastUnstall = performance.now(); 39 | let stallTimeout = STALL_TIMEOUT; 40 | setInterval(() => { 41 | if (!this.startedPlaying) return; 42 | 43 | const state = this.element.readyState; 44 | 45 | if (state >= HTMLMediaElement.HAVE_FUTURE_DATA) { 46 | lastUnstall = performance.now(); 47 | } else if (state >= HTMLMediaElement.HAVE_METADATA && performance.now() - lastUnstall > stallTimeout) { 48 | stallTimeout += STALL_TIMEOUT; 49 | this.forceRetry('stalling'); 50 | } 51 | }, 500); 52 | } 53 | 54 | get playingItem() { 55 | return this.item; 56 | } 57 | 58 | get hasItem() { 59 | return this.item !== undefined; 60 | } 61 | 62 | get hasVideo() { 63 | return this.item && !this.item.media.src.endsWith('.mp3'); 64 | } 65 | 66 | get duration() { 67 | return this.item?.media.duration || 0; 68 | } 69 | 70 | get elapsed() { 71 | return this.hasItem ? performance.now() - this.itemPlayStart : 0; 72 | } 73 | 74 | get remaining() { 75 | return Math.max(0, this.duration - this.elapsed); 76 | } 77 | 78 | get status() { 79 | if (!this.item) { 80 | return 'done'; 81 | } else if (this.element.networkState === HTMLMediaElement.NETWORK_NO_SOURCE) { 82 | return 'no source'; 83 | } else if (this.element.readyState === HTMLMediaElement.HAVE_ENOUGH_DATA) { 84 | return this.elapsed < 0 ? 'ready' : 'playing'; 85 | } else if (this.element.readyState >= HTMLMediaElement.HAVE_METADATA) { 86 | return 'loading video'; 87 | } else if (this.element.readyState === HTMLMediaElement.HAVE_NOTHING) { 88 | return 'loading metadata'; 89 | } else { 90 | return 'loading video'; 91 | } 92 | } 93 | 94 | get problem() { 95 | return this.status !== 'done' 96 | && this.status !== 'ready' 97 | && this.status !== 'playing'; 98 | } 99 | 100 | get volume() { 101 | return this.element.volume; 102 | } 103 | 104 | set volume(value: number) { 105 | this.element.volume = value; 106 | } 107 | 108 | setPlaying(item: QueueItem, seek: number) { 109 | this.itemPlayStart = performance.now() - seek; 110 | 111 | if (item !== this.item) { 112 | this.item = item; 113 | this.removeSource(); 114 | this.reloadSource(); 115 | } else { 116 | this.reseek(); 117 | } 118 | } 119 | 120 | stopPlaying() { 121 | this.item = undefined; 122 | this.itemPlayStart = performance.now(); 123 | 124 | this.element.pause(); 125 | this.removeSource(); 126 | } 127 | 128 | forceRetry(reason: string) { 129 | console.log('forcing retry', reason, this.status); 130 | this.removeSource(); 131 | this.reloadSource(); 132 | } 133 | 134 | private reseek() { 135 | const target = this.elapsed / 1000; 136 | const error = Math.abs(this.element.currentTime - target); 137 | if (error > 0.1) this.element.currentTime = target; 138 | } 139 | 140 | private reloadSubtitles() { 141 | if (!this.item?.media.subtitle) return; 142 | 143 | this.subtrack = document.createElement('track'); 144 | this.subtrack.kind = 'subtitles'; 145 | this.subtrack.label = 'english'; 146 | this.subtrack.src = this.item.media.subtitle; 147 | this.element.appendChild(this.subtrack); 148 | this.element.textTracks[0].mode = 'showing'; 149 | 150 | this.subtrack.addEventListener('cuechange', (event) => { 151 | if (!this.subtrack) return; 152 | const cues = Array.from(this.subtrack.track.activeCues || []) as VTTCue[]; 153 | const lines = cues.map((cue) => cue.text); 154 | this.emit('subtitles', lines); 155 | }); 156 | } 157 | 158 | private async reloadSource(force = false) { 159 | if (!this.item || (!force && !!this.reloading)) return; 160 | this.removeSource(); 161 | 162 | const token = {}; 163 | this.startedPlaying = false; 164 | this.reloading = token; 165 | 166 | const done = () => { 167 | if (this.reloading === token) this.reloading = undefined; 168 | }; 169 | 170 | this.reloadSubtitles(); 171 | 172 | this.element.pause(); 173 | const waiter = expectMetadata(this.element); 174 | this.source = document.createElement('source'); 175 | this.source.src = this.item.media.src; 176 | this.element.appendChild(this.source); 177 | this.element.load(); 178 | 179 | try { 180 | await waiter; 181 | if (this.elapsed < 0) await sleep(-this.elapsed); 182 | this.reseek(); 183 | await this.element.play(); 184 | this.startedPlaying = true; 185 | done(); 186 | } catch (e) { 187 | console.log('source failed', this.status, e); 188 | if (this.reloading === token) { 189 | await sleep(500); 190 | this.reloadSource(true); 191 | } 192 | } finally { 193 | done(); 194 | } 195 | } 196 | 197 | private removeSource() { 198 | this.reloading = undefined; 199 | this.startedPlaying = false; 200 | 201 | if (this.source.parentElement) this.element.removeChild(this.source); 202 | if (this.subtrack.parentElement) this.element.removeChild(this.subtrack); 203 | this.emit("subtitles", []); 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/client/style.css: -------------------------------------------------------------------------------- 1 | [hidden] { display: none !important; } 2 | * { box-sizing: border-box; } 3 | input[type="submit"], button { cursor: pointer; } 4 | .spacer { flex: 1; } 5 | 6 | :root { 7 | --bitsy-blue: #0052cc; 8 | 9 | --button-color: #0052cc; 10 | --disable-color: #888888; 11 | --ascii-small: 'ascii_small_simple', monospace; 12 | 13 | --toolbar-gap: 8px; 14 | --toolbar-icon-scale: 4; 15 | } 16 | 17 | canvas, img { 18 | image-rendering: -moz-crisp-edges; 19 | image-rendering: -webkit-crisp-edges; 20 | image-rendering: pixelated; 21 | image-rendering: crisp-edges; 22 | } 23 | 24 | html, body { 25 | margin: 0; 26 | padding: 0; 27 | overflow: hidden; 28 | 29 | width: 100%; 30 | height: calc(var(--vh, 1vh) * 100); 31 | display: flex; 32 | align-items: center; 33 | justify-content: center; 34 | } 35 | 36 | #zone { 37 | width: 100%; height: 100%; 38 | 39 | display: flex; 40 | flex-direction: row-reverse; 41 | align-items: stretch; 42 | 43 | background: black; 44 | } 45 | 46 | #sidebar { 47 | width: 512px; 48 | 49 | background: rgb(0 0 0 / 100%); 50 | 51 | display: flex; 52 | flex-direction: column; 53 | justify-content: flex-end; 54 | } 55 | 56 | #scene { 57 | flex: 1; 58 | overflow: hidden; 59 | 60 | background: black; 61 | } 62 | 63 | #renderer { 64 | position: relative; 65 | left: 50%; top: 50%; 66 | transform: translate(-50%, -50%); 67 | } 68 | 69 | @media (max-width: 1024px) { 70 | :root { 71 | --mobile: 1; 72 | } 73 | 74 | #sidebar { 75 | position: absolute; 76 | left: 0; 77 | 78 | background: none; 79 | pointer-events: none; 80 | 81 | height: calc(var(--vh, 1vh) * 100); 82 | } 83 | 84 | #menu-tabs { pointer-events: initial; } 85 | #panel-container { pointer-events: initial; } 86 | } 87 | 88 | html { 89 | color: white; 90 | background: black; 91 | font-size: 16px; 92 | line-height: 24px; 93 | font-family: var(--ascii-small); 94 | } 95 | 96 | input, button, option, select { 97 | font-family: var(--ascii-small); 98 | font-size: 16px; 99 | min-height: 32px; 100 | } 101 | 102 | input[type="text"], select { 103 | color: white; 104 | flex: 1; 105 | 106 | background: black; 107 | border: solid black 3px; 108 | } 109 | 110 | input[type="text"]:focus { 111 | border-bottom: solid red 3px; 112 | } 113 | 114 | #entry { 115 | padding: 16px 48px; 116 | display: flex; 117 | } 118 | 119 | #entry-logo-container { 120 | flex: 0; 121 | } 122 | 123 | #entry-logo { 124 | width: 512px; height: 288px; 125 | max-width: 100%; 126 | margin: -64px 0; 127 | } 128 | 129 | #menu-container { 130 | position: absolute; 131 | left: 0; top: 0; 132 | width: 100%; 133 | height: 100%; 134 | 135 | display: flex; 136 | flex-direction: column-reverse; 137 | align-items: start; 138 | 139 | pointer-events: none; 140 | } 141 | 142 | #panel-container { 143 | width: 100%; 144 | 145 | pointer-events: all; 146 | 147 | display: flex; 148 | align-items: flex-end; 149 | } 150 | 151 | .toolbar { 152 | display: flex; 153 | flex-direction: row; 154 | align-items: stretch; 155 | flex-wrap: nowrap; 156 | 157 | background: black; 158 | 159 | padding: 8px; 160 | gap: 8px; 161 | } 162 | 163 | .toolbar > button { 164 | width: calc(10px * var(--toolbar-icon-scale)); 165 | height: calc(10px * var(--toolbar-icon-scale)); 166 | flex: none; 167 | 168 | border: none; 169 | padding: calc(1px * var(--toolbar-icon-scale)); 170 | background-color: var(--button-color); 171 | border-color: var(--button-color); 172 | 173 | pointer-events: initial; 174 | } 175 | 176 | .toolbar > button.active { 177 | filter: invert(); 178 | } 179 | 180 | button > img { 181 | width: 100%; height: 100%; 182 | } 183 | 184 | #tooltip { 185 | position: absolute; 186 | transform: translate(-50%, 24px); 187 | flex: 1; 188 | 189 | background: black; 190 | padding: 8px; 191 | 192 | pointer-events: none; 193 | z-index: 100; 194 | } 195 | 196 | .menu-title { 197 | width: 100%; 198 | display: flex; 199 | flex-direction: row; 200 | align-items: stretch; 201 | padding: 8px; 202 | background: rgb(32 40 64); 203 | flex: 0; 204 | } 205 | 206 | .menu-title > * { 207 | flex: 1; 208 | } 209 | 210 | .menu-body { 211 | width: 100%; 212 | flex: 1; 213 | display: flex; 214 | flex-direction: column; 215 | padding: 8px; 216 | background: rgb(0 21 51); 217 | } 218 | 219 | .icon-button { 220 | width: 24px; 221 | height: 24px; 222 | flex: 0; 223 | 224 | border: none; 225 | padding: 4px; 226 | background-color: var(--button-color); 227 | border-color: var(--button-color); 228 | } 229 | 230 | .icon-button > img { 231 | width: 16px; height: 16px; 232 | } 233 | 234 | #popout-panel { 235 | position: absolute; 236 | background: black; 237 | 238 | left: calc(50vw - 256px); top: 0; 239 | min-width: 512px; 240 | width: 50vw; 241 | max-width: unset; 242 | 243 | resize: horizontal; 244 | overflow: hidden; 245 | 246 | bottom: unset; 247 | right: unset; 248 | 249 | display: flex; 250 | flex-direction: column; 251 | align-items: center; 252 | align-content: center; 253 | justify-content: center; 254 | } 255 | 256 | #popout-panel > video { 257 | width: 100%; 258 | } 259 | 260 | [data-window] { 261 | width: 512px; 262 | max-width: 100%; 263 | display: flex; 264 | flex-direction: column; 265 | } 266 | 267 | .spacer { 268 | flex: 1; 269 | } 270 | 271 | #user-panel { 272 | max-height: 480px; 273 | } 274 | 275 | #user-panel > .menu-body { 276 | overflow-y: auto; 277 | } 278 | 279 | #entry-users { 280 | margin: 1em; 281 | } 282 | 283 | #entry-playing { 284 | margin: 1em; 285 | color: cyan; 286 | } 287 | 288 | .user-dj { 289 | text-decoration: underline 2px #00FFFF; 290 | } 291 | 292 | .user-admin { 293 | text-decoration: underline 2px #FF00FF; 294 | } 295 | 296 | #commands { 297 | overflow-y: auto; 298 | } 299 | 300 | .controls > button { 301 | flex: 1; 302 | } 303 | 304 | .controls > button:not(:first-child) { 305 | margin-left: 8px; 306 | } 307 | 308 | .player { 309 | position: absolute; 310 | width: 100%; height: 100%; 311 | mix-blend-mode: screen; 312 | } 313 | 314 | #zone-logo { 315 | position: absolute; 316 | width: 100%; height: 100%; 317 | opacity: 35%; 318 | mix-blend-mode: screen; 319 | image-rendering: pixelated; 320 | } 321 | 322 | #entry-splash { 323 | position: absolute; 324 | left: 0; top: 0; 325 | width: 100vw; height: 100vh; 326 | 327 | background: black; 328 | color: white; 329 | 330 | display: flex; 331 | align-items: center; 332 | justify-content: center; 333 | } 334 | 335 | #entry-panel { 336 | width: 512px; 337 | text-align: center; 338 | max-width: 100%; 339 | max-height: 100%; 340 | 341 | display: flex; 342 | flex-direction: column; 343 | } 344 | 345 | #join-name { 346 | flex: 1; 347 | border-radius: 3px; 348 | padding: .4em .8em; 349 | } 350 | 351 | #entry-button { 352 | flex: 0; 353 | border-radius: 3px; 354 | padding: .4em .8em; 355 | } 356 | 357 | #play-banger { 358 | max-width: 256px; 359 | } 360 | -------------------------------------------------------------------------------- /src/client/index.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | meta(charset="UTF-8") 5 | meta(name="viewport", content="width=512") 6 | title zone 7 | link(rel="icon", type="image/png", href="icon.png") 8 | link(rel="stylesheet", href="ascii_small_simple/ascii_small_simple.css", type="text/css", charset="utf-8") 9 | link(rel="stylesheet", href="style.css") 10 | link(rel="stylesheet", href="menus.css") 11 | script(src="script.js") 12 | body 13 | #zone 14 | #scene 15 | #tooltip 16 | canvas#renderer 17 | #sidebar 18 | #chat-panel 19 | canvas#chat-canvas2(width=512, height=512) 20 | input#chat-input(type="text" placeholder="chat message..." maxlength="160" autocomplete="off") 21 | #panel-container 22 | #social-panel.menu-panel(data-window data-tab-body="social") 23 | .menu-panel-tab-body-container 24 | #user-items.user-container 25 | #auth-row 26 | input(type="text" placeholder="admin password...")#auth-input 27 | button#auth-button authorise 28 | #auth-content 29 | .button-row 30 | select#user-select 31 | button#despawn-button despawn 32 | button#add-dj-button add dj 33 | button#del-dj-button del dj 34 | button#ban-ip-button ban ip 35 | .button-row 36 | button#event-mode-on event mode on 37 | button#event-mode-off event mode off 38 | #playback-panel.menu-panel(data-window data-tab-body="playback") 39 | .menu-panel-tab-body-container 40 | .menu-panel-tab-body(role="tabpanel" data-tab-body="playback/search" hidden) 41 | #search-results 42 | #search-result-template.search-result 43 | div title 44 | img 45 | form#search-form 46 | select#search-library 47 | option youtube 48 | option library 49 | input#search-input(placeholder="search for a video" type="text" autocomplete="off") 50 | input#search-submit(type="submit" value="search" disabled) 51 | .menu-panel-tab-body(role="tabpanel" data-tab-body="playback/playlist") 52 | #queue-title 53 | .queue-item#current-item 54 | .queue-item-title#current-item-title title 55 | .queue-item-time#current-item-time 00:00 56 | button.queue-item-cancel#skip-button(title="vote to skip this") 57 | img(src="icons/skip.png") 58 | #queue-items 59 | .queue-item#queue-item-template 60 | .queue-item-title title 61 | .queue-item-time 00:00 62 | button.queue-item-cancel(title="unqueue this") 63 | img(src="icons/cancel.png") 64 | .button-rows 65 | .button-row 66 | button#play-banger(title="queue random song", type="button") queue random song 67 | .menu-panel-tab-body.button-rows(role="tabpanel" data-tab-body="playback/player" hidden) 68 | .button-row 69 | label(for="volume-slider") volume 70 | input#volume-slider(type="range", min="0", max="1", step=".01") 71 | .button-row 72 | button#popout-button pop out video 73 | button#resync-button resync video 74 | button#external-button open video in tab 75 | button#pip-button open PiP 76 | .menu-panel-tab-toggle-container(role="tablist") 77 | button.menu-panel-tab-toggle(role="tab" data-tab-toggle="playback/search") search 78 | button.menu-panel-tab-toggle.active(role="tab" data-tab-toggle="playback/playlist") playlist 79 | button.menu-panel-tab-toggle(role="tab" data-tab-toggle="playback/player") player 80 | #avatar-panel.menu-panel(data-window data-tab-body="avatar") 81 | .menu-panel-tab-body-container 82 | .menu-panel-tab-body(role="tabpanel" data-tab-body="avatar/appearance") 83 | #avatar-paint-container 84 | canvas#avatar-paint(width=8 height=8) 85 | #avatar-slot-container 86 | canvas.avatar-slot(width=8 height=8 data-avatar-slot="a") 87 | canvas.avatar-slot(width=8 height=8 data-avatar-slot="b") 88 | canvas.avatar-slot(width=8 height=8 data-avatar-slot="c") 89 | canvas.avatar-slot(width=8 height=8 data-avatar-slot="d") 90 | .button-row 91 | input#avatar-name(type="text", maxlength="16") 92 | button#avatar-update update 93 | .menu-panel-tab-toggle-container 94 | button.menu-panel-tab-toggle.active(role="tab" data-tab-toggle="avatar/appearance") appearance 95 | #menu-tabs.toolbar 96 | button(title="show playback panel (q)" data-tab-toggle="playback") 97 | img(src="icons/music.png") 98 | button(title="show zonies (w)" data-tab-toggle="social") 99 | img(src="icons/users.png") 100 | button#avatar-button(title="change appearance (e)" data-tab-toggle="avatar") 101 | img(src="icons/avatar.png") 102 | button#resync-button2(title="resync video") 103 | img(src="icons/resync.png") 104 | .spacer 105 | button(title="toggle float emote (1)", data-emote-toggle="wvy") 106 | img(src="icons/wvy.png") 107 | button(title="toggle shake emote (2)", data-emote-toggle="shk") 108 | img(src="icons/shk.png") 109 | button(title="toggle rave emote (3)", data-emote-toggle="rbw") 110 | img(src="icons/rbw.png") 111 | button(title="toggle spin emote (4)", data-emote-toggle="spn") 112 | img(src="icons/spn.png") 113 | 114 | #popout-panel.menu-panel(data-window) 115 | .menu-title(data-window-drag) 116 | div video pop-out 117 | button(title="hide panel (esc)" data-window-close).icon-button 118 | img(src="icons/close.png") 119 | #entry-splash 120 | audio#entry-sound(hidden src="buddy-in.mp3") 121 | #entry-panel 122 | #entry-logo-container 123 | img#entry-logo(src="./zone-logo.png") 124 | #entry-playing(hidden) ... 125 | #entry-users.user-container ... 126 | form#entry 127 | input#join-name(type="text" placeholder="your name..." minlength="1" maxlength="16" required) 128 | input#entry-button(type="submit" value="enter" disabled) 129 | -------------------------------------------------------------------------------- /src/common/client.ts: -------------------------------------------------------------------------------- 1 | import Messaging, { Socket } from './messaging'; 2 | import { EventEmitter } from 'events'; 3 | import { specifically } from './utility'; 4 | import { ZoneState, UserState, QueueItem, UserEcho, Media } from './zone'; 5 | import fetch, { HeadersInit } from 'node-fetch'; 6 | const URL = window?.URL || require('url').URL; 7 | 8 | export type StatusMesage = { text: string }; 9 | export type JoinMessage = { name: string; token?: string; avatar?: string }; 10 | export type RejectMessage = { text: string }; 11 | export type UsersMessage = { users: UserState[] }; 12 | export type LeaveMessage = { userId: string }; 13 | export type PlayMessage = { item: QueueItem; time: number }; 14 | export type QueueMessage = { items: QueueItem[] }; 15 | export type UnqueueMessage = { itemId: number }; 16 | 17 | export type SendChat = { text: string }; 18 | export type RecvChat = { text: string; userId: string }; 19 | 20 | export type EchoesMessage = { added?: UserEcho[]; removed?: number[][] }; 21 | 22 | export interface MessageMap { 23 | ready: {}; 24 | reject: RejectMessage; 25 | users: UsersMessage; 26 | leave: LeaveMessage; 27 | play: PlayMessage; 28 | queue: QueueMessage; 29 | 30 | chat: SendChat; 31 | user: UserState; 32 | 33 | echoes: EchoesMessage; 34 | } 35 | 36 | export interface ClientOptions { 37 | urlRoot: string; 38 | quickResponseTimeout: number; 39 | slowResponseTimeout: number; 40 | joinName?: string; 41 | createSocket: (ticket: string) => Promise; 42 | } 43 | 44 | export const DEFAULT_OPTIONS: ClientOptions = { 45 | urlRoot: '.', 46 | quickResponseTimeout: 3000, 47 | slowResponseTimeout: 5000, 48 | createSocket: () => { throw new Error("not implemented"); }, 49 | }; 50 | 51 | export interface ClientEventMap { 52 | disconnect: (event: { clean: boolean }) => void; 53 | 54 | chat: (event: { user: UserState; text: string; local: boolean }) => void; 55 | join: (event: { user: UserState }) => void; 56 | leave: (event: { user: UserState }) => void; 57 | rename: (event: { user: UserState; previous: string; local: boolean }) => void; 58 | status: (event: { text: string }) => void; 59 | users: (event: {}) => void; 60 | 61 | play: (event: { message: PlayMessage }) => void; 62 | queue: (event: { item: QueueItem }) => void; 63 | unqueue: (event: { item: QueueItem }) => void; 64 | 65 | move: (event: { user: UserState; position: number[]; local: boolean }) => void; 66 | emotes: (event: { user: UserState; emotes: string[]; local: boolean }) => void; 67 | avatar: (event: { user: UserState; data: string; local: boolean }) => void; 68 | tags: (event: { user: UserState; tags: string[]; local: boolean }) => void; 69 | } 70 | 71 | export interface ZoneClient { 72 | on(event: K, callback: ClientEventMap[K]): this; 73 | off(event: K, callback: ClientEventMap[K]): this; 74 | once(event: K, callback: ClientEventMap[K]): this; 75 | emit(event: K, ...args: Parameters): boolean; 76 | } 77 | 78 | export class ZoneClient extends EventEmitter { 79 | readonly options: ClientOptions; 80 | readonly messaging = new Messaging(); 81 | readonly zone = new ZoneState(); 82 | 83 | private credentials?: { userId: string, token: string }; 84 | 85 | constructor(options: Partial = {}) { 86 | super(); 87 | this.options = Object.assign({}, DEFAULT_OPTIONS, options); 88 | this.addStandardListeners(); 89 | } 90 | 91 | get localUserId() { 92 | return this.credentials?.userId; 93 | } 94 | 95 | get localUser() { 96 | return this.zone.users.get(this.localUserId || ""); 97 | } 98 | 99 | clear() { 100 | this.zone.clear(); 101 | this.credentials = undefined; 102 | } 103 | 104 | async expect(type: K, timeout?: number): Promise { 105 | return new Promise((resolve, reject) => { 106 | if (timeout) setTimeout(() => reject('timeout'), timeout); 107 | this.messaging.messages.once(type, (message) => resolve(message)); 108 | }); 109 | } 110 | 111 | async join({ name = "anonymous", avatar = "" } = {}) { 112 | this.clear(); 113 | 114 | const { ticket, token, userId } = await this.request("POST", "/zone/join", { name, avatar }); 115 | this.credentials = { userId, token }; 116 | 117 | const socket = await this.options.createSocket(ticket); 118 | this.messaging.setSocket(socket); 119 | 120 | return this.expect("ready"); 121 | } 122 | 123 | async auth(password: string) { 124 | return this.request("POST", "/admin/authorize", { password }); 125 | } 126 | 127 | async command(name: string, args: any[] = []) { 128 | return this.request("POST", "/admin/command", { name, args }); 129 | } 130 | 131 | async rename(name: string): Promise { 132 | return new Promise((resolve, reject) => { 133 | setTimeout(() => reject('timeout'), this.options.quickResponseTimeout); 134 | specifically( 135 | this.messaging.messages, 136 | 'user', 137 | (message: UserState) => message.userId === this.localUserId && message.name === name, 138 | resolve, 139 | ); 140 | this.messaging.send('user', { name }); 141 | }); 142 | } 143 | 144 | async chat(text: string) { 145 | this.messaging.send('chat', { text }); 146 | } 147 | 148 | async move(position: number[]) { 149 | this.messaging.send('user', { position }); 150 | } 151 | 152 | async avatar(avatar: string) { 153 | this.messaging.send('user', { avatar }); 154 | } 155 | 156 | async emotes(emotes: string[]) { 157 | this.messaging.send('user', { emotes }); 158 | } 159 | 160 | async echo(position: number[], text: string) { 161 | return this.request("POST", "/echoes", { position, text }); 162 | } 163 | 164 | async request(method: string, url: string, body?: any): Promise { 165 | const headers: HeadersInit = {}; 166 | 167 | if (this.credentials) { 168 | headers["Authorization"] = "Bearer " + this.credentials.token; 169 | } 170 | 171 | if (body) { 172 | headers["Content-Type"] = "application/json"; 173 | body = JSON.stringify(body); 174 | } 175 | 176 | return fetch(new URL(url, this.options.urlRoot === "." ? document.location.origin : this.options.urlRoot), { method, headers, body }).then(async (response) => { 177 | if (response.ok) return response.json().catch(() => {}); 178 | throw new Error(await response.text()); 179 | }); 180 | } 181 | 182 | async lucky(library: string, query: string) { 183 | const [first, ..._] = await this.searchLibrary(library, query); 184 | this.queue(first.path!); 185 | } 186 | 187 | async searchLibrary(library: string, query?: string, tag?: string): Promise { 188 | const search = new URLSearchParams(); 189 | if (query) search.set("q", query); 190 | if (tag) search.set("tag", tag); 191 | const results = await this.request("GET", `/libraries/${library}?${search}`) as Media[]; 192 | results.forEach((item) => item.path = `${library}:${item.mediaId}`); 193 | return results; 194 | } 195 | 196 | async banger(tag?: string) { 197 | return this.request("POST", "/queue/banger", { tag }); 198 | } 199 | 200 | async queue(path: string) { 201 | return this.request("POST", "/queue", { path }); 202 | } 203 | 204 | async unqueue(item: QueueItem) { 205 | return this.request("DELETE", "/queue/" + item.itemId); 206 | } 207 | 208 | async skip() { 209 | if (!this.zone.lastPlayedItem) return; 210 | const { itemId } = this.zone.lastPlayedItem; 211 | return this.request("POST", "/queue/skip", { itemId }); 212 | } 213 | 214 | private addStandardListeners() { 215 | const unqueue = (itemId: number) => { 216 | const index = this.zone.queue.findIndex((item) => item.itemId === itemId); 217 | if (index >= 0) { 218 | const [item] = this.zone.queue.splice(index, 1); 219 | this.emit('unqueue', { item }); 220 | } 221 | }; 222 | this.messaging.on('close', (code) => { 223 | const clean = code <= 1001 || code >= 4000; 224 | this.emit('disconnect', { clean }); 225 | }); 226 | this.messaging.messages.on('status', (message: StatusMesage) => { 227 | this.emit('status', { text: message.text }); 228 | }); 229 | this.messaging.messages.on('leave', (message: LeaveMessage) => { 230 | const user = this.zone.getUser(message.userId); 231 | this.zone.users.delete(message.userId); 232 | this.emit('leave', { user }); 233 | }); 234 | this.messaging.messages.on('users', (message: UsersMessage) => { 235 | this.zone.users.clear(); 236 | message.users.forEach((user: UserState) => { 237 | this.zone.users.set(user.userId, user); 238 | }); 239 | this.emit('users', {}); 240 | }); 241 | this.messaging.messages.on('echoes', (message: EchoesMessage) => { 242 | if (message.added) { 243 | message.added.forEach((echo) => this.zone.echoes.set(echo.position!, echo)); 244 | } else if (message.removed) { 245 | message.removed.forEach((coord) => this.zone.echoes.delete(coord)); 246 | } 247 | }); 248 | this.messaging.messages.on('chat', (message: RecvChat) => { 249 | const user = this.zone.getUser(message.userId); 250 | const local = user.userId === this.localUserId; 251 | this.emit('chat', { user, text: message.text, local }); 252 | }); 253 | this.messaging.messages.on('play', (message: PlayMessage) => { 254 | this.zone.lastPlayedItem = message.item; 255 | if (message.item) unqueue(message.item.itemId); 256 | this.emit('play', { message }); 257 | }); 258 | this.messaging.messages.on('queue', (message: QueueMessage) => { 259 | this.zone.queue.push(...message.items); 260 | if (message.items.length === 1) this.emit('queue', { item: message.items[0] }); 261 | }); 262 | this.messaging.messages.on('unqueue', (message: UnqueueMessage) => { 263 | unqueue(message.itemId); 264 | }); 265 | this.messaging.messages.on('user', (message: UserState) => { 266 | const user = this.zone.getUser(message.userId); 267 | const local = user.userId === this.localUserId; 268 | 269 | const prev = { ...user }; 270 | const { userId, ...changes } = message; 271 | 272 | if (local && prev.position && changes.position) delete changes.position; 273 | 274 | Object.assign(user, changes); 275 | 276 | if (!prev.name) { 277 | this.emit('join', { user }); 278 | } else if (prev.name !== user.name) { 279 | this.emit('rename', { user, local, previous: prev.name }); 280 | } 281 | 282 | if (changes.position !== undefined) this.emit('move', { user, local, position: changes.position }); 283 | if (changes.emotes) this.emit('emotes', { user, local, emotes: changes.emotes }); 284 | if (changes.avatar) this.emit('avatar', { user, local, data: changes.avatar }); 285 | if (changes.tags) this.emit('tags', { user, local, tags: changes.tags }); 286 | }); 287 | } 288 | } 289 | 290 | export default ZoneClient; 291 | -------------------------------------------------------------------------------- /src/client/scene.ts: -------------------------------------------------------------------------------- 1 | import ZoneClient from '../common/client'; 2 | import { ZoneState, UserState } from '../common/zone'; 3 | import { randomInt } from '../common/utility'; 4 | import { hslToRgb, eventToElementPixel } from './utility'; 5 | import { EventEmitter } from 'events'; 6 | import { createCanvas, createContext2D, decodeAsciiTexture, encodeTexture } from 'blitsy'; 7 | import { Player } from './player'; 8 | 9 | export const avatarImage = decodeAsciiTexture( 10 | ` 11 | ___XX___ 12 | ___XX___ 13 | ___XX___ 14 | __XXXX__ 15 | _XXXXXX_ 16 | X_XXXX_X 17 | __X__X__ 18 | __X__X__ 19 | `, 20 | 'X', 21 | ); 22 | 23 | const recolorBatchImage = createContext2D(8 * 256, 8); 24 | const recolorBatchColor = createContext2D(8 * 256, 8); 25 | 26 | type BatchRecolorItem = { 27 | canvas: HTMLCanvasElement; 28 | color: string; 29 | callback: (index: number) => void; 30 | } 31 | 32 | function batchRecolor(items: BatchRecolorItem[]) { 33 | recolorBatchColor.clearRect(0, 0, 8 * 256, 8); 34 | recolorBatchImage.clearRect(0, 0, 8 * 256, 8); 35 | recolorBatchColor.globalCompositeOperation = 'source-over'; 36 | 37 | items.forEach(({ canvas, color }, i) => { 38 | recolorBatchColor.fillStyle = color; 39 | recolorBatchColor.fillRect(i * 8, 0, 8, 8); 40 | recolorBatchImage.drawImage(canvas, i * 8, 0); 41 | }); 42 | recolorBatchColor.globalCompositeOperation = 'destination-in'; 43 | recolorBatchColor.drawImage(recolorBatchImage.canvas, 0, 0); 44 | 45 | items.forEach((item, i) => item.callback(i)); 46 | } 47 | 48 | const isVideo = (element: HTMLElement | undefined): element is HTMLVideoElement => element?.nodeName === 'VIDEO'; 49 | const isCanvas = (element: HTMLElement | undefined): element is HTMLCanvasElement => element?.nodeName === 'CANVAS'; 50 | const isImage = (element: HTMLElement | undefined): element is HTMLImageElement => element?.nodeName === 'IMG'; 51 | 52 | function getSize(element: HTMLElement) { 53 | if (isVideo(element)) { 54 | return [element.videoWidth, element.videoHeight]; 55 | } else if (isCanvas(element)) { 56 | return [element.width, element.height]; 57 | } else if (isImage(element)) { 58 | return [element.naturalWidth, element.naturalHeight]; 59 | } else { 60 | return [0, 0]; 61 | } 62 | } 63 | 64 | export class SceneRenderer extends EventEmitter { 65 | mediaElement?: HTMLImageElement | HTMLCanvasElement | HTMLVideoElement; 66 | 67 | render: () => void; 68 | 69 | constructor( 70 | private readonly client: ZoneClient, 71 | private readonly zone: ZoneState, 72 | private readonly getTile: (base64: string | undefined) => CanvasRenderingContext2D, 73 | private readonly connecting: () => boolean, 74 | private readonly getStatus: () => string | undefined, 75 | private readonly player: Player, 76 | ) { 77 | super(); 78 | const renderer = document.getElementById('renderer') as HTMLCanvasElement; 79 | const container = renderer.parentElement!; 80 | const context = renderer.getContext('2d')!; 81 | 82 | const logo = document.createElement('img'); 83 | logo.src = 'zone-logo-small.png'; 84 | 85 | const image = document.createElement('img'); 86 | image.src = 'mockup-small.png'; 87 | image.addEventListener('load', resize); 88 | 89 | let scale = 1; 90 | let margin = 0; 91 | 92 | let logox = 0; 93 | let logoy = 0; 94 | let vx = 0.3; 95 | let vy = 0.3; 96 | 97 | const screenWidth = 14 * 8; 98 | const screenHeight = 8 * 8; 99 | 100 | function animateLogo() { 101 | const width = logo.naturalWidth; 102 | const height = logo.naturalHeight; 103 | 104 | if (logox + width + vx > screenWidth || logox + vx < 0) vx *= -1; 105 | 106 | if (logoy + height + vy > screenHeight || logoy + vy < 0) vy *= -1; 107 | 108 | logox += vx; 109 | logoy += vy; 110 | } 111 | 112 | let subtitles: string[] = []; 113 | player.on('subtitles', (lines) => { 114 | subtitles = lines.slice().join('\n').split('\n').reverse(); 115 | }); 116 | 117 | this.render = () => { 118 | const inset = margin; 119 | context.fillStyle = connecting() ? 'red' : 'black'; 120 | context.fillRect(0, 0, renderer.width, renderer.height); 121 | context.drawImage(image, inset, inset, 128 * scale, 128 * scale); 122 | 123 | if (!this.mediaElement) { 124 | animateLogo(); 125 | context.drawImage( 126 | logo, 127 | inset + 8 * scale + logox * scale, 128 | inset + 8 * scale + (logoy + 3) * scale, 129 | logo.naturalWidth * scale, 130 | logo.naturalHeight * scale, 131 | ); 132 | } else { 133 | const [width, height] = getSize(this.mediaElement); 134 | 135 | const ws = (screenWidth * scale) / width; 136 | const hs = (screenHeight * scale) / height; 137 | const s = Math.min(ws, hs); 138 | 139 | const rw = Math.floor(width * s); 140 | const rh = Math.floor(height * s); 141 | 142 | const ox = (8 + 0) * scale + Math.floor((screenWidth * scale - rw) / 2); 143 | const oy = (8 + 3) * scale + Math.floor((screenHeight * scale - rh) / 2); 144 | 145 | const state = getStatus(); 146 | if (state) { 147 | context.font = `${4 * scale}px ascii_small_simple`; 148 | context.fillStyle = 'gray'; 149 | context.fillText( 150 | state, 151 | margin + (8 + 1) * scale, 152 | margin + (8 + 3) * scale + (screenHeight - 1) * scale, 153 | ); 154 | } 155 | 156 | context.save(); 157 | try { 158 | context.globalCompositeOperation = 'source-over'; 159 | context.drawImage(this.mediaElement, inset + ox, inset + oy, rw, rh); 160 | } catch (error) { 161 | console.log("ERROR DRAWING VIDEO:", error); 162 | } 163 | context.restore(); 164 | 165 | context.save(); 166 | context.globalCompositeOperation = 'source-over' 167 | context.font = `${2 * scale}px ascii_small_simple`; 168 | context.textAlign = 'center'; 169 | context.fillStyle = 'black'; 170 | subtitles.forEach((line, i) => { 171 | if (line.trim().length === 0) return; 172 | 173 | const measure = context.measureText(line); 174 | const tx = margin + (8 + 1) * scale + screenWidth * scale * 0.5; 175 | const ty = margin + (8 + 3) * scale + (screenHeight - 1) * scale - 2 * scale * i; 176 | 177 | const border = scale * .5; 178 | const xMin = tx - measure.actualBoundingBoxLeft - border; 179 | const xMax = tx + measure.actualBoundingBoxRight + border; 180 | const yMin = ty - measure.actualBoundingBoxAscent - border; 181 | const yMax = ty - measure.actualBoundingBoxDescent + border; 182 | 183 | context.fillRect(xMin, yMin, xMax - xMin, yMax - yMin); 184 | }); 185 | context.fillStyle = 'gray'; 186 | subtitles.forEach((line, i) => { 187 | if (line.trim().length === 0) return; 188 | 189 | const tx = margin + (8 + 1) * scale + screenWidth * scale * 0.5; 190 | const ty = margin + (8 + 3) * scale + (screenHeight - 1) * scale - 2 * scale * i; 191 | context.fillText(line, tx, ty); 192 | }); 193 | context.restore(); 194 | } 195 | 196 | context.save(); 197 | context.translate(margin, margin); 198 | context.scale(scale, scale); 199 | 200 | const prepareAvatar = (user: UserState, ghost: boolean) => { 201 | if (!user.position) return; 202 | 203 | let [r, g, b] = [255, 255, 255]; 204 | 205 | if (user.emotes && user.emotes.includes('rbw')) { 206 | const h = Math.abs(Math.sin(performance.now() / 600 - user.position![0] / 8)); 207 | [r, g, b] = hslToRgb(h, 1, 0.5); 208 | r = Math.round(r); 209 | g = Math.round(g); 210 | b = Math.round(b); 211 | } 212 | 213 | pairs.push({ 214 | canvas: this.getTile(user.avatar).canvas, 215 | color: `rgb(${r} ${g} ${b})`, 216 | callback: (i) => drawAvatar(user, ghost, i), 217 | }); 218 | } 219 | 220 | const pairs: BatchRecolorItem[] = []; 221 | this.zone.users.forEach((user) => prepareAvatar(user, false)); 222 | this.zone.echoes.forEach((echo) => prepareAvatar(echo, true)); 223 | batchRecolor(pairs); 224 | 225 | context.restore(); 226 | }; 227 | 228 | const drawAvatar = (user: UserState, echo: boolean, index: number) => { 229 | if (!user.position) return; 230 | 231 | const [x, y, z] = user.position; 232 | if (x < 0 || z < 0 || x > 15 || z > 15) return; 233 | 234 | let [dy, dx] = [0, 0]; 235 | if (user.emotes && user.emotes.includes('shk')) { 236 | dy += randomInt(-2, 2); 237 | dx += randomInt(-2, 2); 238 | } 239 | 240 | if (user.emotes && user.emotes.includes('wvy')) { 241 | dy -= 1 + Math.sin(performance.now() / 250 - x / 2) * 1; 242 | } 243 | 244 | const spin = user.emotes && user.emotes.includes('spn'); 245 | 246 | context.save(); 247 | context.globalAlpha = echo ? 0.5 : 1; 248 | context.translate(x * 8 + dx + 4, z * 8 + dy); 249 | if (spin) { 250 | const da = performance.now() / 150 - x; 251 | const sx = Math.cos(da); 252 | context.scale(sx, 1); 253 | } 254 | 255 | context.drawImage( 256 | recolorBatchColor.canvas, 257 | index * 8, 0, 8, 8, 258 | -4, 0, 8, 8, 259 | ); 260 | context.restore(); 261 | }; 262 | 263 | function resize() { 264 | const width = container.clientWidth; 265 | const height = container.clientHeight; 266 | 267 | const inner = Math.min(width, height); 268 | const outer = Math.max(width, height); 269 | const natural = image.naturalWidth; 270 | 271 | scale = Math.max(1, Math.floor(inner / natural)); 272 | const frame = natural * scale; 273 | margin = Math.ceil((outer - frame) / 2); 274 | 275 | renderer.width = frame + margin * 2; 276 | renderer.height = frame + margin * 2; 277 | context.imageSmoothingEnabled = false; 278 | } 279 | 280 | window.addEventListener('resize', resize); 281 | resize(); 282 | 283 | function mouseEventToTile(event: MouseEvent | Touch) { 284 | const [mx, my] = eventToElementPixel(event, renderer); 285 | const [sx, sy] = [mx - margin, my - margin]; 286 | const inv = 1 / (8 * scale); 287 | const [tx, ty] = [Math.floor(sx * inv), Math.floor(sy * inv)]; 288 | 289 | return [tx, ty]; 290 | } 291 | 292 | renderer.addEventListener('click', (event) => { 293 | this.emit('click', event, mouseEventToTile(event)); 294 | }); 295 | 296 | renderer.addEventListener('mousemove', (event) => { 297 | this.emit('hover', event, mouseEventToTile(event)); 298 | }); 299 | 300 | renderer.addEventListener('mouseleave', (event) => { 301 | this.emit('unhover', event); 302 | }); 303 | 304 | renderer.addEventListener('touchstart', (event) => { 305 | this.emit('click', event, mouseEventToTile(event.touches[0])); 306 | }); 307 | 308 | let touchedTile: number[] | undefined; 309 | renderer.addEventListener('touchmove', (event) => { 310 | const [nx, ny] = mouseEventToTile(event.touches[0]); 311 | 312 | if (!touchedTile || touchedTile[0] !== nx || touchedTile[1] !== ny) { 313 | touchedTile = [nx, ny]; 314 | this.emit('click', event, [nx, ny]); 315 | } 316 | }); 317 | 318 | renderer.addEventListener('touchend', (event) => { 319 | touchedTile = undefined; 320 | }); 321 | } 322 | } 323 | -------------------------------------------------------------------------------- /src/client/text.ts: -------------------------------------------------------------------------------- 1 | import { Vector2, Sprite, Font, makeVector2, createContext2D, drawSprite, makeSprite, makeRect } from 'blitsy'; 2 | import { addListener } from 'process'; 3 | import { num2hex } from './utility'; 4 | 5 | const FALLBACK_CODEPOINT = '?'.codePointAt(0)!; 6 | 7 | const REMAPPING: {[codepoint: number]: number} = { 8 | 0x0000: 0, 9 | 0x263A: 1, 10 | 0x263B: 2, 11 | 0x2665: 3, 12 | 0x2666: 4, 13 | 0x2663: 5, 14 | 0x2660: 6, 15 | 0x2022: 7, 16 | 0x25D8: 8, 17 | 0x25CB: 9, 18 | 0x25D9: 10, 19 | 0x2642: 11, 20 | 0x2640: 12, 21 | 0x266A: 13, 22 | 0x266B: 14, 23 | 0x263C: 15, 24 | 25 | 0x25BA: 16, 26 | 0x25C4: 17, 27 | 0x2195: 18, 28 | 0x203C: 19, 29 | 0x00B6: 20, 30 | 0x00A7: 21, 31 | 0x25AC: 22, 32 | 0x21A8: 23, 33 | 0x2191: 24, 34 | 0x2193: 25, 35 | 0x2192: 26, 36 | 0x2190: 27, 37 | 0x221F: 28, 38 | 0x2194: 29, 39 | 0x25B2: 30, 40 | 0x25BC: 31, 41 | 42 | 0x0020: 32, 43 | 0x0021: 33, 44 | 0x0022: 34, 45 | 0x0023: 35, 46 | 0x0024: 36, 47 | 0x0025: 37, 48 | 0x0026: 38, 49 | 0x0027: 39, 50 | 0x0028: 40, 51 | 0x0029: 41, 52 | 0x002A: 42, 53 | 0x002B: 43, 54 | 0x002C: 44, 55 | 0x002D: 45, 56 | 0x002E: 46, 57 | 0x002F: 47, 58 | 59 | 0x0030: 48, 60 | 0x0031: 49, 61 | 0x0032: 50, 62 | 0x0033: 51, 63 | 0x0034: 52, 64 | 0x0035: 53, 65 | 0x0036: 54, 66 | 0x0037: 55, 67 | 0x0038: 56, 68 | 0x0039: 57, 69 | 0x003A: 58, 70 | 0x003B: 59, 71 | 0x003C: 60, 72 | 0x003D: 61, 73 | 0x003E: 62, 74 | 0x003F: 63, 75 | 76 | 0x0040: 64, 77 | 0x0041: 65, 78 | 0x0042: 66, 79 | 0x0043: 67, 80 | 0x0044: 68, 81 | 0x0045: 69, 82 | 0x0046: 70, 83 | 0x0047: 71, 84 | 0x0048: 72, 85 | 0x0049: 73, 86 | 0x004A: 74, 87 | 0x004B: 75, 88 | 0x004C: 76, 89 | 0x004D: 77, 90 | 0x004E: 78, 91 | 0x004F: 79, 92 | 93 | 0x0050: 80, 94 | 0x0051: 81, 95 | 0x0052: 82, 96 | 0x0053: 83, 97 | 0x0054: 84, 98 | 0x0055: 85, 99 | 0x0056: 86, 100 | 0x0057: 87, 101 | 0x0058: 88, 102 | 0x0059: 89, 103 | 0x005A: 90, 104 | 0x005B: 91, 105 | 0x005C: 92, 106 | 0x005D: 93, 107 | 0x005E: 94, 108 | 0x005F: 95, 109 | 110 | 0x0060: 96, 111 | 0x0061: 97, 112 | 0x0062: 98, 113 | 0x0063: 99, 114 | 0x0064: 100, 115 | 0x0065: 101, 116 | 0x0066: 102, 117 | 0x0067: 103, 118 | 0x0068: 104, 119 | 0x0069: 105, 120 | 0x006A: 106, 121 | 0x006B: 107, 122 | 0x006C: 108, 123 | 0x006D: 109, 124 | 0x006E: 110, 125 | 0x006F: 111, 126 | 127 | 0x0070: 112, 128 | 0x0071: 113, 129 | 0x0072: 114, 130 | 0x0073: 115, 131 | 0x0074: 116, 132 | 0x0075: 117, 133 | 0x0076: 118, 134 | 0x0077: 119, 135 | 0x0078: 120, 136 | 0x0079: 121, 137 | 0x007A: 122, 138 | 0x007B: 123, 139 | 0x007C: 124, 140 | 0x007D: 125, 141 | 0x007E: 126, 142 | 0x2302: 127, 143 | 144 | 0x00C7: 128, 145 | 0x00FC: 129, 146 | 0x00E9: 130, 147 | 0x00E2: 131, 148 | 0x00E4: 132, 149 | 0x00E0: 133, 150 | 0x00E5: 134, 151 | 0x00E7: 135, 152 | 0x00EA: 136, 153 | 0x00EB: 137, 154 | 0x00E8: 138, 155 | 0x00EF: 139, 156 | 0x00EE: 140, 157 | 0x00EC: 141, 158 | 0x00C4: 142, 159 | 0x00C5: 143, 160 | 161 | 0x00C9: 144, 162 | 0x00E6: 145, 163 | 0x00C6: 146, 164 | 0x00F4: 147, 165 | 0x00F6: 148, 166 | 0x00F2: 149, 167 | 0x00FB: 150, 168 | 0x00F9: 151, 169 | 0x00FF: 152, 170 | 0x00D6: 153, 171 | 0x00DC: 154, 172 | 0x00A2: 155, 173 | 0x00A3: 156, 174 | 0x00A5: 157, 175 | 0x20A7: 158, 176 | 0x0192: 159, 177 | 178 | 0x00E1: 160, 179 | 0x00ED: 161, 180 | 0x00F3: 162, 181 | 0x00FA: 163, 182 | 0x00F1: 164, 183 | 0x00D1: 165, 184 | 0x00AA: 166, 185 | 0x00BA: 167, 186 | 0x00BF: 168, 187 | 0x2310: 169, 188 | 0x00AC: 170, 189 | 0x00BD: 171, 190 | 0x00BC: 172, 191 | 0x00A1: 173, 192 | 0x00AB: 174, 193 | 0x00BB: 175, 194 | 195 | 0x2591: 176, 196 | 0x2592: 177, 197 | 0x2593: 178, 198 | 0x2502: 179, 199 | 0x2524: 180, 200 | 0x2561: 181, 201 | 0x2562: 182, 202 | 0x2556: 183, 203 | 0x2555: 184, 204 | 0x2563: 185, 205 | 0x2551: 186, 206 | 0x2557: 187, 207 | 0x255D: 188, 208 | 0x255C: 189, 209 | 0x255B: 190, 210 | 0x2510: 191, 211 | 212 | 0x2514: 192, 213 | 0x2534: 193, 214 | 0x252C: 194, 215 | 0x251C: 195, 216 | 0x2500: 196, 217 | 0x253C: 197, 218 | 0x255E: 198, 219 | 0x255F: 199, 220 | 0x255A: 200, 221 | 0x2554: 201, 222 | 0x2569: 202, 223 | 0x2566: 203, 224 | 0x2560: 204, 225 | 0x2550: 205, 226 | 0x256C: 206, 227 | 0x2567: 207, 228 | 229 | 0x2568: 208, 230 | 0x2564: 209, 231 | 0x2565: 210, 232 | 0x2559: 211, 233 | 0x2558: 212, 234 | 0x2552: 213, 235 | 0x2553: 214, 236 | 0x256B: 215, 237 | 0x256A: 216, 238 | 0x2518: 217, 239 | 0x250C: 218, 240 | 0x2588: 219, 241 | 0x2584: 220, 242 | 0x258C: 221, 243 | 0x2590: 222, 244 | 0x2580: 223, 245 | 246 | 0x03B1: 224, 247 | 0x00DF: 225, 248 | 0x0393: 226, 249 | 0x03C0: 227, 250 | 0x03A3: 228, 251 | 0x03C3: 229, 252 | 0x00B5: 230, 253 | 0x03C4: 231, 254 | 0x03A6: 232, 255 | 0x0398: 233, 256 | 0x03A9: 234, 257 | 0x03B4: 235, 258 | 0x221E: 236, 259 | 0x03C6: 237, 260 | 0x03B5: 238, 261 | 0x2229: 239, 262 | 263 | 0x2261: 240, 264 | 0x00B1: 241, 265 | 0x2265: 242, 266 | 0x2264: 243, 267 | 0x2320: 244, 268 | 0x2321: 245, 269 | 0x00F7: 246, 270 | 0x2248: 247, 271 | 0x00B0: 248, 272 | 0x2219: 249, 273 | 0x00B7: 250, 274 | 0x221A: 251, 275 | 0x207F: 252, 276 | 0x00B2: 253, 277 | 0x25A0: 254, 278 | 0x00A0: 255, 279 | } 280 | 281 | export type Page = Glyph[]; 282 | 283 | export interface Glyph { 284 | position: Vector2; 285 | sprite: Sprite; 286 | color: number; 287 | offset: Vector2; 288 | hidden: boolean; 289 | styles: Map; 290 | } 291 | 292 | export function computeLineWidth(font: Font, line: string): number { 293 | let width = 0; 294 | for (const char of line) { 295 | const code = char.codePointAt(0)!; 296 | const fontchar = font.characters.get(code); 297 | if (fontchar) { 298 | width += fontchar.spacing; 299 | } 300 | } 301 | return width; 302 | } 303 | 304 | export function makeGlyph( 305 | position: Vector2, 306 | sprite: Sprite, 307 | color = 0xffffff, 308 | offset = makeVector2(0, 0), 309 | hidden = true, 310 | styles = new Map(), 311 | ): Glyph { 312 | return { position, sprite, color, offset, hidden, styles }; 313 | } 314 | 315 | // TODO: the only reason this is a class rn is it needs those two canvases for 316 | // blending properly... 317 | export class PageRenderer { 318 | public readonly pageImage: CanvasImageSource; 319 | private readonly pageContext: CanvasRenderingContext2D; 320 | private readonly bufferContext: CanvasRenderingContext2D; 321 | 322 | constructor(private readonly width: number, private readonly height: number) { 323 | this.pageContext = createContext2D(width, height); 324 | this.bufferContext = createContext2D(width, height); 325 | this.pageImage = this.pageContext.canvas; 326 | } 327 | 328 | /** 329 | * Render a page of glyphs to the pageImage, offset by (px, py). 330 | * @param page glyphs to be rendered. 331 | * @param px horizontal offset in pixels. 332 | * @param py verticle offest in pixels. 333 | */ 334 | public renderPage(page: Page, px: number, py: number): void { 335 | this.pageContext.clearRect(0, 0, this.width, this.height); 336 | this.bufferContext.clearRect(0, 0, this.width, this.height); 337 | 338 | for (const glyph of page) { 339 | if (glyph.hidden) continue; 340 | 341 | // padding + position + offset 342 | const dx = px + glyph.position.x + glyph.offset.x; 343 | const dy = py + glyph.position.y + glyph.offset.y; 344 | 345 | // draw tint layer 346 | this.pageContext.fillStyle = num2hex(glyph.color); 347 | this.pageContext.fillRect(dx, dy, glyph.sprite.rect.w, glyph.sprite.rect.h); 348 | 349 | // draw text layer 350 | drawSprite(this.bufferContext, glyph.sprite, dx, dy); 351 | } 352 | 353 | // draw text layer in tint color 354 | this.pageContext.globalCompositeOperation = 'destination-in'; 355 | this.pageContext.drawImage(this.bufferContext.canvas, 0, 0); 356 | this.pageContext.globalCompositeOperation = 'source-over'; 357 | } 358 | } 359 | 360 | export function scriptToPages(script: string, context: LayoutContext, styleHandler = defaultStyleHandler) { 361 | const tokens = tokeniseScript(script); 362 | const commands = tokensToCommands(tokens); 363 | return commandsToPages(commands, context, styleHandler); 364 | } 365 | 366 | export type Token = [TokenType, ...string[]]; 367 | export type TokenType = 'text' | 'markup'; 368 | 369 | export type Command = GlyphCommand | BreakCommand | StyleCommand | IconCommand; 370 | export type CommandType = 'glyph' | 'break' | 'style' | 'icon'; 371 | 372 | export type GlyphCommand = { type: 'glyph'; char: string; breakable: boolean }; 373 | export type BreakCommand = { type: 'break'; target: BreakTarget }; 374 | export type StyleCommand = { type: 'style'; style: string }; 375 | export type IconCommand = { type: 'icon'; icon: HTMLCanvasElement }; 376 | export type BreakTarget = 'line' | 'page'; 377 | 378 | export type LayoutContext = { font: Font; lineWidth: number; lineCount: number }; 379 | export type StyleHandler = (styles: Map, style: string) => void; 380 | 381 | export type ArrayPredicate = (element: T, index: number) => boolean; 382 | 383 | function find(array: T[], start: number, step: number, predicate: ArrayPredicate): [T, number] | undefined { 384 | for (let i = start; 0 <= i && i < array.length; i += step) { 385 | if (predicate(array[i], i)) { 386 | return [array[i], i]; 387 | } 388 | } 389 | } 390 | 391 | /** 392 | * Segment the given array into contiguous runs of elements that are not 393 | * considered breakable. 394 | */ 395 | export function filterToSpans(array: T[], breakable: ArrayPredicate): T[][] { 396 | const spans: T[][] = []; 397 | let buffer: T[] = []; 398 | 399 | array.forEach((element, index) => { 400 | if (!breakable(element, index)) { 401 | buffer.push(element); 402 | } else if (buffer.length > 0) { 403 | spans.push(buffer); 404 | buffer = []; 405 | } 406 | }); 407 | 408 | if (buffer.length > 0) { 409 | spans.push(buffer); 410 | } 411 | 412 | return spans; 413 | } 414 | 415 | export const defaultStyleHandler: StyleHandler = (styles, style) => { 416 | if (style.substr(0, 1) === '+') { 417 | styles.set(style.substring(1), true); 418 | } else if (style.substr(0, 1) === '-') { 419 | styles.delete(style.substring(1)); 420 | } else if (style.includes('=')) { 421 | const [key, val] = style.split(/\s*=\s*/); 422 | styles.set(key, val); 423 | } 424 | }; 425 | 426 | export function commandsToPages( 427 | commands: Command[], 428 | layout: LayoutContext, 429 | styleHandler: StyleHandler = defaultStyleHandler, 430 | ): Page[] { 431 | commandsBreakLongSpans(commands, layout); 432 | 433 | const styles = new Map(); 434 | const pages: Page[] = []; 435 | let page: Page = []; 436 | let currLine = 0; 437 | 438 | function newPage() { 439 | pages.push(page); 440 | page = []; 441 | currLine = 0; 442 | } 443 | 444 | function endPage() { 445 | do { 446 | endLine(); 447 | } while (currLine % layout.lineCount !== 0); 448 | } 449 | 450 | function endLine() { 451 | currLine += 1; 452 | if (currLine === layout.lineCount) newPage(); 453 | } 454 | 455 | function doBreak(target: BreakTarget) { 456 | if (target === 'line') endLine(); 457 | else if (target === 'page') endPage(); 458 | } 459 | 460 | function findNextBreakIndex(): number | undefined { 461 | let width = 0; 462 | 463 | const isBreakableGlyph = (command: Command) => command.type === 'glyph' && command.breakable; 464 | 465 | for (let i = 0; i < commands.length; ++i) { 466 | const command = commands[i]; 467 | if (command.type === 'break') return i; 468 | if (command.type === 'style') continue; 469 | 470 | const size = command.type === 'icon' 471 | ? command.icon.width 472 | : computeLineWidth(layout.font, command.char); 473 | 474 | width += size; 475 | // if we overshot, look backward for last possible breakable glyph 476 | if (width > layout.lineWidth) { 477 | const result = find(commands, i, -1, isBreakableGlyph); 478 | 479 | if (result) return result[1]; 480 | } 481 | } 482 | } 483 | 484 | function addGlyph(command: GlyphCommand, offset: number): number { 485 | let codepoint = command.char.codePointAt(0)!; 486 | if (REMAPPING[codepoint] !== undefined) codepoint = REMAPPING[codepoint]; 487 | 488 | const char = layout.font.characters.get(codepoint) || layout.font.characters.get(FALLBACK_CODEPOINT)!; 489 | const pos = makeVector2(offset, currLine * (layout.font.lineHeight + 4)); 490 | const glyph = makeGlyph(pos, char.sprite); 491 | 492 | glyph.styles = new Map(styles.entries()); 493 | 494 | page.push(glyph); 495 | return char.spacing; 496 | } 497 | 498 | function addIcon(command: IconCommand, offset: number): number { 499 | const pos = makeVector2(offset+1, currLine * (layout.font.lineHeight + 4)); 500 | const glyph = makeGlyph(pos, makeSprite(command.icon, makeRect(0, 0, 8, 8))); 501 | glyph.styles = new Map(styles.entries()); 502 | 503 | page.push(glyph); 504 | return command.icon.width+1; 505 | } 506 | 507 | // tslint:disable-next-line:no-shadowed-variable 508 | function generateGlyphLine(commands: Command[]) { 509 | let offset = 0; 510 | for (const command of commands) { 511 | if (command.type === 'glyph') { 512 | offset += addGlyph(command, offset); 513 | } else if (command.type === 'icon') { 514 | offset += addIcon(command, offset); 515 | } else if (command.type === 'style') { 516 | styleHandler(styles, command.style); 517 | } 518 | } 519 | } 520 | 521 | let index: number | undefined; 522 | 523 | // tslint:disable-next-line:no-conditional-assignment 524 | while ((index = findNextBreakIndex()) !== undefined) { 525 | generateGlyphLine(commands.slice(0, index)); 526 | commands = commands.slice(index); 527 | 528 | const command = commands[0]; 529 | if (command.type === 'break') { 530 | doBreak(command.target); 531 | commands.shift(); 532 | } else { 533 | if (command.type === 'glyph' && command.char === ' ') { 534 | commands.shift(); 535 | } 536 | endLine(); 537 | } 538 | } 539 | 540 | generateGlyphLine(commands); 541 | endPage(); 542 | 543 | return pages; 544 | } 545 | 546 | /** 547 | * Find spans of unbreakable commands that are too long to fit within a page 548 | * width and amend those spans so that breaking permitted in all positions. 549 | */ 550 | export function commandsBreakLongSpans(commands: Command[], context: LayoutContext): void { 551 | const canBreak = (command: Command) => command.type === 'break' || (command.type === 'glyph' && command.breakable); 552 | 553 | const spans = filterToSpans(commands, canBreak); 554 | 555 | for (const span of spans) { 556 | const glyphs = span.filter((command) => command.type === 'glyph') as GlyphCommand[]; 557 | const charWidths = glyphs.map((command) => computeLineWidth(context.font, command.char)); 558 | const spanWidth = charWidths.reduce((x, y) => x + y, 0); 559 | 560 | if (spanWidth > context.lineWidth) { 561 | for (const command of glyphs) command.breakable = true; 562 | } 563 | } 564 | } 565 | 566 | export const icons = new Map(); 567 | 568 | export function tokensToCommands(tokens: Token[]): Command[] { 569 | const commands: Command[] = []; 570 | 571 | function handleToken([type, buffer]: Token) { 572 | if (type === 'text') handleText(buffer); 573 | else if (type === 'markup') handleMarkup(buffer); 574 | } 575 | 576 | function handleText(buffer: string) { 577 | for (const char of buffer) { 578 | const breakable = char === ' '; 579 | commands.push({ type: 'glyph', char, breakable }); 580 | } 581 | } 582 | 583 | function handleMarkup(buffer: string) { 584 | if (buffer === 'ep') commands.push({ type: 'break', target: 'page' }); 585 | else if (buffer === 'el') commands.push({ type: 'break', target: 'line' }); 586 | else if (buffer.startsWith('icon:')) { 587 | const id = buffer.split(':')[1]; 588 | const icon = icons.get(id) || createContext2D(32, 4).canvas; 589 | commands.push({ type: 'icon', icon }); 590 | } else commands.push({ type: 'style', style: buffer }); 591 | } 592 | 593 | tokens.forEach(handleToken); 594 | 595 | return commands; 596 | } 597 | 598 | export function tokeniseScript(script: string): Token[] { 599 | const tokens: Token[] = []; 600 | let buffer = ''; 601 | let braceDepth = 0; 602 | 603 | function openBrace() { 604 | if (braceDepth === 0) flushBuffer(); 605 | braceDepth += 1; 606 | } 607 | 608 | function closeBrace() { 609 | if (braceDepth === 1) flushBuffer(); 610 | braceDepth -= 1; 611 | } 612 | 613 | function newLine() { 614 | flushBuffer(); 615 | tokens.push(['markup', 'el']); 616 | } 617 | 618 | function flushBuffer() { 619 | if (buffer.length === 0) return; 620 | const type = braceDepth > 0 ? 'markup' : 'text'; 621 | tokens.push([type, buffer]); 622 | buffer = ''; 623 | } 624 | 625 | const actions: { [char: string]: () => void } = { 626 | '{': openBrace, 627 | '}': closeBrace, 628 | '\n': newLine, 629 | }; 630 | 631 | for (const char of script) { 632 | if (char in actions) actions[char](); 633 | else buffer += char; 634 | } 635 | 636 | flushBuffer(); 637 | 638 | return tokens; 639 | } 640 | 641 | export function getPageHeight(page: Page, font: Font) { 642 | if (page.length === 0) return 0; 643 | 644 | let ymin = page[0].position.y; 645 | let ymax = ymin; 646 | 647 | page.forEach((char) => { 648 | ymin = Math.min(ymin, char.position.y); 649 | ymax = Math.max(ymax, char.position.y); 650 | }); 651 | 652 | ymax += font.lineHeight + 4; 653 | 654 | return ymax - ymin; 655 | } 656 | -------------------------------------------------------------------------------- /src/server/server.ts: -------------------------------------------------------------------------------- 1 | import * as expressWs from 'express-ws'; 2 | import * as low from 'lowdb'; 3 | 4 | import Playback from './playback'; 5 | import { ZoneState, UserId, UserState, Media, QueueItem, UserEcho } from './zone'; 6 | import { nanoid } from 'nanoid'; 7 | import { json, NextFunction, Request, Response } from 'express'; 8 | import { Library, libraryToQueueableMedia } from './libraries'; 9 | import { URL } from 'url'; 10 | import Joi = require('joi'); 11 | import WebSocket = require('ws'); 12 | import { performance } from 'perf_hooks'; 13 | 14 | const SECONDS = 1000; 15 | 16 | declare global { 17 | namespace Express { 18 | interface Request { 19 | user?: UserState; 20 | ticket?: Ticket; 21 | } 22 | } 23 | } 24 | 25 | export type Ticket = { name: string, avatar: string, token: string, userId: string }; 26 | 27 | export type HostOptions = { 28 | pingInterval: number; 29 | nameLengthLimit: number; 30 | chatLengthLimit: number; 31 | 32 | perUserQueueLimit: number; 33 | voteSkipThreshold: number; 34 | 35 | authPassword?: string; 36 | 37 | queueCheckInterval: number; 38 | libraries: Map; 39 | }; 40 | 41 | export const DEFAULT_OPTIONS: HostOptions = { 42 | pingInterval: 5 * SECONDS, 43 | nameLengthLimit: 16, 44 | chatLengthLimit: 160, 45 | 46 | perUserQueueLimit: 3, 47 | voteSkipThreshold: 0.6, 48 | 49 | queueCheckInterval: 5 * SECONDS, 50 | libraries: new Map(), 51 | }; 52 | 53 | const bans = new Map(); 54 | 55 | interface Ban { 56 | ip: unknown; 57 | bannee: string; 58 | banner: string; 59 | reason?: string; 60 | date: string; 61 | } 62 | 63 | function timeToSeconds(time: string) { 64 | let seconds = 0; 65 | const parts = time.split(":").reverse(); 66 | seconds = parseInt(parts.shift() ?? "0", 10); 67 | seconds += parseInt(parts.shift() ?? "0", 10) * 60; 68 | seconds += parseInt(parts.shift() ?? "0", 10) * 60 * 60; 69 | return seconds; 70 | } 71 | 72 | export function host( 73 | xws: expressWs.Instance, 74 | adapter: low.AdapterSync, 75 | options: Partial = {}, 76 | ) { 77 | const opts = Object.assign({}, DEFAULT_OPTIONS, options); 78 | 79 | const db = low(adapter); 80 | db.defaults({ 81 | playback: { current: undefined, queue: [], time: 0 }, 82 | bans: [], 83 | echoes: [], 84 | }).write(); 85 | 86 | function pingAll() { 87 | xws.getWss().clients.forEach((ws) => ws.ping(undefined, undefined, () => {})); 88 | } 89 | 90 | setInterval(pingAll, opts.pingInterval); 91 | setInterval(checkQueue, opts.queueCheckInterval); 92 | 93 | async function checkQueue() { 94 | const checks = playback.queue.map(async (item) => { 95 | const library = opts.libraries.get(item.media.library || ""); 96 | 97 | if (library) { 98 | const mediaStatus = await library.getStatus(item.media.mediaId); 99 | 100 | if (mediaStatus === 'failed') { 101 | playback.unqueue(item); 102 | console.log("FAILED", library.prefix, item.media.mediaId, mediaStatus); 103 | status(`failed to load "${item.media.title}" (${mediaStatus})`); 104 | } else if (mediaStatus === 'none') { 105 | library.request(item.media.mediaId); 106 | } 107 | } 108 | }); 109 | return Promise.all(checks); 110 | } 111 | 112 | function addUserToken(user: UserState, token: string) { 113 | tokenToUser.set(token, user); 114 | userToToken.set(user, token); 115 | } 116 | 117 | function revokeUserToken(user: UserState) { 118 | const token = userToToken.get(user); 119 | if (token) tokenToUser.delete(token); 120 | userToToken.delete(user); 121 | } 122 | 123 | let lastUserId = 0; 124 | const tokenToUser = new Map(); 125 | const userToToken = new Map(); 126 | const userToIp = new Map(); 127 | const sockets = new Map(); 128 | 129 | const zone = new ZoneState(); 130 | const playback = new Playback(opts.libraries); 131 | 132 | let eventMode = false; 133 | 134 | function requireNotBanned( 135 | request: Request, 136 | response: Response, 137 | next: NextFunction, 138 | ) { 139 | if (bans.has(request.ip)) { 140 | response.status(403).send("you are banned"); 141 | } else { 142 | next(); 143 | } 144 | } 145 | 146 | function requireUserToken( 147 | request: Request, 148 | response: Response, 149 | next: NextFunction, 150 | ) { 151 | const auth = request.headers.authorization || ""; 152 | const token = auth.startsWith("Bearer ") ? auth.substr(7) : ""; 153 | request.user = tokenToUser.get(token); 154 | 155 | if (request.user) { 156 | next(); 157 | } else { 158 | response.status(401).send("invalid user"); 159 | } 160 | } 161 | 162 | const tickets = new Map(); 163 | 164 | xws.app.use(json()); 165 | xws.app.use(requireNotBanned); 166 | xws.app.post('/zone/join', (request, response) => { 167 | const { name, avatar } = request.body; 168 | const ticket = nanoid(); 169 | const token = nanoid(); 170 | const userId = (++lastUserId).toString(); 171 | tickets.set(ticket, { name, avatar, token, userId }); 172 | setTimeout(() => tickets.delete(ticket), 60 * SECONDS); 173 | response.json({ ticket, token, userId }); 174 | }); 175 | 176 | xws.app.param('ticket', (request, response, next, id) => { 177 | request.ticket = tickets.get(id); 178 | 179 | if (request.ticket) { 180 | tickets.delete(id); 181 | next(); 182 | } else { 183 | response.status(404).send(); 184 | } 185 | }); 186 | 187 | xws.app.ws('/zone/:ticket', async (websocket, request) => { 188 | const { name, avatar, token, userId } = request.ticket!; 189 | 190 | const user = zone.addUser(userId); 191 | user.name = name; 192 | user.avatar = avatar; 193 | 194 | sendAll('user', user); 195 | 196 | addUserToken(user, token); 197 | userToIp.set(user, request.ip); 198 | 199 | bindSocketToUser(user, websocket); 200 | 201 | websocket.on('error', (error) => killUser(user)); 202 | websocket.on('close', (code: number) => killUser(user)); 203 | 204 | const users = Array.from(zone.users.values()); 205 | sendOnly('users', { users }, user.userId); 206 | sendOnly('queue', { items: playback.queue }, user.userId); 207 | sendOnly('play', { item: playback.currentItem, time: playback.currentTime }, user.userId); 208 | sendOnly('echoes', { added: Array.from(zone.echoes).map(([, echo]) => echo) }, user.userId); 209 | sendOnly("ready", {}, user.userId); 210 | }); 211 | 212 | xws.app.get('/users', (req, res) => { 213 | const users = Array.from(zone.users.values()); 214 | const names = users.map(({ name, avatar, userId }) => ({ name, avatar, userId })); 215 | res.json(names); 216 | }); 217 | 218 | xws.app.get('/playing', (request, response) => response.json({ item: playback.currentItem, time: playback.currentTime })); 219 | xws.app.get('/queue', (request, response) => response.json(playback.queue)); 220 | xws.app.post('/queue', requireUserToken, async (request, response) => { 221 | try { 222 | const media = await pathToMedia(request.body.path); 223 | tryQueueMedia(request.user!, media); 224 | response.status(202).send(); 225 | } catch (error: any) { 226 | response.status(400).send(error.message); 227 | } 228 | }); 229 | 230 | xws.app.post('/queue/banger', requireUserToken, async (request, response) => { 231 | if (!opts.libraries.has("library")) { 232 | response.status(501).send(); 233 | } else { 234 | const banger = await libraryTagToBanger(request.body.tag); 235 | 236 | if (banger) { 237 | try { 238 | tryQueueMedia(request.user!, banger, true); 239 | response.status(202).send(); 240 | } catch (error: any) { 241 | response.status(403).send(error.message); 242 | } 243 | } else { 244 | response.status(503).send("no matching bangers"); 245 | } 246 | } 247 | }); 248 | 249 | xws.app.post('/queue/skip', requireUserToken, async (request, response) => { 250 | const user = request.user!; 251 | const itemId = request.body.itemId; 252 | 253 | if (!playback.currentItem || playback.currentItem.itemId !== itemId) { 254 | response.status(404).send(`queue item ${itemId} is not playing`); 255 | } else if (!eventMode) { 256 | voteSkip(itemId, user); 257 | response.status(202).send(); 258 | } else if (user.tags.includes('dj')) { 259 | skip(`${user.name} skipped ${playback.currentItem!.media.title}`); 260 | response.status(204).send(); 261 | } else { 262 | response.status(403).send("can't skip during event mode"); 263 | } 264 | }); 265 | 266 | xws.app.delete('/queue/:itemId', requireUserToken, async (request, response) => { 267 | const user = request.user!; 268 | const itemId = parseInt(request.params.itemId, 10); 269 | 270 | const item = playback.queue.find((item) => item.itemId === itemId); 271 | if (!item) { 272 | response.status(404).send(); 273 | } else { 274 | const dj = eventMode && user.tags.includes('dj'); 275 | const own = item.info.userId === user.userId; 276 | const auth = user.tags.includes('admin'); 277 | 278 | if (dj || own || auth) { 279 | playback.unqueue(item); 280 | response.status(204).send(); 281 | } else { 282 | response.status(403).send(); 283 | } 284 | } 285 | }); 286 | 287 | xws.app.post('/echoes', requireUserToken, (request, response) => { 288 | const user = request.user!; 289 | const { text, position } = request.body; 290 | 291 | const admin = !!zone.echoes.get(position)?.tags.includes('admin'); 292 | const valid = !admin || user.tags.includes('admin'); 293 | 294 | if (!valid) { 295 | response.status(403).send("can't remove admin echo"); 296 | } else if (text.length > 0) { 297 | const echo = { ...user, position, text: text.slice(0, 512) }; 298 | zone.echoes.set(position, echo); 299 | sendAll('echoes', { added: [echo] }); 300 | response.status(201).send(); 301 | } else { 302 | zone.echoes.delete(position); 303 | sendAll('echoes', { removed: [position] }); 304 | response.status(201).send(); 305 | } 306 | }); 307 | 308 | xws.app.post('/admin/authorize', requireUserToken, async (request, response) => { 309 | const user = request.user!; 310 | 311 | if (!opts.authPassword) { 312 | response.status(501).send(); 313 | } else if (request.body.password !== opts.authPassword) { 314 | response.status(403).send(); 315 | } else { 316 | if (user.tags.includes('admin')) { 317 | status('you are already authorised', user); 318 | response.status(200).send(); 319 | } else { 320 | user.tags.push('admin'); 321 | sendAll('user', { userId: user.userId, tags: user.tags }); 322 | status('you are now authorised', user); 323 | response.status(200).send(); 324 | } 325 | } 326 | }); 327 | 328 | xws.app.post('/admin/command', requireUserToken, async (request, response) => { 329 | const user = request.user!; 330 | 331 | if (!user.tags.includes('admin')) { 332 | response.status(403).send("you are not authorized"); 333 | } else { 334 | const { name, args } = request.body; 335 | const command = authCommands.get(name); 336 | if (command) { 337 | try { 338 | await command(user, ...args); 339 | response.status(202).send(); 340 | } catch (error) { 341 | response.status(503).send(error); 342 | } 343 | } else { 344 | response.status(501).send(`no command "${name}"`); 345 | } 346 | } 347 | }); 348 | 349 | load(); 350 | 351 | xws.app.get('/libraries', async (request, response) => response.json(Array.from(opts.libraries.keys()))); 352 | xws.app.get('/libraries/:prefix', async (request, response) => { 353 | const prefix = request.params.prefix; 354 | const library = opts.libraries.get(prefix); 355 | const query = new URL(request.url, "http://localhost").search; 356 | 357 | if (library) { 358 | response.json(await library.search(query)); 359 | } else { 360 | response.status(404).send(`no library "${prefix}"`); 361 | } 362 | }); 363 | 364 | async function pathToMedia(path: string) { 365 | const parts = path.split(":"); 366 | const prefix = parts.shift()!; 367 | const mediaId = parts.join(":"); 368 | const library = opts.libraries.get(prefix); 369 | 370 | if (library) { 371 | return libraryToQueueableMedia(library, mediaId); 372 | } else { 373 | throw new Error(`no library "${prefix}"`); 374 | } 375 | } 376 | 377 | const randomInt = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min; 378 | async function libraryTagToBanger(tag: string | undefined) { 379 | const EIGHT_MINUTES = 8 * 60 * SECONDS; 380 | const library = await opts.libraries.get("library")!.search(tag ? "?tag=" + tag : ""); 381 | const extras = library.filter((media: any) => media.duration <= EIGHT_MINUTES); 382 | const banger = extras[randomInt(0, extras.length - 1)]; 383 | 384 | return banger; 385 | } 386 | 387 | playback.on('queue', (item: QueueItem) => sendAll('queue', { items: [item] })); 388 | playback.on('play', (item: QueueItem) => sendAll('play', { item, time: playback.currentTime })); 389 | playback.on('stop', () => sendAll('play', {})); 390 | playback.on('unqueue', ({ itemId }) => sendAll('unqueue', { itemId })); 391 | playback.on('failed', (item: QueueItem) => skip("video failed to load")); 392 | 393 | const skips = new Set(); 394 | playback.on('play', async (item) => skips.clear()); 395 | 396 | function load() { 397 | playback.loadState(db.get('playback').value()); 398 | 399 | const banlist = db.get('bans').value() as Ban[]; 400 | banlist.forEach((ban) => bans.set(ban.ip, ban)); 401 | 402 | zone.echoes.clear(); 403 | const echoes = db.get('echoes').value() as UserEcho[]; 404 | echoes.forEach((echo) => zone.echoes.set(echo.position!, echo)); 405 | } 406 | 407 | function save() { 408 | db.set('playback', playback.copyState()).write(); 409 | db.set('bans', Array.from(bans.values())).write(); 410 | db.set( 411 | 'echoes', 412 | Array.from(zone.echoes).map(([, echo]) => echo), 413 | ).write(); 414 | } 415 | 416 | function killUser(user: UserState) { 417 | sockets.get(user.userId)?.close(4001); 418 | sockets.delete(user.userId); 419 | revokeUserToken(user); 420 | 421 | if (zone.users.delete(user.userId)) { 422 | sendAll('leave', { userId: user.userId }); 423 | } 424 | } 425 | 426 | function voteSkip(itemId: number, user: UserState) { 427 | if (!playback.currentItem || playback.currentItem.itemId !== itemId) return; 428 | 429 | skips.add(user.userId); 430 | const current = skips.size; 431 | const target = Math.ceil(zone.users.size * opts.voteSkipThreshold); 432 | if (current >= target) { 433 | skip(`voted to skip ${playback.currentItem.media.title}`); 434 | } else { 435 | status(`${current} of ${target} votes to skip`); 436 | } 437 | } 438 | 439 | function skip(message?: string) { 440 | if (message) status(message); 441 | playback.skip(); 442 | } 443 | 444 | function ifUser(name: string): Promise { 445 | return new Promise((resolve, reject) => { 446 | const users = Array.from(zone.users.values()); 447 | const user = users.find((user) => user.name === name); 448 | if (user) resolve(user); 449 | else reject(`no user "${name}"`); 450 | }); 451 | } 452 | 453 | function status(text: string, user?: UserState) { 454 | if (user) sendOnly('status', { text }, user.userId); 455 | else sendAll('status', { text }); 456 | } 457 | 458 | function statusAuthed(text: string) { 459 | zone.users.forEach((user) => { 460 | if (user.tags.includes('admin')) status(text, user); 461 | }); 462 | } 463 | 464 | function tryQueueMedia(user: UserState, media: Media, banger = false) { 465 | if (eventMode && !user.tags.includes('dj')) { 466 | throw new Error('only djs may queue during event mode'); 467 | } 468 | 469 | const userIp = userToIp.get(user); 470 | const existing = playback.queue.find((queued) => queued.media.src === media.src)?.media; 471 | const count = playback.queue.filter((item) => item.info.ip === userIp).length; 472 | const dj = eventMode && user.tags.includes('dj'); 473 | 474 | if (existing) { 475 | throw new Error(`'${existing.title}' is already queued`); 476 | } else if (!dj && count >= opts.perUserQueueLimit) { 477 | throw new Error(`you already have ${count} videos in the queue`); 478 | } else if (media.duration > 45 * 60 * 1000 && !user.tags.includes('dj')) { 479 | throw new Error('only djs may queue long videos') 480 | } else { 481 | playback.queueMedia(media, { userId: user.userId, ip: userIp, banger }); 482 | } 483 | } 484 | 485 | const USER_SCHEMA = Joi.object({ 486 | name: Joi.string().min(1).max(32), 487 | avatar: Joi.string().base64(), 488 | emotes: Joi.array().items(Joi.string().valid('shk', 'wvy', 'rbw', 'spn')), 489 | position: Joi.array().ordered(Joi.number().required(), Joi.number().required(), Joi.number().required()), 490 | }); 491 | 492 | const messageHandlers = new Map void>(); 493 | 494 | messageHandlers.set('chat', (sender, chat) => { 495 | const text = chat.text.substring(0, opts.chatLengthLimit); 496 | sendAll('chat', { text, userId: sender.userId }); 497 | }); 498 | 499 | messageHandlers.set('user', (sender, changes: Partial) => { 500 | const { value, error } = USER_SCHEMA.validate(changes); 501 | 502 | if (error) { 503 | sendOnly('reject', { text: error.details[0].message }, sender.userId); 504 | } else { 505 | Object.assign(sender, value); 506 | sendAll('user', { ...value, userId: sender.userId }); 507 | } 508 | }); 509 | 510 | function bindSocketToUser(user: UserState, socket: WebSocket) { 511 | sockets.set(user.userId, socket); 512 | 513 | let lastpong = performance.now(); 514 | socket.on("pong", () => lastpong = performance.now()); 515 | setInterval(() => { 516 | if (performance.now() - lastpong > opts.pingInterval * 5) { 517 | killUser(user); 518 | } 519 | }, opts.pingInterval); 520 | 521 | socket.on("message", (data: string) => { 522 | const { type, ...message } = JSON.parse(data); 523 | const handler = messageHandlers.get(type)!; 524 | handler(user, message); 525 | }); 526 | } 527 | 528 | function sendMessage(websocket: WebSocket, type: string, message: any) { 529 | websocket.send(JSON.stringify({ type, ...message }), () => {}); 530 | } 531 | 532 | function sendAll(type: string, message: any) { 533 | sockets.forEach((connection) => sendMessage(connection, type, message)); 534 | } 535 | 536 | function sendOnly(type: string, message: any, userId: UserId) { 537 | sendMessage(sockets.get(userId)!, type, message); 538 | } 539 | 540 | const authCommands = new Map void>(); 541 | authCommands.set('check', async (admin) => { 542 | const item = playback.queue[0]; 543 | const library = opts.libraries.get(item?.media.library || ""); 544 | 545 | if (library) { 546 | const mediaStatus = await library.getStatus(item.media.mediaId); 547 | const mediaProgress = await library.getProgress(item.media.mediaId); 548 | const perc = (mediaProgress * 100).toFixed(1); 549 | status(`${mediaStatus}, ${perc}% (${item.media.title})`); 550 | } 551 | }); 552 | authCommands.set('ban', (admin, name: string, reason?: string) => 553 | ifUser(name).then((user) => { 554 | const ban: Ban = { 555 | ip: userToIp.get(user)!, 556 | bannee: user.name!, 557 | banner: admin.name!, 558 | reason, 559 | date: JSON.stringify(new Date()), 560 | }; 561 | bans.set(ban.ip, ban); 562 | status(`${user.name} is banned`); 563 | killUser(user); 564 | }) 565 | ); 566 | authCommands.set('skip', () => skip(`admin skipped ${playback.currentItem!.media.title}`)); 567 | authCommands.set('mode', (admin, mode: string) => { 568 | eventMode = mode === 'event'; 569 | status(`event mode: ${eventMode}`); 570 | }); 571 | authCommands.set('dj-add', (admin, name: string) => 572 | ifUser(name).then((user) => { 573 | if (user.tags.includes('dj')) { 574 | status(`${user.name} is already a dj`, admin); 575 | } else { 576 | user.tags.push('dj'); 577 | sendAll('user', { userId: user.userId, tags: user.tags }); 578 | status('you are a dj', user); 579 | statusAuthed(`${user.name} is a dj`); 580 | } 581 | }) 582 | ); 583 | authCommands.set('dj-del', (admin, name: string) => 584 | ifUser(name).then((user) => { 585 | if (!user.tags.includes('dj')) { 586 | status(`${user.name} isn't a dj`, admin); 587 | } else { 588 | user.tags.splice(user.tags.indexOf('dj'), 1); 589 | sendAll('user', { userId: user.userId, tags: user.tags }); 590 | status('no longer a dj', user); 591 | statusAuthed(`${user.name} no longer a dj`); 592 | } 593 | }) 594 | ); 595 | authCommands.set('despawn', (admin, name: string) => 596 | ifUser(name).then((user) => { 597 | if (!user.position) { 598 | status(`${user.name} isn't spawned`, admin); 599 | } else { 600 | user.position = undefined; 601 | sendAll('user', { userId: user.userId, position: null }); 602 | status('you were despawned by an admin', user); 603 | statusAuthed(`${user.name} has been despawned`); 604 | } 605 | }) 606 | ); 607 | authCommands.set('queue-clear', (admin) => { 608 | playback.clear(); 609 | }); 610 | authCommands.set('jump', (admin, time: string) => { 611 | const seconds = timeToSeconds(time); 612 | playback.jump(seconds * 1000); 613 | }); 614 | 615 | return { save, sendAll, zone, playback }; 616 | } 617 | -------------------------------------------------------------------------------- /src/client/main.ts: -------------------------------------------------------------------------------- 1 | import * as blitsy from 'blitsy'; 2 | import { secondsToTime, fakedownToTag, eventToElementPixel, withPixels, escapeHtml, hslToRgb, rgb2hex } from './utility'; 3 | import { sleep } from '../common/utility'; 4 | import { ChatPanel } from './chat'; 5 | 6 | import ZoneClient from '../common/client'; 7 | import { Player } from './player'; 8 | import { Media, QueueInfo, QueueItem, UserState } from '../common/zone'; 9 | import { HTMLUI } from './html-ui'; 10 | import { createContext2D } from 'blitsy'; 11 | import { menusFromDataAttributes, indexByDataAttribute } from './menus'; 12 | import { SceneRenderer, avatarImage } from './scene'; 13 | import { icons } from './text'; 14 | import fetch from 'node-fetch'; 15 | 16 | window.addEventListener('load', () => load()); 17 | 18 | export const client = new ZoneClient({ createSocket: socket }); 19 | export const htmlui = new HTMLUI(); 20 | 21 | const avatarTiles = new Map(); 22 | avatarTiles.set(undefined, avatarImage); 23 | 24 | const colorCount = 16; 25 | const colors: string[] = []; 26 | for (let i = 0; i < colorCount; ++i) { 27 | const color = rgb2hex(hslToRgb(i / colorCount, 1, .65) as any); 28 | colors.push(color); 29 | } 30 | 31 | function getUserColor(user: UserState) { 32 | const i = parseInt(user.userId, 10) % colors.length; 33 | const color = colors[i]; 34 | return color; 35 | } 36 | 37 | function decodeBase64(data: string) { 38 | const texture: blitsy.TextureData = { 39 | _type: 'texture', 40 | format: 'M1', 41 | width: 8, 42 | height: 8, 43 | data, 44 | }; 45 | return blitsy.decodeTexture(texture); 46 | } 47 | 48 | function getTile(base64: string | undefined): CanvasRenderingContext2D { 49 | if (!base64) return avatarImage; 50 | let tile = avatarTiles.get(base64); 51 | if (!tile) { 52 | try { 53 | tile = decodeBase64(base64); 54 | avatarTiles.set(base64, tile); 55 | } catch (e) { 56 | console.log('fucked up avatar', base64); 57 | } 58 | } 59 | return tile || avatarImage; 60 | } 61 | 62 | function notify(title: string, body: string, tag: string) { 63 | if ('Notification' in window && Notification.permission === 'granted' && !document.hasFocus()) { 64 | const notification = new Notification(title, { body, tag, renotify: true, icon: './avatar.png' }); 65 | } 66 | } 67 | 68 | function parseFakedown(text: string) { 69 | text = fakedownToTag(text, '##', 'shk'); 70 | text = fakedownToTag(text, '~~', 'wvy'); 71 | text = fakedownToTag(text, '==', 'rbw'); 72 | return text; 73 | } 74 | 75 | const chat = new ChatPanel(); 76 | 77 | function getLocalUser() { 78 | if (!client.localUserId) { 79 | // chat.log("{clr=#FF0000}ERROR: no localUserId"); 80 | } else { 81 | return client.zone.getUser(client.localUserId!); 82 | } 83 | } 84 | 85 | function moveTo(x: number, y: number, z: number) { 86 | const user = getLocalUser()!; 87 | x = Math.max(Math.min(15, x), 0); 88 | y = 0; 89 | z = Math.max(Math.min(15, z), 0); 90 | user.position = [x, y, z]; 91 | client.move(user.position); 92 | } 93 | 94 | const avatarSlots = new Map(); 95 | const avatarToggles = new Map(); 96 | let activeAvatarSlot = ''; 97 | 98 | function getInitialAvatar() { 99 | return localStorage.getItem(localStorage.getItem('avatar-slot-active') || '') 100 | || localStorage.getItem('avatar'); 101 | } 102 | 103 | const emoteToggles = new Map(); 104 | const getEmote = (emote: string) => emoteToggles.get(emote)?.classList.contains('active'); 105 | 106 | let localName = localStorage.getItem('name') || ''; 107 | 108 | function rename(name: string) { 109 | localStorage.setItem('name', name); 110 | localName = name; 111 | client.rename(name); 112 | } 113 | 114 | function socket(ticket: string): Promise { 115 | return new Promise((resolve, reject) => { 116 | const secure = window.location.protocol.startsWith('https'); 117 | const protocol = secure ? 'wss' : 'ws'; 118 | const socket = new WebSocket(`${protocol}://${window.location.host}/zone/${ticket}`); 119 | socket.addEventListener('open', () => resolve(socket)); 120 | socket.addEventListener('error', reject); 121 | }); 122 | } 123 | 124 | async function connect(): Promise { 125 | const joined = !!client.localUserId; 126 | const existing = client.localUser; 127 | 128 | const name = localName; 129 | const avatar = getInitialAvatar() || undefined; 130 | 131 | try { 132 | await client.join({ name, avatar }); 133 | } catch (e) { 134 | console.log("RECONNECT", e); 135 | await sleep(500); 136 | return connect(); 137 | } 138 | 139 | // reload page after 4 hours of idling 140 | detectIdle(4 * 60 * 60 * 1000).then(() => { 141 | client.messaging.close(); 142 | location.reload(); 143 | }); 144 | 145 | chat.log('{clr=#00FF00}*** connected ***'); 146 | if (!joined) listHelp(); 147 | listUsers(); 148 | 149 | if (existing) { 150 | if (existing.position) moveTo(existing.position[0], existing.position[1], existing.position[2]); 151 | if (existing.emotes) client.emotes(['wvy', 'shk', 'rbw', 'spn'].filter(getEmote)); 152 | } 153 | } 154 | 155 | function listUsers() { 156 | const named = Array.from(client.zone.users.values()).filter((user) => !!user.name); 157 | 158 | if (named.length === 0) { 159 | chat.status('no other users'); 160 | } else { 161 | const names = named.map((user) => `{clr=${getUserColor(user)}}${user.name}`); 162 | const line = names.join('{clr=#FF00FF}, '); 163 | chat.status(`${names.length} users: ${line}{-clr}`); 164 | } 165 | } 166 | 167 | const help = [ 168 | 'use the tabs on the bottom left to queue songs, chat to others, and change your appearance. click or arrow keys to move.', 169 | ].join('\n'); 170 | 171 | function listHelp() { 172 | chat.log('{clr=#FFFF00}' + help); 173 | } 174 | 175 | function textToYoutubeVideoId(text: string) { 176 | text = text.trim(); 177 | if (text.length === 11) return text; 178 | return new URL(text).searchParams.get('v'); 179 | } 180 | 181 | export async function load() { 182 | htmlui.addElementsInRoot(document.body); 183 | htmlui.hideAllWindows(); 184 | 185 | function setActiveAvatarSlot(active: string) { 186 | activeAvatarSlot = active; 187 | 188 | avatarToggles.forEach((context, name) => { 189 | context.canvas.classList.toggle('active', name === active); 190 | if (name === active) { 191 | avatarContext.clearRect(0, 0, 8, 8); 192 | avatarContext.drawImage(context.canvas, 0, 0); 193 | client.avatar(blitsy.encodeTexture(context, 'M1').data).catch(() => {}); 194 | } 195 | }); 196 | 197 | localStorage.setItem('avatar-slot-active', activeAvatarSlot); 198 | } 199 | 200 | function setupAvatarSlots() { 201 | const toggles = indexByDataAttribute(document.body, "data-avatar-slot"); 202 | toggles.forEach((element, name) => { 203 | const iconCanvas = element as HTMLCanvasElement; 204 | const iconContext = iconCanvas.getContext('2d')!; 205 | avatarToggles.set(name, iconContext); 206 | element.addEventListener('click', () => setActiveAvatarSlot(name)); 207 | 208 | const context = createContext2D(8, 8); 209 | const existing = getTile(localStorage.getItem(name) || localStorage.getItem('avatar') || undefined); 210 | 211 | context.clearRect(0, 0, 8, 8); 212 | context.drawImage(existing.canvas, 0, 0); 213 | iconContext.clearRect(0, 0, 8, 8); 214 | iconContext.drawImage(existing.canvas, 0, 0); 215 | avatarSlots.set(name, context); 216 | }); 217 | 218 | setActiveAvatarSlot(localStorage.getItem('avatar-slot-active') || 'a'); 219 | } 220 | 221 | function saveToAvatarSlot(name: string, data: string) { 222 | const canvas = getTile(data).canvas; 223 | 224 | const slot = avatarSlots.get(name)!; 225 | slot.clearRect(0, 0, 8, 8); 226 | slot.drawImage(canvas, 0, 0); 227 | 228 | const toggle = avatarToggles.get(name)!; 229 | toggle.clearRect(0, 0, 8, 8); 230 | toggle.drawImage(canvas, 0, 0); 231 | 232 | localStorage.setItem(name, data); 233 | setActiveAvatarSlot(name); 234 | } 235 | 236 | function mobileHeightFix() { 237 | const vh = window.innerHeight / 100; 238 | document.documentElement.style.setProperty('--vh', `${vh}px`); 239 | } 240 | window.addEventListener('resize', mobileHeightFix); 241 | mobileHeightFix(); 242 | 243 | const popoutPanel = document.getElementById('popout-panel') as HTMLElement; 244 | const video = document.createElement('video'); 245 | popoutPanel.appendChild(video); 246 | document.getElementById('popout-button')?.addEventListener('click', () => (popoutPanel.hidden = false)); 247 | 248 | const openButton = document.getElementById('external-button') as HTMLButtonElement; 249 | openButton.addEventListener('click', () => { 250 | window.open(`${player.playingItem?.media.src}#t=${player.elapsed/1000}`); 251 | }); 252 | 253 | const pipButton = document.getElementById('pip-button') as HTMLButtonElement; 254 | if (document.pictureInPictureEnabled) { 255 | pipButton.addEventListener('click', () => { 256 | video.requestPictureInPicture(); 257 | }); 258 | // resync when closing PiP to avoid automatically pausing 259 | video.addEventListener('leavepictureinpicture', () => { 260 | player.forceRetry('left PiP'); 261 | }); 262 | } else { 263 | pipButton.hidden = true; 264 | } 265 | 266 | const player = new Player(video); 267 | const zoneLogo = document.createElement('img'); 268 | zoneLogo.src = 'zone-logo.png'; 269 | const audioLogo = document.createElement('img'); 270 | audioLogo.src = 'audio-logo.png'; 271 | 272 | const joinName = document.querySelector('#join-name') as HTMLInputElement; 273 | const chatInput = document.querySelector('#chat-input') as HTMLInputElement; 274 | 275 | function setVolume(volume: number) { 276 | player.volume = volume / 100; 277 | localStorage.setItem('volume', volume.toString()); 278 | } 279 | 280 | setVolume(parseInt(localStorage.getItem('volume') || '100', 10)); 281 | 282 | joinName.value = localName; 283 | 284 | const menuPanel = document.getElementById('menu-panel')!; 285 | const volumeSlider = document.getElementById('volume-slider') as HTMLInputElement; 286 | 287 | const authRow = document.getElementById('auth-row')!; 288 | const authContent = document.getElementById('auth-content')!; 289 | 290 | volumeSlider.addEventListener('input', () => (player.volume = parseFloat(volumeSlider.value))); 291 | document.getElementById('menu-button')?.addEventListener('click', openMenu); 292 | 293 | function openMenu() { 294 | menuPanel.hidden = false; 295 | volumeSlider.value = player.volume.toString(); 296 | } 297 | 298 | const userItemContainer = document.getElementById('user-items')!; 299 | const userSelect = document.getElementById('user-select') as HTMLSelectElement; 300 | 301 | function formatNameHTML(user: UserState) { 302 | const name = escapeHtml(user.name || ''); 303 | const color = getUserColor(user); 304 | const clas = user.tags.includes('admin') ? 'user-admin' 305 | : user.tags.includes('dj') ? 'user-dj' 306 | : 'user-normal'; 307 | 308 | return `${name}`; 309 | } 310 | 311 | function formatNameChat(user: UserState, icon=true) { 312 | const color = getUserColor(user); 313 | const ico = icon ? ` {icon:${user.userId}}` : ""; 314 | return `{clr=${color}}${user.name}${ico}{-clr}`; 315 | } 316 | 317 | const iconTest = createContext2D(8, 8); 318 | iconTest.fillStyle = '#ff00ff'; 319 | iconTest.fillRect(0, 0, 8, 8); 320 | 321 | const userAvatars = new Map(); 322 | 323 | function refreshUsers() { 324 | const users = Array.from(client.zone.users.values()).filter((user) => !!user.name); 325 | const names = users.map((user) => formatNameHTML(user)); 326 | userItemContainer.innerHTML = `${names.length} people are zoning: ` + names.join(',
'); 327 | 328 | userAvatars.clear(); 329 | users.forEach((user) => { 330 | const context = createContext2D(8, 8); 331 | context.drawImage(getTile(user.avatar).canvas, 0, 0); 332 | userAvatars.set(user, context); 333 | }); 334 | 335 | userAvatars.forEach((rendering, user) => { 336 | icons.set(user.userId, rendering.canvas); 337 | }); 338 | 339 | userItemContainer.innerHTML = ''; 340 | userSelect.innerHTML = ''; 341 | users.forEach((user, index) => { 342 | const option = document.createElement('option'); 343 | option.value = user.name || ''; 344 | option.innerHTML = formatNameHTML(user); 345 | userSelect.appendChild(option); 346 | 347 | const element = document.createElement('div'); 348 | const label = document.createElement('div'); 349 | label.innerHTML = formatNameHTML(user); 350 | element.appendChild((userAvatars.get(user) || iconTest).canvas); 351 | element.appendChild(label); 352 | userItemContainer.appendChild(element); 353 | 354 | element.addEventListener('click', (event) => { 355 | event.stopPropagation(); 356 | userSelect.selectedIndex = index; 357 | }); 358 | }); 359 | userSelect.value = ''; 360 | 361 | const auth = !!getLocalUser()?.tags.includes('admin'); 362 | authRow.hidden = auth; 363 | authContent.hidden = !auth; 364 | } 365 | 366 | document.getElementById('ban-ip-button')!.addEventListener('click', () => { 367 | client.command('ban', [userSelect.value]); 368 | }); 369 | document.getElementById('add-dj-button')!.addEventListener('click', () => { 370 | client.command('dj-add', [userSelect.value]); 371 | }); 372 | document.getElementById('del-dj-button')!.addEventListener('click', () => { 373 | client.command('dj-del', [userSelect.value]); 374 | }); 375 | document.getElementById('despawn-button')!.addEventListener('click', () => { 376 | client.command('despawn', [userSelect.value]); 377 | }); 378 | document.getElementById('event-mode-on')!.addEventListener('click', () => client.command('mode', ['event'])); 379 | document.getElementById('event-mode-off')!.addEventListener('click', () => client.command('mode', [''])); 380 | 381 | const queueItemContainer = document.getElementById('queue-items')!; 382 | const queueItemTemplate = document.getElementById('queue-item-template')!; 383 | queueItemTemplate.parentElement!.removeChild(queueItemTemplate); 384 | 385 | const queueTitle = document.getElementById('queue-title')!; 386 | const currentItemContainer = document.getElementById('current-item')!; 387 | const currentItemTitle = document.getElementById('current-item-title')!; 388 | const currentItemTime = document.getElementById('current-item-time')!; 389 | 390 | function refreshCurrentItem() { 391 | const count = client.zone.queue.length + (player.hasItem ? 1 : 0); 392 | let total = player.remaining / 1000; 393 | client.zone.queue.forEach((item) => (total += item.media.duration / 1000)); 394 | queueTitle.innerText = `playlist (${count} items, ${secondsToTime(total)})`; 395 | 396 | skipButton.disabled = false; // TODO: know when it's event mode 397 | currentItemContainer.hidden = !player.hasItem; 398 | currentItemTitle.innerHTML = escapeHtml(player.playingItem?.media.title || ''); 399 | currentItemTime.innerHTML = secondsToTime(player.remaining / 1000); 400 | 401 | if (client.zone.lastPlayedItem?.info.userId) { 402 | const user = client.zone.getUser(client.zone.lastPlayedItem.info.userId); 403 | currentItemTitle.setAttribute('title', 'queued by ' + user.name); 404 | } 405 | } 406 | 407 | const queueElements: HTMLElement[] = []; 408 | 409 | function refreshQueue() { 410 | queueElements.forEach((item) => item.parentElement!.removeChild(item)); 411 | queueElements.length = 0; 412 | 413 | const user = getLocalUser(); 414 | client.zone.queue.forEach((item) => { 415 | const element = queueItemTemplate.cloneNode(true) as HTMLElement; 416 | const titleElement = element.querySelector('.queue-item-title')!; 417 | const timeElement = element.querySelector('.queue-item-time')!; 418 | const cancelButton = element.querySelector('.queue-item-cancel') as HTMLButtonElement; 419 | 420 | const cancellable = item.info.userId === user?.userId || user?.tags.includes('dj'); 421 | titleElement.innerHTML = escapeHtml(item.media.title); 422 | if (item.info.userId) { 423 | const user = client.zone.getUser(item.info.userId); 424 | titleElement.setAttribute('title', 'queued by ' + user.name); 425 | } 426 | timeElement.innerHTML = secondsToTime(item.media.duration / 1000); 427 | cancelButton.disabled = !cancellable; 428 | cancelButton.addEventListener('click', () => client.unqueue(item)); 429 | 430 | queueItemContainer.appendChild(element); 431 | queueElements.push(element); 432 | }); 433 | 434 | refreshCurrentItem(); 435 | } 436 | 437 | document.getElementById('auth-button')!.addEventListener('click', () => { 438 | const input = document.getElementById('auth-input') as HTMLInputElement; 439 | client.auth(input.value); 440 | }); 441 | 442 | const searchInput = document.getElementById('search-input') as HTMLInputElement; 443 | const searchLibrary = document.getElementById('search-library') as HTMLOptionElement; 444 | const searchSubmit = document.getElementById('search-submit') as HTMLButtonElement; 445 | const searchResults = document.getElementById('search-results')!; 446 | 447 | searchInput.addEventListener('input', () => (searchSubmit.disabled = searchInput.value.length === 0)); 448 | 449 | const searchResultTemplate = document.getElementById('search-result-template')!; 450 | searchResultTemplate.parentElement?.removeChild(searchResultTemplate); 451 | 452 | // player.on('subtitles', (lines) => lines.forEach((line) => chat.log(`{clr=#888888}${line}`))); 453 | 454 | fetch("/libraries").then((res) => res.json()).then((libraries: string[]) => { 455 | searchLibrary.innerHTML = ""; 456 | libraries.forEach((library) => { 457 | const option = document.createElement("option"); 458 | option.textContent = library; 459 | option.value = library; 460 | searchLibrary.appendChild(option); 461 | }); 462 | }); 463 | 464 | document.getElementById('search-form')?.addEventListener('submit', (event) => { 465 | event.preventDefault(); 466 | event.stopPropagation(); 467 | 468 | searchResults.innerText = 'searching...'; 469 | client.searchLibrary(searchLibrary.value, searchInput.value).then((results) => { 470 | searchResults.innerHTML = ''; 471 | results.forEach(({ title, duration, thumbnail, path }) => { 472 | const row = searchResultTemplate.cloneNode(true) as HTMLElement; 473 | row.addEventListener('click', () => { 474 | client.queue(path!).catch((error) => chat.status(`queueing ${title} failed: ${error.message}`)); 475 | menu.open('playback/playlist'); 476 | }); 477 | 478 | const div = row.querySelector('div')!; 479 | const img = row.querySelector('img')!; 480 | 481 | div.innerHTML = escapeHtml(`${title} (${secondsToTime(duration / 1000)})`); 482 | img.src = thumbnail || ''; 483 | searchResults.appendChild(row); 484 | }); 485 | }); 486 | }); 487 | 488 | client.on('disconnect', async ({ clean }) => { 489 | if (clean) return; 490 | await sleep(1000); 491 | await connect(); 492 | }); 493 | 494 | client.on('join', refreshUsers); 495 | client.on('leave', refreshUsers); 496 | client.on('rename', refreshUsers); 497 | client.on('tags', refreshUsers); 498 | client.on('avatar', refreshUsers); 499 | client.on('users', refreshUsers); 500 | refreshUsers(); 501 | 502 | client.on('queue', ({ item }) => { 503 | const { title, duration } = item.media; 504 | const user = item.info.userId ? client.zone.users.get(item.info.userId) : undefined; 505 | const username = user ? formatNameChat(user) : 'server'; 506 | const time = secondsToTime(duration / 1000); 507 | if (item.info.banger) { 508 | chat.log( 509 | `{clr=#00FFFF}+ ${title} (${time}) rolled from {clr=#FF00FF}bangers{clr=#00FFFF} by ${username}`, 510 | ); 511 | } else { 512 | chat.log(`{clr=#00FFFF}+ ${title} (${time}) added by ${username}`); 513 | } 514 | 515 | refreshQueue(); 516 | }); 517 | client.on('unqueue', ({ item }) => { 518 | // chat.log(`{clr=#008888}- ${item.media.title} unqueued`); 519 | refreshQueue(); 520 | }); 521 | 522 | client.on('play', async ({ message: { item, time } }) => { 523 | if (!item) { 524 | player.stopPlaying(); 525 | } else { 526 | player.setPlaying(item, time || 0); 527 | 528 | const { title, duration } = item.media; 529 | chat.log(`{clr=#00FFFF}> ${title} (${secondsToTime(duration / 1000)})`); 530 | } 531 | }); 532 | 533 | client.on('join', (event) => chat.status(`${formatNameChat(event.user)} {clr=#FF00FF}joined`)); 534 | client.on('leave', (event) => chat.status(`${formatNameChat(event.user)}{clr=#FF00FF} left`)); 535 | client.on('status', (event) => chat.status(event.text)); 536 | 537 | client.on('avatar', ({ local, data }) => { 538 | if (local) localStorage.setItem('avatar', data); 539 | }); 540 | 541 | function getUserColor(user: UserState) { 542 | const i = parseInt(user.userId, 10) % colors.length; 543 | const color = colors[i]; 544 | return color; 545 | } 546 | 547 | client.on('chat', (message) => { 548 | const { user, text } = message; 549 | const name = formatNameChat(user, true); 550 | chat.log(`${name} ${text}`); 551 | if (!message.local) { 552 | notify(user.name || 'anonymous', text, 'chat'); 553 | } 554 | }); 555 | client.on('rename', (message) => { 556 | if (message.local) { 557 | chat.status(`you are ${formatNameChat(message.user)}`); 558 | } else { 559 | chat.status(`{clr=#FF0000}${message.previous}{clr=#FF00FF} is now {clr=#FF0000}${message.user.name}`); 560 | } 561 | }); 562 | 563 | function move(dx: number, dz: number) { 564 | const user = getLocalUser()!; 565 | 566 | if (user.position) { 567 | const [px, py, pz] = user.position; 568 | moveTo(px + dx, py, pz + dz); 569 | 570 | // HACK: when moving, focus + blur chat canvas so that next tab press will take you to the chat input 571 | const el = chatInput.previousElementSibling as HTMLCanvasElement; 572 | el.tabIndex = 0; 573 | el.focus(); 574 | el.blur(); 575 | el.tabIndex = -1; 576 | } 577 | } 578 | 579 | async function playFromSearchResult(args: string) { 580 | const index = parseInt(args, 10) - 1; 581 | const results = lastSearchResults; 582 | 583 | if (isNaN(index)) chat.status(`did not understand '${args}' as a number`); 584 | else if (!results || index < 0 || index >= results.length) 585 | chat.status(`there is no #${index + 1} search result`); 586 | else return client.queue(results[index].path!); 587 | } 588 | 589 | document.getElementById('play-banger')?.addEventListener('click', () => client.banger().catch((error) => chat.status(`banger failed: ${error.message}`))); 590 | 591 | const menu = menusFromDataAttributes(document.documentElement); 592 | menu.on('show:avatar', openAvatarEditor); 593 | menu.on('show:playback/playlist', refreshQueue); 594 | menu.on('show:playback/search', () => { 595 | searchInput.value = ''; 596 | searchInput.focus(); 597 | searchResults.innerHTML = ''; 598 | }); 599 | 600 | const avatarPanel = document.querySelector('#avatar-panel') as HTMLElement; 601 | const avatarName = document.querySelector('#avatar-name') as HTMLInputElement; 602 | const avatarPaint = document.querySelector('#avatar-paint') as HTMLCanvasElement; 603 | const avatarUpdate = document.querySelector('#avatar-update') as HTMLButtonElement; 604 | const avatarContext = avatarPaint.getContext('2d')!; 605 | 606 | function openAvatarEditor() { 607 | avatarName.value = getLocalUser()?.name || ''; 608 | /* 609 | const avatar = getTile(getLocalUser()!.avatar) || avatarImage; 610 | avatarContext.clearRect(0, 0, 8, 8); 611 | avatarContext.drawImage(avatar.canvas, 0, 0); 612 | */ 613 | } 614 | 615 | let painting = false; 616 | let erase = false; 617 | 618 | function paint(px: number, py: number) { 619 | withPixels(avatarContext, (pixels) => (pixels[py * 8 + px] = erase ? 0 : 0xffffffff)); 620 | } 621 | 622 | setupAvatarSlots(); 623 | 624 | window.addEventListener('pointerup', (event) => { 625 | if (painting) { 626 | painting = false; 627 | event.preventDefault(); 628 | event.stopPropagation(); 629 | } 630 | }); 631 | avatarPaint.addEventListener('pointerdown', (event) => { 632 | painting = true; 633 | 634 | const scaling = 8 / avatarPaint.clientWidth; 635 | const [cx, cy] = eventToElementPixel(event, avatarPaint); 636 | const [px, py] = [Math.floor(cx * scaling), Math.floor(cy * scaling)]; 637 | 638 | withPixels(avatarContext, (pixels) => { 639 | erase = pixels[py * 8 + px] > 0; 640 | }); 641 | 642 | paint(px, py); 643 | 644 | event.preventDefault(); 645 | event.stopPropagation(); 646 | }); 647 | avatarPaint.addEventListener('pointermove', (event) => { 648 | if (painting) { 649 | const scaling = 8 / avatarPaint.clientWidth; 650 | const [cx, cy] = eventToElementPixel(event, avatarPaint); 651 | const [px, py] = [Math.floor(cx * scaling), Math.floor(cy * scaling)]; 652 | paint(px, py); 653 | } 654 | }); 655 | 656 | avatarUpdate.addEventListener('click', () => { 657 | if (avatarName.value !== getLocalUser()?.name) rename(avatarName.value); 658 | const data = blitsy.encodeTexture(avatarContext, 'M1').data; 659 | saveToAvatarSlot(activeAvatarSlot, data); 660 | }); 661 | 662 | let lastSearchResults: Media[] = []; 663 | 664 | const skipButton = document.getElementById('skip-button') as HTMLButtonElement; 665 | skipButton.addEventListener('click', () => client.skip()); 666 | document.getElementById('resync-button')?.addEventListener('click', () => player.forceRetry('reload button')); 667 | 668 | const quickResync = document.getElementById('resync-button2')!; 669 | quickResync.addEventListener('click', () => player.forceRetry('resync button')); 670 | quickResync.hidden = true; 671 | 672 | async function chatSearch(library: string, query?: string, tag?: string) { 673 | lastSearchResults = await client.searchLibrary(library, query, tag); 674 | const lines = lastSearchResults 675 | .slice(0, 5) 676 | .map(({ title, duration }, i) => `${i + 1}. ${title} (${secondsToTime(duration / 1000)})`); 677 | chat.log('{clr=#FFFF00}? queue Search result with /result n\n{clr=#00FFFF}' + lines.join('\n')); 678 | } 679 | 680 | const chatCommands = new Map void | Promise>(); 681 | chatCommands.set('library', (query) => chatSearch("library", query)); 682 | chatCommands.set('tagged', (tag) => chatSearch("library", undefined, tag)); 683 | chatCommands.set('search', (query) => chatSearch("youtube", query)); 684 | chatCommands.set('result', playFromSearchResult); 685 | chatCommands.set('s', chatCommands.get('search')!); 686 | chatCommands.set('r', chatCommands.get('result')!); 687 | chatCommands.set('youtube', async (args) => client.queue("youtube:" + textToYoutubeVideoId(args)!)); 688 | chatCommands.set('skip', () => client.skip()); 689 | chatCommands.set('banger', async (tag) => client.banger(tag)); 690 | chatCommands.set('lucky', async (query) => client.lucky("youtube", query)); 691 | chatCommands.set('users', () => listUsers()); 692 | chatCommands.set('help', () => listHelp()); 693 | chatCommands.set('avatar', (data) => { 694 | if (data.trim().length === 0) { 695 | openAvatarEditor(); 696 | } else { 697 | client.avatar(data); 698 | } 699 | }); 700 | chatCommands.set('avatar2', (args) => { 701 | const ascii = args.replace(/\s+/g, '\n'); 702 | const avatar = blitsy.decodeAsciiTexture(ascii, '1'); 703 | const data = blitsy.encodeTexture(avatar, 'M1').data; 704 | client.avatar(data); 705 | }); 706 | chatCommands.set('volume', (args) => setVolume(parseInt(args.trim(), 10))); 707 | chatCommands.set('resync', () => player.forceRetry('user request')); 708 | chatCommands.set('notify', async () => { 709 | const permission = await Notification.requestPermission(); 710 | chat.status(`notifications ${permission}`); 711 | }); 712 | chatCommands.set('name', rename); 713 | 714 | chatCommands.set('auth', async (password) => client.auth(password)); 715 | chatCommands.set('admin', async (args) => { 716 | const i = args.indexOf(' '); 717 | 718 | if (i >= 0) { 719 | const name = args.substring(0, i); 720 | const json = `[${args.substring(i + 1)}]`; 721 | return client.command(name, JSON.parse(json)); 722 | } else { 723 | return client.command(args); 724 | } 725 | }); 726 | 727 | chatCommands.set('echo', (message) => client.echo(getLocalUser()!.position!, message)); 728 | 729 | const toggleEmote = (emote: string) => setEmote(emote, !getEmote(emote)); 730 | const setEmote = (emote: string, value: boolean) => { 731 | emoteToggles.get(emote)!.classList.toggle('active', value); 732 | client.emotes(['wvy', 'shk', 'rbw', 'spn'].filter(getEmote)); 733 | }; 734 | 735 | document.querySelectorAll('[data-emote-toggle]').forEach((element) => { 736 | const emote = element.getAttribute('data-emote-toggle'); 737 | if (!emote) return; 738 | emoteToggles.set(emote, element); 739 | element.addEventListener('click', () => toggleEmote(emote)); 740 | }); 741 | 742 | const directions: [number, number][] = [ 743 | [1, 0], 744 | [0, -1], 745 | [-1, 0], 746 | [0, 1], 747 | ]; 748 | 749 | function moveVector(direction: number): [number, number] { 750 | return directions[direction % 4]; 751 | } 752 | 753 | const gameKeys = new Map void>(); 754 | gameKeys.set('1', () => toggleEmote('wvy')); 755 | gameKeys.set('2', () => toggleEmote('shk')); 756 | gameKeys.set('3', () => toggleEmote('rbw')); 757 | gameKeys.set('4', () => toggleEmote('spn')); 758 | gameKeys.set('ArrowLeft', () => move(...moveVector(2))); 759 | gameKeys.set('ArrowRight', () => move(...moveVector(0))); 760 | gameKeys.set('ArrowDown', () => move(...moveVector(3))); 761 | gameKeys.set('ArrowUp', () => move(...moveVector(1))); 762 | 763 | function toggleMenuPath(path: string) { 764 | if (!menu.isVisible(path)) menu.open(path); 765 | else menu.closeChildren(''); 766 | } 767 | 768 | gameKeys.set('u', () => toggleMenuPath('social/users')); 769 | gameKeys.set('s', () => toggleMenuPath('playback/search')); 770 | gameKeys.set('q', () => toggleMenuPath('playback/playlist')); 771 | gameKeys.set('w', () => toggleMenuPath('social')); 772 | gameKeys.set('e', () => toggleMenuPath('avatar')); 773 | 774 | function sendChat() { 775 | const line = chatInput.value; 776 | const slash = line.match(/^\/([^\s]+)\s*(.*)/); 777 | 778 | if (slash) { 779 | const command = chatCommands.get(slash[1]); 780 | if (command) { 781 | const promise = command(slash[2].trim()); 782 | if (promise) promise.catch((error) => chat.status(`${line} failed: ${error.message}`)); 783 | } else { 784 | chat.status(`no command /${slash[1]}`); 785 | listHelp(); 786 | } 787 | } else if (line.length > 0) { 788 | client.chat(parseFakedown(line)); 789 | } 790 | 791 | chatInput.value = ''; 792 | } 793 | 794 | function isInputElement(element: Element | null): element is HTMLInputElement { 795 | return element?.tagName === 'INPUT'; 796 | } 797 | 798 | document.addEventListener('keydown', (event) => { 799 | if (event.key === 'Escape') { 800 | if (isInputElement(document.activeElement)) document.activeElement.blur(); 801 | 802 | event.preventDefault(); 803 | menu.closeChildren(''); 804 | } 805 | 806 | if (isInputElement(document.activeElement) && event.key !== 'Tab') { 807 | if (event.key === 'Enter') { 808 | sendChat(); 809 | } 810 | } else { 811 | const func = gameKeys.get(event.key); 812 | if (func) { 813 | func(); 814 | event.stopPropagation(); 815 | event.preventDefault(); 816 | } 817 | } 818 | }); 819 | 820 | const chatContext2 = document.querySelector('#chat-canvas2')!.getContext('2d')!; 821 | chatContext2.imageSmoothingEnabled = false; 822 | 823 | function clearContext(context: CanvasRenderingContext2D) { 824 | context.clearRect(0, 0, context.canvas.width, context.canvas.height); 825 | } 826 | 827 | setupEntrySplash(); 828 | 829 | function connecting() { 830 | const socket = (client.messaging as any).socket; 831 | const state = socket ? socket.readyState : 0; 832 | 833 | return state !== WebSocket.OPEN; 834 | } 835 | 836 | function getStatus() { 837 | return player.status !== 'playing' ? player.status : undefined; 838 | } 839 | 840 | const sceneRenderer = new SceneRenderer(client, client.zone, getTile, connecting, getStatus, player); 841 | const tooltip = document.getElementById('tooltip')!; 842 | tooltip.hidden = true; 843 | 844 | let mobile = false; 845 | window.addEventListener("resize", resizeChat); 846 | function resizeChat() { 847 | const height = chatContext2.canvas.clientHeight; 848 | chatContext2.canvas.height = height; 849 | chatContext2.imageSmoothingEnabled = false; 850 | chat.height = Math.ceil(height / 2); 851 | mobile = window.getComputedStyle(document.documentElement).getPropertyValue('--mobile').trim() === '1'; 852 | } 853 | resizeChat(); 854 | 855 | function redraw() { 856 | window.requestAnimationFrame(redraw); 857 | 858 | quickResync.hidden = !player.problem; 859 | 860 | refreshCurrentItem(); 861 | clearContext(chatContext2); 862 | 863 | if (chat.height !== 0) { 864 | chat.render(!mobile); 865 | chatContext2.drawImage(chat.context.canvas, 0, 0, 512, chat.height * 2); 866 | } 867 | 868 | const logo = player.hasItem ? audioLogo : undefined; 869 | sceneRenderer.mediaElement = popoutPanel.hidden && player.hasVideo && !document.pictureInPictureElement ? video : logo; 870 | 871 | sceneRenderer.render(); 872 | } 873 | 874 | redraw(); 875 | 876 | sceneRenderer.on('click', (event, [tx, tz]) => { 877 | const objectCoords = `${tx},0,${tz}`; 878 | 879 | const echoes = Array.from(client.zone.echoes) 880 | .map(([, echo]) => echo) 881 | .filter((echo) => echo.position!.join(',') === objectCoords); 882 | 883 | if (echoes.length > 0) { 884 | chat.log(`{clr=#808080}"${parseFakedown(echoes[0].text)}"`); 885 | } else if (tx >= 0 && tz >= 0 && tx < 16 && tz < 16) { 886 | moveTo(tx, 0, tz); 887 | } 888 | }); 889 | 890 | sceneRenderer.on('hover', (event, [tx, tz]) => { 891 | const objectCoords = `${tx},0,${tz}`; 892 | 893 | const users = Array.from(client.zone.users.values()).filter( 894 | (user) => user.position?.join(',') === objectCoords, 895 | ); 896 | const echoes = Array.from(client.zone.echoes) 897 | .map(([, echo]) => echo) 898 | .filter((echo) => echo.position!.join(',') === objectCoords); 899 | 900 | const names = [ 901 | ...users.map((user) => formatNameHTML(user)), 902 | ...echoes.map((echo) => 'echo of ' + formatNameHTML(echo)), 903 | ]; 904 | 905 | tooltip.hidden = names.length === 0; 906 | tooltip.innerHTML = names.join(', '); 907 | const [ttx, tty] = eventToElementPixel(event, tooltip.parentElement?.parentElement!); 908 | tooltip.style.left = ttx + 'px'; 909 | tooltip.style.top = tty + 'px'; 910 | }); 911 | 912 | sceneRenderer.on('unhover', (event) => { 913 | tooltip.hidden = true; 914 | }); 915 | } 916 | 917 | function createAvatarElement(avatar: string) { 918 | const context = createContext2D(8, 8); 919 | context.drawImage(getTile(avatar).canvas, 0, 0); 920 | return context.canvas; 921 | } 922 | 923 | function setupEntrySplash() { 924 | const zone = document.getElementById('zone') as HTMLElement; 925 | const nameInput = document.querySelector('#join-name') as HTMLInputElement; 926 | const entrySplash = document.getElementById('entry-splash') as HTMLElement; 927 | const entryUsers = document.getElementById('entry-users') as HTMLParagraphElement; 928 | const entryButton = document.getElementById('entry-button') as HTMLInputElement; 929 | const entryForm = document.getElementById('entry') as HTMLFormElement; 930 | 931 | const entryPlaying = document.getElementById("entry-playing") as HTMLElement; 932 | 933 | function refreshUsers(users: { name?: string; avatar?: string, userId: string }[]) { 934 | entryUsers.innerHTML = ''; 935 | users.forEach((user) => { 936 | const element = document.createElement('div'); 937 | const label = document.createElement('div'); 938 | const hex = getUserColor(user as any); 939 | label.innerHTML = `` + escapeHtml(user.name || 'anonymous') + ""; 940 | element.appendChild(createAvatarElement(user.avatar || 'GBgYPH69JCQ=')); 941 | element.appendChild(label); 942 | entryUsers.appendChild(element); 943 | }); 944 | } 945 | 946 | function updateEntryUsers() { 947 | if (entrySplash.hidden) return; 948 | 949 | fetch('./users') 950 | .then((res) => res.json()) 951 | .then((users: { name?: string; avatar?: string, userId: string }[]) => { 952 | if (users.length === 0) { 953 | entryUsers.innerHTML = 'zone is currenty empty'; 954 | } else { 955 | refreshUsers(users); 956 | } 957 | }); 958 | } 959 | updateEntryUsers(); 960 | setInterval(updateEntryUsers, 5000); 961 | 962 | let lastItem: QueueItem | undefined; 963 | let lastItemStartTime: number; 964 | 965 | function updateEntryPlaying() { 966 | if (lastItem) { 967 | const time = (performance.now() - lastItemStartTime) / 1000; 968 | const durr = lastItem.media.duration / 1000; 969 | 970 | if (time >= durr) { 971 | lastItem = undefined; 972 | fetchEntryPlaying(); 973 | updateEntryPlaying(); 974 | } else { 975 | entryPlaying.replaceChildren( 976 | lastItem.media.title, 977 | document.createElement("br"), 978 | `${secondsToTime(time)} / ${secondsToTime(durr)}`, 979 | ); 980 | } 981 | 982 | entryPlaying.hidden = durr < 600; 983 | } else { 984 | entryPlaying.hidden = true; 985 | } 986 | } 987 | 988 | function fetchEntryPlaying() { 989 | if (entrySplash.hidden) return; 990 | 991 | fetch("https://tinybird.zone/playing") 992 | .then((res) => res.json()) 993 | .then(({ item, time }) => { 994 | lastItem = item; 995 | lastItemStartTime = performance.now() - time; 996 | }); 997 | } 998 | fetchEntryPlaying(); 999 | setInterval(fetchEntryPlaying, 5000); 1000 | setInterval(updateEntryPlaying, 500); 1001 | 1002 | entryButton.disabled = !entryForm.checkValidity(); 1003 | nameInput.addEventListener('input', () => (entryButton.disabled = !entryForm.checkValidity())); 1004 | zone.hidden = true; 1005 | 1006 | entryForm.addEventListener('submit', async (event) => { 1007 | event.preventDefault(); 1008 | (document.getElementById('entry-sound') as HTMLAudioElement).play(); 1009 | entrySplash.hidden = true; 1010 | zone.hidden = false; 1011 | window.dispatchEvent(new Event('resize')); // trigger a resize to update renderer 1012 | localName = nameInput.value; 1013 | localStorage.setItem('name', localName); 1014 | await connect(); 1015 | }); 1016 | } 1017 | 1018 | async function detectIdle(limit: number) { 1019 | return new Promise((resolve, reject) => { 1020 | let t = 0; 1021 | window.addEventListener('pointermove', resetTimer); 1022 | window.addEventListener('keydown', resetTimer); 1023 | 1024 | function resetTimer() { 1025 | clearTimeout(t); 1026 | t = window.setTimeout(resolve, limit); 1027 | } 1028 | }); 1029 | } 1030 | --------------------------------------------------------------------------------