add test page.

This commit is contained in:
Miroslav Šedivý 2022-07-16 21:30:35 +02:00
parent ecbd2d3ca2
commit 19b9ff6b88
14 changed files with 2955 additions and 1 deletions

View File

@ -14,7 +14,8 @@
"scripts": { "scripts": {
"serve": "vue-cli-service serve --mode development", "serve": "vue-cli-service serve --mode development",
"lint": "vue-cli-service lint", "lint": "vue-cli-service lint",
"build": "vue-cli-service build --target lib --name neko ./src/index.ts" "build": "vue-cli-service build --target lib --name neko ./src/index.ts",
"build:page": "vue-cli-service build"
}, },
"dependencies": { "dependencies": {
"axios": "^0.27.2", "axios": "^0.27.2",

8
src/main.ts Normal file
View File

@ -0,0 +1,8 @@
import Vue from 'vue'
import app from './page/main.vue'
Vue.config.productionTip = false
new Vue({
render: (h) => h(app),
}).$mount('#app')

View File

@ -0,0 +1,360 @@
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font: inherit;
font-size: 100%;
vertical-align: baseline;
}
/* make sure to set some focus styles for accessibility */
:focus {
outline: 0;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
input[type=search]::-webkit-search-cancel-button,
input[type=search]::-webkit-search-decoration,
input[type=search]::-webkit-search-results-button,
input[type=search]::-webkit-search-results-decoration {
-webkit-appearance: none;
-moz-appearance: none;
}
input[type=search] {
-webkit-appearance: none;
-moz-appearance: none;
-webkit-box-sizing: content-box;
-moz-box-sizing: content-box;
box-sizing: content-box;
}
textarea {
overflow: auto;
vertical-align: top;
resize: vertical;
}
/**
* Correct `inline-block` display not defined in IE 6/7/8/9 and Firefox 3.
*/
audio,
canvas,
video {
display: inline-block;
*display: inline;
*zoom: 1;
max-width: 100%;
}
/**
* Prevent modern browsers from displaying `audio` without controls.
* Remove excess height in iOS 5 devices.
*/
audio:not([controls]) {
display: none;
height: 0;
}
/**
* Address styling not present in IE 7/8/9, Firefox 3, and Safari 4.
* Known issue: no IE 6 support.
*/
[hidden] {
display: none;
}
/**
* 1. Correct text resizing oddly in IE 6/7 when body `font-size` is set using
* `em` units.
* 2. Prevent iOS text size adjust after orientation change, without disabling
* user zoom.
*/
html {
font-size: 100%; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
-ms-text-size-adjust: 100%; /* 2 */
}
/**
* Address `outline` inconsistency between Chrome and other browsers.
*/
a:focus {
outline: none;
}
/**
* Improve readability when focused and also mouse hovered in all browsers.
*/
a:active,
a:hover {
outline: 0;
}
/**
* 1. Remove border when inside `a` element in IE 6/7/8/9 and Firefox 3.
* 2. Improve image quality when scaled in IE 7.
*/
img {
border: 0; /* 1 */
-ms-interpolation-mode: bicubic; /* 2 */
}
/**
* Address margin not present in IE 6/7/8/9, Safari 5, and Opera 11.
*/
figure {
margin: 0;
}
/**
* Correct margin displayed oddly in IE 6/7.
*/
form {
margin: 0;
}
/**
* Define consistent border, margin, and padding.
*/
fieldset {
border: 1px solid #c0c0c0;
margin: 0 2px;
padding: 0.35em 0.625em 0.75em;
}
/**
* 1. Correct color not being inherited in IE 6/7/8/9.
* 2. Correct text not wrapping in Firefox 3.
* 3. Correct alignment displayed oddly in IE 6/7.
*/
legend {
border: 0; /* 1 */
padding: 0;
white-space: normal; /* 2 */
*margin-left: -7px; /* 3 */
}
/**
* 1. Correct font size not being inherited in all browsers.
* 2. Address margins set differently in IE 6/7, Firefox 3+, Safari 5,
* and Chrome.
* 3. Improve appearance and consistency in all browsers.
*/
button,
input,
select,
textarea {
font-size: 100%; /* 1 */
margin: 0; /* 2 */
vertical-align: baseline; /* 3 */
*vertical-align: middle; /* 3 */
}
/**
* Address Firefox 3+ setting `line-height` on `input` using `!important` in
* the UA stylesheet.
*/
button,
input {
line-height: normal;
}
/**
* Address inconsistent `text-transform` inheritance for `button` and `select`.
* All other form control elements do not inherit `text-transform` values.
* Correct `button` style inheritance in Chrome, Safari 5+, and IE 6+.
* Correct `select` style inheritance in Firefox 4+ and Opera.
*/
button,
select {
text-transform: none;
}
/**
* 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`
* and `video` controls.
* 2. Correct inability to style clickable `input` types in iOS.
* 3. Improve usability and consistency of cursor style between image-type
* `input` and others.
* 4. Remove inner spacing in IE 7 without affecting normal text inputs.
* Known issue: inner spacing remains in IE 6.
*/
button,
html input[type="button"], /* 1 */
input[type="reset"],
input[type="submit"] {
-webkit-appearance: button; /* 2 */
cursor: pointer; /* 3 */
*overflow: visible; /* 4 */
}
/**
* Re-set default cursor for disabled elements.
*/
button[disabled],
html input[disabled] {
cursor: default;
}
/**
* 1. Address box sizing set to content-box in IE 8/9.
* 2. Remove excess padding in IE 8/9.
* 3. Remove excess padding in IE 7.
* Known issue: excess padding remains in IE 6.
*/
input[type="checkbox"],
input[type="radio"] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
*height: 13px; /* 3 */
*width: 13px; /* 3 */
}
/**
* 1. Address `appearance` set to `searchfield` in Safari 5 and Chrome.
* 2. Address `box-sizing` set to `border-box` in Safari 5 and Chrome
* (include `-moz` to future-proof).
*/
input[type="search"] {
-webkit-appearance: textfield; /* 1 */
-moz-box-sizing: content-box;
-webkit-box-sizing: content-box; /* 2 */
box-sizing: content-box;
}
/**
* Remove inner padding and search cancel button in Safari 5 and Chrome
* on OS X.
*/
input[type="search"]::-webkit-search-cancel-button,
input[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* Remove inner padding and border in Firefox 3+.
*/
button::-moz-focus-inner,
input::-moz-focus-inner {
border: 0;
padding: 0;
}
/**
* 1. Remove default vertical scrollbar in IE 6/7/8/9.
* 2. Improve readability and alignment in all browsers.
*/
textarea {
overflow: auto; /* 1 */
vertical-align: top; /* 2 */
}
/**
* Remove most spacing between table cells.
*/
table {
border-collapse: collapse;
border-spacing: 0;
}
html,
button,
input,
select,
textarea {
color: #222;
}
/*
::-moz-selection {
text-shadow: none;
}
::selection {
text-shadow: none;
}
*/
img {
vertical-align: middle;
}
fieldset {
border: 0;
margin: 0;
padding: 0;
}
textarea {
resize: vertical;
}
.chromeframe {
margin: 0.2em 0;
background: #ccc;
color: #000;
padding: 0.2em 0;
}

View File

@ -0,0 +1,29 @@
$text-family: Whitney, Helvetica Neue, Helvetica, Arial, sans-serif;;
$text-size: 14px;
$text-normal: #dcddde;
$text-muted: #72767d;
$text-link: #00b0f4;
$interactive-normal: #b9bbbe;
$interactive-hover: #dcddde;
$interactive-muted: #4f545c;
$background-primary: #36393f;
$background-secondary: #2f3136;
$background-tertiary: #202225;
$background-accent: #4f545c;
$background-floating: #18191c;
$background-modifier-hover: rgba(79, 84, 92, 0.16);
$background-modifier-active: rgba(79, 84, 92, 0.24);
$background-modifier-selected: rgba(79, 84, 92, 0.32);
$background-modifier-accent: hsla(0, 0%, 100%, 0.06);
$elevation-low: 0 1px 0 rgba(4, 4, 5, 0.2), 0 1.5px 0 rgba(6, 6, 7, 0.05), 0 2px 0 rgba(4, 4, 5, 0.05);
$elevation-high: 0 8px 16px rgba(0, 0, 0, 0.24);
$style-primary: #19bd9c;
$style-error: #d32f2f;
$menu-height: 40px;
$controls-height: 125px;
$side-width: 400px;

View File

@ -0,0 +1,22 @@
@charset "utf-8";
// Import variables
@import "variables";
// Reset CSS
@import "reset";
// Import Vendor
@import "vendor/font-awesome";
html, body {
-webkit-font-smoothing: subpixel-antialiased;
background-color: $background-tertiary;
font-family: $text-family;
font-size: $text-size;
color: $text-normal;
overflow: hidden;
width: 100vw;
height: 100vh;
min-width: 320px;
}

View File

@ -0,0 +1,22 @@
// Variables
@use "sass:math";
$fa-font-path: "~@fortawesome/fontawesome-free/webfonts";
$fa-font-size-base: 16px;
$fa-font-display: auto;
$fa-css-prefix: fa;
$fa-border-color: #eee;
$fa-inverse: #fff;
$fa-li-width: 2em;
$fa-fw-width: math.div(20em, 16);
$fa-primary-opacity: 1;
$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";

View File

@ -0,0 +1,266 @@
<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">
<div class="content">
<div class="content-head">
<span class="session">{{ session(message.id) }}</span>
<span class="timestamp">{{ timestamp(message.created) }}</span>
</div>
<p>{{ message.content }}</p>
</div>
</li>
</template>
</ul>
<div class="chat-send">
<div class="text-container">
<textarea ref="input" placeholder="Send a message" @keydown="onKeyDown" v-model="content" />
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
@import '@/page/assets/styles/main.scss';
.chat {
flex: 1;
flex-direction: column;
display: flex;
max-height: 100%;
max-width: 100%;
overflow-x: hidden;
.chat-history {
flex: 1;
overflow-y: scroll;
overflow-x: hidden;
max-width: 100%;
scrollbar-width: thin;
scrollbar-color: $background-tertiary transparent;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background-color: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: $background-tertiary;
border: 2px solid $background-primary;
border-radius: 4px;
}
&::-webkit-scrollbar-thumb:hover {
background-color: $background-floating;
}
.message {
flex: 1;
border-top: 1px solid var(--border-color);
padding: 10px 5px 0px 10px;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
overflow: hidden;
user-select: text;
word-wrap: break-word;
font-size: 16px;
}
.content-head {
cursor: default;
width: 100%;
margin-bottom: 3px;
display: block;
.session {
display: inline-block;
color: $text-normal;
font-weight: bold;
}
.timestamp {
color: $text-muted;
font-size: 0.7rem;
margin-left: 0.3rem;
line-height: 12px;
&::first-letter {
text-transform: uppercase;
}
}
}
}
.chat-send {
flex-shrink: 0;
height: 80px;
max-height: 80px;
padding: 0 10px 10px 10px;
flex-direction: column;
display: flex;
.text-container {
flex: 1;
width: 100%;
height: 100%;
background-color: rgba($color: #fff, $alpha: 0.05);
border-radius: 5px;
position: relative;
display: flex;
.emoji-menu {
width: 20px;
height: 20px;
font-size: 20px;
margin: 8px 5px 0 0;
cursor: pointer;
}
textarea {
flex: 1;
font-family: $text-family;
border: none;
caret-color: $text-normal;
color: $text-normal;
resize: none;
margin: 5px;
background-color: transparent;
scrollbar-width: thin;
scrollbar-color: $background-tertiary transparent;
&::placeholder {
color: $text-muted;
}
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background-color: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: $background-tertiary;
border-radius: 4px;
}
&::-webkit-scrollbar-thumb:hover {
background-color: $background-floating;
}
&::selection {
background: $text-link;
}
}
}
}
}
</style>
<script lang="ts">
import { Vue, Component, Prop, Watch, Ref } from 'vue-property-decorator'
import Neko from '~/component/main.vue'
const length = 512 // max length of message
@Component({
name: 'neko-chat',
})
export default class extends Vue {
@Ref('history') readonly _history!: HTMLElement
@Prop() readonly neko!: Neko
history = []
content = ''
mounted() {
this.$nextTick(() => {
this._history.scrollTop = this._history.scrollHeight
})
}
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')
)
}
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()
}
}
</script>

