mirror of
https://github.com/m1k1o/neko.git
synced 2024-07-24 14:40:50 +12:00
WIP.
This commit is contained in:
10
src/page/assets/styles/vendor/_font-awesome.scss
vendored
10
src/page/assets/styles/vendor/_font-awesome.scss
vendored
@ -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";
|
||||
|
11
src/page/components/__tests__/HelloWorld.spec.ts
Normal file
11
src/page/components/__tests__/HelloWorld.spec.ts
Normal 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')
|
||||
// })
|
||||
// })
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
Reference in New Issue
Block a user