client refactor progress

This commit is contained in:
Craig 2020-01-20 14:36:18 +00:00
parent 46928ec7de
commit e542627805
19 changed files with 1110 additions and 424 deletions

View File

@ -23,7 +23,9 @@
"vue": "^2.6.10", "vue": "^2.6.10",
"vue-class-component": "^7.0.2", "vue-class-component": "^7.0.2",
"vue-notification": "^1.3.20", "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": { "devDependencies": {
"@vue/cli-plugin-eslint": "^4.1.0", "@vue/cli-plugin-eslint": "^4.1.0",

View File

@ -31,7 +31,7 @@
<li> <li>
<i <i
alt="Request Control" alt="Request Control"
:class="[{ enabled: controlling }, 'request', 'fas', 'fa-keyboard']" :class="[{ enabled: hosting }, 'request', 'fas', 'fa-keyboard']"
@click.stop.prevent="toggleControl" @click.stop.prevent="toggleControl"
/> />
</li> </li>
@ -68,7 +68,8 @@
</div> </div>
<form class="message" v-if="!connecting" @submit.stop.prevent="connect"> <form class="message" v-if="!connecting" @submit.stop.prevent="connect">
<span>Please enter the password:</span> <span>Please enter the password:</span>
<input type="password" v-model="password" /> <input type="text" placeholder="Username" v-model="username" />
<input type="password" placeholder="Password" v-model="password" />
<button type="submit" class="button" @click.stop.prevent="connect"> <button type="submit" class="button" @click.stop.prevent="connect">
Connect Connect
</button> </button>
@ -378,12 +379,7 @@
<script lang="ts"> <script lang="ts">
import { Component, Ref, Watch, Vue } from 'vue-property-decorator' import { Component, Ref, Watch, Vue } from 'vue-property-decorator'
import { EVENT } from '~/client/events'
const OP_MOVE = 0x01
const OP_SCROLL = 0x02
const OP_KEY_DOWN = 0x03
const OP_KEY_UP = 0x04
// const OP_KEY_CLK = 0x05
@Component({ name: 'stream-video' }) @Component({ name: 'stream-video' })
export default class extends Vue { export default class extends Vue {
@ -394,22 +390,29 @@
@Ref('volume') readonly _volume!: HTMLInputElement @Ref('volume') readonly _volume!: HTMLInputElement
private focused = false private focused = false
private connected = false
private connecting = false
private controlling = false
private playing = false private playing = false
private volume = 0 private username = ''
private width = 1280
private height = 720
private state: RTCIceConnectionState = 'disconnected'
private password = '' private password = ''
private ws?: WebSocket get connected() {
private peer?: RTCPeerConnection return this.$accessor.connected
private channel?: RTCDataChannel }
private id?: string
private stream?: MediaStream get connecting() {
private timeout?: number return this.$accessor.connecting
}
get hosting() {
return this.$accessor.remote.hosting
}
get volume() {
return this.$accessor.video.volume
}
get stream() {
return this.$accessor.video.stream
}
@Watch('volume') @Watch('volume')
onVolumeChanged(volume: number) { onVolumeChanged(volume: number) {
@ -418,360 +421,9 @@
} }
} }
mounted() { @Watch('stream')
window.addEventListener('resize', this.onResise) onStreamChanged(stream?: MediaStream) {
this.onResise() if (!this._player || !stream) {
this.volume = this._player.volume * 100
this._volume.value = `${this.volume}`
}
beforeDestroy() {
window.removeEventListener('resize', this.onResise)
this.onClose()
}
toggleControl() {
if (!this.ws) {
return
}
if (this.controlling) {
this.ws.send(JSON.stringify({ event: 'control/release' }))
} else {
this.ws.send(JSON.stringify({ event: 'control/request' }))
}
}
toggleMedia() {
console.log(`[NEKO] toggleMedia`, this.playing)
if (!this.playing) {
this._player
.play()
.then(() => {
this.playing = true
this.width = this._player.videoWidth
this.height = this._player.videoHeight
this.onResise()
})
.catch(err => {
console.error(err)
})
} else {
this._player.pause()
this.playing = false
}
}
setVolume() {
this.volume = parseInt(this._volume.value)
}
fullscreen() {
this._video.requestFullscreen()
}
connect() {
this.ws = new WebSocket(
process.env.NODE_ENV === 'development'
? `ws://${process.env.VUE_APP_SERVER}/ws?password=${this.password}`
: `${/https/gi.test(location.protocol) ? 'wss' : 'ws'}://${location.host}/ws?password=${this.password}`,
)
this.ws.onmessage = this.onMessage.bind(this)
this.ws.onerror = event => console.error((event as ErrorEvent).error)
this.ws.onclose = event => this.onClose.bind(this)
this.timeout = setTimeout(this.onTimeout.bind(this), 5000)
this.onConnecting()
}
createPeer() {
if (!this.ws) {
return
}
this.peer = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun.services.mozilla.com' }],
})
this.peer.onicecandidate = event => {
if (event.candidate === null && this.peer!.localDescription) {
this.ws!.send(
JSON.stringify({
event: 'sdp/provide',
sdp: this.peer!.localDescription.sdp,
}),
)
}
}
this.peer.oniceconnectionstatechange = event => {
this.state = this.peer!.iceConnectionState
switch (this.state) {
case 'connected':
this.onConnected()
break
case 'disconnected':
this.onClose()
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.peer
.createOffer()
.then(d => this.peer!.setLocalDescription(d))
.catch(err => console.log(err))
}
updateControles(event: 'wheel', data: { x: number; y: number }): void
updateControles(event: 'mousemove', data: { x: number; y: number; rect: DOMRect }): void
updateControles(event: 'mousedown' | 'mouseup' | 'keydown' | 'keyup', data: { key: number }): void
updateControles(event: string, data: any) {
if (!this.controlling) {
return
}
let buffer: ArrayBuffer
let payload: DataView
switch (event) {
case 'mousemove':
buffer = new ArrayBuffer(7)
payload = new DataView(buffer)
payload.setUint8(0, OP_MOVE)
payload.setUint16(1, 4, true)
payload.setUint16(3, Math.round((this.width / data.rect.width) * (data.x - data.rect.left)), true)
payload.setUint16(5, Math.round((this.height / data.rect.height) * (data.y - data.rect.top)), true)
break
case 'wheel':
buffer = new ArrayBuffer(7)
payload = new DataView(buffer)
payload.setUint8(0, OP_SCROLL)
payload.setUint16(1, 4, true)
payload.setInt16(3, (data.x * -1) / 10, true)
payload.setInt16(5, (data.y * -1) / 10, true)
break
case 'keydown':
case 'mousedown':
buffer = new ArrayBuffer(5)
payload = new DataView(buffer)
payload.setUint8(0, OP_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, OP_KEY_UP)
payload.setUint16(1, 1, true)
payload.setUint16(3, data.key, true)
break
}
// @ts-ignore
if (this.channel && typeof buffer !== 'undefined') {
this.channel.send(buffer)
}
}
getAspect() {
const { width, height } = this
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,
}
}
onMousePos(e: MouseEvent) {
const rect = this._player.getBoundingClientRect() as DOMRect
this.updateControles('mousemove', {
x: e.clientX,
y: e.clientY,
rect,
})
}
onWheel(e: WheelEvent) {
this.onMousePos(e)
this.updateControles('wheel', { x: e.deltaX, y: e.deltaY })
console.log('wheel', { x: e.deltaX, y: e.deltaY })
}
onMouseDown(e: MouseEvent) {
this.onMousePos(e)
this.updateControles('mousedown', { key: e.button })
console.log('mousedown', { key: e.button })
}
onMouseUp(e: MouseEvent) {
this.onMousePos(e)
this.updateControles('mouseup', { key: e.button })
console.log('mouseup', { key: e.button })
}
onMouseMove(e: MouseEvent) {
this.onMousePos(e)
}
onMouseEnter(e: MouseEvent) {
this._player.focus()
this.focused = true
}
onMouseLeave(e: MouseEvent) {
this.focused = false
}
onKeyDown(e: KeyboardEvent) {
if (!this.focused) {
return
}
this.updateControles('keydown', { key: e.keyCode })
console.log('keydown', { key: e.keyCode })
}
onKeyUp(e: KeyboardEvent) {
if (!this.focused) {
return
}
this.updateControles('keyup', { key: e.keyCode })
console.log('keyup', { key: e.keyCode })
}
onResise() {
const aspect = this.getAspect()
if (!aspect) {
return
}
const { horizontal, vertical } = aspect
this._container.style.maxWidth = `${(horizontal / vertical) * this._video.offsetHeight}px`
this._aspect.style.paddingBottom = `${(vertical / horizontal) * 100}%`
}
onMessage(e: MessageEvent) {
const { event, ...payload } = JSON.parse(e.data)
switch (event) {
case 'sdp/reply':
if (!this.peer) {
return
}
this.peer.setRemoteDescription(new RTCSessionDescription({ type: 'answer', sdp: payload.sdp }))
break
case 'identity/provide':
this.id = payload.id
this.createPeer()
break
case 'control/requesting':
this.controlling = true
this.$notify({
group: 'neko',
type: 'info',
title: 'Another user is requesting the controls',
duration: 3000,
speed: 1000,
})
break
case 'control/give':
this.controlling = true
this.$notify({
group: 'neko',
type: 'info',
title: 'You have the controls',
duration: 5000,
speed: 1000,
})
break
case 'control/locked':
this.controlling = false
this.$notify({
group: 'neko',
type: 'info',
title: 'Another user has the controls',
duration: 3000,
speed: 1000,
})
break
case 'control/given':
this.controlling = false
this.$notify({
group: 'neko',
type: 'info',
title: 'Someone has taken the controls',
duration: 5000,
speed: 1000,
})
break
case 'control/release':
this.controlling = false
this.$notify({
group: 'neko',
type: 'info',
title: 'You released the controls',
duration: 5000,
speed: 1000,
})
break
case 'control/released':
this.controlling = false
this.$notify({
group: 'neko',
type: 'info',
title: 'The controls have been released',
duration: 5000,
speed: 1000,
})
break
default:
console.warn(`[NEKO] unknown message event ${event}`)
}
}
onTrack(event: RTCTrackEvent) {
if (event.track.kind === 'audio') {
return
}
console.log(`[NEKO] track recieved`, event)
this.stream = event.streams[0]
if (!this.stream) {
return return
} }
@ -787,67 +439,133 @@
} }
} }
onTimeout() { mounted() {
this.connected = false window.addEventListener('resize', this.onResise)
this.connecting = false this.onResise()
this.$notify({ this._player.volume = this.volume / 100
group: 'neko', this._volume.value = `${this.volume}`
type: 'error', this.onStreamChanged(this.stream)
title: 'Unable to connect to server!',
duration: 5000,
speed: 1000,
})
} }
onConnecting() { beforeDestroy() {
this.connecting = true window.removeEventListener('resize', this.onResise)
} }
onConnected() { toggleMedia() {
this.connected = true if (!this.playing) {
this.connecting = false this._player
this.$notify({ .play()
group: 'neko', .then(() => {
type: 'success', const { videoWidth, videoHeight } = this._player
title: 'Successfully connected!', this.$accessor.video.setResolution({ width: videoWidth, height: videoHeight })
duration: 5000, this.playing = true
speed: 1000, this.onResise()
}) })
if (this.timeout) { .catch(err => {})
clearTimeout(this.timeout) } else {
this._player.pause()
this.playing = false
} }
} }
onClose() { connect() {
this.controlling = false this.$client.connect(this.password, this.username)
this.connected = false }
this.connecting = false
if (this.ws) { toggleControl() {
try { if (!this.connected) {
this.ws.close() return
} catch (err) {}
this.ws = undefined
} }
if (this.peer) { if (!this.hosting) {
try { this.$client.sendMessage(EVENT.CONTROL.REQUEST)
this.peer.close() } else {
} catch (err) {} this.$client.sendMessage(EVENT.CONTROL.RELEASE)
this.peer = undefined
} }
}
if (this.playing) { setVolume() {
this.toggleMedia() this.$accessor.video.setVolume(parseInt(this._volume.value))
} }
this.$notify({ fullscreen() {
group: 'neko', this._video.requestFullscreen()
type: 'error', }
title: 'Disconnected from server!',
duration: 5000, onMousePos(e: MouseEvent) {
speed: 1000, const { w, h } = this.$accessor.video.resolution
const rect = this._player.getBoundingClientRect()
this.$client.sendData('mousemove', {
x: Math.round((w / rect.width) * (e.clientX - rect.left)),
y: Math.round((h / rect.height) * (e.clientY - rect.top)),
}) })
} }
onWheel(e: WheelEvent) {
if (!this.hosting) {
return
}
this.onMousePos(e)
this.$client.sendData('wheel', {
x: (e.deltaX * -1) / 10,
y: (e.deltaY * -1) / 10,
}) // TODO: Add user settings
}
onMouseDown(e: MouseEvent) {
if (!this.hosting) {
return
}
this.onMousePos(e)
this.$client.sendData('mousedown', { key: e.button })
}
onMouseUp(e: MouseEvent) {
if (!this.hosting) {
return
}
this.onMousePos(e)
this.$client.sendData('mouseup', { key: e.button })
}
onMouseMove(e: MouseEvent) {
if (!this.hosting) {
return
}
this.onMousePos(e)
}
onMouseEnter(e: MouseEvent) {
this._player.focus()
this.focused = true
}
onMouseLeave(e: MouseEvent) {
this.focused = false
}
onKeyDown(e: KeyboardEvent) {
if (!this.focused || !this.hosting) {
return
}
this.$client.sendData('keydown', { key: e.keyCode })
}
onKeyUp(e: KeyboardEvent) {
if (!this.focused || !this.hosting) {
return
}
this.$client.sendData('keyup', { key: e.keyCode })
}
onResise() {
const aspect = this.$accessor.video.aspect
if (!aspect) {
return
}
const { horizontal, vertical } = aspect
this._container.style.maxWidth = `${(horizontal / vertical) * this._video.offsetHeight}px`
this._aspect.style.paddingBottom = `${(vertical / horizontal) * 100}%`
}
} }
</script> </script>