View File

@ -0,0 +1,79 @@
<template>
<div>
<table v-if="!neko.state.authenticated">
<tr>
<th style="padding: 5px; text-align: left">Username</th>
<td><input type="text" placeholder="Username" v-model="username" /></td>
</tr>
<tr>
<th style="padding: 5px; text-align: left">Password</th>
<td><input type="password" placeholder="Password" v-model="password" /></td>
</tr>
<tr>
<th></th>
<td><button @click="login()">Login</button></td>
</tr>
</table>
<div v-else style="text-align: center">
<p style="padding-bottom: 10px">You are not connected to the server.</p>
<button @click="connect()">Connect</button> or
<button @click="logout()">Logout</button>
</div>
</div>
</template>
<style lang="scss"></style>
<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator'
import Neko from '~/component/main.vue'
@Component({
name: 'neko-controls',
})
export default class extends Vue {
@Prop() readonly neko!: Neko
username: string = 'admin'
password: string = 'admin'
async login() {
localStorage.setItem('username', this.username)
localStorage.setItem('password', this.password)
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
}
}
}
</script>

View File

@ -0,0 +1,247 @@
<template>
<ul>
<li>
<i
:class="[!can_host ? 'disabled' : '', !hosting ? 'faded' : '', 'fas', 'fa-keyboard', 'request']"
@click.stop.prevent="toggleControl"
/>
</li>
<li>
<label class="switch">
<input type="checkbox" v-model="locked" />
<span />
</label>
</li>
<li>
<i
:class="[{ disabled: !playable }, playing ? 'fa-pause-circle' : 'fa-play-circle', 'fas', 'play']"
@click.stop.prevent="toggleMedia"
/>
</li>
<li>
<div class="volume">
<i
:class="[volume === 0 || muted ? 'fa-volume-mute' : 'fa-volume-up', 'fas']"
@click.stop.prevent="toggleMute"
/>
<input type="range" min="0" max="100" v-model="volume" />
</div>
</li>
<li>
<i class="fa-sign-out-alt fas" @click.stop.prevent="disconnect" />
</li>
</ul>
</template>
<style lang="scss" scoped>
@import '../assets/styles/_variables.scss';
ul {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
list-style: none;
li {
font-size: 24px;
cursor: pointer;
i {
padding: 0 5px;
&.faded {
color: rgba($color: $text-normal, $alpha: 0.4);
}
&.disabled {
color: rgba($color: $style-error, $alpha: 0.4);
}
}
.volume {
white-space: nowrap;
display: block;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
list-style: none;
input[type='range'] {
width: 100%;
background: transparent;
width: 150px;
height: 20px;
-webkit-appearance: none;
&::-moz-range-thumb {
height: 12px;
width: 12px;
border-radius: 12px;
background: #fff;
cursor: pointer;
}
&::-moz-range-track {
width: 100%;
height: 4px;
cursor: pointer;
background: $style-primary;
border-radius: 2px;
}
&::-webkit-slider-thumb {
-webkit-appearance: none;
height: 12px;
width: 12px;
border-radius: 12px;
background: #fff;
cursor: pointer;
margin-top: -4px;
}
&::-webkit-slider-runnable-track {
width: 100%;
height: 4px;
cursor: pointer;
background: $style-primary;
border-radius: 2px;
}
}
}
.switch {
margin: 0 5px;
display: block;
position: relative;
width: 42px;
height: 24px;
input[type='checkbox'] {
opacity: 0;
width: 0;
height: 0;
}
span {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: $background-secondary;
transition: 0.2s;
border-radius: 34px;
&:before {
color: $background-tertiary;
font-weight: 900;
font-family: 'Font Awesome 5 Free';
content: '\f3c1';
font-size: 8px;
line-height: 18px;
text-align: center;
position: absolute;
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
}
}
input[type='checkbox'] {
&:checked + span {
background-color: $style-primary;
&:before {
content: '\f023';
transform: translateX(18px);
}
}
&:disabled + span {
&:before {
content: '';
background-color: rgba($color: $text-normal, $alpha: 0.4);
}
}
}
}
}
</style>
<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator'
import Neko from '~/component/main.vue'
@Component({
name: 'neko-controls',
})
export default class extends Vue {
@Prop() readonly neko!: Neko
get can_host() {
return this.neko.connected
}
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()
}
}
</script>

