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": "^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",
|
||||||
|
@ -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
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 './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')
|
||||||
|
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": {
|
"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/*"
|
||||||
]
|
]
|
||||||
|
@ -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/'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user