diff --git a/client/package.json b/client/package.json index a4d1a864..dd96b97e 100644 --- a/client/package.json +++ b/client/package.json @@ -23,7 +23,9 @@ "vue": "^2.6.10", "vue-class-component": "^7.0.2", "vue-notification": "^1.3.20", - "vue-property-decorator": "^8.3.0" + "vue-property-decorator": "^8.3.0", + "typed-vuex": "^0.1.15", + "vuex": "^3.1.2" }, "devDependencies": { "@vue/cli-plugin-eslint": "^4.1.0", diff --git a/client/src/App.vue b/client/src/App.vue index 8e6b25c5..cc28c999 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -31,7 +31,7 @@
  • @@ -68,7 +68,8 @@
    Please enter the password: - + + @@ -378,12 +379,7 @@ diff --git a/client/src/client/base.ts b/client/src/client/base.ts new file mode 100644 index 00000000..c104cdda --- /dev/null +++ b/client/src/client/base.ts @@ -0,0 +1,296 @@ +import EventEmitter from 'eventemitter3' + +import { OPCODE } from './data' + +import { EVENT, WebSocketEvents } from './events' + +import { + WebSocketMessages, + WebSocketPayloads, + IdentityPayload, + SignalPayload, + MemberListPayload, + MemberPayload, + ControlPayload, +} from './messages' + +export interface BaseEvents { + info: (...message: any[]) => void + warn: (...message: any[]) => void + debug: (...message: any[]) => void + error: (error: Error) => void +} + +export abstract class BaseClient extends EventEmitter { + protected _ws?: WebSocket + protected _peer?: RTCPeerConnection + protected _channel?: RTCDataChannel + protected _timeout?: number + protected _username?: string + protected _state: RTCIceConnectionState = 'disconnected' + + get socketOpen() { + return typeof this._ws !== 'undefined' && this._ws.readyState === WebSocket.OPEN + } + + get peerConnected() { + return typeof this._peer !== 'undefined' && this._state === 'connected' + } + + get connected() { + return this.peerConnected && this.socketOpen + } + + public connect(url: string, password: string, username: string) { + if (this.socketOpen) { + this.emit('warn', `attempting to create websocket while connection open`) + return + } + + if (username === '') { + throw new Error('Must add a username') // TODO: Better handleing + } + this._username = username + + this._ws = new WebSocket(`${url}ws?password=${password}`) + this.emit('debug', `connecting to ${this._ws.url}`) + this._ws.onmessage = this.onMessage.bind(this) + this._ws.onerror = event => this.onError.bind(this) + this._ws.onclose = event => this.onDisconnected.bind(this, new Error('websocket closed')) + this._timeout = setTimeout(this.onTimeout.bind(this), 5000) + this[EVENT.CONNECTING]() + } + + public sendData(event: 'wheel' | 'mousemove', data: { x: number; y: number }): void + public sendData(event: 'mousedown' | 'mouseup' | 'keydown' | 'keyup', data: { key: number }): void + public sendData(event: string, data: any) { + if (!this.connected) { + this.emit('warn', `attemping to send data while dissconneted`) + return + } + + let buffer: ArrayBuffer + let payload: DataView + switch (event) { + case 'mousemove': + buffer = new ArrayBuffer(7) + payload = new DataView(buffer) + payload.setUint8(0, OPCODE.MOVE) + payload.setUint16(1, 4, true) + payload.setUint16(3, data.x, true) + payload.setUint16(5, data.y, true) + break + case 'wheel': + buffer = new ArrayBuffer(7) + payload = new DataView(buffer) + payload.setUint8(0, OPCODE.SCROLL) + payload.setUint16(1, 4, true) + payload.setInt16(3, data.x, true) + payload.setInt16(5, data.y, true) + break + case 'keydown': + case 'mousedown': + buffer = new ArrayBuffer(5) + payload = new DataView(buffer) + payload.setUint8(0, OPCODE.KEY_DOWN) + payload.setUint16(1, 1, true) + payload.setUint16(3, data.key, true) + break + case 'keyup': + case 'mouseup': + buffer = new ArrayBuffer(5) + payload = new DataView(buffer) + payload.setUint8(0, OPCODE.KEY_UP) + payload.setUint16(1, 1, true) + payload.setUint16(3, data.key, true) + break + default: + this.emit('warn', `unknown data event: ${event}`) + } + + // @ts-ignore + if (typeof buffer !== 'undefined') { + this._channel!.send(buffer) + } + } + + public sendMessage(event: WebSocketEvents, payload?: WebSocketPayloads) { + if (!this.connected) { + this.emit('warn', `attemping to send message while dissconneted`) + return + } + this.emit('debug', `sending event '${event}' ${payload ? `with payload: ` : ''}`, payload) + this._ws!.send(JSON.stringify({ event, ...payload })) + } + + public createPeer() { + this.emit('debug', `creating peer`) + if (!this.socketOpen) { + this.emit( + 'warn', + `attempting to create peer with no websocket: `, + this._ws ? `state: ${this._ws.readyState}` : 'no socket', + ) + return + } + + if (this.peerConnected) { + this.emit('warn', `attempting to create peer while connected`) + return + } + + this._peer = new RTCPeerConnection({ + iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], + }) + + this._peer.onicecandidate = event => { + if (event.candidate === null && this._peer!.localDescription) { + this.emit('debug', `sending event '${EVENT.SIGNAL.PROVIDE}' with payload`, this._peer!.localDescription.sdp) + this._ws!.send( + JSON.stringify({ + event: EVENT.SIGNAL.PROVIDE, + sdp: this._peer!.localDescription.sdp, + }), + ) + } + } + + this._peer.oniceconnectionstatechange = event => { + this._state = this._peer!.iceConnectionState + this.emit('debug', `peer connection state chagned: ${this._state}`) + + switch (this._state) { + case 'connected': + this.onConnected() + break + case 'disconnected': + this.onDisconnected(new Error('peer disconnected')) + break + } + } + + this._peer.ontrack = this.onTrack.bind(this) + this._peer.addTransceiver('audio', { direction: 'recvonly' }) + this._peer.addTransceiver('video', { direction: 'recvonly' }) + + this._channel = this._peer.createDataChannel('data') + this._channel.onerror = this.onError.bind(this) + this._channel.onmessage = this.onData.bind(this) + this._channel.onclose = this.onDisconnected.bind(this, new Error('peer data channel closed')) + + this._peer + .createOffer() + .then(d => this._peer!.setLocalDescription(d)) + .catch(err => this.emit('error', err)) + } + + private setRemoteDescription(payload: SignalPayload) { + if (this.peerConnected) { + this.emit('warn', `received ${event} with no peer!`) + return + } + this._peer!.setRemoteDescription({ type: 'answer', sdp: payload.sdp }) + } + + private onMessage(e: MessageEvent) { + const { event, ...payload } = JSON.parse(e.data) as WebSocketMessages + + this.emit('debug', `received websocket event ${event} ${payload ? `with payload: ` : ''}`, payload) + + switch (event) { + case EVENT.IDENTITY.PROVIDE: + this[EVENT.IDENTITY.PROVIDE](payload as IdentityPayload) + this.createPeer() + break + case EVENT.SIGNAL.ANSWER: + this.setRemoteDescription(payload as SignalPayload) + break + default: + // @ts-ignore + if (typeof this[event] === 'function') { + // @ts-ignore + this[event](payload) + } else { + this[EVENT.MESSAGE](event, payload) + } + } + } + + private onData(e: MessageEvent) { + this[EVENT.DATA](e.data) + } + + private onTrack(event: RTCTrackEvent) { + this.emit('debug', `received ${event.track.kind} track from peer: ${event.track.id}`, event) + const stream = event.streams[0] + if (!stream) { + this.emit('warn', `no stream provided for track ${event.track.id}(${event.track.label})`) + return + } + this[EVENT.TRACK](event) + } + + private onError(event: Event) { + this.emit('error', (event as ErrorEvent).error) + } + + private onConnected() { + if (this._timeout) { + clearTimeout(this._timeout) + } + + if (!this.connected) { + this.emit('warn', `onConnected called while being disconnected`) + return + } + + this.emit('debug', `sending event '${EVENT.IDENTITY.DETAILS}' with payload`, { username: this._username }) + this._ws!.send( + JSON.stringify({ + event: EVENT.IDENTITY.DETAILS, + username: this._username, + }), + ) + + this.emit('debug', `connected`) + this[EVENT.CONNECTED]() + } + + private onTimeout() { + this.emit('debug', `connection timedout`) + if (this._timeout) { + clearTimeout(this._timeout) + } + this.onDisconnected(new Error('connection timeout')) + } + + private onDisconnected(reason?: Error) { + if (this.socketOpen) { + try { + this._ws!.close() + } catch (err) {} + this._ws = undefined + } + + if (this.peerConnected) { + try { + this._peer!.close() + } catch (err) {} + this._peer = undefined + } + + this.emit('debug', `disconnected:`, reason) + this[EVENT.DISCONNECTED](reason) + } + + [EVENT.MESSAGE](event: string, payload: any) { + this.emit('warn', `unhandled websocket event '${event}':`, payload) + } + + abstract [EVENT.CONNECTING](): void + abstract [EVENT.CONNECTED](): void + abstract [EVENT.DISCONNECTED](reason?: Error): void + abstract [EVENT.TRACK](event: RTCTrackEvent): void + abstract [EVENT.DATA](data: any): void + abstract [EVENT.IDENTITY.PROVIDE](payload: IdentityPayload): void +} diff --git a/client/src/client/data.ts b/client/src/client/data.ts new file mode 100644 index 00000000..dbf7f41b --- /dev/null +++ b/client/src/client/data.ts @@ -0,0 +1,6 @@ +export const OPCODE = { + MOVE: 0x01, + SCROLL: 0x02, + KEY_DOWN: 0x03, + KEY_UP: 0x04, +} as const diff --git a/client/src/client/events.ts b/client/src/client/events.ts new file mode 100644 index 00000000..fad0de51 --- /dev/null +++ b/client/src/client/events.ts @@ -0,0 +1,51 @@ +export const EVENT = { + CONNECTING: 'CONNECTING', + CONNECTED: 'CONNECTED', + DISCONNECTED: 'DISCONNECTED', + TRACK: 'TRACK', + MESSAGE: 'MESSAGE', + DATA: 'DATA', + SIGNAL: { + ANSWER: 'signal/answer', + PROVIDE: 'signal/provide', + }, + IDENTITY: { + PROVIDE: 'identity/provide', + DETAILS: 'identity/details', + }, + MEMBER: { + LIST: 'member/list', + CONNECTED: 'member/connected', + DISCONNECTED: 'member/disconnected', + }, + CONTROL: { + LOCKED: 'control/locked', + RELEASE: 'control/release', + REQUEST: 'control/request', + REQUESTING: 'control/requesting', + }, + CHAT: { + SEND: 'chat/send', + RECEIVE: 'chat/receive', + EMOJI: 'chat/emoji', + }, + ADMIN: { + BAN: 'admin/ban', + KICK: 'admin/kick', + LOCK: 'admin/lock', + MUTE: 'admin/mute', + UNMUTE: 'admin/unmute', + FORCE: { + CONTROL: 'admin/force/control', + RELEASE: 'admin/force/release', + }, + }, +} as const + +export type Events = typeof EVENT +export type WebSocketEvents = ControlEvents | IdentityEvents | MemberEvents | SignalEvents | ChatEvents +export type ControlEvents = typeof EVENT.CONTROL.LOCKED | typeof EVENT.CONTROL.RELEASE | typeof EVENT.CONTROL.REQUEST +export type IdentityEvents = typeof EVENT.IDENTITY.PROVIDE | typeof EVENT.IDENTITY.DETAILS +export type MemberEvents = typeof EVENT.MEMBER.LIST | typeof EVENT.MEMBER.CONNECTED | typeof EVENT.MEMBER.DISCONNECTED +export type SignalEvents = typeof EVENT.SIGNAL.ANSWER | typeof EVENT.SIGNAL.PROVIDE +export type ChatEvents = typeof EVENT.CHAT.SEND | typeof EVENT.CHAT.RECEIVE diff --git a/client/src/client/index.ts b/client/src/client/index.ts new file mode 100644 index 00000000..a66e3d2a --- /dev/null +++ b/client/src/client/index.ts @@ -0,0 +1,179 @@ +import Vue from 'vue' +import EventEmitter from 'eventemitter3' +import { BaseClient, BaseEvents } from './base' + +import { EVENT } from './events' +import { accessor } from '~/store' +import { IdentityPayload, MemberListPayload, MemberDisconnectPayload, MemberPayload, ControlPayload } from './messages' + +interface NekoEvents extends BaseEvents {} + +export class NekoClient extends BaseClient implements EventEmitter { + private $vue!: Vue + private $accessor!: typeof accessor + + init(vue: Vue) { + this.$vue = vue + this.$accessor = vue.$accessor + } + + connect(password: string, username: string) { + const url = + process.env.NODE_ENV === 'development' + ? `ws://${process.env.VUE_APP_SERVER}/` + : `${/https/gi.test(location.protocol) ? 'wss' : 'ws'}://${location.host}/` + + super.connect(url, password, username) + } + + ///////////////////////////// + // Internal Events + ///////////////////////////// + [EVENT.CONNECTING]() { + this.$accessor.setConnnecting(true) + } + + [EVENT.CONNECTED]() { + this.$accessor.setConnected(true) + this.$accessor.setConnnecting(false) + this.$accessor.video.clearStream() + this.$accessor.remote.clearHost() + this.$vue.$notify({ + group: 'neko', + type: 'success', + title: 'Successfully connected', + duration: 5000, + speed: 1000, + }) + } + + [EVENT.DISCONNECTED](reason?: Error) { + this.$accessor.setConnected(false) + this.$accessor.setConnnecting(false) + this.$accessor.video.clearStream() + this.$accessor.user.clearMembers() + this.$vue.$notify({ + group: 'neko', + type: 'error', + title: `Disconnected`, + text: reason ? reason.message : undefined, + duration: 5000, + speed: 1000, + }) + } + + [EVENT.TRACK](event: RTCTrackEvent) { + if (event.track.kind === 'audio') { + return + } + this.$accessor.video.addStream(event.streams[0]) + this.$accessor.video.setStream(0) + } + + [EVENT.DATA](data: any) {} + + ///////////////////////////// + // Identity Events + ///////////////////////////// + [EVENT.IDENTITY.PROVIDE]({ id }: IdentityPayload) { + this.$accessor.user.setMember(id) + } + + ///////////////////////////// + // Member Events + ///////////////////////////// + [EVENT.MEMBER.LIST]({ members }: MemberListPayload) { + this.$accessor.user.setMembers(members) + } + + [EVENT.MEMBER.CONNECTED](member: MemberPayload) { + this.$accessor.user.addMember(member) + + if (member.id !== this.$accessor.user.id) { + this.$vue.$notify({ + group: 'neko', + type: 'info', + title: `${member.username} connected`, + duration: 5000, + speed: 1000, + }) + } + } + + [EVENT.MEMBER.DISCONNECTED]({ id }: MemberDisconnectPayload) { + this.$vue.$notify({ + group: 'neko', + type: 'info', + title: `${this.$accessor.user.members[id].username} disconnected`, + duration: 5000, + speed: 1000, + }) + this.$accessor.user.delMember(id) + } + + ///////////////////////////// + // Control Events + ///////////////////////////// + [EVENT.CONTROL.LOCKED]({ id }: ControlPayload) { + this.$accessor.remote.setHost(id) + if (this.$accessor.user.id === id) { + this.$vue.$notify({ + group: 'neko', + type: 'info', + title: `You have the controls`, + duration: 5000, + speed: 1000, + }) + } else { + this.$vue.$notify({ + group: 'neko', + type: 'info', + title: `${this.$accessor.user.members[id].username} took the controls`, + duration: 5000, + speed: 1000, + }) + } + } + + [EVENT.CONTROL.RELEASE]({ id }: ControlPayload) { + this.$accessor.remote.clearHost() + if (this.$accessor.user.id === id) { + this.$vue.$notify({ + group: 'neko', + type: 'info', + title: `You released the controls`, + duration: 5000, + speed: 1000, + }) + } else { + this.$vue.$notify({ + group: 'neko', + type: 'info', + title: `The controls released from ${this.$accessor.user.members[id].username}`, + duration: 5000, + speed: 1000, + }) + } + } + + [EVENT.CONTROL.REQUEST]({ id }: ControlPayload) { + this.$vue.$notify({ + group: 'neko', + type: 'info', + title: `${this.$accessor.user.members[id].username} has the controls`, + text: 'But I let them know you wanted it', + duration: 5000, + speed: 1000, + }) + } + + [EVENT.CONTROL.REQUESTING]({ id }: ControlPayload) { + this.$vue.$notify({ + group: 'neko', + type: 'info', + title: `${this.$accessor.user.members[id].username} is requesting the controls`, + duration: 5000, + speed: 1000, + }) + } +} diff --git a/client/src/client/messages.ts b/client/src/client/messages.ts new file mode 100644 index 00000000..73b4720c --- /dev/null +++ b/client/src/client/messages.ts @@ -0,0 +1,95 @@ +import { WebSocketEvents, EVENT } from './events' +import { Member } from './types' + +export type WebSocketMessages = + | WebSocketMessage + | IdentityMessage + | SignalMessage + | MemberListMessage + | MembeConnectMessage + | MembeDisconnectMessage + | ControlMessage + | ChatMessage + +export type WebSocketPayloads = + | IdentityPayload + | SignalPayload + | MemberListPayload + | Member + | ControlPayload + | ChatPayload + +export interface WebSocketMessage { + event: WebSocketEvents | string +} + +/* + IDENTITY MESSAGES/PAYLOADS +*/ +// identity/provide +export interface IdentityMessage extends WebSocketMessage, IdentityPayload { + event: typeof EVENT.IDENTITY.PROVIDE +} +export interface IdentityPayload { + id: string +} + +/* + SIGNAL MESSAGES/PAYLOADS +*/ +// signal/answer +export interface SignalMessage extends WebSocketMessage, SignalPayload { + event: typeof EVENT.SIGNAL.ANSWER +} +export interface SignalPayload { + sdp: string +} + +/* + MEMBER MESSAGES/PAYLOADS +*/ +// member/list +export interface MemberListMessage extends WebSocketMessage, MemberListPayload { + event: typeof EVENT.MEMBER.LIST +} +export interface MemberListPayload { + members: Member[] +} + +// member/connected +export interface MembeConnectMessage extends WebSocketMessage, MemberPayload { + event: typeof EVENT.MEMBER.CONNECTED +} +export type MemberPayload = Member + +// member/disconnected +export interface MembeDisconnectMessage extends WebSocketMessage, MemberPayload { + event: typeof EVENT.MEMBER.DISCONNECTED +} +export interface MemberDisconnectPayload { + id: string +} + +/* + CONTROL MESSAGES/PAYLOADS +*/ +// control/locked & control/release & control/request +export interface ControlMessage extends WebSocketMessage, ControlPayload { + event: typeof EVENT.CONTROL.LOCKED | typeof EVENT.CONTROL.RELEASE | typeof EVENT.CONTROL.REQUEST +} +export interface ControlPayload { + id: string +} + +/* + CHAT PAYLOADS +*/ +// chat/send & chat/receive +export interface ChatMessage extends WebSocketMessage, ChatPayload { + event: typeof EVENT.CHAT.SEND | typeof EVENT.CHAT.RECEIVE +} + +export interface ChatPayload { + id: string + content: string +} diff --git a/client/src/client/types.ts b/client/src/client/types.ts new file mode 100644 index 00000000..db27a759 --- /dev/null +++ b/client/src/client/types.ts @@ -0,0 +1,5 @@ +export interface Member { + id: string + username: string + admin: boolean +} diff --git a/client/src/main.ts b/client/src/main.ts index b44d343e..9dba38d4 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -1,12 +1,22 @@ import './assets/styles/main.scss' +import { EVENT } from '~/client/events' + import Vue from 'vue' import Notifications from 'vue-notification' +import Client from './plugins/neko' import App from './App.vue' +import store from './store' Vue.config.productionTip = false + Vue.use(Notifications) +Vue.use(Client) new Vue({ + store, render: h => h(App), + created() { + this.$client.init(this) + }, }).$mount('#neko') diff --git a/client/src/plugins/neko.ts b/client/src/plugins/neko.ts new file mode 100644 index 00000000..8e7cf5d3 --- /dev/null +++ b/client/src/plugins/neko.ts @@ -0,0 +1,17 @@ +import { PluginObject } from 'vue' +import { NekoClient } from '~/client' + +const plugin: PluginObject = { + install(Vue) { + console.log() + const client = new NekoClient() + .on('error', error => console.error('[%cNEKO%c] %cERR', 'color: #498ad8;', '', 'color: #d84949;', error)) + .on('warn', (...log) => console.warn('[%cNEKO%c] %cWRN', 'color: #498ad8;', '', 'color: #eae364;', ...log)) + .on('info', (...log) => console.info('[%cNEKO%c] %cINF', 'color: #498ad8;', '', 'color: #4ac94c;', ...log)) + .on('debug', (...log) => console.log('[%cNEKO%c] %cDBG', 'color: #498ad8;', '', 'color: #eae364;', ...log)) + + Vue.prototype.$client = client + }, +} + +export default plugin diff --git a/client/src/store/chat.ts b/client/src/store/chat.ts new file mode 100644 index 00000000..3f2e6c24 --- /dev/null +++ b/client/src/store/chat.ts @@ -0,0 +1,30 @@ +import { getterTree, mutationTree, actionTree } from 'typed-vuex' + +export const namespaced = true + +interface Message { + id: string + content: string + created: Date +} + +export const state = () => ({ + messages: [] as Message[], +}) + +export const getters = getterTree(state, { + // +}) + +export const mutations = mutationTree(state, { + addMessage(state, message: Message) { + state.messages = state.messages.concat([message]) + }, +}) + +export const actions = actionTree( + { state, getters, mutations }, + { + // + }, +) diff --git a/client/src/store/index.ts b/client/src/store/index.ts new file mode 100644 index 00000000..6c3f3949 --- /dev/null +++ b/client/src/store/index.ts @@ -0,0 +1,53 @@ +import Vue from 'vue' +import Vuex from 'vuex' +import { useAccessor, mutationTree, actionTree } from 'typed-vuex' + +import * as video from './video' +import * as remote from './remote' +import * as user from './user' + +export const state = () => ({ + connecting: false, + connected: false, +}) + +// type RootState = ReturnType + +export const getters = { + // connected: (state: RootState) => state.connected +} + +export const mutations = mutationTree(state, { + initialiseStore() { + // TODO: init with localstorage to retrieve save settings + }, + setConnnecting(state, connecting: boolean) { + state.connecting = connecting + }, + setConnected(state, connected: boolean) { + state.connected = connected + }, +}) + +export const actions = actionTree( + { state, getters, mutations }, + { + // + }, +) + +export const storePattern = { + state, + mutations, + actions, + modules: { video, user, remote }, +} + +Vue.use(Vuex) + +const store = new Vuex.Store(storePattern) +export const accessor = useAccessor(store, storePattern) + +Vue.prototype.$accessor = accessor + +export default store diff --git a/client/src/store/remote.ts b/client/src/store/remote.ts new file mode 100644 index 00000000..dd470e0e --- /dev/null +++ b/client/src/store/remote.ts @@ -0,0 +1,37 @@ +import { getterTree, mutationTree, actionTree } from 'typed-vuex' +import { Member } from '~/client/types' + +export const namespaced = true + +export const state = () => ({ + id: '', +}) + +export const getters = getterTree(state, { + hosting: (state, getters, root) => { + return root.user.id === state.id + }, + host: (state, getters, root) => { + return root.user.member[state.id] || null + }, +}) + +export const mutations = mutationTree(state, { + clearHost(state) { + state.id = '' + }, + setHost(state, host: string | Member) { + if (typeof host === 'string') { + state.id = host + } else { + state.id = host.id + } + }, +}) + +export const actions = actionTree( + { state, getters, mutations }, + { + // + }, +) diff --git a/client/src/store/user.ts b/client/src/store/user.ts new file mode 100644 index 00000000..8440c524 --- /dev/null +++ b/client/src/store/user.ts @@ -0,0 +1,52 @@ +import { getterTree, mutationTree, actionTree } from 'typed-vuex' +import { Member } from '~/client/types' + +export const namespaced = true + +interface Members { + [id: string]: Member +} + +export const state = () => ({ + id: '', + members: {} as Members, +}) + +export const getters = getterTree(state, { + member: state => state.members[state.id] || null, + admin: state => (state.members[state.id] ? state.members[state.id].admin : false), +}) + +export const mutations = mutationTree(state, { + setMembers(state, members: Member[]) { + const data: Members = {} + for (const member of members) { + data[member.id] = member + } + state.members = data + }, + setMember(state, id: string) { + state.id = id + }, + addMember(state, member: Member) { + state.members = { + ...state.members, + [member.id]: member, + } + }, + delMember(state, id: string) { + const data = { ...state.members } + delete data[id] + state.members = data + }, + clearMembers(state) { + state.members = {} + }, +}) + +export const actions = actionTree( + { state, getters, mutations }, + { + // + }, +) diff --git a/client/src/store/video.ts b/client/src/store/video.ts new file mode 100644 index 00000000..64b237cc --- /dev/null +++ b/client/src/store/video.ts @@ -0,0 +1,89 @@ +import { getterTree, mutationTree, actionTree } from 'typed-vuex' + +export const namespaced = true + +export const state = () => ({ + index: -1, + streams: [] as MediaStream[], + width: 1280, + height: 720, + volume: 0, + playing: false, +}) + +export const getters = getterTree(state, { + stream: state => state.streams[state.index], + resolution: state => ({ w: state.width, h: state.height }), + aspect: state => { + const { width, height } = state + + if ((height == 0 && width == 0) || (height == 0 && width != 0) || (height != 0 && width == 0)) { + return null + } + + if (height == width) { + return { + horizontal: 1, + vertical: 1, + } + } + + let dividend = width + let divisor = height + let gcd = -1 + + if (height > width) { + dividend = height + divisor = width + } + + while (gcd == -1) { + const remainder = dividend % divisor + if (remainder == 0) { + gcd = divisor + } else { + dividend = divisor + divisor = remainder + } + } + + return { + horizontal: width / gcd, + vertical: height / gcd, + } + }, +}) + +export const mutations = mutationTree(state, { + setResolution(state, { width, height }: { width: number; height: number }) { + state.width = width + state.height = height + }, + + setVolume(state, volume: number) { + state.volume = volume + }, + + setStream(state, index: number) { + state.index = index + }, + + addStream(state, stream: MediaStream) { + state.streams = state.streams.concat([stream]) + }, + + delStream(state, index: number) { + state.streams = state.streams.filter((_, i) => i !== index) + }, + + clearStream(state) { + state.streams = [] + }, +}) + +export const actions = actionTree( + { state, getters, mutations }, + { + // + }, +) diff --git a/client/src/types/eventemitter.d.ts b/client/src/types/eventemitter.d.ts new file mode 100644 index 00000000..5bdd7623 --- /dev/null +++ b/client/src/types/eventemitter.d.ts @@ -0,0 +1,25 @@ +declare module 'eventemitter3' { + type Arguments = [T] extends [(...args: infer U) => any] ? U : [T] extends [void] ? [] : [T] + + class TypedEventEmitter { + addListener(event: E, listener: Events[E]): this + on(event: E, listener: Events[E]): this + once(event: E, listener: Events[E]): this + prependListener(event: E, listener: Events[E]): this + prependOnceListener(event: E, listener: Events[E]): this + + off(event: E, listener: Events[E]): this + removeAllListeners(event?: E): this + removeListener(event: E, listener: Events[E]): this + + emit(event: E, ...args: Arguments): boolean + eventNames(): (keyof Events)[] + listeners(event: E): Function[] + listenerCount(event: E): number + + getMaxListeners(): number + setMaxListeners(maxListeners: number): this + } + + export = TypedEventEmitter +} diff --git a/client/src/types/vue.d.ts b/client/src/types/vue.d.ts new file mode 100644 index 00000000..4fd4b5be --- /dev/null +++ b/client/src/types/vue.d.ts @@ -0,0 +1,9 @@ +import { NekoClient } from '~/client' +import { accessor } from '~/store' + +declare module 'vue/types/vue' { + interface Vue { + $accessor: typeof accessor + $client: NekoClient + } +} diff --git a/client/tsconfig.json b/client/tsconfig.json index 41a3a627..62ea38f5 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "esnext", "module": "esnext", "strict": true, "jsx": "preserve", @@ -15,6 +15,9 @@ "webpack-env" ], "paths": { + "~/*": [ + "src/*" + ], "@/*": [ "src/*" ] diff --git a/client/vue.config.js b/client/vue.config.js index a3ae613e..0844eaa9 100644 --- a/client/vue.config.js +++ b/client/vue.config.js @@ -1,3 +1,5 @@ +const path = require('path') + module.exports = { css: { loaderOptions: { @@ -8,4 +10,11 @@ module.exports = { }, }, }, + configureWebpack: { + resolve: { + alias: { + '~': path.resolve(__dirname, 'src/'), + }, + }, + }, }