View File

@ -0,0 +1,497 @@
<template>
<div class="tab-states">
<table class="states">
<tr>
<th style="width: 50%">authenticated</th>
<td :style="!neko.state.authenticated ? 'background: red;' : ''">{{ neko.state.authenticated }}</td>
</tr>
<tr>
<th>connection.url</th>
<td style="word-break: break-all">{{ neko.state.connection.url }}</td>
</tr>
<tr>
<th>connection.token</th>
<td>{{ neko.state.connection.token ? 'yes' : 'no' }}</td>
</tr>
<tr>
<th>connection.status</th>
<td
:style="
neko.state.connection.status == 'disconnected'
? 'background: red;'
: neko.state.connection.status == 'connecting'
? 'background: #17448a;'
: ''
"
>
{{ neko.state.connection.status }}
</td>
</tr>
<tr>
<th title="connection.websocket.connected">connection.websocket.con...</th>
<td>{{ neko.state.connection.websocket.connected }}</td>
</tr>
<tr>
<th>connection.websocket.config</th>
<td>
<details>
<summary>Show</summary>
<table class="states">
<tr>
<th style="width: 40%">max_reconnects</th>
<td>
{{ neko.state.connection.websocket.config.max_reconnects }}
</td>
</tr>
<tr>
<th style="width: 40%">timeout_ms</th>
<td>
{{ neko.state.connection.websocket.config.timeout_ms }}
</td>
</tr>
<tr>
<th style="width: 40%">backoff_ms</th>
<td>
{{ neko.state.connection.websocket.config.backoff_ms }}
</td>
</tr>
</table>
</details>
</td>
</tr>
<tr>
<th title="connection.webrtc.connected">connection.webrtc.connect...</th>
<td>{{ neko.state.connection.webrtc.connected }}</td>
</tr>
<tr>
<th>connection.webrtc.stable</th>
<td>{{ neko.state.connection.webrtc.stable }}</td>
</tr>
<tr>
<th>connection.webrtc.config</th>
<td>
<details>
<summary>Show</summary>
<table class="states">
<tr>
<th style="width: 40%">max_reconnects</th>
<td>
{{ neko.state.connection.webrtc.config.max_reconnects }}
</td>
</tr>
<tr>
<th style="width: 40%">timeout_ms</th>
<td>
{{ neko.state.connection.webrtc.config.timeout_ms }}
</td>
</tr>
<tr>
<th style="width: 40%">backoff_ms</th>
<td>
{{ neko.state.connection.webrtc.config.backoff_ms }}
</td>
</tr>
</table>
</details>
</td>
</tr>
<tr>
<th>connection.webrtc.stats</th>
<td>
<table class="states" v-if="neko.state.connection.webrtc.stats != null">
<tr>
<th style="width: 40%">muted</th>
<td :style="neko.state.connection.webrtc.stats.muted ? 'background: red' : ''">
{{ neko.state.connection.webrtc.stats.muted }}
</td>
</tr>
<tr>
<th style="width: 40%">bitrate</th>
<td>{{ Math.floor(neko.state.connection.webrtc.stats.bitrate / 1024 / 8) }} KB/s</td>
</tr>
<tr>
<th>loss</th>
<td :style="neko.state.connection.webrtc.stats.packetLoss >= 1 ? 'background: red' : ''">
{{ Math.floor(neko.state.connection.webrtc.stats.packetLoss) }}%
</td>
</tr>
<tr>
<td
colspan="2"
style="background: green; text-align: center"
v-if="neko.state.connection.webrtc.stats.paused"
>
webrtc is paused
</td>
<td
colspan="2"
style="background: red; text-align: center"
v-else-if="!neko.state.connection.webrtc.stats.fps"
>
frame rate is zero
</td>
<td colspan="2" v-else>
{{
neko.state.connection.webrtc.stats.width +
'x' +
neko.state.connection.webrtc.stats.height +
'@' +
Math.floor(neko.state.connection.webrtc.stats.fps * 100) / 100
}}
</td>
</tr>
</table>
</td>
</tr>
<tr>
<th>connection.webrtc.video</th>
<td>{{ neko.state.connection.webrtc.video }}</td>
</tr>
<tr>
<th rowspan="2">connection.webrtc.videos</th>
<td>Total {{ neko.state.connection.webrtc.videos.length }} videos.</td>
</tr>
<tr>
<td>
<select :value="neko.state.connection.webrtc.video" @input="neko.setWebRTCVideo($event.target.value)">
<option v-for="video in neko.state.connection.webrtc.videos" :key="video" :value="video">
{{ video }}
</option>
</select>
</td>
</tr>
<tr>
<th>connection.screencast</th>
<td>{{ neko.state.connection.screencast }}</td>
</tr>
<tr>
<th>connection.type</th>
<td :style="neko.state.connection.type == 'fallback' ? 'background: #17448a;' : ''">
{{ neko.state.connection.type }}
</td>
</tr>
<tr>
<th>video.playable</th>
<td>{{ neko.state.video.playable }}</td>
</tr>
<tr>
<th rowspan="2">video.playing</th>
<td>{{ neko.state.video.playing }}</td>
</tr>
<tr>
<td>
<button v-if="!neko.state.video.playing" @click="neko.play()">play</button>
<button v-else @click="neko.pause()">pause</button>
</td>
</tr>
<tr>
<th rowspan="2">video.volume</th>
<td>{{ neko.state.video.volume }}</td>
</tr>
<tr>
<td>
<input
type="range"
min="0"
max="1"
:value="neko.state.video.volume"
@input="neko.setVolume(Number($event.target.value))"
step="0.01"
/>
</td>
</tr>
<tr>
<th rowspan="2">video.muted</th>
<td>{{ neko.state.video.muted }}</td>
</tr>
<tr>
<td>
<button v-if="!neko.state.video.muted" @click="neko.mute()">mute</button>
<button v-else @click="neko.unmute()">unmute</button>
</td>
</tr>
<tr>
<th>control.scroll.inverse</th>
<td>
<div class="space-between">
<span>{{ neko.state.control.scroll.inverse }}</span>
<button @click="neko.setScrollInverse(!neko.state.control.scroll.inverse)">
<i class="fas fa-toggle-on"></i>
</button>
</div>
</td>
</tr>
<tr>
<th rowspan="2">control.scroll.sensitivity</th>
<td>{{ neko.state.control.scroll.sensitivity }}</td>
</tr>
<tr>
<td>
<input
type="range"
min="-5"
max="5"
:value="neko.state.control.scroll.sensitivity"
@input="neko.setScrollSensitivity(Number($event.target.value))"
step="1"
/>
</td>
</tr>
<tr>
<th>control.clipboard</th>
<td>
<textarea
:readonly="!neko.controlling"
:value="neko.state.control.clipboard ? neko.state.control.clipboard.text : ''"
@input="clipboardText = $event.target.value"
></textarea>
<button :disabled="!neko.controlling" @click="neko.room.clipboardSetText({ text: clipboardText })">
send clipboard
</button>
</td>
</tr>
<tr>
<th rowspan="2">control.keyboard</th>
<td>
{{
neko.state.control.keyboard.layout +
(neko.state.control.keyboard.variant ? ' (' + neko.state.control.keyboard.variant + ')' : '')
}}
</td>
</tr>
<tr>
<td>
<input
type="text"
placeholder="Layout"
:value="neko.state.control.keyboard.layout"
@input="neko.setKeyboard($event.target.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)"
style="width: 50%; box-sizing: border-box"
/>
</td>
</tr>
<tr>
<th rowspan="2">control.host_id</th>
<td>{{ neko.state.control.host_id }}</td>
</tr>
<tr>
<td>
<button v-if="!neko.controlling" @click="neko.room.controlRequest()">request control</button>
<button v-else @click="neko.room.controlRelease()">release control</button>
</td>
</tr>
<tr>
<th>screen.size</th>
<td>
{{ neko.state.screen.size.width }}x{{ neko.state.screen.size.height }}@{{ neko.state.screen.size.rate }}
</td>
</tr>
<template v-if="neko.is_admin">
<tr>
<th rowspan="2">screen.configurations</th>
<td>Total {{ neko.state.screen.configurations.length }} configurations.</td>
</tr>
<tr>
<td>
<select
:value="Object.values(neko.state.screen.size).join()"
@input="
a = String($event.target.value).split(',')
neko.setScreenSize(parseInt(a[0]), parseInt(a[1]), parseInt(a[2]))
"
>
<option
v-for="{ width, height, rate } in neko.state.screen.configurations"
:key="String(width) + String(height) + String(rate)"
:value="[width, height, rate].join()"
>
{{ width }}x{{ height }}@{{ rate }}
</option>
</select>
<button @click="screenChangingToggle">screenChangingToggle</button>
</td>
</tr>
</template>
<tr v-else>
<th>screen.configurations</th>
<td>Session is not admin.</td>
</tr>
<tr>
<th>session_id</th>
<td>{{ neko.state.session_id }}</td>
</tr>
<tr>
<th>sessions</th>
<td>Total {{ Object.values(neko.state.sessions).length }} sessions.</td>
</tr>
<tr>
<th class="middle">settings.private_mode</th>
<td>
<div class="space-between">
<span>{{ neko.state.settings.private_mode }}</span>
<button @click="updateSettings({ private_mode: !neko.state.settings.private_mode })">
<i class="fas fa-toggle-on"></i>
</button>
</div>
</td>
</tr>
<tr>
<th class="middle">settings.implicit_hosting</th>
<td>
<div class="space-between">
<span>{{ neko.state.settings.implicit_hosting }}</span>
<button @click="updateSettings({ implicit_hosting: !neko.state.settings.implicit_hosting })">
<i class="fas fa-toggle-on"></i>
</button>
</div>
</td>
</tr>
<tr>
<th class="middle">settings.inactive_cursors</th>
<td>
<div class="space-between">
<span>{{ neko.state.settings.inactive_cursors }}</span>
<button @click="updateSettings({ inactive_cursors: !neko.state.settings.inactive_cursors })">
<i class="fas fa-toggle-on"></i>
</button>
</div>
</td>
</tr>
<tr>
<th class="middle">settings.merciful_reconnect</th>
<td>
<div class="space-between">
<span>{{ neko.state.settings.merciful_reconnect }}</span>
<button @click="updateSettings({ merciful_reconnect: !neko.state.settings.merciful_reconnect })">
<i class="fas fa-toggle-on"></i>
</button>
</div>
</td>
</tr>
<tr>
<th>cursors</th>
<td>{{ neko.state.cursors }}</td>
</tr>
<tr>
<th>control actions</th>
<td>
<button title="cut" @click="neko.control.cut()"><i class="fas fa-cut" /></button>
<button title="copy" @click="neko.control.copy()"><i class="fas fa-copy" /></button>
<button title="paste" @click="neko.control.paste()"><i class="fas fa-paste" /></button>
<button title="select all" @click="neko.control.selectAll()"><i class="fas fa-i-cursor" /></button>
</td>
</tr>
<tr>
<th>control keypress</th>
<td style="text-align: center">
<button style="width: 20px" v-for="l in letters" :key="l" @click="neko.control.keyPress(l)">
{{ String.fromCharCode(l) }}
</button>
<div style="display: flex">
<button title="shift" @click="shift = !shift">
<i v-if="shift" class="fas fa-caret-square-up" />
<i v-else class="far fa-caret-square-up" />
</button>
<button style="width: 100%" @click="neko.control.keyPress(' '.charCodeAt(0))">space</button>
<button title="shift" @click="shift = !shift">
<i v-if="shift" class="fas fa-caret-square-up" />
<i v-else class="far fa-caret-square-up" />
</button>
</div>
</td>
</tr>
</table>
</div>
</template>
<style lang="scss">
.tab-states {
&,
.states {
width: 100%;
}
td,
th {
border: 1px solid #ccc;
padding: 4px;
}
th {
text-align: left;
}
.middle {
vertical-align: middle;
}
.space-between {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
}
}
</style>
<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator'
import Neko from '~/component/main.vue'
@Component({
name: 'neko-events',
})
export default class extends Vue {
@Prop() readonly neko!: Neko
clipboardText: string = ''
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
}
}
async updateSettings(settings: any) {
try {
await this.neko.room.settingsSet(settings)
} catch (e: any) {
alert(e.response ? e.response.data.message : e)
}
}
}
</script>

