add test page.

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

View File

@ -0,0 +1,266 @@
<template>
<div class="chat">
<ul class="chat-history" ref="history">
<template v-for="(message, index) in history">
<li :key="index" class="message" v-show="neko && neko.connected">
<div class="content">
<div class="content-head">
<span class="session">{{ session(message.id) }}</span>
<span class="timestamp">{{ timestamp(message.created) }}</span>
</div>
<p>{{ message.content }}</p>
</div>
</li>
</template>
</ul>
<div class="chat-send">
<div class="text-container">
<textarea ref="input" placeholder="Send a message" @keydown="onKeyDown" v-model="content" />
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
@import '@/page/assets/styles/main.scss';
.chat {
flex: 1;
flex-direction: column;
display: flex;
max-height: 100%;
max-width: 100%;
overflow-x: hidden;
.chat-history {
flex: 1;
overflow-y: scroll;
overflow-x: hidden;
max-width: 100%;
scrollbar-width: thin;
scrollbar-color: $background-tertiary transparent;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background-color: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: $background-tertiary;
border: 2px solid $background-primary;
border-radius: 4px;
}
&::-webkit-scrollbar-thumb:hover {
background-color: $background-floating;
}
.message {
flex: 1;
border-top: 1px solid var(--border-color);
padding: 10px 5px 0px 10px;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
overflow: hidden;
user-select: text;
word-wrap: break-word;
font-size: 16px;
}
.content-head {
cursor: default;
width: 100%;
margin-bottom: 3px;
display: block;
.session {
display: inline-block;
color: $text-normal;
font-weight: bold;
}
.timestamp {
color: $text-muted;
font-size: 0.7rem;
margin-left: 0.3rem;
line-height: 12px;
&::first-letter {
text-transform: uppercase;
}
}
}
}
.chat-send {
flex-shrink: 0;
height: 80px;
max-height: 80px;
padding: 0 10px 10px 10px;
flex-direction: column;
display: flex;
.text-container {
flex: 1;
width: 100%;
height: 100%;
background-color: rgba($color: #fff, $alpha: 0.05);
border-radius: 5px;
position: relative;
display: flex;
.emoji-menu {
width: 20px;
height: 20px;
font-size: 20px;
margin: 8px 5px 0 0;
cursor: pointer;
}
textarea {
flex: 1;
font-family: $text-family;
border: none;
caret-color: $text-normal;
color: $text-normal;
resize: none;
margin: 5px;
background-color: transparent;
scrollbar-width: thin;
scrollbar-color: $background-tertiary transparent;
&::placeholder {
color: $text-muted;
}
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background-color: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: $background-tertiary;
border-radius: 4px;
}
&::-webkit-scrollbar-thumb:hover {
background-color: $background-floating;
}
&::selection {
background: $text-link;
}
}
}
}
}
</style>
<script lang="ts">
import { Vue, Component, Prop, Watch, Ref } from 'vue-property-decorator'
import Neko from '~/component/main.vue'
const length = 512 // max length of message
@Component({
name: 'neko-chat',
})
export default class extends Vue {
@Ref('history') readonly _history!: HTMLElement
@Prop() readonly neko!: Neko
history = []
content = ''
mounted() {
this.$nextTick(() => {
this._history.scrollTop = this._history.scrollHeight
})
}
timestamp(date: Date | string) {
date = new Date(date)
return (
date.getFullYear() +
'-' +
String(date.getMonth() + 1).padStart(2, '0') +
'-' +
String(date.getDate()).padStart(2, '0') +
' ' +
String(date.getHours()).padStart(2, '0') +
':' +
String(date.getMinutes()).padStart(2, '0') +
':' +
String(date.getSeconds()).padStart(2, '0')
)
}
session(id: string) {
let session = this.neko.state.sessions[id]
return session ? session.profile.name : id
}
@Watch('neko')
onNekoChange() {
this.neko.events.on('receive.broadcast', (sender: string, subject: string, body: string) => {
if (subject === 'chat') {
Vue.set(this, 'history', [...this.history, body])
}
})
}
@Watch('history')
onHistroyChange() {
this.$nextTick(() => {
this._history.scrollTop = this._history.scrollHeight
})
}
onKeyDown(event: KeyboardEvent) {
if (this.content.length > length) {
this.content = this.content.substring(0, length)
}
if (this.content.length == length) {
if (
[8, 16, 17, 18, 20, 33, 34, 35, 36, 37, 38, 39, 40, 45, 46, 91, 93, 144].includes(event.keyCode) ||
(event.ctrlKey && [67, 65, 88].includes(event.keyCode))
) {
return
}
event.preventDefault()
return
}
if (event.keyCode !== 13 || event.shiftKey) {
return
}
if (this.content === '') {
event.preventDefault()
return
}
this.$emit('send_message', this.content)
let message = {
id: this.neko.state.session_id,
created: new Date(),
content: this.content,
}
this.neko.sendBroadcast('chat', message)
Vue.set(this, 'history', [...this.history, message])
this.content = ''
event.preventDefault()
}
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,145 @@
<template>
<div class="media" style="width: 100%">
<!--
<button @click="getDevices">List available devices</button>
<button v-for="d in devices" :key="d.deviceId">
{{ d.kind }} : {{ d.label }} id = {{ d.deviceId }}
</button>
-->
<button v-if="micTracks.length == 0" @click="addMicrophone">Add microphone</button>
<button v-else @click="stopMicrophone">Stop microphone</button>
<br />
<audio v-show="micTracks.length > 0" ref="audio" controls />
<hr />
<button v-if="camTracks.length == 0" @click="addWebcam">Add webcam</button>
<button v-else @click="stopWebcam">Stop webcam</button>
<br />
<video v-show="camTracks.length > 0" ref="video" controls />
</div>
</template>
<style lang="scss" scoped></style>
<script lang="ts">
import { Vue, Component, Prop, Ref } from 'vue-property-decorator'
import Neko from '~/component/main.vue'
@Component({
name: 'neko-media',
})
export default class extends Vue {
@Prop() readonly neko!: Neko
@Ref('audio') readonly _audio!: HTMLAudioElement
@Ref('video') readonly _video!: HTMLVideoElement
private micTracks: MediaStreamTrack[] = []
private micSenders: RTCRtpSender[] = []
private camTracks: MediaStreamTrack[] = []
private camSenders: RTCRtpSender[] = []
//private devices: any[] = []
async addMicrophone() {
this.micTracks = []
this.micSenders = []
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true })
this._audio.srcObject = stream
console.log('Got MediaStream:', stream)
const tracks = stream.getTracks()
console.log('Got tracks:', tracks)
tracks.forEach((track) => {
this.micTracks.push(track)
console.log('Adding track', track, stream)
const rtcp = this.neko.addTrack(track, stream)
this.micSenders.push(rtcp)
console.log('rtcp sender', rtcp, rtcp.transport)
// TODO: Can be null.
rtcp.transport?.addEventListener('statechange', () => {
console.log('track - on state change', rtcp.transport?.state)
})
})
} catch (error) {
alert('Error accessing media devices.' + error)
}
}
stopMicrophone() {
this.micTracks.forEach((track) => {
track.stop()
})
this.micSenders.forEach((rtcp) => {
this.neko.removeTrack(rtcp)
})
this._audio.srcObject = null
this.micTracks = []
this.micSenders = []
}
async addWebcam() {
this.camTracks = []
this.camSenders = []
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
width: 1280,
height: 720,
},
audio: false,
})
this._video.srcObject = stream
console.log('Got MediaStream:', stream)
const tracks = stream.getTracks()
console.log('Got tracks:', tracks)
tracks.forEach((track) => {
this.camTracks.push(track)
console.log('Adding track', track, stream)
const rtcp = this.neko.addTrack(track, stream)
this.camSenders.push(rtcp)
console.log('rtcp sender', rtcp, rtcp.transport)
// TODO: Can be null.
rtcp.transport?.addEventListener('statechange', () => {
console.log('track - on state change', rtcp.transport?.state)
})
})
} catch (error) {
alert('Error accessing media devices.' + error)
}
}
stopWebcam() {
this.camTracks.forEach((track) => {
track.stop()
})
this.camSenders.forEach((rtcp) => {
this.neko.removeTrack(rtcp)
})
this._audio.srcObject = null
this.camTracks = []
this.camSenders = []
}
/*async getDevices() {
const devices = await navigator.mediaDevices.enumerateDevices();
this.devices = devices.map(({ kind, label, deviceId }) => ({ kind, label, deviceId }))
console.log(this.devices)
}*/
}
</script>

View File

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