neko/client/src/App.vue
2020-01-20 14:36:18 +00:00

571 lines
13 KiB
Vue

<template>
<div class="video-player">
<div ref="video" class="video">
<div ref="container" class="video-container">
<video
ref="player"
tabindex="0"
@click.stop.prevent
@contextmenu.stop.prevent
@wheel.stop.prevent="onWheel"
@mousemove.stop.prevent="onMouseMove"
@mousedown.stop.prevent="onMouseDown"
@mouseup.stop.prevent="onMouseUp"
@mouseenter.stop.prevent="onMouseEnter"
@mouseleave.stop.prevent="onMouseLeave"
@keydown.stop.prevent="onKeyDown"
@keyup.stop.prevent="onKeyUp"
/>
<div v-if="!playing" class="video-overlay">
<i @click.stop.prevent="toggleMedia" class="fas fa-play-circle" />
</div>
<div ref="aspect" class="aspect" />
</div>
</div>
<div class="controls">
<div class="neko">
<img src="@/assets/logo.svg" alt="n.eko" />
<span><b>n</b>.eko</span>
</div>
<ul>
<li>
<i
alt="Request Control"
:class="[{ enabled: hosting }, 'request', 'fas', 'fa-keyboard']"
@click.stop.prevent="toggleControl"
/>
</li>
<li>
<i
alt="Play/Pause"
:class="[playing ? 'fa-pause-circle' : 'fa-play-circle', 'play', 'fas']"
@click.stop.prevent="toggleMedia"
/>
</li>
<li>
<div class="volume">
<input
@input="setVolume"
:class="[volume === 0 ? 'fa-volume-mute' : 'fa-volume-up', 'fas']"
ref="volume"
type="range"
min="0"
max="100"
/>
</div>
</li>
<li>
<i @click.stop.prevent="fullscreen" alt="Full Screen" class="fullscreen fas fa-expand-alt" />
</li>
</ul>
<div class="right"></div>
</div>
<div class="connect" v-if="!connected">
<div class="window">
<div class="logo">
<img src="@/assets/logo.svg" alt="n.eko" />
<span><b>n</b>.eko</span>
</div>
<form class="message" v-if="!connecting" @submit.stop.prevent="connect">
<span>Please enter the password:</span>
<input type="text" placeholder="Username" v-model="username" />
<input type="password" placeholder="Password" v-model="password" />
<button type="submit" class="button" @click.stop.prevent="connect">
Connect
</button>
</form>
<div class="spinner" v-if="connecting">
<div class="double-bounce1"></div>
<div class="double-bounce2"></div>
</div>
<div class="loader" v-if="connecting" />
</div>
</div>
<notifications group="neko" position="bottom left" />
</div>
</template>
<style lang="scss" scoped>
.video-player {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
.video {
position: absolute;
top: 60px;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
.video-container {
position: relative;
width: 100%;
max-width: 16 / 9 * 100vh;
video {
position: absolute;
top: 0;
bottom: 0;
width: 100%;
height: 100%;
display: flex;
background: #000;
&::-webkit-media-controls {
display: none !important;
}
}
.video-overlay {
position: absolute;
top: 0;
bottom: 0;
width: 100%;
height: 100%;
background: rgba($color: $style-darker, $alpha: 0.2);
display: flex;
justify-content: center;
align-items: center;
i {
cursor: pointer;
&::before {
font-size: 120px;
color: rgba($color: $style-light, $alpha: 0.4);
text-align: center;
}
}
&.hidden {
display: none;
}
}
.aspect {
display: block;
padding-bottom: 56.25%;
}
}
}
.controls {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 60px;
background: $style-darker;
padding: 0 50px;
display: flex;
.neko {
flex: 1; /* shorthand for: flex-grow: 1, flex-shrink: 1, flex-basis: 0 */
display: flex;
justify-content: flex-start;
align-items: center;
width: 150px;
img {
display: block;
float: left;
height: 54px;
margin-right: 10px;
}
span {
color: $style-light;
font-size: 30px;
line-height: 56px;
b {
font-weight: 900;
}
}
}
ul {
flex: 1;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
list-style: none;
li {
padding: 0 10px;
color: $style-light;
font-size: 20px;
cursor: pointer;
.request {
color: rgba($color: $style-light, $alpha: 0.5);
&.enabled {
color: $style-light;
}
}
.volume {
display: block;
margin-top: 3px;
input[type='range'] {
-webkit-appearance: none;
width: 100%;
background: transparent;
width: 200px;
height: 20px;
&::-webkit-slider-thumb {
-webkit-appearance: none;
height: 12px;
width: 12px;
border-radius: 12px;
background: $style-light;
cursor: pointer;
margin-top: -4px;
}
&::-webkit-slider-runnable-track {
width: 100%;
height: 4px;
cursor: pointer;
background: $style-primary;
border-radius: 2px;
margin-bottom: 2px;
}
&::before {
color: $style-light;
text-align: center;
margin-right: 5px;
}
}
}
}
}
.right {
flex: 1;
display: flex;
justify-content: flex-end;
align-items: center;
}
}
.connect {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba($color: $style-darker, $alpha: 0.8);
display: flex;
justify-content: center;
align-items: center;
.window {
width: 300px;
background: $style-light;
border-radius: 5px;
padding: 10px;
.logo {
color: $style-darker;
width: 100%;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
img {
filter: invert(100%);
height: 90px;
margin-right: 10px;
}
span {
font-size: 30px;
line-height: 56px;
b {
font-weight: 900;
}
}
}
.message {
display: flex;
flex-direction: column;
span {
text-align: center;
text-transform: uppercase;
margin: 5px 0;
}
input {
border: solid 1px rgba($color: $style-darker, $alpha: 0.4);
padding: 3px;
line-height: 20px;
border-radius: 5px;
margin: 5px 0;
}
.button {
cursor: pointer;
border-radius: 5px;
padding: 4px;
background: $style-primary;
color: $style-light;
text-align: center;
text-transform: uppercase;
font-weight: bold;
line-height: 30px;
margin: 5px 0;
}
}
.spinner {
width: 90px;
height: 90px;
position: relative;
margin: 0 auto;
.double-bounce1,
.double-bounce2 {
width: 100%;
height: 100%;
border-radius: 50%;
background-color: $style-primary;
opacity: 0.6;
position: absolute;
top: 0;
left: 0;
-webkit-animation: sk-bounce 2s infinite ease-in-out;
animation: sk-bounce 2s infinite ease-in-out;
}
.double-bounce2 {
-webkit-animation-delay: -1s;
animation-delay: -1s;
}
}
}
}
}
@keyframes sk-bounce {
0%,
100% {
transform: scale(0);
-webkit-transform: scale(0);
}
50% {
transform: scale(1);
-webkit-transform: scale(1);
}
}
</style>
<script lang="ts">
import { Component, Ref, Watch, Vue } from 'vue-property-decorator'
import { EVENT } from '~/client/events'
@Component({ name: 'stream-video' })
export default class extends Vue {
@Ref('player') readonly _player!: HTMLVideoElement
@Ref('container') readonly _container!: HTMLElement
@Ref('aspect') readonly _aspect!: HTMLElement
@Ref('video') readonly _video!: HTMLElement
@Ref('volume') readonly _volume!: HTMLInputElement
private focused = false
private playing = false
private username = ''
private password = ''
get connected() {
return this.$accessor.connected
}
get connecting() {
return this.$accessor.connecting
}
get hosting() {
return this.$accessor.remote.hosting
}
get volume() {
return this.$accessor.video.volume
}
get stream() {
return this.$accessor.video.stream
}
@Watch('volume')
onVolumeChanged(volume: number) {
if (this._player) {
this._player.volume = this.volume / 100
}
}
@Watch('stream')
onStreamChanged(stream?: MediaStream) {
if (!this._player || !stream) {
return
}
if ('srcObject' in this._player) {
this._player.srcObject = this.stream
} else {
// @ts-ignore
this._player.src = window.URL.createObjectURL(this.stream) // for older browsers
}
if (this._player.paused) {
this.toggleMedia()
}
}
mounted() {
window.addEventListener('resize', this.onResise)
this.onResise()
this._player.volume = this.volume / 100
this._volume.value = `${this.volume}`
this.onStreamChanged(this.stream)
}
beforeDestroy() {
window.removeEventListener('resize', this.onResise)
}
toggleMedia() {
if (!this.playing) {
this._player
.play()
.then(() => {
const { videoWidth, videoHeight } = this._player
this.$accessor.video.setResolution({ width: videoWidth, height: videoHeight })
this.playing = true
this.onResise()
})
.catch(err => {})
} else {
this._player.pause()
this.playing = false
}
}
connect() {
this.$client.connect(this.password, this.username)
}
toggleControl() {
if (!this.connected) {
return
}
if (!this.hosting) {
this.$client.sendMessage(EVENT.CONTROL.REQUEST)
} else {
this.$client.sendMessage(EVENT.CONTROL.RELEASE)
}
}
setVolume() {
this.$accessor.video.setVolume(parseInt(this._volume.value))
}
fullscreen() {
this._video.requestFullscreen()
}
onMousePos(e: MouseEvent) {
const { w, h } = this.$accessor.video.resolution
const rect = this._player.getBoundingClientRect()
this.$client.sendData('mousemove', {
x: Math.round((w / rect.width) * (e.clientX - rect.left)),
y: Math.round((h / rect.height) * (e.clientY - rect.top)),
})
}
onWheel(e: WheelEvent) {
if (!this.hosting) {
return
}
this.onMousePos(e)
this.$client.sendData('wheel', {
x: (e.deltaX * -1) / 10,
y: (e.deltaY * -1) / 10,
}) // TODO: Add user settings
}
onMouseDown(e: MouseEvent) {
if (!this.hosting) {
return
}
this.onMousePos(e)
this.$client.sendData('mousedown', { key: e.button })
}
onMouseUp(e: MouseEvent) {
if (!this.hosting) {
return
}
this.onMousePos(e)
this.$client.sendData('mouseup', { key: e.button })
}
onMouseMove(e: MouseEvent) {
if (!this.hosting) {
return
}
this.onMousePos(e)
}
onMouseEnter(e: MouseEvent) {
this._player.focus()
this.focused = true
}
onMouseLeave(e: MouseEvent) {
this.focused = false
}
onKeyDown(e: KeyboardEvent) {
if (!this.focused || !this.hosting) {
return
}
this.$client.sendData('keydown', { key: e.keyCode })
}
onKeyUp(e: KeyboardEvent) {
if (!this.focused || !this.hosting) {
return
}
this.$client.sendData('keyup', { key: e.keyCode })
}
onResise() {
const aspect = this.$accessor.video.aspect
if (!aspect) {
return
}
const { horizontal, vertical } = aspect
this._container.style.maxWidth = `${(horizontal / vertical) * this._video.offsetHeight}px`
this._aspect.style.paddingBottom = `${(vertical / horizontal) * 100}%`
}
}
</script>