296
client/src/client/base.ts Normal file
View File

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

View File

@ -0,0 +1,6 @@
export const OPCODE = {
MOVE: 0x01,
SCROLL: 0x02,
KEY_DOWN: 0x03,
KEY_UP: 0x04,
} as const

View File

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

179
client/src/client/index.ts Normal file
View File

@ -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<NekoEvents> {
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,
})
}
}

View File

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

View File

@ -0,0 +1,5 @@
export interface Member {
id: string
username: string
admin: boolean
}

View File

@ -1,12 +1,22 @@
import './assets/styles/main.scss' import './assets/styles/main.scss'
import { EVENT } from '~/client/events'
import Vue from 'vue' import Vue from 'vue'
import Notifications from 'vue-notification' import Notifications from 'vue-notification'
import Client from './plugins/neko'
import App from './App.vue' import App from './App.vue'
import store from './store'
Vue.config.productionTip = false Vue.config.productionTip = false
Vue.use(Notifications) Vue.use(Notifications)
Vue.use(Client)
new Vue({ new Vue({
store,
render: h => h(App), render: h => h(App),
created() {
this.$client.init(this)
},
}).$mount('#neko') }).$mount('#neko')

View File

@ -0,0 +1,17 @@
import { PluginObject } from 'vue'
import { NekoClient } from '~/client'
const plugin: PluginObject<undefined> = {
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

30
client/src/store/chat.ts Normal file
View File

@ -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 },
{
//
},
)

