neko/src/component/main.vue
2022-03-11 00:23:44 +01:00

595 lines
17 KiB
Vue

<template>
<div ref="component" class="neko-component">
<div ref="container" class="neko-container">
<video ref="video" :autoplay="autoplay" :muted="autoplay" playsinline />
<neko-screencast
v-show="screencast && screencastReady"
:enabled="screencast || !state.connection.webrtc.stable"
:api="api.room"
@imageReady="screencastReady = $event"
/>
<neko-cursors
v-if="state.cursors.enabled && state.sessions[state.session_id].profile.can_see_inactive_cursors"
:sessions="state.sessions"
:sessionId="state.session_id"
:hostId="state.control.host_id"
:screenSize="state.screen.size"
:canvasSize="canvasSize"
:cursors="state.cursors.list"
:cursorDraw="inactiveCursorDrawFunction"
/>
<neko-overlay
:style="{ pointerEvents: state.control.locked ? 'none' : 'auto' }"
:sessions="state.sessions"
:hostId="state.control.host_id"
:webrtc="connection.webrtc"
:scroll="state.control.scroll"
:screenSize="state.screen.size"
:canvasSize="canvasSize"
:isControling="controlling"
:cursorDraw="cursorDrawFunction"
:implicitControl="state.control.implicit_hosting && state.sessions[state.session_id].profile.can_host"
:inactiveCursors="state.cursors.enabled && state.sessions[state.session_id].profile.sends_inactive_cursor"
@implicitControlRequest="control.request()"
@implicitControlRelease="control.release()"
@updateKeyboardModifiers="updateKeyboardModifiers($event)"
@uploadDrop="uploadDrop($event)"
@onAction="control.emit('overlay.' + $event.action, $event.target)"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.neko-component {
width: 100%;
height: 100%;
}
.neko-container {
position: relative;
video,
img {
position: absolute;
top: 0;
bottom: 0;
width: 100%;
height: 100%;
display: flex;
background: transparent !important;
&::-webkit-media-controls {
display: none !important;
}
}
}
</style>
<script lang="ts">
export * as ApiModels from './api/models'
export * as StateModels from './types/state'
import * as EVENT from './types/events'
import { Vue, Component, Ref, Watch, Prop } from 'vue-property-decorator'
import ResizeObserver from 'resize-observer-polyfill'
import { NekoApi, MembersApi, RoomApi } from './internal/api'
import { NekoConnection } from './internal/connection'
import { NekoMessages } from './internal/messages'
import { NekoControl } from './internal/control'
import { register as VideoRegister } from './internal/video'
import { ReconnectorConfig } from './types/reconnector'
import NekoState from './types/state'
import { CursorDrawFunction, InactiveCursorDrawFunction, Dimension } from './types/cursors'
import Overlay from './overlay.vue'
import Screencast from './screencast.vue'
import Cursors from './cursors.vue'
@Component({
name: 'neko-canvas',
components: {
'neko-overlay': Overlay,
'neko-screencast': Screencast,
'neko-cursors': Cursors,
},
})
export default class extends Vue {
@Ref('component') readonly _component!: HTMLElement
@Ref('container') readonly _container!: HTMLElement
@Ref('video') readonly _video!: HTMLVideoElement
api = new NekoApi()
observer = new ResizeObserver(this.onResize.bind(this))
canvasSize: Dimension = { width: 0, height: 0 }
cursorDrawFunction: CursorDrawFunction | null = null
inactiveCursorDrawFunction: InactiveCursorDrawFunction | null = null
@Prop({ type: String })
private readonly server!: string
@Prop({ type: Boolean })
private readonly autologin!: boolean
@Prop({ type: Boolean })
private readonly autoconnect!: boolean
@Prop({ type: Boolean })
private readonly autoplay!: boolean
/////////////////////////////
// Public state
/////////////////////////////
public state = {
authenticated: false,
connection: {
url: location.href,
token: undefined,
status: 'disconnected',
websocket: {
connected: false,
config: {
max_reconnects: 15,
timeout_ms: 5000,
backoff_ms: 1500,
},
},
webrtc: {
connected: false,
stable: false,
config: {
max_reconnects: 15,
timeout_ms: 10000,
backoff_ms: 1500,
},
stats: null,
video: null,
videos: [],
},
screencast: true, // TODO: Should get by API call.
type: 'none',
},
video: {
playable: false,
playing: false,
volume: 0,
muted: false,
},
control: {
scroll: {
inverse: true,
sensitivity: 0,
},
clipboard: null,
keyboard: {
layout: 'us',
variant: '',
},
host_id: null,
implicit_hosting: false,
locked: false,
},
screen: {
size: {
width: 1280,
height: 720,
rate: 30,
},
configurations: [],
},
session_id: null,
sessions: {},
cursors: {
enabled: false,
list: [],
},
} as NekoState
/////////////////////////////
// Public connection manager
/////////////////////////////
public connection = new NekoConnection(this.state.connection)
public get connected() {
return this.state.connection.status == 'connected'
}
public get controlling() {
return this.state.control.host_id !== null && this.state.session_id === this.state.control.host_id
}
public get is_admin() {
return this.state.session_id != null ? this.state.sessions[this.state.session_id].profile.is_admin : false
}
screencastReady = false
public get screencast() {
return (
this.state.authenticated &&
this.state.connection.status != 'disconnected' &&
this.state.connection.screencast &&
(!this.state.connection.webrtc.connected ||
(this.state.connection.webrtc.connected && !this.state.video.playing))
)
}
/////////////////////////////
// Public events
/////////////////////////////
public events = new NekoMessages(this.connection, this.state)
/////////////////////////////
// Public methods
/////////////////////////////
@Watch('server', { immediate: true })
public async setUrl(url: string) {
if (!url) {
url = location.href
}
const httpURL = url.replace(/^ws/, 'http').replace(/\/$|\/ws\/?$/, '')
this.api.setUrl(httpURL)
Vue.set(this.state.connection, 'url', httpURL)
try {
this.disconnect()
} catch {}
if (this.state.authenticated) {
Vue.set(this.state, 'authenticated', false)
}
if (!this.autologin) return
await this.authenticate()
if (!this.autoconnect) return
this.connect()
}
public async authenticate(token?: string) {
if (!token && this.autologin) {
token = localStorage.getItem('neko_session') ?? undefined
}
if (token) {
this.api.setToken(token)
Vue.set(this.state.connection, 'token', token)
}
await this.api.session.whoami()
Vue.set(this.state, 'authenticated', true)
if (token && this.autologin) {
localStorage.setItem('neko_session', token)
}
}
public async login(username: string, password: string) {
if (this.state.authenticated) {
throw new Error('client already authenticated')
}
const res = await this.api.session.login({ username, password })
if (res.data.token) {
this.api.setToken(res.data.token)
Vue.set(this.state.connection, 'token', res.data.token)
if (this.autologin) {
localStorage.setItem('neko_session', res.data.token)
}
}
Vue.set(this.state, 'authenticated', true)
}
public async logout() {
if (!this.state.authenticated) {
throw new Error('client not authenticated')
}
try {
this.disconnect()
} catch {}
try {
await this.api.session.logout()
} finally {
this.api.setToken('')
Vue.delete(this.state.connection, 'token')
if (this.autologin) {
localStorage.removeItem('neko_session')
}
Vue.set(this.state, 'authenticated', false)
}
}
public connect(video?: string) {
if (!this.state.authenticated) {
throw new Error('client not authenticated')
}
if (this.connected) {
throw new Error('client is already connected')
}
this.connection.open(video)
}
public disconnect() {
this.connection.close()
}
public setReconnectorConfig(type: 'websocket' | 'webrtc', config: ReconnectorConfig) {
if (type != 'websocket' && type != 'webrtc') {
throw new Error('unknown reconnector type')
}
Vue.set(this.state.connection[type], 'config', config)
this.connection.reloadConfigs()
}
public play() {
this._video.play()
}
public pause() {
this._video.pause()
}
public mute() {
this._video.muted = true
}
public unmute() {
this._video.muted = false
}
public setVolume(value: number) {
if (value < 0 || value > 1) {
throw new Error('volume must be between 0 and 1')
}
this._video.volume = value
}
public setScrollInverse(value: boolean = true) {
Vue.set(this.state.control.scroll, 'inverse', value)
}
public setScrollSensitivity(value: number) {
Vue.set(this.state.control.scroll, 'sensitivity', value)
}
public setKeyboard(layout: string, variant: string = '') {
Vue.set(this.state.control, 'keyboard', { layout, variant })
}
public setCursorDrawFunction(fn?: CursorDrawFunction) {
Vue.set(this, 'cursorDrawFunction', fn)
}
public setInactiveCursorDrawFunction(fn?: InactiveCursorDrawFunction) {
Vue.set(this, 'inactiveCursorDrawFunction', fn)
}
// TODO: Remove? Use REST API only?
public setScreenSize(width: number, height: number, rate: number) {
this.connection.websocket.send(EVENT.SCREEN_SET, { width, height, rate })
}
public setWebRTCVideo(video: string) {
this.connection.setVideo(video)
}
public addTrack(track: MediaStreamTrack, ...streams: MediaStream[]): RTCRtpSender {
return this.connection.webrtc.addTrack(track, ...streams)
}
public removeTrack(sender: RTCRtpSender) {
this.connection.webrtc.removeTrack(sender)
}
public sendUnicast(receiver: string, subject: string, body: any) {
this.connection.websocket.send(EVENT.SEND_UNICAST, { receiver, subject, body })
}
public sendBroadcast(subject: string, body: any) {
this.connection.websocket.send(EVENT.SEND_BROADCAST, { subject, body })
}
public control = new NekoControl(this.connection, this.state.control)
public get room(): RoomApi {
return this.api.room
}
public get members(): MembersApi {
return this.api.members
}
async uploadDrop({ x, y, files }: { x: number; y: number; files: Array<Blob> }) {
try {
this.events.emit('upload.drop.started')
await this.api.room.uploadDrop(x, y, files, {
onUploadProgress: (progressEvent: ProgressEvent) => {
this.events.emit('upload.drop.progress', progressEvent)
},
})
this.events.emit('upload.drop.finished')
} catch (err: any) {
this.events.emit('upload.drop.finished', err)
}
}
/////////////////////////////
// Component lifecycle
/////////////////////////////
mounted() {
// component size change
this.observer.observe(this._component)
// video events
VideoRegister(this._video, this.state.video)
this.connection.on('close', (error) => {
this.events.emit('connection.closed', error)
this.clear()
})
this.connection.webrtc.on('track', (event: RTCTrackEvent) => {
const { track, streams } = event
if (track.kind === 'audio') return
// apply track only once it is unmuted
track.addEventListener(
'unmute',
() => {
// create stream
if ('srcObject' in this._video) {
this._video.srcObject = streams[0]
} else {
// @ts-ignore
this._video.src = window.URL.createObjectURL(streams[0]) // for older browsers
}
if (this.autoplay || this.connection.activated) {
this._video.play()
}
},
{ once: true },
)
})
// unmute on users first interaction
if (this.autoplay) {
document.addEventListener('click', this.unmute, { once: true })
}
}
beforeDestroy() {
this.observer.disconnect()
this.connection.destroy()
this.clear()
// remove users first interaction event
document.removeEventListener('click', this.unmute)
}
@Watch('controlling')
@Watch('state.control.keyboard')
updateKeyboard() {
if (this.controlling && this.state.control.keyboard.layout) {
this.connection.websocket.send(EVENT.KEYBOARD_MAP, this.state.control.keyboard)
}
}
updateKeyboardModifiers(modifiers: { capslock: boolean; numlock: boolean }) {
this.connection.websocket.send(EVENT.KEYBOARD_MODIFIERS, modifiers)
}
@Watch('state.screen.size')
onResize() {
const { width, height } = this.state.screen.size
const screenRatio = width / height
const { offsetWidth, offsetHeight } = this._component
const canvasRatio = offsetWidth / offsetHeight
// vertical centering
if (screenRatio > canvasRatio) {
const vertical = offsetWidth / screenRatio
this._container.style.width = `${offsetWidth}px`
this._container.style.height = `${vertical}px`
this._container.style.marginTop = `${(offsetHeight - vertical) / 2}px`
this._container.style.marginLeft = `0px`
Vue.set(this, 'canvasSize', {
width: offsetWidth,
height: vertical,
})
}
// horizontal centering
else if (screenRatio < canvasRatio) {
const horizontal = screenRatio * offsetHeight
this._container.style.width = `${horizontal}px`
this._container.style.height = `${offsetHeight}px`
this._container.style.marginTop = `0px`
this._container.style.marginLeft = `${(offsetWidth - horizontal) / 2}px`
Vue.set(this, 'canvasSize', {
width: horizontal,
height: offsetHeight,
})
}
// no centering
else {
this._container.style.width = `${offsetWidth}px`
this._container.style.height = `${offsetHeight}px`
this._container.style.marginTop = `0px`
this._container.style.marginLeft = `0px`
Vue.set(this, 'canvasSize', {
width: offsetWidth,
height: offsetHeight,
})
}
}
@Watch('screencast')
@Watch('state.connection.webrtc.connected')
updateConnectionType() {
if (this.screencast) {
Vue.set(this.state.connection, 'type', 'fallback')
} else if (this.state.connection.webrtc.connected) {
Vue.set(this.state.connection, 'type', 'webrtc')
} else {
Vue.set(this.state.connection, 'type', 'none')
}
}
@Watch('state.connection.status')
onConnectionStatusChange(status: 'connected' | 'connecting' | 'disconnected') {
this.events.emit('connection.status', status)
}
@Watch('state.connection.type')
onConnectionTypeChange(type: 'fallback' | 'webrtc' | 'none') {
this.events.emit('connection.type', type)
}
clear() {
// destroy video
if (this._video) {
if ('srcObject' in this._video) {
this._video.srcObject = null
} else {
// @ts-ignore
this._video.removeAttribute('src')
}
}
// websocket
Vue.set(this.state.connection.webrtc, 'videos', [])
Vue.set(this.state.control, 'clipboard', null)
Vue.set(this.state.control, 'host_id', null)
Vue.set(this.state.control, 'implicit_hosting', false)
Vue.set(this.state.screen, 'size', { width: 1280, height: 720, rate: 30 })
Vue.set(this.state.screen, 'configurations', [])
Vue.set(this.state, 'session_id', null)
Vue.set(this.state, 'sessions', {})
Vue.set(this.state.cursors, 'enabled', false)
Vue.set(this.state.cursors, 'list', [])
// webrtc
Vue.set(this.state.connection.webrtc, 'stats', null)
Vue.set(this.state.connection.webrtc, 'video', null)
Vue.set(this.state.connection, 'type', 'none')
}
}
</script>