mirror of
https://github.com/m1k1o/neko.git
synced 2024-07-24 14:40:50 +12:00
client refactor progress
This commit is contained in:
parent
46928ec7de
commit
e542627805
@ -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",
|
||||
|
@ -31,7 +31,7 @@
|
||||
<li>
|
||||
<i
|
||||
alt="Request Control"
|
||||
:class="[{ enabled: controlling }, 'request', 'fas', 'fa-keyboard']"
|
||||
:class="[{ enabled: hosting }, 'request', 'fas', 'fa-keyboard']"
|
||||
@click.stop.prevent="toggleControl"
|
||||
/>
|
||||
</li>
|
||||
@ -68,7 +68,8 @@
|
||||
</div>
|
||||
<form class="message" v-if="!connecting" @submit.stop.prevent="connect">
|
||||
<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">
|
||||
Connect
|
||||
</button>
|
||||
@ -378,12 +379,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Ref, Watch, Vue } from 'vue-property-decorator'
|
||||
|
||||
const OP_MOVE = 0x01
|
||||
const OP_SCROLL = 0x02
|
||||
const OP_KEY_DOWN = 0x03
|
||||
const OP_KEY_UP = 0x04
|
||||
// const OP_KEY_CLK = 0x05
|
||||
import { EVENT } from '~/client/events'
|
||||
|
||||
@Component({ name: 'stream-video' })
|
||||
export default class extends Vue {
|
||||
@ -394,22 +390,29 @@
|
||||
@Ref('volume') readonly _volume!: HTMLInputElement
|
||||
|
||||
private focused = false
|
||||
private connected = false
|
||||
private connecting = false
|
||||
private controlling = false
|
||||
private playing = false
|
||||
private volume = 0
|
||||
private width = 1280
|
||||
private height = 720
|
||||
private state: RTCIceConnectionState = 'disconnected'
|
||||
private username = ''
|
||||
private password = ''
|
||||
|
||||
private ws?: WebSocket
|
||||
private peer?: RTCPeerConnection
|
||||
private channel?: RTCDataChannel
|
||||
private id?: string
|
||||
private stream?: MediaStream
|
||||
private timeout?: number
|
||||
get connected() {
|
||||
return this.$accessor.connected
|
||||
}
|
||||
|
||||
get connecting() {
|
||||
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')
|
||||
onVolumeChanged(volume: number) {
|
||||
@ -418,360 +421,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
mounted() {
|
||||
window.addEventListener('resize', this.onResise)
|
||||
this.onResise()
|
||||
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) {
|
||||
@Watch('stream')
|
||||
onStreamChanged(stream?: MediaStream) {
|
||||
if (!this._player || !stream) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -787,67 +439,133 @@
|
||||
}
|
||||
}
|
||||
|
||||
onTimeout() {
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
this.$notify({
|
||||
group: 'neko',
|
||||
type: 'error',
|
||||
title: 'Unable to connect to server!',
|
||||
duration: 5000,
|
||||
speed: 1000,
|
||||
mounted() {
|
||||
window.addEventListener('resize', this.onResise)
|
||||
this.onResise()
|
||||
this._player.volume = this.volume / 100
|
||||
this._volume.value = `${this.volume}`
|
||||
this.onStreamChanged(this.stream)
|
||||
}
|
||||
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.onResise)
|
||||
}
|
||||
|
||||
toggleMedia() {
|
||||
if (!this.playing) {
|
||||
this._player
|
||||
.play()
|
||||
.then(() => {
|
||||
const { videoWidth, videoHeight } = this._player
|
||||
this.$accessor.video.setResolution({ width: videoWidth, height: videoHeight })
|
||||
this.playing = true
|
||||
this.onResise()
|
||||
})
|
||||
.catch(err => {})
|
||||
} else {
|
||||
this._player.pause()
|
||||
this.playing = false
|
||||
}
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.$client.connect(this.password, this.username)
|
||||
}
|
||||
|
||||
toggleControl() {
|
||||
if (!this.connected) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.hosting) {
|
||||
this.$client.sendMessage(EVENT.CONTROL.REQUEST)
|
||||
} else {
|
||||
this.$client.sendMessage(EVENT.CONTROL.RELEASE)
|
||||
}
|
||||
}
|
||||
|
||||
setVolume() {
|
||||
this.$accessor.video.setVolume(parseInt(this._volume.value))
|
||||
}
|
||||
|
||||
fullscreen() {
|
||||
this._video.requestFullscreen()
|
||||
}
|
||||
|
||||
onMousePos(e: MouseEvent) {
|
||||
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)),
|
||||
})
|
||||
}
|
||||
|
||||
onConnecting() {
|
||||
this.connecting = true
|
||||
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
|
||||
}
|
||||
|
||||
onConnected() {
|
||||
this.connected = true
|
||||
this.connecting = false
|
||||
this.$notify({
|
||||
group: 'neko',
|
||||
type: 'success',
|
||||
title: 'Successfully connected!',
|
||||
duration: 5000,
|
||||
speed: 1000,
|
||||
})
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout)
|
||||
onMouseDown(e: MouseEvent) {
|
||||
if (!this.hosting) {
|
||||
return
|
||||
}
|
||||
this.onMousePos(e)
|
||||
this.$client.sendData('mousedown', { key: e.button })
|
||||
}
|
||||
|
||||
onClose() {
|
||||
this.controlling = false
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
|
||||
if (this.ws) {
|
||||
try {
|
||||
this.ws.close()
|
||||
} catch (err) {}
|
||||
this.ws = undefined
|
||||
onMouseUp(e: MouseEvent) {
|
||||
if (!this.hosting) {
|
||||
return
|
||||
}
|
||||
this.onMousePos(e)
|
||||
this.$client.sendData('mouseup', { key: e.button })
|
||||
}
|
||||
|
||||
if (this.peer) {
|
||||
try {
|
||||
this.peer.close()
|
||||
} catch (err) {}
|
||||
this.peer = undefined
|
||||
onMouseMove(e: MouseEvent) {
|
||||
if (!this.hosting) {
|
||||
return
|
||||
}
|
||||
this.onMousePos(e)
|
||||
}
|
||||
|
||||
if (this.playing) {
|
||||
this.toggleMedia()
|
||||
onMouseEnter(e: MouseEvent) {
|
||||
this._player.focus()
|
||||
this.focused = true
|
||||
}
|
||||
|
||||
this.$notify({
|
||||
group: 'neko',
|
||||
type: 'error',
|
||||
title: 'Disconnected from server!',
|
||||
duration: 5000,
|
||||
speed: 1000,
|
||||
})
|
||||
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>
|
||||
|
296
client/src/client/base.ts
Normal file
296
client/src/client/base.ts
Normal 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
|
||||
}
|
6
client/src/client/data.ts
Normal file
6
client/src/client/data.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export const OPCODE = {
|
||||
MOVE: 0x01,
|
||||
SCROLL: 0x02,
|
||||
KEY_DOWN: 0x03,
|
||||
KEY_UP: 0x04,
|
||||
} as const
|
51
client/src/client/events.ts
Normal file
51
client/src/client/events.ts
Normal 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
179
client/src/client/index.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
95
client/src/client/messages.ts
Normal file
95
client/src/client/messages.ts
Normal 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
|
||||
}
|
5
client/src/client/types.ts
Normal file
5
client/src/client/types.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export interface Member {
|
||||
id: string
|
||||
username: string
|
||||
admin: boolean
|
||||
}
|
@ -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')
|
||||
|
17
client/src/plugins/neko.ts
Normal file
17
client/src/plugins/neko.ts
Normal 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
30
client/src/store/chat.ts
Normal 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
53
client/src/store/index.ts
Normal 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
|
37
client/src/store/remote.ts
Normal file
37
client/src/store/remote.ts
Normal 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
52
client/src/store/user.ts
Normal 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
89
client/src/store/video.ts
Normal 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
25
client/src/types/eventemitter.d.ts
vendored
Normal 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
9
client/src/types/vue.d.ts
vendored
Normal 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
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
@ -15,6 +15,9 @@
|
||||
"webpack-env"
|
||||
],
|
||||
"paths": {
|
||||
"~/*": [
|
||||
"src/*"
|
||||
],
|
||||
"@/*": [
|
||||
"src/*"
|
||||
]
|
||||
|
@ -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/'),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user