View File

@ -0,0 +1,112 @@
<template>
<div class="header">
<div class="neko">
<span class="logo"><b>n</b>.eko</span>
<div class="server">
<span>Server:</span>
<input type="text" placeholder="URL" v-model="url" />
<button @click="setUrl">change</button>
</div>
<ul class="menu">
<li>
<i class="fas fa-bars toggle" @click="toggleMenu" />
</li>
</ul>
</div>
</div>
</template>
<style lang="scss" scoped>
@import '../assets/styles/_variables.scss';
.header {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
height: 100%;
.neko {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
width: 150px;
margin-left: 20px;
.logo {
font-size: 30px;
line-height: 30px;
b {
font-weight: 900;
}
}
.server {
max-width: 850px;
width: 100%;
margin: 0 20px;
display: flex;
align-items: center;
input {
margin: 0 5px;
width: 100%;
}
}
}
.menu {
justify-self: flex-end;
margin-right: 10px;
white-space: nowrap;
li {
display: inline-block;
margin-right: 10px;
i {
display: block;
width: 30px;
height: 30px;
text-align: center;
line-height: 32px;
border-radius: 3px;
cursor: pointer;
}
.toggle {
background: $background-primary;
}
}
}
}
</style>
<script lang="ts">
import { Component, Prop, Ref, Watch, Vue } from 'vue-property-decorator'
import Neko from '~/component/main.vue'
@Component({
name: 'neko-header',
})
export default class extends Vue {
@Prop() readonly neko!: Neko
url: string = location.href
async setUrl() {
if (this.url == '') {
this.url = location.href
}
await this.neko.setUrl(this.url)
}
toggleMenu() {
this.$emit('toggle')
//this.$accessor.client.toggleSide()
}
}
</script>