53
client/src/store/index.ts Normal file
View File

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

View File

@ -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 },
{
//
},
)

52
client/src/store/user.ts Normal file
View File

@ -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 },
{
//
},
)

89
client/src/store/video.ts Normal file
View File

@ -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 },
{
//
},
)

25
client/src/types/eventemitter.d.ts vendored Normal file
View File

@ -0,0 +1,25 @@
declare module 'eventemitter3' {
type Arguments<T> = [T] extends [(...args: infer U) => any] ? U : [T] extends [void] ? [] : [T]
class TypedEventEmitter<Events> {
addListener<E extends keyof Events>(event: E, listener: Events[E]): this
on<E extends keyof Events>(event: E, listener: Events[E]): this
once<E extends keyof Events>(event: E, listener: Events[E]): this
prependListener<E extends keyof Events>(event: E, listener: Events[E]): this
prependOnceListener<E extends keyof Events>(event: E, listener: Events[E]): this
off<E extends keyof Events>(event: E, listener: Events[E]): this
removeAllListeners<E extends keyof Events>(event?: E): this
removeListener<E extends keyof Events>(event: E, listener: Events[E]): this
emit<E extends keyof Events>(event: E, ...args: Arguments<Events[E]>): boolean
eventNames(): (keyof Events)[]
listeners<E extends keyof Events>(event: E): Function[]
listenerCount<E extends keyof Events>(event: E): number
getMaxListeners(): number
setMaxListeners(maxListeners: number): this
}
export = TypedEventEmitter
}

9
client/src/types/vue.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
import { NekoClient } from '~/client'
import { accessor } from '~/store'
declare module 'vue/types/vue' {
interface Vue {
$accessor: typeof accessor
$client: NekoClient
}
}

View File

@ -1,6 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "esnext",
"module": "esnext", "module": "esnext",
"strict": true, "strict": true,
"jsx": "preserve", "jsx": "preserve",
@ -15,6 +15,9 @@
"webpack-env" "webpack-env"
], ],
"paths": { "paths": {
"~/*": [
"src/*"
],
"@/*": [ "@/*": [
"src/*" "src/*"
] ]

View File

@ -1,3 +1,5 @@
const path = require('path')
module.exports = { module.exports = {
css: { css: {
loaderOptions: { loaderOptions: {
@ -8,4 +10,11 @@ module.exports = {
}, },
}, },
}, },
configureWebpack: {
resolve: {
alias: {
'~': path.resolve(__dirname, 'src/'),
},
},
},
} }