neko/src/page/main.vue
Miroslav Šedivý 27d88cee08 WIP.
2024-03-17 00:00:14 +01:00

651 lines
18 KiB
Vue

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