View File

@ -0,0 +1,145 @@
<template>
<div class="media" style="width: 100%">
<!--
<button @click="getDevices">List available devices</button>
<button v-for="d in devices" :key="d.deviceId">
{{ d.kind }} : {{ d.label }} id = {{ d.deviceId }}
</button>
-->
<button v-if="micTracks.length == 0" @click="addMicrophone">Add microphone</button>
<button v-else @click="stopMicrophone">Stop microphone</button>
<br />
<audio v-show="micTracks.length > 0" ref="audio" controls />
<hr />
<button v-if="camTracks.length == 0" @click="addWebcam">Add webcam</button>
<button v-else @click="stopWebcam">Stop webcam</button>
<br />
<video v-show="camTracks.length > 0" ref="video" controls />
</div>
</template>
<style lang="scss" scoped></style>
<script lang="ts">
import { Vue, Component, Prop, Ref } from 'vue-property-decorator'
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
private micTracks: MediaStreamTrack[] = []
private micSenders: RTCRtpSender[] = []
private camTracks: MediaStreamTrack[] = []
private camSenders: RTCRtpSender[] = []
//private devices: any[] = []
async addMicrophone() {
this.micTracks = []
this.micSenders = []
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true })
this._audio.srcObject = stream
console.log('Got MediaStream:', stream)
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()
})
this.micSenders.forEach((rtcp) => {
this.neko.removeTrack(rtcp)
})
this._audio.srcObject = null
this.micTracks = []
this.micSenders = []
}
async addWebcam() {
this.camTracks = []
this.camSenders = []
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
width: 1280,
height: 720,
},
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 = []
}
/*async getDevices() {
const devices = await navigator.mediaDevices.enumerateDevices();
this.devices = devices.map(({ kind, label, deviceId }) => ({ kind, label, deviceId }))
console.log(this.devices)
}*/
}
</script>

