This commit is contained in:
Miroslav Šedivý
2024-03-17 00:00:14 +01:00
parent 37f93eae6b
commit 27d88cee08
70 changed files with 6271 additions and 13595 deletions

View File

@ -2,7 +2,7 @@
@use "sass:math";
$fa-font-path: "~@fortawesome/fontawesome-free/webfonts";
$fa-font-path: "@fortawesome/fontawesome-free/webfonts";
$fa-font-size-base: 16px;
$fa-font-display: auto;
$fa-css-prefix: fa;
@ -16,7 +16,7 @@ $fa-secondary-opacity: .4;
$fa-family-default: 'Font Awesome 6 Free';
// Import FA source files
@import "~@fortawesome/fontawesome-free/scss/brands";
@import "~@fortawesome/fontawesome-free/scss/solid";
@import "~@fortawesome/fontawesome-free/scss/regular";
@import "~@fortawesome/fontawesome-free/scss/fontawesome";
@import "@fortawesome/fontawesome-free/scss/brands";
@import "@fortawesome/fontawesome-free/scss/solid";
@import "@fortawesome/fontawesome-free/scss/regular";
@import "@fortawesome/fontawesome-free/scss/fontawesome";

View File

@ -0,0 +1,11 @@
// import { describe, it, expect } from 'vitest'
//
// import { mount } from '@vue/test-utils'
// import HelloWorld from '../HelloWorld.vue'
//
// describe('HelloWorld', () => {
// it('renders properly', () => {
// const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } })
// expect(wrapper.text()).toContain('Hello Vitest')
// })
// })

View File

