mirror of
https://github.com/m1k1o/neko.git
synced 2024-07-24 14:40:50 +12:00
add test page.
This commit is contained in:
parent
ecbd2d3ca2
commit
19b9ff6b88
@ -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
8
src/main.ts
Normal 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')
|
360
src/page/assets/styles/_reset.scss
Normal file
360
src/page/assets/styles/_reset.scss
Normal 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;
|
||||||
|
}
|
29
src/page/assets/styles/_variables.scss
Normal file
29
src/page/assets/styles/_variables.scss
Normal 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;
|
22
src/page/assets/styles/main.scss
Normal file
22
src/page/assets/styles/main.scss
Normal 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;
|
||||||
|
}
|
22
src/page/assets/styles/vendor/_font-awesome.scss
vendored
Normal file
22
src/page/assets/styles/vendor/_font-awesome.scss
vendored
Normal 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";
|
266
src/page/components/chat.vue
Normal file
266
src/page/components/chat.vue
Normal 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>
|
79
src/page/components/connect.vue
Normal file
79
src/page/components/connect.vue
Normal 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>
|
247
src/page/components/controls.vue
Normal file
247
src/page/components/controls.vue
Normal 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>
|
497
src/page/components/events.vue
Normal file
497
src/page/components/events.vue
Normal 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>
|
112
src/page/components/header.vue
Normal file
112
src/page/components/header.vue
Normal 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>
|
145
src/page/components/media.vue
Normal file
145
src/page/components/media.vue
Normal 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>
|
569
src/page/components/members.vue
Normal file
569
src/page/components/members.vue
Normal 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
597
src/page/main.vue
Normal 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>
|
Loading…
Reference in New Issue
Block a user