743 lines
19 KiB
Vue
Raw Normal View History

2020-01-22 17:16:40 +00:00
<template>
<div ref="component" class="video">
<div ref="player" class="player">
<div ref="container" class="player-container">
2021-02-12 22:48:31 +01:00
<video ref="video" playsinline />
2020-01-23 15:23:26 +00:00
<div class="emotes">
<template v-for="(emote, index) in emotes">
<neko-emote :id="index" :key="index" />
</template>
</div>
2020-01-22 17:16:40 +00:00
<div
ref="overlay"
class="overlay"
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"
/>
2021-11-27 18:20:01 +01:00
<div v-if="!playing && playable" class="player-overlay" @click.stop.prevent="toggle">
<i class="fas fa-play-circle" />
</div>
2021-11-28 14:16:29 +01:00
<div v-if="mutedOverlay && muted" class="player-overlay" @click.stop.prevent="unmute">
2021-11-27 18:20:01 +01:00
<i class="fas fa-volume-up" />
2020-01-22 17:16:40 +00:00
</div>
<div ref="aspect" class="player-aspect" />
</div>
2021-04-03 22:25:11 +02:00
<ul v-if="!fullscreen && !hideControls" class="video-menu top">
2020-02-11 05:15:59 +00:00
<li><i @click.stop.prevent="requestFullscreen" class="fas fa-expand"></i></li>
2020-02-11 18:43:31 +00:00
<li v-if="admin"><i @click.stop.prevent="onResolution" class="fas fa-desktop"></i></li>
<li class="request-control">
<i
:class="[hosted && !hosting ? 'disabled' : '', !hosted && !hosting ? 'faded' : '', 'fas', 'fa-keyboard']"
@click.stop.prevent="toggleControl"
/>
</li>
2020-02-11 05:15:59 +00:00
</ul>
2021-04-03 22:25:11 +02:00
<ul v-if="!fullscreen && !hideControls" class="video-menu bottom">
2021-02-13 12:05:59 +01:00
<li v-if="hosting && (!clipboard_read_available || !clipboard_write_available)">
<i @click.stop.prevent="onClipboard" class="fas fa-clipboard"></i>
</li>
2020-12-18 19:12:41 +01:00
<li>
<i
2021-02-18 18:02:23 +01:00
v-if="pip_available"
2020-12-18 19:12:41 +01:00
@click.stop.prevent="requestPictureInPicture"
v-tooltip="{ content: 'Picture-in-Picture', placement: 'left', offset: 5, boundariesElement: 'body' }"
class="fas fa-external-link-alt"
/>
</li>
2020-06-19 15:03:49 +02:00
</ul>
<neko-resolution ref="resolution" v-if="admin" />
<neko-clipboard ref="clipboard" v-if="hosting && (!clipboard_read_available || !clipboard_write_available)" />
2020-01-22 17:16:40 +00:00
</div>
</div>
</template>
<style lang="scss" scoped>
.video {
width: 100%;
height: 100%;
.player {
position: absolute;
display: flex;
justify-content: center;
align-items: center;
2020-02-11 05:15:59 +00:00
.video-menu {
2020-01-22 17:16:40 +00:00
position: absolute;
right: 20px;
2020-06-19 15:03:49 +02:00
&.top {
top: 15px;
}
&.bottom {
bottom: 15px;
}
2020-02-11 05:15:59 +00:00
li {
margin: 0 0 10px 0;
i {
width: 30px;
height: 30px;
background: rgba($color: #fff, $alpha: 0.2);
border-radius: 5px;
line-height: 30px;
font-size: 16px;
text-align: center;
color: rgba($color: #fff, $alpha: 0.6);
cursor: pointer;
&.faded {
color: rgba($color: $text-normal, $alpha: 0.4);
}
&.disabled {
color: rgba($color: $style-error, $alpha: 0.4);
}
2020-02-11 05:15:59 +00:00
}
&.request-control {
display: none;
}
@media (max-width: 768px) {
&.request-control {
display: inline-block;
}
2020-02-11 05:15:59 +00:00
}
2020-06-19 15:03:49 +02:00
&:last-child {
margin: 0;
}
2020-02-11 05:15:59 +00:00
}
2020-01-22 17:16:40 +00:00
}
.player-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;
}
}
2020-01-23 15:23:26 +00:00
.player-overlay,
.emotes {
2020-01-22 17:16:40 +00:00
position: absolute;
top: 0;
bottom: 0;
width: 100%;
height: 100%;
2020-01-23 15:23:26 +00:00
overflow: hidden;
}
.player-overlay {
2020-01-22 17:16:40 +00:00
background: rgba($color: #000, $alpha: 0.2);
display: flex;
justify-content: center;
align-items: center;
2021-11-27 18:20:01 +01:00
cursor: pointer;
2020-01-22 17:16:40 +00:00
2021-11-27 18:20:01 +01:00
i::before {
font-size: 120px;
text-align: center;
2020-01-22 17:16:40 +00:00
}
&.hidden {
display: none;
}
}
.overlay {
position: absolute;
top: 0;
bottom: 0;
width: 100%;
height: 100%;
}
.player-aspect {
display: block;
padding-bottom: 56.25%;
}
}
}
}
</style>
<script lang="ts">
2021-04-03 22:25:11 +02:00
import { Component, Ref, Watch, Vue, Prop } from 'vue-property-decorator'
2020-01-22 17:16:40 +00:00
import ResizeObserver from 'resize-observer-polyfill'
2020-01-23 15:23:26 +00:00
import Emote from './emote.vue'
2020-02-11 05:15:59 +00:00
import Resolution from './resolution.vue'
2020-06-19 15:03:49 +02:00
import Clipboard from './clipboard.vue'
2020-01-23 15:23:26 +00:00
2021-03-28 19:44:43 +00:00
// @ts-ignore
2020-06-15 20:45:32 +02:00
import GuacamoleKeyboard from '~/utils/guacamole-keyboard.ts'
2021-07-17 11:56:26 +02:00
const WHEEL_LINE_HEIGHT = 19
2020-01-23 15:23:26 +00:00
@Component({
name: 'neko-video',
components: {
'neko-emote': Emote,
2020-02-11 05:15:59 +00:00
'neko-resolution': Resolution,
2020-06-19 15:03:49 +02:00
'neko-clipboard': Clipboard,
2020-01-23 15:23:26 +00:00
},
})
2020-01-22 17:16:40 +00:00
export default class extends Vue {
@Ref('component') readonly _component!: HTMLElement
@Ref('container') readonly _container!: HTMLElement
@Ref('overlay') readonly _overlay!: HTMLElement
@Ref('aspect') readonly _aspect!: HTMLElement
@Ref('player') readonly _player!: HTMLElement
@Ref('video') readonly _video!: HTMLVideoElement
2020-02-11 05:15:59 +00:00
@Ref('resolution') readonly _resolution!: any
2020-06-19 15:03:49 +02:00
@Ref('clipboard') readonly _clipboard!: any
2020-01-22 17:16:40 +00:00
2021-11-16 22:50:11 +01:00
@Prop(Boolean) readonly hideControls!: boolean
2021-04-03 22:25:11 +02:00
2020-06-15 20:45:32 +02:00
private keyboard = GuacamoleKeyboard()
2020-01-22 17:16:40 +00:00
private observer = new ResizeObserver(this.onResise.bind(this))
private focused = false
private fullscreen = false
2021-06-07 11:57:44 +02:00
private startsMuted = true
2021-11-27 18:20:01 +01:00
private mutedOverlay = true
2020-01-22 17:16:40 +00:00
2020-02-11 05:15:59 +00:00
get admin() {
return this.$accessor.user.admin
}
2020-01-22 17:16:40 +00:00
get connected() {
return this.$accessor.connected
}
get connecting() {
return this.$accessor.connecting
}
get hosting() {
return this.$accessor.remote.hosting
}
get hosted() {
return this.$accessor.remote.hosted
}
2020-01-22 17:16:40 +00:00
get volume() {
return this.$accessor.video.volume
}
get muted() {
return this.$accessor.video.muted
}
get stream() {
return this.$accessor.video.stream
}
get playing() {
return this.$accessor.video.playing
}
get playable() {
return this.$accessor.video.playable
}
2020-01-23 15:23:26 +00:00
get emotes() {
return this.$accessor.chat.emotes
}
get autoplay() {
return this.$accessor.settings.autoplay
}
2020-02-11 23:51:57 +00:00
get locked() {
return this.$accessor.remote.locked
}
2020-01-23 15:23:26 +00:00
get scroll() {
return this.$accessor.settings.scroll
}
get scroll_invert() {
return this.$accessor.settings.scroll_invert
}
2021-02-18 18:02:23 +01:00
get pip_available() {
//@ts-ignore
return typeof document.createElement('video').requestPictureInPicture === 'function'
}
get clipboard_read_available() {
return 'clipboard' in navigator && typeof navigator.clipboard.readText === 'function'
}
get clipboard_write_available() {
return 'clipboard' in navigator && typeof navigator.clipboard.writeText === 'function'
2020-07-12 21:34:44 +02:00
}
2020-01-25 14:29:52 +00:00
get clipboard() {
return this.$accessor.remote.clipboard
}
2020-02-11 05:15:59 +00:00
get width() {
return this.$accessor.video.width
}
get height() {
return this.$accessor.video.height
}
get rate() {
return this.$accessor.video.rate
}
get vertical() {
return this.$accessor.video.vertical
}
get horizontal() {
return this.$accessor.video.horizontal
}
@Watch('width')
onWidthChanged(width: number) {
this.onResise()
}
@Watch('height')
onHeightChanged(height: number) {
this.onResise()
}
2020-01-22 17:16:40 +00:00
@Watch('volume')
onVolumeChanged(volume: number) {
2021-11-28 14:50:14 +01:00
volume /= 100
if (this._video && this._video.volume != volume) {
this._video.volume = volume
2020-01-22 17:16:40 +00:00
}
}
@Watch('muted')
onMutedChanged(muted: boolean) {
2021-11-28 14:33:52 +01:00
if (this._video && this._video.muted != muted) {
2020-01-22 17:16:40 +00:00
this._video.muted = muted
2021-06-07 11:57:44 +02:00
this.startsMuted = muted
2021-11-28 14:33:52 +01:00
2021-11-28 14:50:14 +01:00
if (!muted) {
this.mutedOverlay = false
}
2020-01-22 17:16:40 +00:00
}
}
@Watch('stream')
onStreamChanged(stream?: MediaStream) {
if (!this._video || !stream) {
return
}
if ('srcObject' in this._video) {
this._video.srcObject = stream
} else {
// @ts-ignore
this._video.src = window.URL.createObjectURL(this.stream) // for older browsers
}
}
@Watch('playing')
onPlayingChanged(playing: boolean) {
2021-11-28 14:50:14 +01:00
if (this._video && this._video.paused && playing) {
2020-01-22 17:16:40 +00:00
this.play()
2021-11-28 14:50:14 +01:00
}
if (this._video && !this._video.paused && !playing) {
2020-01-22 17:16:40 +00:00
this.pause()
}
}
2020-01-25 14:29:52 +00:00
@Watch('clipboard')
onClipboardChanged(clipboard: string) {
if (this.clipboard_write_available) {
2020-01-25 15:14:46 +00:00
navigator.clipboard.writeText(clipboard).catch(console.error)
}
2020-01-25 14:29:52 +00:00
}
2020-01-22 17:16:40 +00:00
mounted() {
this._container.addEventListener('resize', this.onResise)
this.onVolumeChanged(this.volume)
2021-11-28 14:50:14 +01:00
this.onMutedChanged(this.muted)
2020-01-22 17:16:40 +00:00
this.onStreamChanged(this.stream)
this.onResise()
this.observer.observe(this._component)
this._player.addEventListener('fullscreenchange', () => {
this.fullscreen = document.fullscreenElement !== null
this.onResise()
})
this._video.addEventListener('canplaythrough', () => {
this.$accessor.video.setPlayable(true)
2020-01-23 15:23:26 +00:00
if (this.autoplay) {
2021-11-28 14:33:52 +01:00
// start as muted due to restrictive browsers autoplay policy
2021-06-07 11:57:44 +02:00
if (this.startsMuted && (!document.hasFocus() || !this.$accessor.active)) {
2020-02-12 04:16:49 +00:00
this.$accessor.video.setMuted(true)
}
2020-01-25 14:29:52 +00:00
this.$nextTick(() => {
this.$accessor.video.play()
})
2020-01-23 15:23:26 +00:00
}
2020-01-22 17:16:40 +00:00
})
this._video.addEventListener('ended', () => {
this.$accessor.video.setPlayable(false)
})
this._video.addEventListener('error', (event) => {
2020-02-11 23:51:57 +00:00
this.$log.error(event.error)
2020-01-22 17:16:40 +00:00
this.$accessor.video.setPlayable(false)
})
2020-01-25 14:29:52 +00:00
2021-11-28 14:50:14 +01:00
this._video.addEventListener('volumechange', (event) => {
this.$accessor.video.setMuted(this._video.muted)
this.$accessor.video.setVolume(this._video.volume * 100)
})
this._video.addEventListener('playing', () => {
this.$accessor.video.play()
})
this._video.addEventListener('pause', () => {
this.$accessor.video.pause()
})
2020-01-25 14:29:52 +00:00
document.addEventListener('focusin', this.onFocus.bind(this))
2020-06-15 15:55:31 +02:00
2020-06-15 20:45:32 +02:00
/* Initialize Guacamole Keyboard */
this.keyboard.onkeydown = (key: number) => {
2020-06-15 15:55:31 +02:00
if (!this.focused || !this.hosting || this.locked) {
2020-06-16 13:12:36 +02:00
return true
2020-06-15 15:55:31 +02:00
}
2021-07-17 11:56:16 +02:00
this.$client.sendData('keydown', { key: this.keyMap(key) })
2020-06-16 13:12:36 +02:00
return false
2020-06-15 20:45:32 +02:00
}
this.keyboard.onkeyup = (key: number) => {
2020-06-15 15:55:31 +02:00
if (!this.focused || !this.hosting || this.locked) {
return
}
2021-07-17 11:56:16 +02:00
this.$client.sendData('keyup', { key: this.keyMap(key) })
2020-06-15 17:28:05 +02:00
}
2020-06-15 20:45:32 +02:00
this.keyboard.listenTo(this._overlay)
2020-01-22 17:16:40 +00:00
}
beforeDestroy() {
this.observer.disconnect()
this.$accessor.video.setPlayable(false)
2020-01-25 14:29:52 +00:00
document.removeEventListener('focusin', this.onFocus.bind(this))
2020-06-15 20:45:32 +02:00
/* Guacamole Keyboard does not provide destroy functions */
2020-01-22 17:16:40 +00:00
}
2021-07-17 11:56:16 +02:00
get hasMacOSKbd() {
return /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform)
}
KeyTable = {
XK_ISO_Level3_Shift: 0xfe03, // AltGr
XK_Mode_switch: 0xff7e, // Character set switch
XK_Control_L: 0xffe3, // Left control
XK_Control_R: 0xffe4, // Right control
XK_Meta_L: 0xffe7, // Left meta
XK_Meta_R: 0xffe8, // Right meta
XK_Alt_L: 0xffe9, // Left alt
XK_Alt_R: 0xffea, // Right alt
XK_Super_L: 0xffeb, // Left super
XK_Super_R: 0xffec, // Right super
}
keyMap(key: number): number {
// Alt behaves more like AltGraph on macOS, so shuffle the
// keys around a bit to make things more sane for the remote
// server. This method is used by noVNC, RealVNC and TigerVNC
// (and possibly others).
if (this.hasMacOSKbd) {
switch (key) {
case this.KeyTable.XK_Meta_L:
key = this.KeyTable.XK_Control_L
break
case this.KeyTable.XK_Super_L:
key = this.KeyTable.XK_Alt_L
break
case this.KeyTable.XK_Super_R:
key = this.KeyTable.XK_Super_L
break
case this.KeyTable.XK_Alt_L:
key = this.KeyTable.XK_Mode_switch
break
case this.KeyTable.XK_Alt_R:
key = this.KeyTable.XK_ISO_Level3_Shift
break
}
}
return key
}
2021-08-31 17:58:32 +02:00
async play() {
2020-01-22 17:16:40 +00:00
if (!this._video.paused || !this.playable) {
return
}
2020-02-12 22:41:52 +00:00
try {
2021-08-31 17:58:32 +02:00
await this._video.play()
this.onResise()
2021-08-31 18:25:06 +02:00
} catch (err: any) {
2020-02-12 22:41:52 +00:00
this.$log.error(err)
}
2020-01-22 17:16:40 +00:00
}
pause() {
if (this._video.paused || !this.playable) {
return
}
this._video.pause()
}
toggle() {
if (!this.playable) {
return
}
if (!this.playing) {
this.$accessor.video.play()
} else {
this.$accessor.video.pause()
}
}
2021-11-27 18:20:01 +01:00
unmute() {
this.$accessor.video.setMuted(false)
}
toggleControl() {
if (!this.playable) {
return
}
this.$accessor.remote.toggle()
}
2021-05-24 23:56:01 +02:00
_elementRequestFullscreen(el: HTMLElement) {
if (typeof el.requestFullscreen === 'function') {
el.requestFullscreen()
2021-02-18 20:07:06 +01:00
//@ts-ignore
2021-05-24 23:56:01 +02:00
} else if (typeof el.webkitRequestFullscreen === 'function') {
2021-02-18 20:07:06 +01:00
//@ts-ignore
2021-05-24 23:56:01 +02:00
el.webkitRequestFullscreen()
2021-02-18 20:07:06 +01:00
//@ts-ignore
2021-05-24 23:56:01 +02:00
} else if (typeof el.webkitEnterFullscreen === 'function') {
2021-02-18 20:07:06 +01:00
//@ts-ignore
2021-05-24 23:56:01 +02:00
el.webkitEnterFullscreen()
2021-02-18 20:07:06 +01:00
//@ts-ignore
2021-05-24 23:56:01 +02:00
} else if (typeof el.msRequestFullScreen === 'function') {
2021-02-18 20:07:06 +01:00
//@ts-ignore
2021-05-24 23:56:01 +02:00
el.msRequestFullScreen()
} else {
return false
2021-02-18 20:07:06 +01:00
}
2021-05-24 23:56:01 +02:00
return true
}
requestFullscreen() {
// try to fullscreen player element
if (this._elementRequestFullscreen(this._player)) {
this.onResise()
return
}
// fallback to fullscreen video itself (on mobile devices)
if (this._elementRequestFullscreen(this._video)) {
this.onResise()
return
}
2020-01-22 17:16:40 +00:00
}
2020-12-18 19:12:41 +01:00
requestPictureInPicture() {
//@ts-ignore
this._video.requestPictureInPicture()
this.onResise()
}
2021-08-31 17:58:32 +02:00
async onFocus() {
2020-02-12 22:41:52 +00:00
if (!document.hasFocus() || !this.$accessor.active) {
2020-01-25 14:29:52 +00:00
return
}
if (this.hosting && this.clipboard_read_available) {
2021-08-31 17:58:32 +02:00
try {
const text = await navigator.clipboard.readText()
if (this.clipboard !== text) {
this.$accessor.remote.setClipboard(text)
this.$accessor.remote.sendClipboard(text)
}
2021-08-31 18:25:06 +02:00
} catch (err: any) {
2021-08-31 17:58:32 +02:00
this.$log.error(err)
}
2020-01-25 14:29:52 +00:00
}
}
2020-01-22 17:16:40 +00:00
onMousePos(e: MouseEvent) {
const { w, h } = this.$accessor.video.resolution
const rect = this._overlay.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)),
})
}
2021-07-17 11:56:26 +02:00
wheelThrottle = false
2020-01-22 17:16:40 +00:00
onWheel(e: WheelEvent) {
2020-02-11 23:51:57 +00:00
if (!this.hosting || this.locked) {
2020-01-22 17:16:40 +00:00
return
}
this.onMousePos(e)
2020-01-23 15:23:26 +00:00
let x = e.deltaX
let y = e.deltaY
2021-07-17 11:56:26 +02:00
// Pixel units unless it's non-zero.
// Note that if deltamode is line or page won't matter since we aren't
// sending the mouse wheel delta to the server anyway.
// The difference between pixel and line can be important however since
// we have a threshold that can be smaller than the line height.
if (e.deltaMode !== 0) {
x *= WHEEL_LINE_HEIGHT
y *= WHEEL_LINE_HEIGHT
}
2020-01-23 15:23:26 +00:00
if (this.scroll_invert) {
x = x * -1
y = y * -1
}
x = Math.min(Math.max(x, -this.scroll), this.scroll)
y = Math.min(Math.max(y, -this.scroll), this.scroll)
2021-07-17 11:56:26 +02:00
if (!this.wheelThrottle) {
this.wheelThrottle = true
this.$client.sendData('wheel', { x, y })
window.setTimeout(() => {
this.wheelThrottle = false
}, 100)
}
2020-01-22 17:16:40 +00:00
}
onMouseDown(e: MouseEvent) {
2021-04-04 16:57:23 +02:00
if (!this.hosting) {
this.$emit('control-attempt', e)
}
2020-02-11 23:51:57 +00:00
if (!this.hosting || this.locked) {
2020-01-22 17:16:40 +00:00
return
}
2021-04-04 16:57:23 +02:00
2020-01-22 17:16:40 +00:00
this.onMousePos(e)
2020-06-13 16:21:11 +02:00
this.$client.sendData('mousedown', { key: e.button + 1 })
2020-01-22 17:16:40 +00:00
}
onMouseUp(e: MouseEvent) {
2020-02-11 23:51:57 +00:00
if (!this.hosting || this.locked) {
2020-01-22 17:16:40 +00:00
return
}
2021-04-04 16:57:23 +02:00
2020-01-22 17:16:40 +00:00
this.onMousePos(e)
2020-06-13 16:21:11 +02:00
this.$client.sendData('mouseup', { key: e.button + 1 })
2020-01-22 17:16:40 +00:00
}
onMouseMove(e: MouseEvent) {
2020-02-11 23:51:57 +00:00
if (!this.hosting || this.locked) {
2020-01-22 17:16:40 +00:00
return
}
2020-01-22 17:16:40 +00:00
this.onMousePos(e)
}
onMouseEnter(e: MouseEvent) {
2021-02-13 12:05:59 +01:00
if (this.hosting) {
this.$accessor.remote.syncKeyboardModifierState({
2021-02-13 12:05:59 +01:00
capsLock: e.getModifierState('CapsLock'),
numLock: e.getModifierState('NumLock'),
scrollLock: e.getModifierState('ScrollLock'),
})
}
2020-01-22 17:16:40 +00:00
this._overlay.focus()
2020-01-25 14:29:52 +00:00
this.onFocus()
2020-01-22 17:16:40 +00:00
this.focused = true
}
onMouseLeave(e: MouseEvent) {
2021-02-13 12:05:59 +01:00
if (this.hosting) {
this.$accessor.remote.setKeyboardModifierState({
2021-02-13 12:05:59 +01:00
capsLock: e.getModifierState('CapsLock'),
numLock: e.getModifierState('NumLock'),
scrollLock: e.getModifierState('ScrollLock'),
})
}
2020-06-21 14:58:52 +02:00
this.keyboard.reset()
2020-01-22 17:16:40 +00:00
this.focused = false
}
onResise() {
let height = 0
if (!this.fullscreen) {
const { offsetWidth, offsetHeight } = this._component
this._player.style.width = `${offsetWidth}px`
this._player.style.height = `${offsetHeight}px`
height = offsetHeight
} else {
const { offsetWidth, offsetHeight } = this._player
height = offsetHeight
}
2020-02-11 05:15:59 +00:00
this._container.style.maxWidth = `${(this.horizontal / this.vertical) * height}px`
this._aspect.style.paddingBottom = `${(this.vertical / this.horizontal) * 100}%`
}
onResolution(event: MouseEvent) {
this._resolution.open(event)
2020-01-22 17:16:40 +00:00
}
2020-06-19 15:03:49 +02:00
onClipboard(event: MouseEvent) {
this._clipboard.open(event)
}
2020-01-22 17:16:40 +00:00
}
</script>