@ -1,8 +1,8 @@
<template>
<div class="chat">
<ul class="chat-history" ref="history">
<template v-for="(message, index) in history">
<li :key="index" class="message" v-show="neko && neko.connected">
<template v-for="(message, index) in messages" :key="index">
<li class="message" v-show="neko && neko.connected">
<div class="content">
<div class="content-head">
<span class="session">{{ session(message.id) }}</span>
@ -162,105 +162,114 @@
}
</style>
<script lang="ts">
import { Vue, Component, Prop, Watch, Ref } from 'vue-property-decorator'
import Neko from '~/component/main.vue'
<script lang="ts" setup>
import { ref, watch, onMounted } from 'vue'
import Neko from '@/component/main.vue'
const length = 512 // max length of message
const length = 512 // max length of message
@Component({
name: 'neko-chat',
const history = ref<HTMLUListElement | null>(null)
const props = defineProps<{
neko: typeof Neko
}>()
const emit = defineEmits(['send_message'])
type Message = {
id: string
created: Date
content: string
}
const messages = ref<Message[]>([])
const content = ref('')
onMounted(() => {
setTimeout(() => {
history.value!.scrollTop = history.value!.scrollHeight
}, 0)
})
function timestamp(date: Date | string) {
date = new Date(date)
return (
date.getFullYear() +
'-' +
String(date.getMonth() + 1).padStart(2, '0') +
'-' +
String(date.getDate()).padStart(2, '0') +
' ' +
String(date.getHours()).padStart(2, '0') +
':' +
String(date.getMinutes()).padStart(2, '0') +
':' +
String(date.getSeconds()).padStart(2, '0')
)
}
function session(id: string) {
let session = props.neko.state.sessions[id]
return session ? session.profile.name : id
}
function onNekoChange() {
props.neko.events.on('receive.broadcast', (sender: string, subject: string, body: any) => {
if (subject === 'chat') {
const message = body as Message
messages.value = [...messages.value, message]
}
})
export default class extends Vue {
@Ref('history') readonly _history!: HTMLElement
@Prop() readonly neko!: Neko
}
history = []
content = ''
watch(() => props.neko, onNekoChange)
mounted() {
this.$nextTick(() => {
this._history.scrollTop = this._history.scrollHeight
})
}
function onHistroyChange() {
setTimeout(() => {
history.value!.scrollTop = history.value!.scrollHeight
}, 0)
}
timestamp(date: Date | string) {
date = new Date(date)
watch(messages, onHistroyChange)
return (
date.getFullYear() +
'-' +
String(date.getMonth() + 1).padStart(2, '0') +
'-' +
String(date.getDate()).padStart(2, '0') +
' ' +
String(date.getHours()).padStart(2, '0') +
':' +
String(date.getMinutes()).padStart(2, '0') +
':' +
String(date.getSeconds()).padStart(2, '0')
)
}
session(id: string) {
let session = this.neko.state.sessions[id]
return session ? session.profile.name : id
}
@Watch('neko')
onNekoChange() {
this.neko.events.on('receive.broadcast', (sender: string, subject: string, body: string) => {
if (subject === 'chat') {
Vue.set(this, 'history', [...this.history, body])
}
})
}
@Watch('history')
onHistroyChange() {
this.$nextTick(() => {
this._history.scrollTop = this._history.scrollHeight
})
}
onKeyDown(event: KeyboardEvent) {
if (this.content.length > length) {
this.content = this.content.substring(0, length)
}
if (this.content.length == length) {
if (
[8, 16, 17, 18, 20, 33, 34, 35, 36, 37, 38, 39, 40, 45, 46, 91, 93, 144].includes(event.keyCode) ||
(event.ctrlKey && [67, 65, 88].includes(event.keyCode))
) {
return
}
event.preventDefault()
return
}
if (event.keyCode !== 13 || event.shiftKey) {
return
}
if (this.content === '') {
event.preventDefault()
return
}
this.$emit('send_message', this.content)
let message = {
id: this.neko.state.session_id,
created: new Date(),
content: this.content,
}
this.neko.sendBroadcast('chat', message)
Vue.set(this, 'history', [...this.history, message])
this.content = ''
event.preventDefault()
}
function onKeyDown(event: KeyboardEvent) {
if (content.value.length > length) {
content.value = content.value.substring(0, length)
}
if (content.value.length == length) {
if (
[8, 16, 17, 18, 20, 33, 34, 35, 36, 37, 38, 39, 40, 45, 46, 91, 93, 144].includes(event.keyCode) ||
(event.ctrlKey && [67, 65, 88].includes(event.keyCode))
) {
return
}
event.preventDefault()
return
}
if (event.keyCode !== 13 || event.shiftKey) {
return
}
if (content.value === '') {
event.preventDefault()
return
}
emit('send_message', content.value)
let message = {
id: props.neko.state.session_id,
created: new Date(),
content: content.value,
}
props.neko.sendBroadcast('chat', message)
messages.value = [...messages.value, message]
content.value = ''
event.preventDefault()
}
</script>

View File

@ -35,185 +35,182 @@
}
</style>
<script lang="ts">
import { Vue, Component, Ref, Watch } from 'vue-property-decorator'
<script lang="ts" setup>
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
@Component({
name: 'neko-color',
})
export default class extends Vue {
@Ref('canvas') readonly _canvas!: HTMLCanvasElement
@Ref('color') readonly _color!: HTMLDivElement
const canvas = ref<HTMLCanvasElement | null>(null)
const color = ref<HTMLDivElement | null>(null)
ctx!: CanvasRenderingContext2D | null
video!: HTMLVideoElement | null
interval!: number
picker!: HTMLDivElement | null
bullet!: HTMLDivElement | null
color!: string
const ctx = ref<CanvasRenderingContext2D | null>(null)
const video = ref<HTMLVideoElement | null>(null)
const interval = ref<number>(0)
const picker = ref<HTMLDivElement | null>(null)
const bullet = ref<HTMLDivElement | null>(null)
const currColor = ref<string>('')
x = 0
y = 0
clickOnChange = false
const x = ref<number>(0)
const y = ref<number>(0)
const clickOnChange = ref<boolean>(false)
mounted() {
this.video = document.querySelector('video')
this.ctx = this._canvas.getContext('2d')
}
const emit = defineEmits(['colorChange'])
beforeDestroy() {
if (this.interval) {
window.clearInterval(this.interval)
}
onMounted(() => {
video.value = document.querySelector('video')
ctx.value = canvas.value?.getContext('2d') || null
})
this.clearPoint()
}
@Watch('clickOnChange')
clickOnChangeChanged() {
if (this.clickOnChange) {
// register interval timer
this.interval = window.setInterval(this.intervalTimer, 0)
} else {
// unregister interval timer
window.clearInterval(this.interval)
this.color = ''
}
}
intervalTimer() {
if (!this.video || !this.ctx) {
return
}
this._canvas.width = this.video.videoWidth
this._canvas.height = this.video.videoHeight
this.ctx.clearRect(0, 0, this.video.videoWidth, this.video.videoHeight)
this.ctx.drawImage(this.video, 0, 0, this.video.videoWidth, this.video.videoHeight)
// get color from pixel at x,y
var pixel = this.ctx.getImageData(this.x, this.y, 1, 1)
var data = pixel.data
var rgba = 'rgba(' + data[0] + ', ' + data[1] + ', ' + data[2] + ', ' + data[3] / 255 + ')'
// if color is different, update it
if (this.color != rgba) {
if (this.clickOnChange && this.color) {
this.$emit('colorChange', { x: this.x, y: this.y })
this.clickOnChange = false
}
console.log('color change', rgba, this.color)
this._color.style.backgroundColor = rgba
this.color = rgba
}
}
getCoords(elem: HTMLElement) {
// crossbrowser version
let box = elem.getBoundingClientRect()
let body = document.body
let docEl = document.documentElement
let scrollTop = window.pageYOffset || docEl.scrollTop || body.scrollTop
let scrollLeft = window.pageXOffset || docEl.scrollLeft || body.scrollLeft
let clientTop = docEl.clientTop || body.clientTop || 0
let clientLeft = docEl.clientLeft || body.clientLeft || 0
let top = box.top + scrollTop - clientTop
let left = box.left + scrollLeft - clientLeft
return { top: Math.round(top), left: Math.round(left) }
}
setPoint() {
// create new element and add to body
var picker = document.createElement('div')
// coordinates of video element
var video = this.getCoords(this.video!)
// match that dimensions and offset matches video
picker.style.width = this.video!.offsetWidth + 'px'
picker.style.height = this.video!.offsetHeight + 'px'
picker.style.left = video.left + 'px'
picker.style.top = video.top + 'px'
picker.style.position = 'absolute'
picker.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
picker.style.cursor = 'crosshair'
// put it on top of video
picker.style.zIndex = '100'
document.body.appendChild(picker)
// add click event listener to new element
picker.addEventListener('click', this.clickPicker)
this.picker = picker
}
clearPoint() {
this.x = 0
this.y = 0
this.color = ''
this._color.style.backgroundColor = 'transparent'
if (this.bullet) {
this.bullet.remove()
}
if (this.picker) {
this.picker.remove()
}
}
clickPicker(e: any) {
// get mouse position
var x = e.pageX
var y = e.pageY
// get picker position
var picker = this.getCoords(this.picker!)
// calculate new x,y position
var newX = x - picker.left
var newY = y - picker.top
// make it relative to video size
newX = Math.round((newX / this.video!.offsetWidth) * this.video!.videoWidth)
newY = Math.round((newY / this.video!.offsetHeight) * this.video!.videoHeight)
console.log(newX, newY)
// set new x,y position
this.x = newX
this.y = newY
// remove picker element
this.picker!.remove()
// add bullet element to the position
if (this.bullet) {
this.bullet.remove()
}
var bullet = document.createElement('div')
bullet.style.left = x + 'px'
bullet.style.top = y + 'px'
// width and height of bullet
bullet.style.width = '10px'
bullet.style.height = '10px'
// background color of bullet
bullet.style.backgroundColor = 'red'
// border radius of bullet
bullet.style.borderRadius = '50%'
// transform bullet to center
bullet.style.transform = 'translate(-50%, -50%)'
bullet.style.position = 'absolute'
bullet.style.zIndex = '100'
document.body.appendChild(bullet)
this.bullet = bullet
}
onBeforeUnmount(() => {
if (interval.value) {
window.clearInterval(interval.value)
}
clearPoint()
})
function clickOnChangeChanged() {
if (clickOnChange.value) {
// register interval timer
interval.value = window.setInterval(intervalTimer, 0)
} else {
// unregister interval timer
window.clearInterval(interval.value)
currColor.value = ''
}
}
watch(clickOnChange, clickOnChangeChanged)
function intervalTimer() {
if (!video.value || !ctx.value) {
return
}
canvas.value!.width = video.value.videoWidth
canvas.value!.height = video.value.videoHeight
ctx.value.clearRect(0, 0, video.value.videoWidth, video.value.videoHeight)
ctx.value.drawImage(video.value, 0, 0, video.value.videoWidth, video.value.videoHeight)
// get color from pixel at x,y
const pixel = ctx.value.getImageData(x.value, y.value, 1, 1)
const data = pixel.data
const rgba = `rgba(${data[0]}, ${data[1]}, ${data[2]}, ${data[3] / 255})`
// if color is different, update it
if (currColor.value !== rgba) {
if (clickOnChange.value && currColor.value) {
emit('colorChange', { x: x.value, y: y.value })
clickOnChange.value = false
}
console.log('color change', rgba, currColor.value)
color.value!.style.backgroundColor = rgba
currColor.value = rgba
}
}
function getCoords(elem: HTMLElement) {
// crossbrowser version
const box = elem.getBoundingClientRect()
const body = document.body
const docEl = document.documentElement
const scrollTop = window.pageYOffset || docEl.scrollTop || body.scrollTop
const scrollLeft = window.pageXOffset || docEl.scrollLeft || body.scrollLeft
const clientTop = docEl.clientTop || body.clientTop || 0
const clientLeft = docEl.clientLeft || body.clientLeft || 0
const top = box.top + scrollTop - clientTop
const left = box.left + scrollLeft - clientLeft
return { top: Math.round(top), left: Math.round(left) }
}
function setPoint() {
// create new element and add to body
const p = document.createElement('div')
// coordinates of video element
const v = getCoords(video.value!)
// match that dimensions and offset matches video
p.style.width = video.value!.offsetWidth + 'px'
p.style.height = video.value!.offsetHeight + 'px'
p.style.left = v.left + 'px'
p.style.top = v.top + 'px'
p.style.position = 'absolute'
p.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
p.style.cursor = 'crosshair'
// put it on top of video
p.style.zIndex = '100'
document.body.appendChild(p)
// add click event listener to new element
p.addEventListener('click', clickPicker)
picker.value = p
}
function clearPoint() {
x.value = 0
y.value = 0
color.value!.style.backgroundColor = 'transparent'
if (bullet.value) {
bullet.value.remove()
}
if (picker.value) {
picker.value.remove()
}
}
function clickPicker(e: any) {
// get mouse position
const x = e.pageX
const y = e.pageY
// get picker position
const p = getCoords(picker.value!)
// calculate new x,y position
let newX = x - p.left
let newY = y - p.top
// make it relative to video size
newX = Math.round((newX / video.value!.offsetWidth) * video.value!.videoWidth)
newY = Math.round((newY / video.value!.offsetHeight) * video.value!.videoHeight)
console.log(newX, newY)
// set new x,y position
x.value = newX
y.value = newY
// remove picker element
picker.value!.remove()
// add bullet element to the position
if (bullet.value) {
bullet.value.remove()
}
const b = document.createElement('div')
b.style.left = x + 'px'
b.style.top = y + 'px'
// width and height of bullet
b.style.width = '10px'
b.style.height = '10px'
// background color of bullet
b.style.backgroundColor = 'red'
// border radius of bullet
b.style.borderRadius = '50%'
// transform bullet to center
b.style.transform = 'translate(-50%, -50%)'
b.style.position = 'absolute'
b.style.zIndex = '100'
document.body.appendChild(b)
bullet.value = b
}
</script>

View File

@ -24,56 +24,53 @@
<style lang="scss"></style>
<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator'
import Neko from '~/component/main.vue'
<script lang="ts" setup>
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
import Neko from '@/component/main.vue'
@Component({
name: 'neko-controls',
})
export default class extends Vue {
@Prop() readonly neko!: Neko
const props = defineProps<{
neko: typeof Neko
}>()
username: string = 'admin'
password: string = 'admin'
const username = ref('admin')
const password = ref('admin')
async login() {
localStorage.setItem('username', this.username)
localStorage.setItem('password', this.password)
async function login() {
localStorage.setItem('username', username.value)
localStorage.setItem('password', password.value)
try {
await this.neko.login(this.username, this.password)
} catch (e: any) {
alert(e.response ? e.response.data.message : e)
}
}
async connect() {
try {
await this.neko.connect()
} catch (e: any) {
alert(e)
}
}
async logout() {
try {
await this.neko.logout()
} catch (e: any) {
alert(e.response ? e.response.data.message : e)
}
}
mounted() {
const username = localStorage.getItem('username')
if (username) {
this.username = username
}
const password = localStorage.getItem('password')
if (password) {
this.password = password
}
}
try {
await props.neko.login(username.value, password.value)
} catch (e: any) {
alert(e.response ? e.response.data.message : e)
}
}
async function connect() {
try {
await props.neko.connect()
} catch (e: any) {
alert(e)
}
}
async function logout() {
try {
await props.neko.logout()
} catch (e: any) {
alert(e.response ? e.response.data.message : e)
}
}
onMounted(() => {
const u = localStorage.getItem('username')
if (u) {
username.value = u
}
const p = localStorage.getItem('password')
if (p) {
password.value = p
}
})
</script>

View File

@ -67,7 +67,6 @@
background: transparent;
width: 150px;
height: 20px;
-webkit-appearance: none;
&::-moz-range-thumb {
height: 12px;
width: 12px;
@ -160,88 +159,67 @@
}
</style>
<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator'
import Neko from '~/component/main.vue'
<script lang="ts" setup>
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
import Neko from '@/component/main.vue'
@Component({
name: 'neko-controls',
})
export default class extends Vue {
@Prop() readonly neko!: Neko
const props = defineProps<{
neko: typeof Neko
}>()
get can_host() {
return this.neko.connected
const can_host = computed(() => props.neko.connected)
const hosting = computed(() => props.neko.controlling)
const volume = computed({
get: () => props.neko.state.video.volume * 100,
set: (volume: number) => {
props.neko.setVolume(volume / 100)
},
})
const muted = computed(() => props.neko.state.video.muted || props.neko.state.video.volume === 0)
const playing = computed(() => props.neko.state.video.playing)
const playable = computed(() => props.neko.state.video.playable)
const locked = computed({
get: () => props.neko.state.control.locked,
set: (lock: boolean) => {
if (lock) {
props.neko.control.lock()
} else {
props.neko.control.unlock()
}
},
})
get hosting() {
return this.neko.controlling
}
get volume() {
return this.neko.state.video.volume * 100
}
set volume(volume: number) {
this.neko.setVolume(volume / 100)
}
get muted() {
return this.neko.state.video.muted || this.neko.state.video.volume === 0
}
get playing() {
return this.neko.state.video.playing
}
get playable() {
return this.neko.state.video.playable
}
get locked() {
return this.neko.state.control.locked
}
set locked(lock: boolean) {
if (lock) {
this.neko.control.lock()
} else {
this.neko.control.unlock()
}
}
toggleControl() {
if (this.can_host && this.hosting) {
this.neko.room.controlRelease()
}
if (this.can_host && !this.hosting) {
this.neko.room.controlRequest()
}
}
toggleMedia() {
if (this.playable && this.playing) {
this.neko.pause()
}
if (this.playable && !this.playing) {
this.neko.play()
}
}
toggleMute() {
if (this.playable && this.muted) {
this.neko.unmute()
}
if (this.playable && !this.muted) {
this.neko.mute()
}
}
disconnect() {
this.neko.logout()
}
function toggleControl() {
if (can_host.value && hosting.value) {
props.neko.room.controlRelease()
}
if (can_host.value && !hosting.value) {
props.neko.room.controlRequest()
}
}
function toggleMedia() {
if (playable.value && playing.value) {
props.neko.video.pause()
}
if (playable.value && !playing.value) {
props.neko.video.play()
}
}
function toggleMute() {
if (playable.value && muted.value) {
props.neko.video.unmute()
}
if (playable.value && !muted.value) {
props.neko.video.mute()
}
}
function disconnect() {
props.neko.logout()
}
</script>

View File

@ -209,7 +209,7 @@
<td>
<select
:value="neko.state.connection.webrtc.video.id"
@input="neko.setWebRTCVideo({ selector: { id: $event.target.value } })"
@input="neko.setWebRTCVideo({ selector: { id: ($event.target as HTMLSelectElement)!.value || '' } })"
>
<option v-for="video in neko.state.connection.webrtc.videos" :key="video" :value="video">
{{ video }}
@ -252,7 +252,7 @@
min="0"
max="1"
:value="neko.state.video.volume"
@input="neko.setVolume(Number($event.target.value))"
@input="neko.setVolume(Number(($event.target as HTMLInputElement)!.value))"
step="0.01"
/>
</td>
@ -289,7 +289,7 @@
min="-5"
max="5"
:value="neko.state.control.scroll.sensitivity"
@input="neko.setScrollSensitivity(Number($event.target.value))"
@input="neko.setScrollSensitivity(Number(($event.target as HTMLInputElement)!.value))"
step="1"
/>
</td>
@ -300,7 +300,7 @@
<textarea
:readonly="!neko.controlling"
:value="neko.state.control.clipboard ? neko.state.control.clipboard.text : ''"
@input="clipboardText = $event.target.value"
@input="clipboardText = ($event.target as HTMLTextAreaElement)!.value"
></textarea>
<button :disabled="!neko.controlling" @click="neko.room.clipboardSetText({ text: clipboardText })">
send clipboard
@ -322,14 +322,14 @@
type="text"
placeholder="Layout"
:value="neko.state.control.keyboard.layout"
@input="neko.setKeyboard($event.target.value, neko.state.control.keyboard.variant)"
@input="neko.setKeyboard(($event.target as HTMLInputElement)!.value, neko.state.control.keyboard.variant)"
style="width: 50%; box-sizing: border-box"
/>
<input
type="text"
placeholder="Variant"
:value="neko.state.control.keyboard.variant"
@input="neko.setKeyboard(neko.state.control.keyboard.layout, $event.target.value)"
@input="neko.setKeyboard(neko.state.control.keyboard.layout, ($event.target as HTMLInputElement)!.value)"
style="width: 50%; box-sizing: border-box"
/>
</td>
@ -409,7 +409,7 @@
min="0"
max="10"
:value="neko.state.screen.sync.multiplier"
@input="neko.state.screen.sync.multiplier = Number($event.target.value)"
@input="neko.state.screen.sync.multiplier = Number(($event.target as HTMLInputElement)!.value)"
step="0.1"
/>
</td>
@ -425,7 +425,7 @@
min="5"
max="60"
:value="neko.state.screen.sync.rate"
@input="neko.state.screen.sync.rate = Number($event.target.value)"
@input="neko.state.screen.sync.rate = Number(($event.target as HTMLInputElement)!.value)"
step="5"
/>
</td>
@ -597,94 +597,82 @@
}
</style>
<script lang="ts">
import { Vue, Component, Prop, Watch } from 'vue-property-decorator'
import Neko from '~/component/main.vue'
import NekoColor from './color.vue'
<script lang="ts" setup>
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
import Neko from '@/component/main.vue'
import NekoColor from './color.vue'
@Component({
name: 'neko-events',
components: {
NekoColor,
},
})
export default class extends Vue {
@Prop() readonly neko!: Neko
const props = defineProps<{
neko: typeof Neko
}>()
clipboardText: string = ''
bitrate: number | null = null
const clipboardText = ref('')
const bitrate = ref<number | null>(null)
@Watch('neko.state.connection.webrtc.bitrate')
onBitrateChange(val: number) {
this.bitrate = val
}
watch(() => props.neko.state.connection.webrtc.bitrate, (val) => {
bitrate.value = val
})
shift = false
get letters(): number[] {
let letters = [] as number[]
for (let i = (this.shift ? 'A' : 'a').charCodeAt(0); i <= (this.shift ? 'Z' : 'z').charCodeAt(0); i++) {
letters.push(i)
}
return letters
}
// fast sceen changing test
screen_interval = null
screenChangingToggle() {
if (this.screen_interval === null) {
let sizes = this.neko.state.screen.configurations
let len = sizes.length
//@ts-ignore
this.screen_interval = setInterval(() => {
let { width, height, rate } = sizes[Math.floor(Math.random() * len)]
this.neko.setScreenSize(width, height, rate)
}, 10)
} else {
//@ts-ignore
clearInterval(this.screen_interval)
this.screen_interval = null
}
}
screenConfiguration = ''
setScreenConfiguration() {
let [width, height, rate] = this.screenConfiguration.split(/[@x]/)
this.neko.setScreenSize(parseInt(width), parseInt(height), parseInt(rate))
}
@Watch('neko.state.screen.size', { immediate: true })
onScreenSizeChange(val: any) {
this.screenConfiguration = `${val.width}x${val.height}@${val.rate}`
}
// fast cursor moving test
cursor_interval = null
cursorMovingToggle() {
if (this.cursor_interval === null) {
let len = this.neko.state.screen.size.width
//@ts-ignore
this.cursor_interval = setInterval(() => {
let x = Math.floor(Math.random() * len)
let y = Math.floor(Math.random() * len)
this.neko.control.move({ x, y })
}, 10)
} else {
//@ts-ignore
clearInterval(this.cursor_interval)
this.cursor_interval = null
}
}
async updateSettings(settings: any) {
try {
await this.neko.room.settingsSet(settings)
} catch (e: any) {
alert(e.response ? e.response.data.message : e)
}
}
const shift = ref(false)
const letters = computed(() => {
let letters = [] as number[]
for (let i = (shift.value ? 'A' : 'a').charCodeAt(0); i <= (shift.value ? 'Z' : 'z').charCodeAt(0); i++) {
letters.push(i)
}
return letters
})
// fast sceen changing test
let screen_interval: number | null = null
function screenChangingToggle() {
if (screen_interval === null) {
let sizes = props.neko.state.screen.configurations
let len = sizes.length
screen_interval = setInterval(() => {
let { width, height, rate } = sizes[Math.floor(Math.random() * len)]
props.neko.setScreenSize(width, height, rate)
}, 10)
} else {
clearInterval(screen_interval)
screen_interval = null
}
}
const screenConfiguration = ref('')
function setScreenConfiguration() {
let [width, height, rate] = screenConfiguration.value.split(/[@x]/)
props.neko.setScreenSize(parseInt(width), parseInt(height), parseInt(rate))
}
watch(props.neko.state.screen.size, (val) => {
screenConfiguration.value = `${val.width}x${val.height}@${val.rate}`
})
// fast cursor moving test
let cursor_interval: number | null = null
function cursorMovingToggle() {
if (cursor_interval === null) {
let len = props.neko.state.screen.size.width
cursor_interval = setInterval(() => {
let x = Math.floor(Math.random() * len)
let y = Math.floor(Math.random() * len)
props.neko.control.move({ x, y })
}, 10)
} else {
clearInterval(cursor_interval)
cursor_interval = null
}
}
async function updateSettings(settings: any) {
try {
await props.neko.room.settingsSet(settings)
} catch (e: any) {
alert(e.response ? e.response.data.message : e)
}
}
</script>

View File

@ -84,34 +84,29 @@
}
</style>
<script lang="ts">
import { Component, Prop, Watch, Vue } from 'vue-property-decorator'
import Neko from '~/component/main.vue'
<script lang="ts" setup>
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
import Neko from '@/component/main.vue'
@Component({
name: 'neko-header',
})
export default class extends Vue {
@Prop() readonly neko!: Neko
const props = defineProps<{
neko: typeof Neko
}>()
@Watch('neko.state.connection.url')
updateUrl(url: string) {
this.url = url
}
const url = ref('')
url: string = ''
watch(() => props.neko.state.connection.url, (u) => {
url.value = u
})
async setUrl() {
if (this.url == '') {
this.url = location.href
}
await this.neko.setUrl(this.url)
}
toggleMenu() {
this.$emit('toggle')
//this.$accessor.client.toggleSide()
}
async function setUrl() {
if (url.value == '') {
url.value = location.href
}
await props.neko.setUrl(url.value)
}
function toggleMenu() {
props.neko.toggleSide()
}
</script>

View File

@ -40,146 +40,144 @@
<style lang="scss" scoped></style>
<script lang="ts">
import { Vue, Component, Prop, Ref } from 'vue-property-decorator'
import Neko from '~/component/main.vue'
<script lang="ts" setup>
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
import Neko from '@/component/main.vue'
@Component({
name: 'neko-media',
})
export default class extends Vue {
@Prop() readonly neko!: Neko
@Ref('audio') readonly _audio!: HTMLAudioElement
@Ref('video') readonly _video!: HTMLVideoElement
const props = defineProps<{
neko: typeof Neko
}>()
private audioDevice: string = ''
private audioDevices: MediaDeviceInfo[] = []
const audio = ref<HTMLAudioElement | null>(null)
const video = ref<HTMLVideoElement | null>(null)
private micTracks: MediaStreamTrack[] = []
private micSenders: RTCRtpSender[] = []
const audioDevice = ref<string>('')
const audioDevices = ref<MediaDeviceInfo[]>([])
private videoDevice: string = ''
private videoDevices: MediaDeviceInfo[] = []
const micTracks = ref<MediaStreamTrack[]>([])
const micSenders = ref<RTCRtpSender[]>([])
private camTracks: MediaStreamTrack[] = []
private camSenders: RTCRtpSender[] = []
const videoDevice = ref<string>('')
const videoDevices = ref<MediaDeviceInfo[]>([])
mounted() {
this.loadAudioDevices()
this.loadVideoDevices()
const camTracks = ref<MediaStreamTrack[]>([])
const camSenders = ref<RTCRtpSender[]>([])
onMounted(() => {
loadAudioDevices()
loadVideoDevices()
})
async function loadAudioDevices() {
let devices = await navigator.mediaDevices.enumerateDevices()
audioDevices.value = devices.filter((device) => device.kind === 'audioinput')
console.log('audioDevices', audioDevices.value)
}
async function addMicrophone() {
micTracks.value = []
micSenders.value = []
try {
let a = { echoCancellation: true } as MediaTrackConstraints
if (audioDevice.value != '') {
a.deviceId = audioDevice.value
}
async loadAudioDevices() {
let devices = await navigator.mediaDevices.enumerateDevices()
this.audioDevices = devices.filter((device) => device.kind === 'audioinput')
console.log('audioDevices', this.audioDevices)
}
const stream = await navigator.mediaDevices.getUserMedia({ video: false, audio: a })
audio.value!.srcObject = stream
console.log('Got MediaStream:', stream)
async addMicrophone() {
this.micTracks = []
this.micSenders = []
const tracks = stream.getTracks()
console.log('Got tracks:', tracks)
try {
let audio = { echoCancellation: true } as MediaTrackConstraints
if (this.audioDevice != '') {
audio.deviceId = this.audioDevice
}
tracks.forEach((track) => {
micTracks.value.push(track)
console.log('Adding track', track, stream)
const stream = await navigator.mediaDevices.getUserMedia({ video: false, audio })
this._audio.srcObject = stream
console.log('Got MediaStream:', stream)
const rtcp = props.neko.addTrack(track, stream)
micSenders.value.push(rtcp)
console.log('rtcp sender', rtcp, rtcp.transport)
const tracks = stream.getTracks()
console.log('Got tracks:', tracks)
tracks.forEach((track) => {
this.micTracks.push(track)
console.log('Adding track', track, stream)
const rtcp = this.neko.addTrack(track, stream)
this.micSenders.push(rtcp)
console.log('rtcp sender', rtcp, rtcp.transport)
// TODO: Can be null.
rtcp.transport?.addEventListener('statechange', () => {
console.log('track - on state change', rtcp.transport?.state)
})
})
} catch (error) {
alert('Error accessing media devices.' + error)
}
}
stopMicrophone() {
this.micTracks.forEach((track) => {
track.stop()
// TODO: Can be null.
rtcp.transport?.addEventListener('statechange', () => {
console.log('track - on state change', rtcp.transport?.state)
})
this.micSenders.forEach((rtcp) => {
this.neko.removeTrack(rtcp)
})
this._audio.srcObject = null
this.micTracks = []
this.micSenders = []
}
async loadVideoDevices() {
let devices = await navigator.mediaDevices.enumerateDevices()
this.videoDevices = devices.filter((device) => device.kind === 'videoinput')
console.log('videoDevices', this.videoDevices)
}
async addWebcam() {
this.camTracks = []
this.camSenders = []
try {
let video = {
width: 1280,
height: 720,
} as MediaTrackConstraints
if (this.videoDevice != '') {
video.deviceId = this.videoDevice
}
const stream = await navigator.mediaDevices.getUserMedia({ video, audio: false })
this._video.srcObject = stream
console.log('Got MediaStream:', stream)
const tracks = stream.getTracks()
console.log('Got tracks:', tracks)
tracks.forEach((track) => {
this.camTracks.push(track)
console.log('Adding track', track, stream)
const rtcp = this.neko.addTrack(track, stream)
this.camSenders.push(rtcp)
console.log('rtcp sender', rtcp, rtcp.transport)
// TODO: Can be null.
rtcp.transport?.addEventListener('statechange', () => {
console.log('track - on state change', rtcp.transport?.state)
})
})
} catch (error) {
alert('Error accessing media devices.' + error)
}
}
stopWebcam() {
this.camTracks.forEach((track) => {
track.stop()
})
this.camSenders.forEach((rtcp) => {
this.neko.removeTrack(rtcp)
})
this._audio.srcObject = null
this.camTracks = []
this.camSenders = []
}
})
} catch (error) {
alert('Error accessing media devices.' + error)
}
}
function stopMicrophone() {
micTracks.value.forEach((track) => {
track.stop()
})
micSenders.value.forEach((rtcp) => {
props.neko.removeTrack(rtcp)
})
audio.value!.srcObject = null
micTracks.value = []
micSenders.value = []
}
async function loadVideoDevices() {
let devices = await navigator.mediaDevices.enumerateDevices()
videoDevices.value = devices.filter((device) => device.kind === 'videoinput')
console.log('videoDevices', videoDevices.value)
}
async function addWebcam() {
camTracks.value = []
camSenders.value = []
try {
let v = {
width: 1280,
height: 720,
} as MediaTrackConstraints
if (videoDevice.value != '') {
v.deviceId = videoDevice.value
}
const stream = await navigator.mediaDevices.getUserMedia({ video: v, audio: false })
video.value!.srcObject = stream
console.log('Got MediaStream:', stream)
const tracks = stream.getTracks()
console.log('Got tracks:', tracks)
tracks.forEach((track) => {
camTracks.value.push(track)
console.log('Adding track', track, stream)
const rtcp = props.neko.addTrack(track, stream)
camSenders.value.push(rtcp)
console.log('rtcp sender', rtcp, rtcp.transport)
// TODO: Can be null.
rtcp.transport?.addEventListener('statechange', () => {
console.log('track - on state change', rtcp.transport?.state)
})
})
} catch (error) {
alert('Error accessing media devices.' + error)
}
}
function stopWebcam() {
camTracks.value.forEach((track) => {
track.stop()
})
camSenders.value.forEach((rtcp) => {
props.neko.removeTrack(rtcp)
})
video.value!.srcObject = null
camTracks.value = []
camSenders.value = []
}
</script>

View File

@ -14,7 +14,7 @@
</tr>
<tr>
<td colspan="2" style="text-align: center">
<button @click="$set(plugins, 'new', [...plugins.new, ['', '']])">+</button>
<button @click="plugins.new = [...plugins.new, ['', '']]">+</button>
</td>
</tr>
<tr>
@ -114,7 +114,7 @@
'state-is':
session.profile.sends_inactive_cursor &&
neko.state.settings.inactive_cursors &&
neko.state.cursors.some((e) => e.id == id),
neko.state.cursors.some((e:any) => e.id == id),
'state-disabled': !session.profile.can_login || !session.profile.can_connect,
}"
@click="
@ -141,7 +141,7 @@
<p class="title">
<span>Members</span>
<button @click="membersLoad">reload</button>
<button @click="membersLoad()">reload</button>
</p>
<div
@ -152,7 +152,7 @@
v-for="member in membersWithoutSessions"
:key="'member-' + member.id"
>
<div class="topbar">
<div class="topbar" v-if="member.profile && member.id">
<div class="name">
<i v-if="neko.is_admin" class="fa fa-trash-alt" @click="memberRemove(member.id)" title="remove" />
{{ member.profile.name }}
@ -406,168 +406,160 @@
}
</style>
<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator'
import Neko, { ApiModels, StateModels } from '~/component/main.vue'
<script lang="ts" setup>
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
import Neko from '@/component/main.vue'
@Component({
name: 'neko-members',
})
export default class extends Vue {
@Prop() readonly neko!: Neko
// TODO: get from lib ts?
import type * as ApiModels from '@/component/api/models'
import type * as StateModels from '@/component/types/state'
constructor() {
super()
const props = defineProps<{
neko: typeof Neko
}>()
// init
this.newProfile = Object.assign({}, this.defProfile)
const sessions = computed(() => props.neko.state.sessions as Record<string, StateModels.Session>)
const membersWithoutSessions = computed(() => {
return props.neko.state.members.filter(({ id }: { id: string }) => id && !(id in sessions.value)) as ApiModels.MemberData[]
})
const members = ref<ApiModels.MemberData[]>([])
const plugins = ref<{
id: string
old: Array<Array<string>>
new: Array<Array<string>>
profile: ApiModels.MemberProfile
} | null>(null)
const newUsername = ref('')
const newPassword = ref('')
const newProfile = ref<ApiModels.MemberProfile>({})
const defProfile = ref<ApiModels.MemberProfile>({
name: '',
is_admin: false,
can_login: true,
can_connect: true,
can_watch: true,
can_host: true,
can_share_media: true,
can_access_clipboard: true,
sends_inactive_cursor: true,
can_see_inactive_cursors: true,
})
newProfile.value = Object.assign({}, defProfile.value)
async function memberCreate() {
try {
const res = await props.neko.members.membersCreate({
username: newUsername.value,
password: newPassword.value,
profile: newProfile.value,
})
if (res.data) {
members.value = [...members.value, res.data]
}
get sessions(): Record<string, StateModels.Session> {
return this.neko.state.sessions
}
get membersWithoutSessions(): ApiModels.MemberData[] {
return this.members.filter(({ id }) => id && !(id in this.sessions))
}
members: ApiModels.MemberData[] = []
plugins: {
id: string
old: Array<Array<string>>
new: Array<Array<string>>
profile: ApiModels.MemberProfile
} | null = null
newUsername: string = ''
newPassword: string = ''
newProfile: ApiModels.MemberProfile = {}
defProfile: ApiModels.MemberProfile = {
name: '',
is_admin: false,
can_login: true,
can_connect: true,
can_watch: true,
can_host: true,
can_share_media: true,
can_access_clipboard: true,
sends_inactive_cursor: true,
can_see_inactive_cursors: true,
}
async memberCreate() {
try {
const res = await this.neko.members.membersCreate({
username: this.newUsername,
password: this.newPassword,
profile: this.newProfile,
})
if (res.data) {
Vue.set(this, 'members', [...this.members, res.data])
}
// clear
Vue.set(this, 'newUsername', '')
Vue.set(this, 'newPassword', '')
Vue.set(this, 'newProfile', Object.assign({}, this.defProfile))
} catch (e: any) {
alert(e.response ? e.response.data.message : e)
}
}
async membersLoad(limit: number = 0) {
const offset = 0
try {
const res = await this.neko.members.membersList(limit, offset)
Vue.set(this, 'members', res.data)
} catch (e: any) {
alert(e.response ? e.response.data.message : e)
}
}
async memberGetProfile(memberId: string): Promise<ApiModels.MemberProfile | undefined> {
try {
const res = await this.neko.members.membersGetProfile(memberId)
return res.data
} catch (e: any) {
alert(e.response ? e.response.data.message : e)
}
}
async updateProfile(memberId: string, memberProfile: ApiModels.MemberProfile) {
try {
await this.neko.members.membersUpdateProfile(memberId, memberProfile)
const members = this.members.map((member) => {
if (member.id == memberId) {
return {
id: memberId,
profile: { ...member.profile, ...memberProfile },
}
} else {
return member
}
})
Vue.set(this, 'members', members)
} catch (e: any) {
alert(e.response ? e.response.data.message : e)
}
}
async updatePassword(memberId: string, password: string) {
try {
await this.neko.members.membersUpdatePassword(memberId, { password })
} catch (e: any) {
alert(e.response ? e.response.data.message : e)
}
}
async memberRemove(memberId: string) {
try {
await this.neko.members.membersRemove(memberId)
const members = this.members.filter(({ id }) => id != memberId)
Vue.set(this, 'members', members)
} catch (e: any) {
alert(e.response ? e.response.data.message : e)
}
}
showPlugins(id: string, profile: ApiModels.MemberProfile) {
const old = Object.entries(profile.plugins || {}).map(([key, val]) => [key, JSON.stringify(val, null, 2)])
this.plugins = {
id,
old,
new: old.length > 0 ? [] : [['', '']],
profile,
}
}
savePlugins() {
if (!this.plugins) return
let errKey = ''
try {
let plugins = {} as any
for (let [key, val] of this.plugins.old) {
errKey = key
plugins[key] = JSON.parse(val)
}
for (let [key, val] of this.plugins.new) {
errKey = key
plugins[key] = JSON.parse(val)
}
this.updateProfile(this.plugins.id, { plugins })
this.plugins = null
} catch (e: any) {
alert(errKey + ': ' + e)
}
}
mounted() {
this.membersLoad(10)
}
// clear
newUsername.value = ''
newPassword.value = ''
newProfile.value = Object.assign({}, defProfile.value)
} catch (e: any) {
alert(e.response ? e.response.data.message : e)
}
}
async function membersLoad(limit: number = 0) {
const offset = 0
try {
const res = await props.neko.members.membersList(limit, offset)
members.value = res.data
} catch (e: any) {
alert(e.response ? e.response.data.message : e)
}
}
async function memberGetProfile(memberId: string): Promise<ApiModels.MemberProfile | undefined> {
try {
const res = await props.neko.members.membersGetProfile(memberId)
return res.data
} catch (e: any) {
alert(e.response ? e.response.data.message : e)
}
}
async function updateProfile(memberId: string, memberProfile: ApiModels.MemberProfile) {
try {
await props.neko.members.membersUpdateProfile(memberId, memberProfile)
const newMembers = members.value.map((member) => {
if (member.id == memberId) {
return {
id: memberId,
profile: { ...member.profile, ...memberProfile },
}
} else {
return member
}
})
members.value = newMembers // TODO: Vue.Set
} catch (e: any) {
alert(e.response ? e.response.data.message : e)
}
}
async function updatePassword(memberId: string, password: string) {
try {
await props.neko.members.membersUpdatePassword(memberId, { password })
} catch (e: any) {
alert(e.response ? e.response.data.message : e)
}
}
async function memberRemove(memberId: string) {
try {
await props.neko.members.membersRemove(memberId)
const newMembers = members.value.filter(({ id }) => id != memberId)
members.value = newMembers // TODO: Vue.Set
} catch (e: any) {
alert(e.response ? e.response.data.message : e)
}
}
function showPlugins(id: string, profile: ApiModels.MemberProfile) {
const old = Object.entries(profile.plugins || {}).map(([key, val]) => [key, JSON.stringify(val, null, 2)])
plugins.value = {
id,
old,
new: old.length > 0 ? [] : [['', '']],
profile,
}
}
function savePlugins() {
if (!plugins.value) return
let errKey = ''
try {
let plugins = {} as any
for (let [key, val] of plugins.value.old) {
errKey = key
plugins[key] = JSON.parse(val)
}
for (let [key, val] of plugins.value.new) {
errKey = key
plugins[key] = JSON.parse(val)
}
updateProfile(plugins.value.id, { plugins })
plugins.value = null
} catch (e: any) {
alert(errKey + ': ' + e)
}
}
onMounted(() => {
membersLoad(10)
})
</script>

View File

@ -1,17 +1,17 @@
<template>
<div id="neko" :class="[expanded ? 'expanded' : '']">
<main class="neko-main">
<div class="header-container">
<neko-header :neko="neko" @toggle="expanded = !expanded" />
<div class="header-container" v-if="neko">
<NekoHeader :neko="neko" @toggle="expanded = !expanded" />
</div>
<div class="video-container">
<neko-canvas ref="neko" :server="server" autologin autoconnect autoplay />
<div v-if="loaded && neko.private_mode_enabled" class="player-notif">Private mode is currently enabled.</div>
<NekoCanvas ref="neko" :server="server" autologin autoconnect autoplay />
<div v-if="loaded && neko!.private_mode_enabled" class="player-notif">Private mode is currently enabled.</div>
<div
v-if="loaded && neko.state.connection.type === 'webrtc' && !neko.state.video.playing"
v-if="loaded && neko!.state.connection.type === 'webrtc' && !neko!.state.video.playing"
class="player-overlay"
>
<i @click.stop.prevent="neko.play()" v-if="neko.state.video.playable" class="fas fa-play-circle" />
<i @click.stop.prevent="neko!.play()" v-if="neko!.state.video.playable" class="fas fa-play-circle" />
</div>
<div v-if="uploadActive" class="player-overlay" style="background: rgba(0, 0, 0, 0.8); font-size: 1vw">
UPLOAD IN PROGRESS: {{ Math.round(uploadProgress) }}%
@ -30,11 +30,11 @@
@dragenter.stop.prevent
@dragleave.stop.prevent
@dragover.stop.prevent
@drop.stop.prevent="dialogUploadFiles($event.dataTransfer.files)"
@drop.stop.prevent="dialogUploadFiles([...$event.dataTransfer!.files])"
>
<span style="padding: 1em">UPLOAD REQUESTED:</span>
<span style="background: white">
<input type="file" @change="dialogUploadFiles($event.target.files)" multiple />
<input type="file" @change="dialogUploadFiles([...($event.target as HTMLInputElement)!.files!])" multiple />
</span>
<span style="padding: 1em; padding-bottom: 0; font-style: italic">(or drop files here)</span>
<span style="padding: 1em">
@ -45,15 +45,15 @@
<div class="room-container" style="text-align: center">
<button
v-if="loaded && isTouchDevice"
@click="neko.mobileKeyboardToggle"
@click="neko!.mobileKeyboardToggle"
style="position: absolute; left: 5px; transform: translateY(-100%)"
>
<i class="fa fa-keyboard" />
</button>
<span v-if="loaded && neko.state.session_id" style="padding-top: 10px">
<span v-if="loaded && neko!.state.session_id" style="padding-top: 10px">
You are logged in as
<strong style="font-weight: bold">
{{ neko.state.sessions[neko.state.session_id].profile.name }}
{{ neko!.state.sessions[neko!.state.session_id].profile.name }}
</strong>
</span>
@ -65,14 +65,14 @@
</button>
</div>
<div class="controls">
<template v-if="loaded">
<neko-connect v-if="neko.state.connection.status == 'disconnected'" :neko="neko" />
<neko-controls v-else :neko="neko" />
<template v-if="loaded && neko">
<NekoConnect v-if="neko!.state.connection.status == 'disconnected'" :neko="neko" />
<NekoControls v-else :neko="neko" />
</template>
</div>
<div class="right-menu">
<div style="text-align: right" v-if="loaded">
<button v-if="neko.state.connection.status != 'disconnected'" @click="neko.disconnect()">
<button v-if="neko!.state.connection.status != 'disconnected'" @click="neko!.disconnect()">
disconnect
</button>
</div>
@ -104,11 +104,11 @@
<component v-for="(el, key) in pluginsTabs" :key="key" :is="el" :tab="tab" @tab="tab = $event" />
</ul>
</div>
<div class="page-container">
<neko-events v-if="tab === 'events'" :neko="neko" />
<neko-members v-if="tab === 'members'" :neko="neko" />
<neko-media v-if="tab === 'media'" :neko="neko" />
<neko-chat v-show="tab === 'chat'" :neko="neko" />
<div class="page-container" v-if="neko">
<NekoEvents v-if="tab === 'events'" :neko="neko" />
<NekoMembers v-if="tab === 'members'" :neko="neko" />
<NekoMedia v-if="tab === 'media'" :neko="neko" />
<NekoChat v-show="tab === 'chat'" :neko="neko" />
<!-- Plugins -->
<component v-for="(el, key) in pluginsComponents" :key="key" :is="el" :tab="tab" :neko="neko" />
@ -318,345 +318,331 @@
}
</style>
<script lang="ts">
// plugins must be available at:
// ./plugins/{name}/main-tabs.vue
// ./plugins/{name}/main-components.vue
let plugins = [] as string[]
<script lang="ts" setup>
// plugins must be available at:
// ./plugins/{name}/main-tabs.vue
// ./plugins/{name}/main-components.vue
let plugins = [] as string[]
// dynamic plugins loader
;(function (r: any) {
r.keys().forEach((key: string) => {
const found = key.match(/\.\/(.*?)\//)
if (found) {
plugins.push(found[1])
console.log('loading a plugin:', found[1])
}
})
})(require.context('./plugins/', true, /(main-tabs|main-components)\.vue$/))
// dynamic plugins loader
//;(function (r: any) {
// r.keys().forEach((key: string) => {
// const found = key.match(/\.\/(.*?)\//)
// if (found) {
// plugins.push(found[1])
// console.log('loading a plugin:', found[1])
// }
// })
//})(require.context('./plugins/', true, /(main-tabs|main-components)\.vue$/))
import { Vue, Component, Ref } from 'vue-property-decorator'
import { AxiosProgressEvent } from 'axios'
import NekoCanvas from '~/component/main.vue'
import NekoHeader from './components/header.vue'
import NekoConnect from './components/connect.vue'
import NekoControls from './components/controls.vue'
import NekoEvents from './components/events.vue'
import NekoMembers from './components/members.vue'
import NekoMedia from './components/media.vue'
import NekoChat from './components/chat.vue'
import { ref, computed, onMounted } from 'vue'
@Component({
name: 'neko',
components: {
'neko-canvas': NekoCanvas,
'neko-header': NekoHeader,
'neko-connect': NekoConnect,
'neko-controls': NekoControls,
'neko-events': NekoEvents,
'neko-members': NekoMembers,
'neko-media': NekoMedia,
'neko-chat': NekoChat,
},
computed: {
pluginsTabs() {
let x = {} as Record<string, any>
for (let p of plugins) {
x[p] = () => import('./plugins/' + p + '/main-tabs.vue')
}
return x
},
pluginsComponents() {
let x = {} as Record<string, any>
for (let p of plugins) {
x[p] = () => import('./plugins/' + p + '/main-components.vue')
}
return x
},
},
})
export default class extends Vue {
@Ref('neko') readonly neko!: NekoCanvas
expanded: boolean = !window.matchMedia('(max-width: 600px)').matches // default to expanded on bigger screens
loaded: boolean = false
tab: string = ''
import type { AxiosProgressEvent } from 'axios'
import NekoCanvas from '@/component/main.vue'
import NekoHeader from './components/header.vue'
import NekoConnect from './components/connect.vue'
import NekoControls from './components/controls.vue'
import NekoEvents from './components/events.vue'
import NekoMembers from './components/members.vue'
import NekoMedia from './components/media.vue'
import NekoChat from './components/chat.vue'
server: string = location.href
const pluginsTabs = computed(() => {
let x = {} as Record<string, any>
for (let p of plugins) {
x[p] = () => import('./plugins/' + p + '/main-tabs.vue')
}
return x
})
uploadActive = false
uploadProgress = 0
const pluginsComponents = computed(() => {
let x = {} as Record<string, any>
for (let p of plugins) {
x[p] = () => import('./plugins/' + p + '/main-components.vue')
}
return x
})
get isTouchDevice(): boolean {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0
}
const neko = ref<typeof NekoCanvas>()
dialogOverlayActive = false
dialogRequestActive = false
async dialogUploadFiles(files: File[]) {
console.log('will upload files', files)
const expanded = ref(!window.matchMedia('(max-width: 600px)').matches) // default to expanded on bigger screens
const loaded = ref(false)
const tab = ref('')
this.uploadActive = true
this.uploadProgress = 0
try {
await this.neko.room.uploadDialog([...files], {
onUploadProgress: (progressEvent: AxiosProgressEvent) => {
if (!progressEvent.total) {
this.uploadProgress = 0
return
}
this.uploadProgress = (progressEvent.loaded / progressEvent.total) * 100
},
})
} catch (e: any) {
alert(e.response ? e.response.data.message : e)
} finally {
this.uploadActive = false
}
}
const server = ref(location.href)
dialogCancel() {
this.neko.room.uploadDialogClose()
}
const uploadActive = ref(false)
const uploadProgress = ref(0)
mounted() {
this.loaded = true
this.tab = 'events'
//@ts-ignore
window.neko = this.neko
const isTouchDevice = computed(() => 'ontouchstart' in window || navigator.maxTouchPoints > 0)
// initial URL
const url = new URL(location.href).searchParams.get('url')
if (url) {
this.server = url
}
const dialogOverlayActive = ref(false)
const dialogRequestActive = ref(false)
async function dialogUploadFiles(files: File[]) {
console.log('will upload files', files)
//
// connection events
//
this.neko.events.on('connection.status', (status: 'connected' | 'connecting' | 'disconnected') => {
console.log('connection.status', status)
})
this.neko.events.on('connection.type', (type: 'fallback' | 'webrtc' | 'none') => {
console.log('connection.type', type)
})
this.neko.events.on('connection.webrtc.sdp', (type: 'local' | 'remote', data: string) => {
console.log('connection.webrtc.sdp', type, data)
})
this.neko.events.on('connection.webrtc.sdp.candidate', (type: 'local' | 'remote', data: RTCIceCandidateInit) => {
console.log('connection.webrtc.sdp.candidate', type, data)
})
this.neko.events.on('connection.closed', (error?: Error) => {
if (error) {
alert('Connection closed with error: ' + error.message)
} else {
alert('Connection closed without error.')
}
})
//
// drag and drop events
//
this.neko.events.on('upload.drop.started', () => {
this.uploadActive = true
this.uploadProgress = 0
})
this.neko.events.on('upload.drop.progress', (progressEvent: AxiosProgressEvent) => {
uploadActive.value = true
uploadProgress.value = 0
try {
await neko.value!.room.uploadDialog([...files], {
onUploadProgress: (progressEvent: AxiosProgressEvent) => {
if (!progressEvent.total) {
this.uploadProgress = 0
uploadProgress.value = 0
return
}
this.uploadProgress = (progressEvent.loaded / progressEvent.total) * 100
})
this.neko.events.on('upload.drop.finished', (e?: any) => {
this.uploadActive = false
if (e) {
alert(e.response ? e.response.data.message : e)
}
})
//
// upload dialog events
//
this.neko.events.on('upload.dialog.requested', () => {
this.dialogRequestActive = true
})
this.neko.events.on('upload.dialog.overlay', (id: string) => {
this.dialogOverlayActive = true
console.log('upload.dialog.overlay', id)
})
this.neko.events.on('upload.dialog.closed', () => {
this.dialogOverlayActive = false
this.dialogRequestActive = false
})
//
// custom messages events
//
this.neko.events.on('receive.unicast', (sender: string, subject: string, body: string) => {
console.log('receive.unicast', sender, subject, body)
})
this.neko.events.on('receive.broadcast', (sender: string, subject: string, body: string) => {
console.log('receive.broadcast', sender, subject, body)
})
//
// session events
//
this.neko.events.on('session.created', (id: string) => {
console.log('session.created', id)
})
this.neko.events.on('session.deleted', (id: string) => {
console.log('session.deleted', id)
})
this.neko.events.on('session.updated', (id: string) => {
console.log('session.updated', id)
})
//
// room events
//
this.neko.events.on('room.control.host', (hasHost: boolean, hostID?: string) => {
console.log('room.control.host', hasHost, hostID)
})
this.neko.events.on('room.screen.updated', (width: number, height: number, rate: number) => {
console.log('room.screen.updated', width, height, rate)
})
this.neko.events.on('room.clipboard.updated', (text: string) => {
console.log('room.clipboard.updated', text)
})
this.neko.events.on('room.broadcast.status', (isActive: boolean, url?: string) => {
console.log('room.broadcast.status', isActive, url)
})
//
// control events
//
this.neko.control.on('overlay.click', (e: MouseEvent) => {
console.log('control: overlay.click', e)
})
this.neko.control.on('overlay.contextmenu', (e: MouseEvent) => {
console.log('control: overlay.contextmenu', e)
})
// custom inactive cursor draw function
this.neko.setInactiveCursorDrawFunction(
(ctx: CanvasRenderingContext2D, x: number, y: number, sessionId: string) => {
const cursorTag = this.neko.state.sessions[sessionId]?.profile.name || ''
const colorLight = '#CCDFF6'
const colorDark = '#488DDE'
// get current cursor position
x -= 4
y -= 4
// draw arrow path
const arrowPath = new Path2D('M5 5L19 12.5L12.3286 14.465L8.29412 20L5 5Z')
ctx.globalAlpha = 0.5
ctx.translate(x, y)
ctx.fillStyle = colorLight
ctx.fill(arrowPath)
ctx.lineWidth = 1.5
ctx.lineJoin = 'miter'
ctx.miterLimit = 10
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.strokeStyle = colorDark
ctx.stroke(arrowPath)
// draw cursor tag
if (cursorTag) {
const x = 20 // box margin x
const y = 20 // box margin y
ctx.globalAlpha = 0.5
ctx.font = '10px Arial, sans-serif'
ctx.textBaseline = 'top'
ctx.shadowColor = 'black'
ctx.shadowBlur = 2
ctx.lineWidth = 2
ctx.fillStyle = 'black'
ctx.strokeText(cursorTag, x, y)
ctx.shadowBlur = 0
ctx.fillStyle = 'white'
ctx.fillText(cursorTag, x, y)
}
},
)
this.toggleCursor()
}
private usesCursor = false
toggleCursor() {
if (this.usesCursor) {
this.usesCursor = false
this.neko.setCursorDrawFunction()
return
}
// custom cursor draw function
this.neko.setCursorDrawFunction(
(ctx: CanvasRenderingContext2D, x: number, y: number, {}, {}, sessionId: string) => {
const cursorTag = this.neko.state.sessions[sessionId]?.profile.name || ''
const colorLight = '#CCDFF6'
const colorDark = '#488DDE'
const fontColor = '#ffffff'
// get current cursor position
x -= 4
y -= 4
// draw arrow path
const arrowPath = new Path2D('M5 5L26 16.5L15.9929 19.513L9.94118 28L5 5Z')
ctx.translate(x, y)
ctx.fillStyle = colorLight
ctx.fill(arrowPath)
ctx.lineWidth = 2
ctx.lineJoin = 'miter'
ctx.miterLimit = 10
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.strokeStyle = colorDark
ctx.stroke(arrowPath)
// draw cursor tag
if (cursorTag) {
const fontSize = 12
const boxPaddingX = 9
const boxPaddingY = 6
const x = 22 // box margin x
const y = 28 // box margin y
// prepare tag text
ctx.font = '500 ' + fontSize + 'px Roboto, sans-serif'
ctx.textBaseline = 'ideographic'
// create tag container
const txtWidth = ctx.measureText(cursorTag).width
const w = txtWidth + boxPaddingX * 2
const h = fontSize + boxPaddingY * 2
const r = Math.min(w / 2, h / 2)
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.arcTo(x + w, y, x + w, y + h, r) // Top-Right
ctx.arcTo(x + w, y + h, x, y + h, r) // Bottom-Right
ctx.arcTo(x, y + h, x, y, r) // Bottom-Left
ctx.arcTo(x, y, x + w, y, 2) // Top-Left
ctx.closePath()
ctx.fillStyle = colorDark
ctx.fill()
// fill in tag text
ctx.fillStyle = fontColor
ctx.fillText(cursorTag, x + boxPaddingX, y + fontSize + boxPaddingY)
}
},
)
this.usesCursor = true
}
uploadProgress.value = (progressEvent.loaded / progressEvent.total) * 100
},
})
} catch (e: any) {
alert(e.response ? e.response.data.message : e)
} finally {
uploadActive.value = false
}
}
function dialogCancel() {
neko.value!.room.uploadDialogClose()
}
onMounted(() => {
loaded.value = true
tab.value = 'events'
//@ts-ignore
window.neko = neko
// initial URL
const url = new URL(location.href).searchParams.get('url')
if (url) {
server.value = url
}
//
// connection events
//
neko.value!.events.on('connection.status', (status: 'connected' | 'connecting' | 'disconnected') => {
console.log('connection.status', status)
})
neko.value!.events.on('connection.type', (type: 'fallback' | 'webrtc' | 'none') => {
console.log('connection.type', type)
})
neko.value!.events.on('connection.webrtc.sdp', (type: 'local' | 'remote', data: string) => {
console.log('connection.webrtc.sdp', type, data)
})
neko.value!.events.on('connection.webrtc.sdp.candidate', (type: 'local' | 'remote', data: RTCIceCandidateInit) => {
console.log('connection.webrtc.sdp.candidate', type, data)
})
neko.value!.events.on('connection.closed', (error?: Error) => {
if (error) {
alert('Connection closed with error: ' + error.message)
} else {
alert('Connection closed without error.')
}
})
//
// drag and drop events
//
neko.value!.events.on('upload.drop.started', () => {
uploadActive.value = true
uploadProgress.value = 0
})
neko.value!.events.on('upload.drop.progress', (progressEvent: AxiosProgressEvent) => {
if (!progressEvent.total) {
uploadProgress.value = 0
return
}
uploadProgress.value = (progressEvent.loaded / progressEvent.total) * 100
})
neko.value!.events.on('upload.drop.finished', (e?: any) => {
uploadActive.value = false
if (e) {
alert(e.response ? e.response.data.message : e)
}
})
//
// upload dialog events
//
neko.value!.events.on('upload.dialog.requested', () => {
dialogRequestActive.value = true
})
neko.value!.events.on('upload.dialog.overlay', (id: string) => {
dialogOverlayActive.value = true
console.log('upload.dialog.overlay', id)
})
neko.value!.events.on('upload.dialog.closed', () => {
dialogOverlayActive.value = false
dialogRequestActive.value = false
})
//
// custom messages events
//
neko.value!.events.on('receive.unicast', (sender: string, subject: string, body: string) => {
console.log('receive.unicast', sender, subject, body)
})
neko.value!.events.on('receive.broadcast', (sender: string, subject: string, body: string) => {
console.log('receive.broadcast', sender, subject, body)
})
//
// session events
//
neko.value!.events.on('session.created', (id: string) => {
console.log('session.created', id)
})
neko.value!.events.on('session.deleted', (id: string) => {
console.log('session.deleted', id)
})
neko.value!.events.on('session.updated', (id: string) => {
console.log('session.updated', id)
})
//
// room events
//
neko.value!.events.on('room.control.host', (hasHost: boolean, hostID?: string) => {
console.log('room.control.host', hasHost, hostID)
})
neko.value!.events.on('room.screen.updated', (width: number, height: number, rate: number) => {
console.log('room.screen.updated', width, height, rate)
})
neko.value!.events.on('room.clipboard.updated', (text: string) => {
console.log('room.clipboard.updated', text)
})
neko.value!.events.on('room.broadcast.status', (isActive: boolean, url?: string) => {
console.log('room.broadcast.status', isActive, url)
})
//
// control events
//
neko.value!.control.on('overlay.click', (e: MouseEvent) => {
console.log('control: overlay.click', e)
})
neko.value!.control.on('overlay.contextmenu', (e: MouseEvent) => {
console.log('control: overlay.contextmenu', e)
})
// custom inactive cursor draw function
neko.value!.setInactiveCursorDrawFunction(
(ctx: CanvasRenderingContext2D, x: number, y: number, sessionId: string) => {
const cursorTag = neko.value!.state.sessions[sessionId]?.profile.name || ''
const colorLight = '#CCDFF6'
const colorDark = '#488DDE'
// get current cursor position
x -= 4
y -= 4
// draw arrow path
const arrowPath = new Path2D('M5 5L19 12.5L12.3286 14.465L8.29412 20L5 5Z')
ctx.globalAlpha = 0.5
ctx.translate(x, y)
ctx.fillStyle = colorLight
ctx.fill(arrowPath)
ctx.lineWidth = 1.5
ctx.lineJoin = 'miter'
ctx.miterLimit = 10
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.strokeStyle = colorDark
ctx.stroke(arrowPath)
// draw cursor tag
if (cursorTag) {
const x = 20 // box margin x
const y = 20 // box margin y
ctx.globalAlpha = 0.5
ctx.font = '10px Arial, sans-serif'
ctx.textBaseline = 'top'
ctx.shadowColor = 'black'
ctx.shadowBlur = 2
ctx.lineWidth = 2
ctx.fillStyle = 'black'
ctx.strokeText(cursorTag, x, y)
ctx.shadowBlur = 0
ctx.fillStyle = 'white'
ctx.fillText(cursorTag, x, y)
}
},
)
toggleCursor()
})
const usesCursor = ref(false)
function toggleCursor() {
if (usesCursor.value) {
neko.value!.setCursorDrawFunction()
usesCursor.value = false
return
}
// custom cursor draw function
neko.value!.setCursorDrawFunction(
(ctx: CanvasRenderingContext2D, x: number, y: number, {}, {}, sessionId: string) => {
const cursorTag = neko.value!.state.sessions[sessionId]?.profile.name || ''
const colorLight = '#CCDFF6'
const colorDark = '#488DDE'
const fontColor = '#ffffff'
// get current cursor position
x -= 4
y -= 4
// draw arrow path
const arrowPath = new Path2D('M5 5L26 16.5L15.9929 19.513L9.94118 28L5 5Z')
ctx.translate(x, y)
ctx.fillStyle = colorLight
ctx.fill(arrowPath)
ctx.lineWidth = 2
ctx.lineJoin = 'miter'
ctx.miterLimit = 10
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.strokeStyle = colorDark
ctx.stroke(arrowPath)
// draw cursor tag
if (cursorTag) {
const fontSize = 12
const boxPaddingX = 9
const boxPaddingY = 6
const x = 22 // box margin x
const y = 28 // box margin y
// prepare tag text
ctx.font = '500 ' + fontSize + 'px Roboto, sans-serif'
ctx.textBaseline = 'ideographic'
// create tag container
const txtWidth = ctx.measureText(cursorTag).width
const w = txtWidth + boxPaddingX * 2
const h = fontSize + boxPaddingY * 2
const r = Math.min(w / 2, h / 2)
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.arcTo(x + w, y, x + w, y + h, r) // Top-Right
ctx.arcTo(x + w, y + h, x, y + h, r) // Bottom-Right
ctx.arcTo(x, y + h, x, y, r * 2) // Bottom-Left
ctx.arcTo(x, y, x + w, y, r * 2) // Top-Left
ctx.closePath()
ctx.fillStyle = colorDark
ctx.fill()
// fill in tag text
ctx.fillStyle = fontColor
ctx.fillText(cursorTag, x + boxPaddingX, y + fontSize + boxPaddingY)
}
},
)
usesCursor.value = true
}
</script>
<style>