View File

@ -0,0 +1,569 @@
<template>
<div class="members">
<table class="plugins" v-if="plugins">
<tr>
<td colspan="2" class="name">Plugins for {{ plugins.profile.name }}</td>
</tr>
<tr v-for="([key], i) in plugins.old" :key="key">
<th>{{ key }}</th>
<td><input type="text" v-model="plugins.old[i][1]" placeholder="value (JSON)" /></td>
</tr>
<tr v-for="([key], i) in plugins.new" :key="key">
<th><input type="text" v-model="plugins.new[i][0]" placeholder="key (string)" /></th>
<td><input type="text" v-model="plugins.new[i][1]" placeholder="value (JSON)" /></td>
</tr>
<tr>
<td colspan="2" style="text-align: center">
<button @click="$set(plugins, 'new', [...plugins.new, ['', '']])">+</button>
</td>
</tr>
<tr>
<td colspan="2">
<button @click="savePlugins">save</button>
<button @click="plugins = null">close</button>
</td>
</tr>
</table>
<p class="title">
<span>Sessions</span>
</p>
<div
class="member"
:class="{
'is-admin': neko.is_admin,
}"
v-for="(session, id) in sessions"
:key="'session-' + id"
>
<div class="topbar">
<div class="name">
<i v-if="neko.is_admin" class="fa fa-trash-alt" @click="memberRemove(id)" title="remove" />
{{ session.profile.name }}
</div>
<div class="controls">
<i
class="fa fa-shield-alt"
:class="{
'state-has': session.profile.is_admin,
}"
@click="neko.is_admin && updateProfile(id, { is_admin: !session.profile.is_admin })"
title="is_admin"
/>
<i
class="fa fa-lock-open"
:class="{
'state-has': session.profile.can_login,
}"
@click="neko.is_admin && updateProfile(id, { can_login: !session.profile.can_login })"
title="can_login"
/>
<i
class="fa fa-sign-in-alt"
:class="{
'state-has': session.profile.can_connect,
'state-is': session.state.is_connected,
'state-disabled': !session.profile.can_login,
}"
@click="neko.is_admin && updateProfile(id, { can_connect: !session.profile.can_connect })"
title="can_connect"
/>
<i
class="fa fa-desktop"
:class="{
'state-has': session.profile.can_watch,
'state-is': session.state.is_watching,
'state-disabled': !session.profile.can_login || !session.profile.can_connect,
}"
@click="neko.is_admin && updateProfile(id, { can_watch: !session.profile.can_watch })"
title="can_watch"
/>
<i
class="fa fa-keyboard"
:class="{
'state-has': session.profile.can_host,
'state-is': neko.state.control.host_id == id,
'state-disabled': !session.profile.can_login || !session.profile.can_connect,
}"
@click="neko.is_admin && updateProfile(id, { can_host: !session.profile.can_host })"
title="can_host"
/>
<i
class="fa fa-microphone"
:class="{
'state-has': session.profile.can_share_media,
'state-disabled': !session.profile.can_login || !session.profile.can_connect,
}"
@click="neko.is_admin && updateProfile(id, { can_share_media: !session.profile.can_share_media })"
title="can_share_media"
/>
<i
class="fa fa-clipboard"
:class="{
'state-has': session.profile.can_access_clipboard,
'state-disabled': !session.profile.can_login || !session.profile.can_connect,
}"
@click="neko.is_admin && updateProfile(id, { can_access_clipboard: !session.profile.can_access_clipboard })"
title="can_access_clipboard"
/>
<i
class="fa fa-mouse"
:class="{
'state-has': session.profile.sends_inactive_cursor,
'state-is':
session.profile.sends_inactive_cursor &&
neko.state.settings.inactive_cursors &&
neko.state.cursors.some((e) => e.id == id),
'state-disabled': !session.profile.can_login || !session.profile.can_connect,
}"
@click="
neko.is_admin && updateProfile(id, { sends_inactive_cursor: !session.profile.sends_inactive_cursor })
"
title="sends_inactive_cursor"
/>
<i
class="fa fa-mouse-pointer"
:class="{
'state-has': session.profile.can_see_inactive_cursors,
'state-disabled': !session.profile.can_login || !session.profile.can_connect,
}"
@click="
neko.is_admin &&
updateProfile(id, { can_see_inactive_cursors: !session.profile.can_see_inactive_cursors })
"
title="can_see_inactive_cursors"
/>
<i class="fa fa-puzzle-piece state-has" @click="showPlugins(id, session.profile)" title="plugins" />
</div>
</div>
</div>
<p class="title">
<span>Members</span>
<button @click="membersLoad">reload</button>
</p>
<div
class="member"
:class="{
'is-admin': neko.is_admin,
}"
v-for="member in membersWithoutSessions"
:key="'member-' + member.id"
>
<div class="topbar">
<div class="name">
<i v-if="neko.is_admin" class="fa fa-trash-alt" @click="memberRemove(member.id)" title="remove" />
{{ member.profile.name }}
</div>
<div class="controls">
<i
class="fa fa-shield-alt"
:class="{
'state-has': member.profile.is_admin,
}"
@click="neko.is_admin && updateProfile(member.id, { is_admin: !member.profile.is_admin })"
title="is_admin"
/>
<i
class="fa fa-lock-open"
:class="{
'state-has': member.profile.can_login,
}"
@click="neko.is_admin && updateProfile(member.id, { can_login: !member.profile.can_login })"
title="can_login"
/>
<i
class="fa fa-sign-in-alt"
:class="{
'state-has': member.profile.can_connect,
'state-disabled': !member.profile.can_login,
}"
@click="neko.is_admin && updateProfile(member.id, { can_connect: !member.profile.can_connect })"
title="can_connect"
/>
<i
class="fa fa-desktop"
:class="{
'state-has': member.profile.can_watch,
'state-disabled': !member.profile.can_login || !member.profile.can_connect,
}"
@click="neko.is_admin && updateProfile(member.id, { can_watch: !member.profile.can_watch })"
title="can_watch"
/>
<i
class="fa fa-keyboard"
:class="{
'state-has': member.profile.can_host,
'state-disabled': !member.profile.can_login || !member.profile.can_connect,
}"
@click="neko.is_admin && updateProfile(member.id, { can_host: !member.profile.can_host })"
title="can_host"
/>
<i
class="fa fa-microphone"
:class="{
'state-has': member.profile.can_share_media,
'state-disabled': !member.profile.can_login || !member.profile.can_connect,
}"
@click="neko.is_admin && updateProfile(member.id, { can_share_media: !member.profile.can_share_media })"
title="can_share_media"
/>
<i
class="fa fa-clipboard"
:class="{
'state-has': member.profile.can_access_clipboard,
'state-disabled': !member.profile.can_login || !member.profile.can_connect,
}"
@click="
neko.is_admin && updateProfile(member.id, { can_access_clipboard: !member.profile.can_access_clipboard })
"
title="can_access_clipboard"
/>
<i
class="fa fa-mouse"
:class="{
'state-has': member.profile.sends_inactive_cursor,
'state-disabled': !member.profile.can_login || !member.profile.can_connect,
}"
@click="
neko.is_admin &&
updateProfile(member.id, { sends_inactive_cursor: !member.profile.sends_inactive_cursor })
"
title="sends_inactive_cursor"
/>
<i
class="fa fa-mouse-pointer"
:class="{
'state-has': member.profile.can_see_inactive_cursors,
'state-disabled': !member.profile.can_login || !member.profile.can_connect,
}"
@click="
neko.is_admin &&
updateProfile(member.id, { can_see_inactive_cursors: !member.profile.can_see_inactive_cursors })
"
title="can_see_inactive_cursors"
/>
<i class="fa fa-puzzle-piece state-has" @click="showPlugins(member.id, member.profile)" title="plugins" />
</div>
</div>
</div>
<table class="new-member" v-if="neko.is_admin">
<tr>
<td colspan="2" class="name">New Member</td>
</tr>
<tr>
<th>username</th>
<td><input type="text" v-model="newUsername" /></td>
</tr>
<tr>
<th>password</th>
<td><input type="text" v-model="newPassword" /></td>
</tr>
<tr>
<td colspan="2" class="name" style="text-align: center">Profile</td>
</tr>
<tr>
<th>name</th>
<td><input type="text" v-model="newProfile.name" /></td>
</tr>
<tr>
<th>is_admin</th>
<td><input type="checkbox" v-model="newProfile.is_admin" /></td>
</tr>
<tr>
<th>can_login</th>
<td><input type="checkbox" v-model="newProfile.can_login" /></td>
</tr>
<tr>
<th>can_connect</th>
<td><input type="checkbox" v-model="newProfile.can_connect" /></td>
</tr>
<tr>
<th>can_watch</th>
<td><input type="checkbox" v-model="newProfile.can_watch" /></td>
</tr>
<tr>
<th>can_host</th>
<td><input type="checkbox" v-model="newProfile.can_host" /></td>
</tr>
<tr>
<th>can_share_media</th>
<td><input type="checkbox" v-model="newProfile.can_share_media" /></td>
</tr>
<tr>
<th>can_access_clipboard</th>
<td><input type="checkbox" v-model="newProfile.can_access_clipboard" /></td>
</tr>
<tr>
<th>sends_inactive_cursor</th>
<td><input type="checkbox" v-model="newProfile.sends_inactive_cursor" /></td>
</tr>
<tr>
<th>can_see_inactive_cursors</th>
<td><input type="checkbox" v-model="newProfile.can_see_inactive_cursors" /></td>
</tr>
<tr>
<td colspan="2"><button @click="memberCreate">create</button></td>
</tr>
</table>
</div>
</template>
<style lang="scss" scoped>
@import '@/page/assets/styles/main.scss';
.title {
padding: 4px;
font-weight: bold;
display: flex;
justify-content: space-between;
align-items: center;
}
.members {
display: block;
width: 100%;
padding: 5px;
overflow: hidden;
.member {
padding: 5px;
margin: 5px 0;
border: 1px solid white;
box-sizing: border-box;
&.is-admin .fa {
cursor: pointer;
}
.topbar {
display: flex;
align-items: center;
.name {
flex: 1 1;
}
}
.fa {
padding: 5px;
color: rgb(211, 47, 47);
&.state-has {
color: #fff;
}
&.state-is {
color: green;
}
&.state-disabled {
color: #555;
}
}
}
.new-member,
.plugins {
width: 100%;
margin: 5px 0;
.name {
font-weight: bold;
}
td,
th {
border: 1px solid #ccc;
padding: 4px;
width: 50%;
}
th {
text-align: right;
}
}
.plugins {
position: absolute;
width: auto;
box-shadow: 0px 0px 10px 5px black;
background: $background-tertiary;
textarea,
input {
width: 100%;
box-sizing: border-box;
}
}
}
</style>
<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator'
import Neko, { ApiModels, StateModels } from '~/component/main.vue'
@Component({
name: 'neko-members',
})
export default class extends Vue {
@Prop() readonly neko!: Neko
constructor() {
super()
// init
this.newProfile = Object.assign({}, this.defProfile)
}
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 {
const res = 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)
}
}
</script>

597
src/page/main.vue Normal file
View File

@ -0,0 +1,597 @@
<template>
<div id="neko" :class="[expanded ? 'expanded' : '']">
<main class="neko-main">
<div class="header-container">
<neko-header :neko="neko" @toggle="expanded = !expanded" />
</div>
<div class="video-container">
<neko-canvas ref="neko" 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"
class="player-overlay"
>
<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) }}%
</div>
<div
v-else-if="dialogOverlayActive"
class="player-overlay"
style="background: rgba(0, 0, 0, 0.8); font-size: 1vw"
>
SOMEONE IS UPLOADING A FILE, PLEASE WAIT
</div>
<div
v-else-if="dialogRequestActive"
class="player-overlay"
style="background: rgba(0, 0, 0, 0.8); font-size: 1vw; flex-flow: column"
@dragenter.stop.prevent
@dragleave.stop.prevent
@dragover.stop.prevent
@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 />
</span>
<span style="padding: 1em; padding-bottom: 0; font-style: italic">(or drop files here)</span>
<span style="padding: 1em">
<button @click="dialogCancel()">CANCEL</button>
</span>
</div>
</div>
<div class="room-container" style="text-align: center">
<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 }}
</strong>
</span>
<div class="room-menu">
<div class="left-menu">
<button @click="toggleCursor">
<i v-if="usesCursor" class="fas fa-mouse-pointer" />
<i v-else class="fas fa-location-arrow" />
</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>
</div>
<div class="right-menu">
<div style="text-align: right" v-if="loaded">
<button v-if="neko.state.connection.status != 'disconnected'" @click="neko.disconnect()">
disconnect
</button>
</div>
</div>
</div>
</div>
</main>
<aside class="neko-menu" v-if="expanded">
<div class="tabs-container">
<ul>
<li :class="{ active: tab === 'events' }" @click.prevent="tab = 'events'">
<i class="fas fa-sliders-h" />
<span v-show="tab === 'events'">Events</span>
</li>
<li :class="{ active: tab === 'members' }" @click.prevent="tab = 'members'">
<i class="fas fa-users" />
<span v-show="tab === 'members'">Members</span>
</li>
<li :class="{ active: tab === 'media' }" @click.prevent="tab = 'media'">
<i class="fas fa-microphone" />
<span v-show="tab === 'media'">Media</span>
</li>
<li :class="{ active: tab === 'chat' }" @click.prevent="tab = 'chat'">
<i class="fas fa-comment-alt" />
<span v-show="tab === 'chat'">Chat</span>
</li>
</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>
</aside>
</div>
</template>
<style lang="scss">
@import '@/page/assets/styles/main.scss';
.video-container {
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
}
.player-notif {
position: absolute;
top: 0;
overflow: hidden;
background: #2a5f2a;
padding: 10px;
}
.player-overlay {
position: absolute;
top: 0;
bottom: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: rgba($color: #000, $alpha: 0.2);
display: flex;
justify-content: center;
align-items: center;
i {
cursor: pointer;
&::before {
font-size: 120px;
text-align: center;
}
}
&.hidden {
display: none;
}
}
#neko {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
max-width: 100vw;
max-height: 100vh;
flex-direction: row;
display: flex;
}
.neko-main {
min-width: 360px;
max-width: 100%;
flex-grow: 1;
flex-direction: column;
display: flex;
overflow: auto;
.header-container {
background: $background-tertiary;
height: $menu-height;
flex-shrink: 0;
}
.video-container {
background: rgba($color: #000, $alpha: 0.4);
max-width: 100%;
flex-grow: 1;
}
.room-container {
background: $background-tertiary;
height: $controls-height;
max-width: 100%;
flex-shrink: 0;
flex-direction: column;
display: flex;
.room-menu {
max-width: 100%;
flex: 1;
display: flex;
.left-menu {
margin-left: 10px;
flex: 1;
justify-content: flex-start;
align-items: center;
display: flex;
}
.controls {
flex: 1;
justify-content: center;
align-items: center;
display: flex;
}
.right-menu {
margin-right: 10px;
flex: 1;
justify-content: flex-end;
align-items: center;
display: flex;
}
}
}
}
.neko-menu {
width: $side-width;
background-color: $background-primary;
flex-shrink: 0;
max-height: 100%;
max-width: 100%;
display: flex;
flex-direction: column;
.tabs-container {
background: $background-tertiary;
height: $menu-height;
max-height: 100%;
max-width: 100%;
display: flex;
flex-shrink: 0;
ul {
display: inline-block;
padding: 16px 0 0 0;
li {
background: $background-secondary;
border-radius: 3px 3px 0 0;
border-bottom: none;
display: inline-block;
padding: 5px 10px;
margin-right: 4px;
font-weight: 600;
cursor: pointer;
i {
margin-right: 4px;
font-size: 10px;
}
&.active {
background: $background-primary;
}
}
}
}
.page-container {
max-height: 100%;
flex-grow: 1;
display: flex;
overflow: auto;
padding-top: 5px;
}
}
@media only screen and (max-width: 600px) {
#neko.expanded {
.neko-main {
transform: translateX(calc(-100% + 65px));
video {
display: none;
}
}
.neko-menu {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 65px;
width: calc(100% - 65px);
}
}
}
@media only screen and (max-width: 768px) {
#neko .neko-main .room-container {
display: none;
}
}
</style>
<script lang="ts">
import { Vue, Component, Ref, Watch } from 'vue-property-decorator'
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'
@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,
},
})
export default class extends Vue {
@Ref('neko') readonly neko!: NekoCanvas
expanded: boolean = true
loaded: boolean = false
tab: string = ''
uploadActive = false
uploadProgress = 0
dialogOverlayActive = false
dialogRequestActive = false
async dialogUploadFiles(files: File[]) {
console.log('will upload files', files)
this.uploadActive = true
this.uploadProgress = 0
try {
await this.neko.room.uploadDialog(files, {
onUploadProgress: (progressEvent: ProgressEvent) => {
this.uploadProgress = (progressEvent.loaded / progressEvent.total) * 100
},
})
} catch (e: any) {
alert(e.response ? e.response.data.message : e)
} finally {
this.uploadActive = false
}
}
dialogCancel() {
this.neko.room.uploadDialogClose()
}
mounted() {
this.loaded = true
this.tab = 'events'
//@ts-ignore
window.neko = this.neko
//
// 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: ProgressEvent) => {
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
})
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
ctx.save()
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)
ctx.restore()
ctx.save()
// draw cursor tag
if (cursorTag) {
x += 20 // box margin x
y += 20 // box margin y
ctx.save()
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)
ctx.restore()
}
},
)
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
ctx.save()
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)
ctx.restore()
// draw cursor tag
if (cursorTag) {
const fontSize = 12
const boxPaddingX = 9
const boxPaddingY = 6
x += 22 // box margin x
y += 28 // box margin y
// prepare tag text
ctx.font = '500 ' + fontSize + 'px Roboto, sans-serif'
ctx.textBaseline = 'ideographic'
// create tag container
ctx.save()
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()
ctx.restore()
// fill in tag text
ctx.fillStyle = fontColor
ctx.fillText(cursorTag, x + boxPaddingX, y + fontSize + boxPaddingY)
}
},
)
this.usesCursor = true
}
}
</script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap');
</style>