Merge pull request #120 from owncast/0809gw-messagemodule
frontend refactor with Preact
1
doc
@ -1 +0,0 @@
|
||||
Subproject commit 54a0ee13964c70585c24a9b5869604373faaa926
|
||||
@ -29,23 +29,23 @@
|
||||
<meta property="twitter:description" content="{{.Config.Summary}}">
|
||||
<meta property="twitter:image" content="{{.Image}}">
|
||||
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/apple-icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/apple-icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/apple-icon-72x72.png">
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="/apple-icon-76x76.png">
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="/apple-icon-114x114.png">
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/apple-icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/apple-icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/apple-icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-icon-180x180.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/android-icon-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/img/favicon/apple-icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/img/favicon/apple-icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/img/favicon/apple-icon-72x72.png">
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="/img/favicon/apple-icon-76x76.png">
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="/img/favicon/apple-icon-114x114.png">
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/img/favicon/apple-icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/img/favicon/apple-icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/img/favicon/apple-icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/img/favicon/apple-icon-180x180.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/img/favicon/android-icon-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/img/favicon/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon/favicon-16x16.png">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
|
||||
|
||||
<meta name="msapplication-TileColor" content="#ffffff">
|
||||
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png">
|
||||
<meta name="msapplication-TileImage" content="/img/favicon/ms-icon-144x144.png">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
</head>
|
||||
@ -67,11 +67,11 @@
|
||||
<br/>
|
||||
|
||||
<h3>Connect with {{.Config.Name}} elsewhere by visiting:</h3>
|
||||
|
||||
|
||||
{{range .Config.SocialHandles}}
|
||||
<li><a href="{{.URL}}">{{.Platform}}</a></li>
|
||||
{{end}}
|
||||
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.6 KiB |
24
webroot/index-standalone-chat.html
Normal file
@ -0,0 +1,24 @@
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"/>
|
||||
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet" />
|
||||
<link href="./styles/chat.css" rel="stylesheet" />
|
||||
<link href="./styles/standalone-chat.css" rel="stylesheet" />
|
||||
|
||||
<script src="//unpkg.com/showdown/dist/showdown.min.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="messages-only"></div>
|
||||
|
||||
<script type="module">
|
||||
import { render, html } from 'https://unpkg.com/htm/preact/standalone.module.js';
|
||||
import StandaloneChat from './js/app-standalone-chat.js';
|
||||
render(
|
||||
html`<${StandaloneChat} messagesOnly />`, document.getElementById("messages-only")
|
||||
);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
28
webroot/index-video-only.html
Normal file
@ -0,0 +1,28 @@
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"/>
|
||||
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet" />
|
||||
|
||||
<link href="//unpkg.com/video.js@7.9.2/dist/video-js.css" rel="stylesheet">
|
||||
<link href="https://unpkg.com/@videojs/themes@1/dist/fantasy/index.css" rel="stylesheet" />
|
||||
<script src="//unpkg.com/video.js@7.9.2/dist/video.js"></script>
|
||||
|
||||
<link href="./styles/video.css" rel="stylesheet" />
|
||||
<link href="./styles/video-only.css" rel="stylesheet" />
|
||||
|
||||
<script src="//unpkg.com/showdown/dist/showdown.min.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div id="video-only"></div>
|
||||
|
||||
<script type="module">
|
||||
import { render, html } from 'https://unpkg.com/htm/preact/standalone.module.js';
|
||||
import VideoOnly from './js/app-video-only.js';
|
||||
render(html`<${VideoOnly} />`, document.getElementById("video-only"));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,221 +1,74 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"/>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"/>
|
||||
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/apple-icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/apple-icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/apple-icon-72x72.png">
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="/apple-icon-76x76.png">
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="/apple-icon-114x114.png">
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/apple-icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/apple-icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/apple-icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-icon-180x180.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/android-icon-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="msapplication-TileColor" content="#ffffff">
|
||||
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/img/favicon/apple-icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/img/favicon/apple-icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/img/favicon/apple-icon-72x72.png">
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="/img/favicon/apple-icon-76x76.png">
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="/img/favicon/apple-icon-114x114.png">
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/img/favicon/apple-icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/img/favicon/apple-icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/img/favicon/apple-icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/img/favicon/apple-icon-180x180.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/img/favicon/android-icon-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/img/favicon/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon/favicon-16x16.png">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="msapplication-TileColor" content="#ffffff">
|
||||
<meta name="msapplication-TileImage" content="/img/favicon/ms-icon-144x144.png">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet" />
|
||||
<script src="//cdn.jsdelivr.net/npm/vue@2.6.11/dist/vue.min.js"></script>
|
||||
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet" />
|
||||
|
||||
<link href="//unpkg.com/video.js@7.9.2/dist/video-js.css" rel="stylesheet">
|
||||
<link href="https://unpkg.com/@videojs/themes@1/dist/fantasy/index.css" rel="stylesheet" />
|
||||
<script src="//unpkg.com/video.js@7.9.2/dist/video.js"></script>
|
||||
<script src="//unpkg.com/showdown/dist/showdown.min.js"></script>
|
||||
|
||||
<link href="./styles/layout.css" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-300 text-gray-800">
|
||||
|
||||
<div id="app-container" class="flex chat">
|
||||
<div id="top-content">
|
||||
<header class="flex border-b border-gray-900 border-solid shadow-md">
|
||||
<h1 v-cloak class="flex text-gray-400">
|
||||
<span
|
||||
id="logo-container"
|
||||
class="rounded-full bg-white px-1 py-1"
|
||||
v-bind:style="{ backgroundImage: 'url(' + logo + ')' }"
|
||||
>
|
||||
<img class="logo visually-hidden" v-bind:src="logo">
|
||||
</span>
|
||||
<span class="instance-title">{{title}}</span>
|
||||
</h1>
|
||||
|
||||
<div id="user-options-container" class="flex">
|
||||
<div id="user-info">
|
||||
<div id="user-info-display" title="Click to update user name" class="flex">
|
||||
<img
|
||||
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
||||
alt=""
|
||||
id="username-avatar"
|
||||
class="rounded-full bg-black bg-opacity-50 border border-solid border-gray-700"
|
||||
/>
|
||||
<span id="username-display" class="text-indigo-600"></span>
|
||||
</div>
|
||||
|
||||
<div id="user-info-change">
|
||||
<input type="text"
|
||||
id="username-change-input"
|
||||
class="appearance-none block w-full bg-gray-200 text-gray-700 border border-black-500 rounded py-1 px-1 leading-tight focus:bg-white"
|
||||
value="Random Username 123"
|
||||
maxlength="100"
|
||||
placeholder="Update username"
|
||||
>
|
||||
<button id="button-update-username" class="bg-blue-500 hover:bg-blue-700 text-white py-1 px-1 rounded user-btn">Update</button>
|
||||
<button id="button-cancel-change" class="bg-gray-900 hover:bg-gray-800 py-1 px-2 rounded user-btn text-white text-opacity-50" title="cancel">X</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" id="chat-toggle" class="flex bg-gray-800 hover:bg-gray-700">💬</button>
|
||||
</div>
|
||||
|
||||
</header>
|
||||
|
||||
<main v-bind:class="{ online: playerOn }">
|
||||
<div
|
||||
id="video-container"
|
||||
class="flex owncast-video-container bg-black"
|
||||
v-bind:style="{ backgroundImage: 'url(' + logoLarge + ')' }"
|
||||
>
|
||||
<video
|
||||
class="video-js vjs-big-play-centered"
|
||||
id="video"
|
||||
preload="auto"
|
||||
controls
|
||||
playsinline
|
||||
>
|
||||
</video>
|
||||
</div>
|
||||
<link href="//unpkg.com/video.js@7.9.2/dist/video-js.css" rel="stylesheet">
|
||||
<link href="https://unpkg.com/@videojs/themes@1/dist/fantasy/index.css" rel="stylesheet" />
|
||||
<script src="//unpkg.com/video.js@7.9.2/dist/video.js"></script>
|
||||
<!-- markdown renderer -->
|
||||
<script src="//unpkg.com/showdown/dist/showdown.min.js"></script>
|
||||
<script type="module" src="https://cdn.jsdelivr.net/npm/@justinribeiro/lite-youtube@0.6.2/lite-youtube.js"></script>
|
||||
|
||||
|
||||
<section id="stream-info" aria-label="Stream status" v-cloak class="flex font-mono bg-gray-900 text-indigo-200 shadow-md border-b border-gray-100 border-solid">
|
||||
<span>{{ streamStatus }}</span>
|
||||
<span v-if="isOnline">{{ viewerCount }} {{ 'viewer' | plural(viewerCount) }}.</span>
|
||||
<span v-if="isOnline">Max {{ sessionMaxViewerCount }} {{ 'viewer' | plural(sessionMaxViewerCount) }}.</span>
|
||||
<span v-if="isOnline">{{ overallMaxViewerCount }} overall.</span>
|
||||
</section>
|
||||
</main>
|
||||
<link href="./styles/video.css" rel="stylesheet" />
|
||||
<link href="./styles/chat.css" rel="stylesheet" />
|
||||
<link href="./styles/user-content.css" rel="stylesheet" />
|
||||
<link href="./styles/app.css" rel="stylesheet" />
|
||||
</head>
|
||||
<body class="bg-gray-300 text-gray-800">
|
||||
<div id="app"></div>
|
||||
|
||||
<section id="user-content" aria-label="User information">
|
||||
<user-details
|
||||
v-bind:logo="logo"
|
||||
v-bind:platforms="socialHandles"
|
||||
v-bind:summary="summary"
|
||||
v-bind:tags="tags"
|
||||
>{{streamerName}}</user-details>
|
||||
<script type="module">
|
||||
import { render, html } from 'https://unpkg.com/htm/preact/standalone.module.js';
|
||||
import App from './js/app.js';
|
||||
render(html`<${App} />`, document.getElementById("app"));
|
||||
</script>
|
||||
|
||||
<div v-html="extraUserContent" class="extra-user-content">{{extraUserContent}}</div>
|
||||
<noscript>
|
||||
<style>
|
||||
.noscript {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
</section>
|
||||
|
||||
<owncast-footer v-bind:app-version="appVersion"></owncast-footer>
|
||||
|
||||
</div>
|
||||
|
||||
<section id="chat-container-wrap" class="flex">
|
||||
<div id="chat-container" class="bg-gray-800">
|
||||
<div id="messages-container">
|
||||
<div v-for="message in messages" v-cloak>
|
||||
<!-- Regular user chat message-->
|
||||
<div class="message flex" v-if="message.type === 'CHAT'">
|
||||
<div class="message-avatar rounded-full flex items-center justify-center" v-bind:style="{ backgroundColor: message.userColor() }">
|
||||
<img
|
||||
v-bind:src="message.image"
|
||||
/>
|
||||
</div>
|
||||
<div class="message-content">
|
||||
<p class="message-author text-white font-bold">{{ message.author }}</p>
|
||||
<p class="message-text text-gray-400 font-thin " v-html="message.formatText()"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Username change message -->
|
||||
<div class="message flex" v-else-if="message.type === 'NAME_CHANGE'">
|
||||
<img
|
||||
class="mr-2"
|
||||
width="30px"
|
||||
v-bind:src="message.image"
|
||||
/>
|
||||
<div class="text-white text-center">
|
||||
<span class="font-bold">{{ message.oldName }}</span> is now known as <span class="font-bold">{{ message.newName }}</span>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="message-input-container" class="shadow-md bg-gray-900 border-t border-gray-700 border-solid">
|
||||
<form id="message-form" class="flex">
|
||||
|
||||
<input type="hidden" name="inputAuthor" id="self-message-author" />
|
||||
<div id="message-body-form" contenteditable="true" placeholder=""
|
||||
class="appearance-none block w-full bg-gray-200 text-gray-700 border border-black-500 rounded py-2 px-2 my-2 focus:bg-white"
|
||||
></div>
|
||||
<div id="emoji-button">😏</div>
|
||||
|
||||
<div id="message-form-actions" class="flex">
|
||||
<span id="message-form-warning" class="text-red-600 text-xs"></span>
|
||||
<button
|
||||
id="button-submit-message"
|
||||
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-2 rounded"
|
||||
> Chat
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
.noscript a {
|
||||
display: inline;
|
||||
color: blue;
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
<div class="noscript">
|
||||
<img src="https://owncast.online/images/logo.png" />
|
||||
<br/>
|
||||
<p>
|
||||
This <a href="https://owncast.online" target="_blank">Owncast</a> stream requires Javascript to play.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="js/usercolors.js"></script>
|
||||
<script src="js/utils.js?v=2"></script>
|
||||
<script type="module" src="js/message.js?v=2"></script>
|
||||
<script src="js/social.js"></script>
|
||||
<script src="js/components.js"></script>
|
||||
<script type="module">
|
||||
import Owncast from './js/app.js';
|
||||
|
||||
(function () {
|
||||
const app = new Owncast();
|
||||
app.init();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<noscript>
|
||||
<style>
|
||||
[v-cloak] { display: none; }
|
||||
.noscript {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.noscript a {
|
||||
display: inline;
|
||||
color: blue;
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
<div class="noscript">
|
||||
<img src="https://github.com/gabek/owncast/raw/master/doc/logo.png">
|
||||
<br/>
|
||||
<p>
|
||||
This <a href="https://github.com/gabek/owncast" target="_blank">Owncast</a> stream requires Javascript to play.
|
||||
</p>
|
||||
</div>
|
||||
</noscript>
|
||||
|
||||
<script type='module' src="/js/emoji.js"></script>
|
||||
</body>
|
||||
</noscript>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
46
webroot/js/app-standalone-chat.js
Normal file
@ -0,0 +1,46 @@
|
||||
import { h, Component } from 'https://unpkg.com/preact?module';
|
||||
import htm from 'https://unpkg.com/htm?module';
|
||||
const html = htm.bind(h);
|
||||
|
||||
import Chat from './components/chat/chat.js';
|
||||
import Websocket from './utils/websocket.js';
|
||||
import { getLocalStorage, generateAvatar, generateUsername } from './utils/helpers.js';
|
||||
import { KEY_USERNAME, KEY_AVATAR } from './utils/constants.js';
|
||||
|
||||
export default class StandaloneChat extends Component {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
websocket: new Websocket(),
|
||||
chatEnabled: true, // always true for standalone chat
|
||||
username: getLocalStorage(KEY_USERNAME) || generateUsername(),
|
||||
userAvatarImage: getLocalStorage(KEY_AVATAR) || generateAvatar(`${this.username}${Date.now()}`),
|
||||
};
|
||||
|
||||
this.websocket = null;
|
||||
this.handleUsernameChange = this.handleUsernameChange.bind(this);
|
||||
}
|
||||
|
||||
handleUsernameChange(newName, newAvatar) {
|
||||
this.setState({
|
||||
username: newName,
|
||||
userAvatarImage: newAvatar,
|
||||
});
|
||||
}
|
||||
|
||||
render(props, state) {
|
||||
const { username, userAvatarImage, websocket } = state;
|
||||
|
||||
return (
|
||||
html`
|
||||
<${Chat}
|
||||
websocket=${websocket}
|
||||
username=${username}
|
||||
userAvatarImage=${userAvatarImage}
|
||||
messagesOnly
|
||||
/>
|
||||
`
|
||||
);
|
||||
}
|
||||
}
|
||||
265
webroot/js/app-video-only.js
Normal file
@ -0,0 +1,265 @@
|
||||
import { h, Component } from 'https://unpkg.com/preact?module';
|
||||
import htm from 'https://unpkg.com/htm?module';
|
||||
const html = htm.bind(h);
|
||||
|
||||
import { OwncastPlayer } from './components/player.js';
|
||||
|
||||
import {
|
||||
addNewlines,
|
||||
pluralize,
|
||||
} from './utils/helpers.js';
|
||||
import {
|
||||
URL_CONFIG,
|
||||
URL_STATUS,
|
||||
TIMER_STATUS_UPDATE,
|
||||
TIMER_STREAM_DURATION_COUNTER,
|
||||
TEMP_IMAGE,
|
||||
MESSAGE_OFFLINE,
|
||||
MESSAGE_ONLINE,
|
||||
} from './utils/constants.js';
|
||||
|
||||
export default class VideoOnly extends Component {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
configData: {},
|
||||
|
||||
playerActive: false, // player object is active
|
||||
streamOnline: false, // stream is active/online
|
||||
|
||||
//status
|
||||
streamStatusMessage: MESSAGE_OFFLINE,
|
||||
viewerCount: '',
|
||||
sessionMaxViewerCount: '',
|
||||
overallMaxViewerCount: '',
|
||||
};
|
||||
|
||||
// timers
|
||||
this.playerRestartTimer = null;
|
||||
this.offlineTimer = null;
|
||||
this.statusTimer = null;
|
||||
this.streamDurationTimer = null;
|
||||
|
||||
this.handleOfflineMode = this.handleOfflineMode.bind(this);
|
||||
this.handleOnlineMode = this.handleOnlineMode.bind(this);
|
||||
|
||||
// player events
|
||||
this.handlePlayerReady = this.handlePlayerReady.bind(this);
|
||||
this.handlePlayerPlaying = this.handlePlayerPlaying.bind(this);
|
||||
this.handlePlayerEnded = this.handlePlayerEnded.bind(this);
|
||||
this.handlePlayerError = this.handlePlayerError.bind(this);
|
||||
|
||||
// fetch events
|
||||
this.getConfig = this.getConfig.bind(this);
|
||||
this.getStreamStatus = this.getStreamStatus.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.getConfig();
|
||||
|
||||
this.player = new OwncastPlayer();
|
||||
this.player.setupPlayerCallbacks({
|
||||
onReady: this.handlePlayerReady,
|
||||
onPlaying: this.handlePlayerPlaying,
|
||||
onEnded: this.handlePlayerEnded,
|
||||
onError: this.handlePlayerError,
|
||||
});
|
||||
this.player.init();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// clear all the timers
|
||||
clearInterval(this.playerRestartTimer);
|
||||
clearInterval(this.offlineTimer);
|
||||
clearInterval(this.statusTimer);
|
||||
clearInterval(this.streamDurationTimer);
|
||||
}
|
||||
|
||||
// fetch /config data
|
||||
getConfig() {
|
||||
fetch(URL_CONFIG)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Network response was not ok ${response.ok}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(json => {
|
||||
this.setConfigData(json);
|
||||
})
|
||||
.catch(error => {
|
||||
this.handleNetworkingError(`Fetch config: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
// fetch stream status
|
||||
getStreamStatus() {
|
||||
fetch(URL_STATUS)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Network response was not ok ${response.ok}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(json => {
|
||||
this.updateStreamStatus(json);
|
||||
})
|
||||
.catch(error => {
|
||||
this.handleOfflineMode();
|
||||
this.handleNetworkingError(`Stream status: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
setConfigData(data = {}) {
|
||||
const { title, summary } = data;
|
||||
window.document.title = title;
|
||||
this.setState({
|
||||
configData: {
|
||||
...data,
|
||||
summary: summary && addNewlines(summary),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// handle UI things from stream status result
|
||||
updateStreamStatus(status = {}) {
|
||||
const { streamOnline: curStreamOnline } = this.state;
|
||||
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
viewerCount,
|
||||
sessionMaxViewerCount,
|
||||
overallMaxViewerCount,
|
||||
online,
|
||||
} = status;
|
||||
|
||||
this.lastDisconnectTime = status.lastDisconnectTime;
|
||||
|
||||
if (status.online && !curStreamOnline) {
|
||||
// stream has just come online.
|
||||
this.handleOnlineMode();
|
||||
} else if (!status.online && curStreamOnline) {
|
||||
// stream has just flipped offline.
|
||||
this.handleOfflineMode();
|
||||
}
|
||||
if (status.online) {
|
||||
// only do this if video is paused, so no unnecessary img fetches
|
||||
if (this.player.vjsPlayer && this.player.vjsPlayer.paused()) {
|
||||
this.player.setPoster();
|
||||
}
|
||||
}
|
||||
this.setState({
|
||||
viewerCount,
|
||||
sessionMaxViewerCount,
|
||||
overallMaxViewerCount,
|
||||
streamOnline: online,
|
||||
});
|
||||
}
|
||||
|
||||
// when videojs player is ready, start polling for stream
|
||||
handlePlayerReady() {
|
||||
this.getStreamStatus();
|
||||
this.statusTimer = setInterval(this.getStreamStatus, TIMER_STATUS_UPDATE);
|
||||
}
|
||||
|
||||
handlePlayerPlaying() {
|
||||
// do something?
|
||||
}
|
||||
|
||||
// likely called some time after stream status has gone offline.
|
||||
// basically hide video and show underlying "poster"
|
||||
handlePlayerEnded() {
|
||||
this.setState({
|
||||
playerActive: false,
|
||||
});
|
||||
}
|
||||
|
||||
handlePlayerError() {
|
||||
// do something?
|
||||
this.handleOfflineMode();
|
||||
this.handlePlayerEnded();
|
||||
}
|
||||
|
||||
// stop status timer and disable chat after some time.
|
||||
handleOfflineMode() {
|
||||
clearInterval(this.streamDurationTimer);
|
||||
this.setState({
|
||||
streamOnline: false,
|
||||
streamStatusMessage: MESSAGE_OFFLINE,
|
||||
});
|
||||
}
|
||||
|
||||
// play video!
|
||||
handleOnlineMode() {
|
||||
this.player.startPlayer();
|
||||
|
||||
this.streamDurationTimer =
|
||||
setInterval(this.setCurrentStreamDuration, TIMER_STREAM_DURATION_COUNTER);
|
||||
|
||||
this.setState({
|
||||
playerActive: true,
|
||||
streamOnline: true,
|
||||
streamStatusMessage: MESSAGE_ONLINE,
|
||||
});
|
||||
}
|
||||
|
||||
handleNetworkingError(error) {
|
||||
console.log(`>>> App Error: ${error}`);
|
||||
}
|
||||
|
||||
render(props, state) {
|
||||
const {
|
||||
configData,
|
||||
|
||||
viewerCount,
|
||||
sessionMaxViewerCount,
|
||||
overallMaxViewerCount,
|
||||
playerActive,
|
||||
streamOnline,
|
||||
streamStatusMessage,
|
||||
} = state;
|
||||
|
||||
const {
|
||||
version: appVersion,
|
||||
logo = {},
|
||||
socialHandles = [],
|
||||
name: streamerName,
|
||||
summary,
|
||||
tags = [],
|
||||
title,
|
||||
} = configData;
|
||||
const { small: smallLogo = TEMP_IMAGE, large: largeLogo = TEMP_IMAGE } = logo;
|
||||
|
||||
const bgLogoLarge = { backgroundImage: `url(${largeLogo})` };
|
||||
|
||||
const mainClass = playerActive ? 'online' : '';
|
||||
return (
|
||||
html`
|
||||
<main class=${mainClass}>
|
||||
<div
|
||||
id="video-container"
|
||||
class="flex owncast-video-container bg-black w-full bg-center bg-no-repeat flex flex-col items-center justify-start"
|
||||
style=${bgLogoLarge}
|
||||
>
|
||||
<video
|
||||
class="video-js vjs-big-play-centered display-block w-full h-full"
|
||||
id="video"
|
||||
preload="auto"
|
||||
controls
|
||||
playsinline
|
||||
></video>
|
||||
</div>
|
||||
|
||||
<section id="stream-info" aria-label="Stream status" class="flex text-center flex-row justify-between items-center font-mono py-2 px-8 bg-gray-900 text-indigo-200">
|
||||
<span>${streamStatusMessage}</span>
|
||||
<span>${viewerCount} ${pluralize('viewer', viewerCount)}.</span>
|
||||
<span>Max ${pluralize('viewer', sessionMaxViewerCount)}.</span>
|
||||
<span>${overallMaxViewerCount} overall.</span>
|
||||
</section>
|
||||
</main>
|
||||
`);
|
||||
}
|
||||
}
|
||||
@ -1,28 +1,69 @@
|
||||
import Websocket from './websocket.js';
|
||||
import { MessagingInterface, Message } from './message.js';
|
||||
import SOCKET_MESSAGE_TYPES from './chat/socketMessageTypes.js';
|
||||
import { OwncastPlayer } from './player.js';
|
||||
import { h, Component } from 'https://unpkg.com/preact?module';
|
||||
import htm from 'https://unpkg.com/htm?module';
|
||||
const html = htm.bind(h);
|
||||
|
||||
const MESSAGE_OFFLINE = 'Stream is offline.';
|
||||
const MESSAGE_ONLINE = 'Stream is online';
|
||||
import { OwncastPlayer } from './components/player.js';
|
||||
import SocialIcon from './components/social.js';
|
||||
import UsernameForm from './components/chat/username.js';
|
||||
import Chat from './components/chat/chat.js';
|
||||
import Websocket from './utils/websocket.js';
|
||||
|
||||
const TEMP_IMAGE = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||
import {
|
||||
addNewlines,
|
||||
classNames,
|
||||
clearLocalStorage,
|
||||
debounce,
|
||||
generateAvatar,
|
||||
generateUsername,
|
||||
getLocalStorage,
|
||||
pluralize,
|
||||
setLocalStorage,
|
||||
} from './utils/helpers.js';
|
||||
import {
|
||||
HEIGHT_SHORT_WIDE,
|
||||
KEY_AVATAR,
|
||||
KEY_CHAT_DISPLAYED,
|
||||
KEY_USERNAME,
|
||||
MESSAGE_OFFLINE,
|
||||
MESSAGE_ONLINE,
|
||||
TEMP_IMAGE,
|
||||
TIMER_DISABLE_CHAT_AFTER_OFFLINE,
|
||||
TIMER_STATUS_UPDATE,
|
||||
TIMER_STREAM_DURATION_COUNTER,
|
||||
URL_CONFIG,
|
||||
URL_OWNCAST,
|
||||
URL_STATUS,
|
||||
WIDTH_SINGLE_COL,
|
||||
} from './utils/constants.js';
|
||||
|
||||
const URL_CONFIG = `/config`;
|
||||
const URL_STATUS = `/status`;
|
||||
const URL_CHAT_HISTORY = `/chat`;
|
||||
export default class App extends Component {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
const TIMER_STATUS_UPDATE = 5000; // ms
|
||||
const TIMER_DISABLE_CHAT_AFTER_OFFLINE = 5 * 60 * 1000; // 5 mins
|
||||
const TIMER_STREAM_DURATION_COUNTER = 1000;
|
||||
this.state = {
|
||||
websocket: new Websocket(),
|
||||
displayChat: getLocalStorage(KEY_CHAT_DISPLAYED), // chat panel state
|
||||
chatEnabled: false, // chat input box state
|
||||
username: getLocalStorage(KEY_USERNAME) || generateUsername(),
|
||||
userAvatarImage:
|
||||
getLocalStorage(KEY_AVATAR) || generateAvatar(`${this.username}${Date.now()}`),
|
||||
|
||||
class Owncast {
|
||||
constructor() {
|
||||
this.player;
|
||||
configData: {},
|
||||
extraUserContent: '',
|
||||
|
||||
this.configData;
|
||||
this.vueApp;
|
||||
this.messagingInterface = null;
|
||||
playerActive: false, // player object is active
|
||||
streamOnline: false, // stream is active/online
|
||||
|
||||
// status
|
||||
streamStatusMessage: MESSAGE_OFFLINE,
|
||||
viewerCount: '',
|
||||
sessionMaxViewerCount: '',
|
||||
overallMaxViewerCount: '',
|
||||
|
||||
// dom
|
||||
windowWidth: window.innerWidth,
|
||||
windowHeight: window.innerHeight,
|
||||
};
|
||||
|
||||
// timers
|
||||
this.playerRestartTimer = null;
|
||||
@ -31,67 +72,30 @@ class Owncast {
|
||||
this.disableChatTimer = null;
|
||||
this.streamDurationTimer = null;
|
||||
|
||||
// misc
|
||||
this.streamStatus = null;
|
||||
// misc dom events
|
||||
this.handleChatPanelToggle = this.handleChatPanelToggle.bind(this);
|
||||
this.handleUsernameChange = this.handleUsernameChange.bind(this);
|
||||
this.handleWindowResize = debounce(this.handleWindowResize.bind(this), 400);
|
||||
|
||||
Vue.filter('plural', pluralize);
|
||||
|
||||
// bindings
|
||||
this.vueAppMounted = this.vueAppMounted.bind(this);
|
||||
this.setConfigData = this.setConfigData.bind(this);
|
||||
this.getStreamStatus = this.getStreamStatus.bind(this);
|
||||
this.getExtraUserContent = this.getExtraUserContent.bind(this);
|
||||
this.updateStreamStatus = this.updateStreamStatus.bind(this);
|
||||
this.handleNetworkingError = this.handleNetworkingError.bind(this);
|
||||
this.handleOfflineMode = this.handleOfflineMode.bind(this);
|
||||
this.handleOnlineMode = this.handleOnlineMode.bind(this);
|
||||
this.handleNetworkingError = this.handleNetworkingError.bind(this);
|
||||
this.disableChatInput = this.disableChatInput.bind(this);
|
||||
|
||||
// player events
|
||||
this.handlePlayerReady = this.handlePlayerReady.bind(this);
|
||||
this.handlePlayerPlaying = this.handlePlayerPlaying.bind(this);
|
||||
this.handlePlayerEnded = this.handlePlayerEnded.bind(this);
|
||||
this.handlePlayerError = this.handlePlayerError.bind(this);
|
||||
this.setCurrentStreamDuration = this.setCurrentStreamDuration.bind(this);
|
||||
|
||||
// fetch events
|
||||
this.getConfig = this.getConfig.bind(this);
|
||||
this.getStreamStatus = this.getStreamStatus.bind(this);
|
||||
this.getExtraUserContent = this.getExtraUserContent.bind(this);
|
||||
}
|
||||
|
||||
init() {
|
||||
this.messagingInterface = new MessagingInterface();
|
||||
this.setupWebsocket();
|
||||
|
||||
this.vueApp = new Vue({
|
||||
el: '#app-container',
|
||||
data: {
|
||||
playerOn: false,
|
||||
messages: [],
|
||||
overallMaxViewerCount: 0,
|
||||
sessionMaxViewerCount: 0,
|
||||
streamStatus: MESSAGE_OFFLINE, // Default state.
|
||||
viewerCount: 0,
|
||||
isOnline: false,
|
||||
|
||||
// from config
|
||||
appVersion: '',
|
||||
extraUserContent: '',
|
||||
logo: TEMP_IMAGE,
|
||||
logoLarge: TEMP_IMAGE,
|
||||
socialHandles: [],
|
||||
streamerName: '',
|
||||
summary: '',
|
||||
tags: [],
|
||||
title: '',
|
||||
},
|
||||
watch: {
|
||||
messages: {
|
||||
deep: true,
|
||||
handler: this.messagingInterface.onReceivedMessages,
|
||||
},
|
||||
},
|
||||
mounted: this.vueAppMounted,
|
||||
});
|
||||
}
|
||||
// do all these things after Vue.js has mounted, else we'll get weird DOM issues.
|
||||
vueAppMounted() {
|
||||
componentDidMount() {
|
||||
this.getConfig();
|
||||
this.messagingInterface.init();
|
||||
window.addEventListener('resize', this.handleWindowResize);
|
||||
|
||||
this.player = new OwncastPlayer();
|
||||
this.player.setupPlayerCallbacks({
|
||||
@ -101,50 +105,16 @@ class Owncast {
|
||||
onError: this.handlePlayerError,
|
||||
});
|
||||
this.player.init();
|
||||
|
||||
this.getChatHistory();
|
||||
};
|
||||
|
||||
setConfigData(data) {
|
||||
this.vueApp.appVersion = data.version;
|
||||
this.vueApp.logo = data.logo.small;
|
||||
this.vueApp.logoLarge = data.logo.large;
|
||||
this.vueApp.socialHandles = data.socialHandles;
|
||||
this.vueApp.streamerName = data.name;
|
||||
this.vueApp.summary = data.summary && addNewlines(data.summary);
|
||||
this.vueApp.tags = data.tags;
|
||||
this.vueApp.title = data.title;
|
||||
|
||||
window.document.title = data.title;
|
||||
|
||||
this.getExtraUserContent(`${data.extraUserInfoFileName}`);
|
||||
|
||||
this.configData = data;
|
||||
}
|
||||
|
||||
// websocket for messaging
|
||||
setupWebsocket() {
|
||||
this.websocket = new Websocket();
|
||||
this.websocket.addListener('rawWebsocketMessageReceived', this.receivedWebsocketMessage.bind(this));
|
||||
this.messagingInterface.send = this.websocket.send;
|
||||
};
|
||||
|
||||
receivedWebsocketMessage(model) {
|
||||
if (model.type === SOCKET_MESSAGE_TYPES.CHAT) {
|
||||
const message = new Message(model);
|
||||
this.addMessage(message);
|
||||
} else if (model.type === SOCKET_MESSAGE_TYPES.NAME_CHANGE) {
|
||||
this.addMessage(model);
|
||||
}
|
||||
}
|
||||
|
||||
addMessage(message) {
|
||||
const existing = this.vueApp.messages.filter(function (item) {
|
||||
return item.id === message.id;
|
||||
})
|
||||
if (existing.length === 0 || !existing) {
|
||||
this.vueApp.messages = [...this.vueApp.messages, message];
|
||||
}
|
||||
componentWillUnmount() {
|
||||
// clear all the timers
|
||||
clearInterval(this.playerRestartTimer);
|
||||
clearInterval(this.offlineTimer);
|
||||
clearInterval(this.statusTimer);
|
||||
clearTimeout(this.disableChatTimer);
|
||||
clearInterval(this.streamDurationTimer);
|
||||
window.removeEventListener('resize', this.handleWindowResize);
|
||||
}
|
||||
|
||||
// fetch /config data
|
||||
@ -180,7 +150,7 @@ class Owncast {
|
||||
this.handleOfflineMode();
|
||||
this.handleNetworkingError(`Stream status: ${error}`);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// fetch content.md
|
||||
getExtraUserContent(path) {
|
||||
@ -192,141 +162,319 @@ class Owncast {
|
||||
return response.text();
|
||||
})
|
||||
.then(text => {
|
||||
const descriptionHTML = new showdown.Converter().makeHtml(text);
|
||||
this.vueApp.extraUserContent = descriptionHTML;
|
||||
this.setState({
|
||||
extraUserContent: new showdown.Converter().makeHtml(text),
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
this.handleNetworkingError(`Fetch extra content: ${error}`);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// fetch chat history
|
||||
getChatHistory() {
|
||||
fetch(URL_CHAT_HISTORY)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Network response was not ok ${response.ok}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
const formattedMessages = data.map(function (message) {
|
||||
return new Message(message);
|
||||
})
|
||||
this.vueApp.messages = formattedMessages.concat(this.vueApp.messages);
|
||||
})
|
||||
.catch(error => {
|
||||
this.handleNetworkingError(`Fetch getChatHistory: ${error}`);
|
||||
setConfigData(data = {}) {
|
||||
const { title, extraUserInfoFileName, summary } = data;
|
||||
|
||||
window.document.title = title;
|
||||
if (extraUserInfoFileName) {
|
||||
this.getExtraUserContent(extraUserInfoFileName);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
configData: {
|
||||
...data,
|
||||
summary: summary && addNewlines(summary),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// handle UI things from stream status result
|
||||
updateStreamStatus(status = {}) {
|
||||
const { streamOnline: curStreamOnline } = this.state;
|
||||
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
// update UI
|
||||
this.vueApp.viewerCount = status.viewerCount;
|
||||
this.vueApp.sessionMaxViewerCount = status.sessionMaxViewerCount;
|
||||
this.vueApp.overallMaxViewerCount = status.overallMaxViewerCount;
|
||||
const {
|
||||
viewerCount,
|
||||
sessionMaxViewerCount,
|
||||
overallMaxViewerCount,
|
||||
online,
|
||||
} = status;
|
||||
|
||||
this.lastDisconnectTime = status.lastDisconnectTime;
|
||||
|
||||
if (!this.streamStatus) {
|
||||
// display offline mode the first time we get status, and it's offline.
|
||||
if (!status.online) {
|
||||
this.handleOfflineMode();
|
||||
} else {
|
||||
this.handleOnlineMode();
|
||||
}
|
||||
} else {
|
||||
if (status.online && !this.streamStatus.online) {
|
||||
// stream has just come online.
|
||||
this.handleOnlineMode();
|
||||
} else if (!status.online && this.streamStatus.online) {
|
||||
// stream has just flipped offline.
|
||||
this.handleOfflineMode();
|
||||
}
|
||||
if (status.online && !curStreamOnline) {
|
||||
// stream has just come online.
|
||||
this.handleOnlineMode();
|
||||
} else if (!status.online && curStreamOnline) {
|
||||
// stream has just flipped offline.
|
||||
this.handleOfflineMode();
|
||||
}
|
||||
|
||||
// keep a local copy
|
||||
this.streamStatus = status;
|
||||
|
||||
if (status.online) {
|
||||
// only do this if video is paused, so no unnecessary img fetches
|
||||
if (this.player.vjsPlayer && this.player.vjsPlayer.paused()) {
|
||||
this.player.setPoster();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// update vueApp.streamStatus text when online
|
||||
setCurrentStreamDuration() {
|
||||
// Default to something
|
||||
let streamDurationString = '';
|
||||
|
||||
if (this.streamStatus.lastConnectTime) {
|
||||
const diff = (Date.now() - Date.parse(this.streamStatus.lastConnectTime)) / 1000;
|
||||
streamDurationString = secondsToHMMSS(diff);
|
||||
}
|
||||
this.vueApp.streamStatus = `${MESSAGE_ONLINE} ${streamDurationString}.`
|
||||
}
|
||||
|
||||
handleNetworkingError(error) {
|
||||
console.log(`>>> App Error: ${error}`)
|
||||
};
|
||||
|
||||
// stop status timer and disable chat after some time.
|
||||
handleOfflineMode() {
|
||||
this.vueApp.isOnline = false;
|
||||
clearInterval(this.streamDurationTimer);
|
||||
this.vueApp.streamStatus = MESSAGE_OFFLINE;
|
||||
if (this.streamStatus) {
|
||||
const remainingChatTime = TIMER_DISABLE_CHAT_AFTER_OFFLINE - (Date.now() - new Date(this.lastDisconnectTime));
|
||||
const countdown = (remainingChatTime < 0) ? 0 : remainingChatTime;
|
||||
this.disableChatTimer = setTimeout(this.messagingInterface.disableChat, countdown);
|
||||
}
|
||||
};
|
||||
|
||||
// play video!
|
||||
handleOnlineMode() {
|
||||
this.vueApp.playerOn = true;
|
||||
this.vueApp.isOnline = true;
|
||||
this.vueApp.streamStatus = MESSAGE_ONLINE;
|
||||
|
||||
this.player.startPlayer();
|
||||
clearTimeout(this.disableChatTimer);
|
||||
this.disableChatTimer = null;
|
||||
this.messagingInterface.enableChat();
|
||||
|
||||
this.streamDurationTimer =
|
||||
setInterval(this.setCurrentStreamDuration, TIMER_STREAM_DURATION_COUNTER);
|
||||
this.setState({
|
||||
viewerCount,
|
||||
sessionMaxViewerCount,
|
||||
overallMaxViewerCount,
|
||||
streamOnline: online,
|
||||
});
|
||||
}
|
||||
|
||||
// when videojs player is ready, start polling for stream
|
||||
handlePlayerReady() {
|
||||
this.getStreamStatus();
|
||||
this.statusTimer = setInterval(this.getStreamStatus, TIMER_STATUS_UPDATE);
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
handlePlayerPlaying() {
|
||||
// do something?
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// likely called some time after stream status has gone offline.
|
||||
// basically hide video and show underlying "poster"
|
||||
handlePlayerEnded() {
|
||||
this.vueApp.playerOn = false;
|
||||
};
|
||||
this.setState({
|
||||
playerActive: false,
|
||||
});
|
||||
}
|
||||
|
||||
handlePlayerError() {
|
||||
// do something?
|
||||
this.handleOfflineMode();
|
||||
this.handlePlayerEnded();
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// stop status timer and disable chat after some time.
|
||||
handleOfflineMode() {
|
||||
clearInterval(this.streamDurationTimer);
|
||||
const remainingChatTime = TIMER_DISABLE_CHAT_AFTER_OFFLINE - (Date.now() - new Date(this.lastDisconnectTime));
|
||||
const countdown = (remainingChatTime < 0) ? 0 : remainingChatTime;
|
||||
this.disableChatTimer = setTimeout(this.disableChatInput, countdown);
|
||||
this.setState({
|
||||
streamOnline: false,
|
||||
streamStatusMessage: MESSAGE_OFFLINE,
|
||||
});
|
||||
}
|
||||
|
||||
// play video!
|
||||
handleOnlineMode() {
|
||||
this.player.startPlayer();
|
||||
clearTimeout(this.disableChatTimer);
|
||||
this.disableChatTimer = null;
|
||||
|
||||
this.streamDurationTimer =
|
||||
setInterval(this.setCurrentStreamDuration, TIMER_STREAM_DURATION_COUNTER);
|
||||
|
||||
this.setState({
|
||||
playerActive: true,
|
||||
streamOnline: true,
|
||||
chatEnabled: true,
|
||||
streamStatusMessage: MESSAGE_ONLINE,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
handleUsernameChange(newName, newAvatar) {
|
||||
this.setState({
|
||||
username: newName,
|
||||
userAvatarImage: newAvatar,
|
||||
});
|
||||
}
|
||||
|
||||
handleChatPanelToggle() {
|
||||
const { displayChat: curDisplayed } = this.state;
|
||||
|
||||
const displayChat = !curDisplayed;
|
||||
if (displayChat) {
|
||||
setLocalStorage(KEY_CHAT_DISPLAYED, displayChat);
|
||||
} else {
|
||||
clearLocalStorage(KEY_CHAT_DISPLAYED);
|
||||
}
|
||||
this.setState({
|
||||
displayChat,
|
||||
});
|
||||
}
|
||||
|
||||
disableChatInput() {
|
||||
this.setState({
|
||||
chatEnabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
handleNetworkingError(error) {
|
||||
console.log(`>>> App Error: ${error}`);
|
||||
}
|
||||
|
||||
handleWindowResize() {
|
||||
this.setState({
|
||||
windowWidth: window.innerWidth,
|
||||
windowHeight: window.innerHeight,
|
||||
});
|
||||
}
|
||||
|
||||
render(props, state) {
|
||||
const {
|
||||
chatEnabled,
|
||||
configData,
|
||||
displayChat,
|
||||
extraUserContent,
|
||||
overallMaxViewerCount,
|
||||
playerActive,
|
||||
sessionMaxViewerCount,
|
||||
streamOnline,
|
||||
streamStatusMessage,
|
||||
userAvatarImage,
|
||||
username,
|
||||
viewerCount,
|
||||
websocket,
|
||||
windowHeight,
|
||||
windowWidth,
|
||||
} = state;
|
||||
|
||||
const {
|
||||
version: appVersion,
|
||||
logo = {},
|
||||
socialHandles = [],
|
||||
name: streamerName,
|
||||
summary,
|
||||
tags = [],
|
||||
title,
|
||||
} = configData;
|
||||
const { small: smallLogo = TEMP_IMAGE, large: largeLogo = TEMP_IMAGE } = logo;
|
||||
|
||||
const bgLogo = { backgroundImage: `url(${smallLogo})` };
|
||||
const bgLogoLarge = { backgroundImage: `url(${largeLogo})` };
|
||||
|
||||
const tagList = !tags.length ?
|
||||
null :
|
||||
tags.map((tag, index) => html`
|
||||
<li key="tag${index}" class="tag rounded-sm text-gray-100 bg-gray-700 text-xs uppercase mr-3 p-2 whitespace-no-wrap">${tag}</li>
|
||||
`);
|
||||
|
||||
const socialIconsList =
|
||||
!socialHandles.length ?
|
||||
null :
|
||||
socialHandles.map((item, index) => html`
|
||||
<li key="social${index}">
|
||||
<${SocialIcon} platform=${item.platform} url=${item.url} />
|
||||
</li>
|
||||
`);
|
||||
|
||||
const mainClass = playerActive ? 'online' : '';
|
||||
const streamInfoClass = streamOnline ? 'online' : ''; // need?
|
||||
|
||||
const shortHeight = windowHeight <= HEIGHT_SHORT_WIDE;
|
||||
const singleColMode = windowWidth <= WIDTH_SINGLE_COL && !shortHeight;
|
||||
const extraAppClasses = classNames({
|
||||
'chat': displayChat,
|
||||
'no-chat': !displayChat,
|
||||
'single-col': singleColMode,
|
||||
'bg-gray-800': singleColMode && displayChat,
|
||||
'short-wide': shortHeight,
|
||||
})
|
||||
|
||||
return (
|
||||
html`
|
||||
<div id="app-container" class="flex w-full flex-col justify-start relative ${extraAppClasses}">
|
||||
<div id="top-content" class="z-50">
|
||||
<header class="flex border-b border-gray-900 border-solid shadow-md fixed z-10 w-full top-0 left-0 flex flex-row justify-between flex-no-wrap">
|
||||
<h1 class="flex flex-row items-center justify-start p-2 uppercase text-gray-400 text-xl font-thin tracking-wider overflow-hidden whitespace-no-wrap">
|
||||
<span
|
||||
id="logo-container"
|
||||
class="inline-block rounded-full bg-white w-8 min-w-8 min-h-8 h-8 p-1 mr-2 bg-no-repeat bg-center"
|
||||
style=${bgLogo}
|
||||
>
|
||||
<img class="logo visually-hidden" src=${smallLogo} alt=""/>
|
||||
</span>
|
||||
<span class="instance-title overflow-hidden truncate">${title}</span>
|
||||
</h1>
|
||||
<div id="user-options-container" class="flex flex-row justify-end items-center flex-no-wrap">
|
||||
<${UsernameForm}
|
||||
username=${username}
|
||||
userAvatarImage=${userAvatarImage}
|
||||
handleUsernameChange=${this.handleUsernameChange}
|
||||
/>
|
||||
<button type="button" id="chat-toggle" onClick=${this.handleChatPanelToggle} class="flex cursor-pointer text-center justify-center items-center min-w-12 h-full bg-gray-800 hover:bg-gray-700">💬</button>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<main class=${mainClass}>
|
||||
<div
|
||||
id="video-container"
|
||||
class="flex owncast-video-container bg-black w-full bg-center bg-no-repeat flex flex-col items-center justify-start"
|
||||
style=${bgLogoLarge}
|
||||
>
|
||||
<video
|
||||
class="video-js vjs-big-play-centered display-block w-full h-full"
|
||||
id="video"
|
||||
preload="auto"
|
||||
controls
|
||||
playsinline
|
||||
></video>
|
||||
</div>
|
||||
|
||||
<section id="stream-info" aria-label="Stream status" class="flex text-center flex-row justify-between font-mono py-2 px-8 bg-gray-900 text-indigo-200 shadow-md border-b border-gray-100 border-solid ${streamInfoClass}">
|
||||
<span>${streamStatusMessage}</span>
|
||||
<span>${viewerCount} ${pluralize('viewer', viewerCount)}.</span>
|
||||
<span>Max ${pluralize('viewer', sessionMaxViewerCount)}.</span>
|
||||
<span>${overallMaxViewerCount} overall.</span>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<section id="user-content" aria-label="User information" class="p-8">
|
||||
<div class="user-content flex flex-row p-8">
|
||||
<div
|
||||
class="user-image rounded-full bg-white p-4 mr-8 bg-no-repeat bg-center"
|
||||
style=${bgLogoLarge}
|
||||
>
|
||||
<img
|
||||
class="logo visually-hidden"
|
||||
alt="Logo"
|
||||
src=${largeLogo}/>
|
||||
</div>
|
||||
<div class="user-content-header border-b border-gray-500 border-solid">
|
||||
<h2 class="font-semibold text-5xl">
|
||||
About <span class="streamer-name text-indigo-600">${streamerName}</span>
|
||||
</h2>
|
||||
<ul id="social-list" class="social-list flex flex-row items-center justify-start flex-wrap">
|
||||
<span class="follow-label text-xs font-bold mr-2 uppercase">Follow me: </span>
|
||||
${socialIconsList}
|
||||
</ul>
|
||||
<div id="stream-summary" class="stream-summary my-4" dangerouslySetInnerHTML=${{ __html: summary }}></div>
|
||||
<ul id="tag-list" class="tag-list flex flex-row my-4">
|
||||
${tagList}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="extra-user-content"
|
||||
class="extra-user-content px-8"
|
||||
dangerouslySetInnerHTML=${{ __html: extraUserContent }}
|
||||
></div>
|
||||
</section>
|
||||
|
||||
<footer class="flex flex-row justify-start p-8 opacity-50 text-xs">
|
||||
<span class="mx-1 inline-block">
|
||||
<a href="${URL_OWNCAST}" target="_blank">About Owncast</a>
|
||||
</span>
|
||||
<span class="mx-1 inline-block">Version ${appVersion}</span>
|
||||
</footer>
|
||||
|
||||
<${Chat}
|
||||
websocket=${websocket}
|
||||
username=${username}
|
||||
userAvatarImage=${userAvatarImage}
|
||||
chatEnabled //=${chatEnabled}
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Owncast;
|
||||
@ -1,11 +0,0 @@
|
||||
/**
|
||||
* These are the types of messages that we can handle with the websocket.
|
||||
* Mostly used by `websocket.js` but if other components need to handle
|
||||
* different types then it can import this file.
|
||||
*/
|
||||
export default {
|
||||
CHAT: 'CHAT',
|
||||
PING: 'PING',
|
||||
NAME_CHANGE: 'NAME_CHANGE',
|
||||
PONG: 'PONG'
|
||||
}
|
||||
@ -1,62 +0,0 @@
|
||||
Vue.component('owncast-footer', {
|
||||
props: {
|
||||
appVersion: {
|
||||
type: String,
|
||||
default: '0.1',
|
||||
},
|
||||
},
|
||||
|
||||
template: `
|
||||
<footer class="flex">
|
||||
<span>
|
||||
<a href="${URL_OWNCAST}" target="_blank">About Owncast</a>
|
||||
</span>
|
||||
<span>Version {{appVersion}}</span>
|
||||
</footer>
|
||||
`,
|
||||
});
|
||||
|
||||
|
||||
Vue.component('stream-tags', {
|
||||
props: ['tags'],
|
||||
template: `
|
||||
<ul
|
||||
class="tag-list flex"
|
||||
v-if="this.tags.length"
|
||||
>
|
||||
<li class="tag rounded-sm text-gray-100 bg-gray-700"
|
||||
v-for="tag in this.tags"
|
||||
v-bind:key="tag"
|
||||
>
|
||||
{{tag}}
|
||||
</li>
|
||||
</ul>
|
||||
`,
|
||||
});
|
||||
|
||||
Vue.component('user-details', {
|
||||
props: ['logo', 'platforms', 'summary', 'tags'],
|
||||
template: `
|
||||
<div class="user-content">
|
||||
<div
|
||||
class="user-image rounded-full bg-white"
|
||||
v-bind:style="{ backgroundImage: 'url(' + logo + ')' }"
|
||||
>
|
||||
<img
|
||||
class="logo visually-hidden"
|
||||
alt="Logo"
|
||||
v-bind:src="logo">
|
||||
</div>
|
||||
<div class="user-content-header border-b border-gray-500 border-solid">
|
||||
<h2 class="font-semibold">
|
||||
About <span class="streamer-name text-indigo-600">
|
||||
<slot></slot>
|
||||
</span>
|
||||
</h2>
|
||||
<social-list v-bind:platforms="platforms"></social-list>
|
||||
<div class="stream-summary" v-html="summary"></div>
|
||||
<stream-tags v-bind:tags="tags"></stream-tags>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
291
webroot/js/components/chat/chat-input.js
Normal file
@ -0,0 +1,291 @@
|
||||
import { h, Component, createRef } from 'https://unpkg.com/preact?module';
|
||||
import htm from 'https://unpkg.com/htm?module';
|
||||
const html = htm.bind(h);
|
||||
|
||||
import { EmojiButton } from 'https://cdn.skypack.dev/@joeattardi/emoji-button';
|
||||
import ContentEditable from './content-editable.js';
|
||||
import { generatePlaceholderText, getCaretPosition } from '../../utils/chat.js';
|
||||
import { getLocalStorage, setLocalStorage } from '../../utils/helpers.js';
|
||||
import { URL_CUSTOM_EMOJIS, KEY_CHAT_FIRST_MESSAGE_SENT } from '../../utils/constants.js';
|
||||
|
||||
export default class ChatInput extends Component {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this.formMessageInput = createRef();
|
||||
this.emojiPickerButton = createRef();
|
||||
|
||||
this.messageCharCount = 0;
|
||||
this.maxMessageLength = 500;
|
||||
this.maxMessageBuffer = 20;
|
||||
|
||||
this.emojiPicker = null;
|
||||
|
||||
this.prepNewLine = false;
|
||||
|
||||
this.state = {
|
||||
inputHTML: '',
|
||||
inputWarning: '',
|
||||
hasSentFirstChatMessage: getLocalStorage(KEY_CHAT_FIRST_MESSAGE_SENT),
|
||||
};
|
||||
|
||||
this.handleEmojiButtonClick = this.handleEmojiButtonClick.bind(this);
|
||||
this.handleEmojiSelected = this.handleEmojiSelected.bind(this);
|
||||
this.getCustomEmojis = this.getCustomEmojis.bind(this);
|
||||
|
||||
this.handleMessageInputKeydown = this.handleMessageInputKeydown.bind(this);
|
||||
this.handleMessageInputKeyup = this.handleMessageInputKeyup.bind(this);
|
||||
this.handleMessageInputBlur = this.handleMessageInputBlur.bind(this);
|
||||
this.handleSubmitChatButton = this.handleSubmitChatButton.bind(this);
|
||||
this.handlePaste = this.handlePaste.bind(this);
|
||||
|
||||
this.handleContentEditableChange = this.handleContentEditableChange.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.getCustomEmojis();
|
||||
}
|
||||
|
||||
getCustomEmojis() {
|
||||
fetch(URL_CUSTOM_EMOJIS)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Network response was not ok ${response.ok}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(json => {
|
||||
this.emojiPicker = new EmojiButton({
|
||||
zIndex: 100,
|
||||
theme: 'dark',
|
||||
custom: json,
|
||||
initialCategory: 'custom',
|
||||
showPreview: false,
|
||||
emojiSize: '30px',
|
||||
position: 'right-start',
|
||||
strategy: 'absolute',
|
||||
});
|
||||
this.emojiPicker.on('emoji', emoji => {
|
||||
this.handleEmojiSelected(emoji);
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
// this.handleNetworkingError(`Emoji Fetch: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
handleEmojiButtonClick() {
|
||||
if (this.emojiPicker) {
|
||||
this.emojiPicker.togglePicker(this.emojiPickerButton.current);
|
||||
}
|
||||
}
|
||||
|
||||
handleEmojiSelected(emoji) {
|
||||
const { inputHTML } = this.state;
|
||||
let content = '';
|
||||
if (emoji.url) {
|
||||
const url = location.protocol + "//" + location.host + "/" + emoji.url;
|
||||
const name = url.split('\\').pop().split('/').pop();
|
||||
content = "<img class=\"emoji\" alt=\"" + name + "\" src=\"" + url + "\"/>";
|
||||
} else {
|
||||
content = emoji.emoji;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
inputHTML: inputHTML + content,
|
||||
});
|
||||
}
|
||||
|
||||
// autocomplete user names
|
||||
autoCompleteNames() {
|
||||
const { chatUserNames } = this.props;
|
||||
const { inputHTML } = this.state;
|
||||
const position = getCaretPosition(this.formMessageInput.current);
|
||||
const at = inputHTML.lastIndexOf('@', position - 1);
|
||||
if (at === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let partial = inputHTML.substring(at + 1, position).trim();
|
||||
|
||||
if (partial === this.suggestion) {
|
||||
partial = this.partial;
|
||||
} else {
|
||||
this.partial = partial;
|
||||
}
|
||||
|
||||
const possibilities = chatUserNames.filter(function (username) {
|
||||
return username.toLowerCase().startsWith(partial.toLowerCase());
|
||||
});
|
||||
|
||||
if (this.completionIndex === undefined || ++this.completionIndex >= possibilities.length) {
|
||||
this.completionIndex = 0;
|
||||
}
|
||||
|
||||
if (possibilities.length > 0) {
|
||||
this.suggestion = possibilities[this.completionIndex];
|
||||
|
||||
this.setState({
|
||||
inputHTML: inputHTML.substring(0, at + 1) + this.suggestion + ' ' + inputHTML.substring(position),
|
||||
})
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
handleMessageInputKeydown(event) {
|
||||
const okCodes = [
|
||||
'ArrowLeft',
|
||||
'ArrowUp',
|
||||
'ArrowRight',
|
||||
'ArrowDown',
|
||||
'Shift',
|
||||
'Meta',
|
||||
'Alt',
|
||||
'Delete',
|
||||
'Backspace',
|
||||
];
|
||||
const formField = this.formMessageInput.current;
|
||||
|
||||
let textValue = formField.innerText.trim(); // get this only to count chars
|
||||
|
||||
let numCharsLeft = this.maxMessageLength - textValue.length;
|
||||
const key = event.key;
|
||||
|
||||
if (key === 'Enter') {
|
||||
if (!this.prepNewLine) {
|
||||
this.sendMessage();
|
||||
event.preventDefault();
|
||||
this.prepNewLine = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (key === 'Control' || key === 'Shift') {
|
||||
this.prepNewLine = true;
|
||||
}
|
||||
if (key === 'Tab') {
|
||||
if (this.autoCompleteNames()) {
|
||||
event.preventDefault();
|
||||
|
||||
// value could have been changed, update char count
|
||||
textValue = formField.innerText.trim();
|
||||
numCharsLeft = this.maxMessageLength - textValue.length;
|
||||
}
|
||||
}
|
||||
|
||||
// text count
|
||||
if (numCharsLeft <= this.maxMessageBuffer) {
|
||||
this.setState({
|
||||
inputWarning: `${numCharsLeft} chars left`,
|
||||
});
|
||||
if (numCharsLeft <= 0 && !okCodes.includes(key)) {
|
||||
event.preventDefault(); // prevent typing more
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
this.setState({
|
||||
inputWarning: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleMessageInputKeyup(event) {
|
||||
if (event.key === 'Control' || event.key === 'Shift') {
|
||||
this.prepNewLine = false;
|
||||
}
|
||||
}
|
||||
|
||||
handleMessageInputBlur(event) {
|
||||
this.prepNewLine = false;
|
||||
}
|
||||
|
||||
handlePaste(event) {
|
||||
event.preventDefault();
|
||||
document.execCommand('inserttext', false, event.clipboardData.getData('text/plain'));
|
||||
}
|
||||
|
||||
handleSubmitChatButton(event) {
|
||||
event.preventDefault();
|
||||
this.sendMessage();
|
||||
}
|
||||
|
||||
sendMessage() {
|
||||
const { handleSendMessage } = this.props;
|
||||
const { hasSentFirstChatMessage, inputHTML } = this.state;
|
||||
const message = inputHTML.trim();
|
||||
const newStates = {
|
||||
inputWarning: '',
|
||||
inputHTML: '',
|
||||
};
|
||||
|
||||
handleSendMessage(message);
|
||||
|
||||
if (!hasSentFirstChatMessage) {
|
||||
newStates.hasSentFirstChatMessage = true;
|
||||
setLocalStorage(KEY_CHAT_FIRST_MESSAGE_SENT, true);
|
||||
}
|
||||
|
||||
// clear things out.
|
||||
this.setState(newStates);
|
||||
}
|
||||
|
||||
handleContentEditableChange(event) {
|
||||
this.setState({ inputHTML: event.target.value });
|
||||
}
|
||||
|
||||
render(props, state) {
|
||||
const { hasSentFirstChatMessage, inputWarning, inputHTML } = state;
|
||||
const { inputEnabled } = props;
|
||||
const emojiButtonStyle = {
|
||||
display: this.emojiPicker ? 'block' : 'none',
|
||||
};
|
||||
|
||||
const placeholderText = generatePlaceholderText(inputEnabled, hasSentFirstChatMessage);
|
||||
return (
|
||||
html`
|
||||
<div id="message-input-container" class="fixed bottom-0 shadow-md bg-gray-900 border-t border-gray-700 border-solid p-4">
|
||||
|
||||
<${ContentEditable}
|
||||
id="message-input"
|
||||
class="appearance-none block w-full bg-gray-200 text-sm text-gray-700 border border-black-500 rounded py-2 px-2 my-2 focus:bg-white h-20 overflow-auto"
|
||||
|
||||
placeholderText=${placeholderText}
|
||||
innerRef=${this.formMessageInput}
|
||||
html=${inputHTML}
|
||||
disabled=${!inputEnabled}
|
||||
onChange=${this.handleContentEditableChange}
|
||||
onKeyDown=${this.handleMessageInputKeydown}
|
||||
onKeyUp=${this.handleMessageInputKeyup}
|
||||
onBlur=${this.handleMessageInputBlur}
|
||||
|
||||
onPaste=${this.handlePaste}
|
||||
/>
|
||||
|
||||
<div id="message-form-actions" class="flex flex-row justify-between items-center w-full">
|
||||
<span id="message-form-warning" class="text-red-600 text-xs">${inputWarning}</span>
|
||||
|
||||
<div id="message-form-actions-buttons" class="flex flex-row justify-end items-center">
|
||||
<button
|
||||
ref=${this.emojiPickerButton}
|
||||
id="emoji-button"
|
||||
class="mr-2 text-2xl cursor-pointer"
|
||||
type="button"
|
||||
style=${emojiButtonStyle}
|
||||
onclick=${this.handleEmojiButtonClick}
|
||||
disabled=${!inputEnabled}
|
||||
>😏</button>
|
||||
|
||||
<button
|
||||
onclick=${this.handleSubmitChatButton}
|
||||
disabled=${!inputEnabled}
|
||||
type="button"
|
||||
id="button-submit-message"
|
||||
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-2 rounded"
|
||||
> Chat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
}
|
||||
218
webroot/js/components/chat/chat.js
Normal file
@ -0,0 +1,218 @@
|
||||
import { h, Component, createRef } from 'https://unpkg.com/preact?module';
|
||||
import htm from 'https://unpkg.com/htm?module';
|
||||
const html = htm.bind(h);
|
||||
|
||||
import Message from './message.js';
|
||||
import ChatInput from './chat-input.js';
|
||||
import { CALLBACKS, SOCKET_MESSAGE_TYPES } from '../../utils/websocket.js';
|
||||
import { setVHvar, hasTouchScreen, jumpToBottom } from '../../utils/helpers.js';
|
||||
import { extraUserNamesFromMessageHistory } from '../../utils/chat.js';
|
||||
import { URL_CHAT_HISTORY } from '../../utils/constants.js';
|
||||
|
||||
export default class Chat extends Component {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
inputEnabled: true,
|
||||
messages: [],
|
||||
chatUserNames: [],
|
||||
};
|
||||
|
||||
this.scrollableMessagesContainer = createRef();
|
||||
|
||||
this.websocket = null;
|
||||
|
||||
this.getChatHistory = this.getChatHistory.bind(this);
|
||||
this.receivedWebsocketMessage = this.receivedWebsocketMessage.bind(this);
|
||||
this.websocketDisconnected = this.websocketDisconnected.bind(this);
|
||||
this.submitChat = this.submitChat.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.setupWebSocketCallbacks();
|
||||
this.getChatHistory();
|
||||
|
||||
if (hasTouchScreen()) {
|
||||
setVHvar();
|
||||
window.addEventListener("orientationchange", setVHvar);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
const { username: prevName } = prevProps;
|
||||
const { username, userAvatarImage } = this.props;
|
||||
|
||||
const { messages: prevMessages } = prevState;
|
||||
const { messages } = this.state;
|
||||
|
||||
// if username updated, send a message
|
||||
if (prevName !== username) {
|
||||
this.sendUsernameChange(prevName, username, userAvatarImage);
|
||||
}
|
||||
// scroll to bottom of messages list when new ones come in
|
||||
if (messages.length > prevMessages.length) {
|
||||
jumpToBottom(this.scrollableMessagesContainer.current);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (hasTouchScreen()) {
|
||||
window.removeEventListener("orientationchange", setVHvar);
|
||||
}
|
||||
}
|
||||
|
||||
setupWebSocketCallbacks() {
|
||||
this.websocket = this.props.websocket;
|
||||
if (this.websocket) {
|
||||
this.websocket.addListener(CALLBACKS.RAW_WEBSOCKET_MESSAGE_RECEIVED, this.receivedWebsocketMessage);
|
||||
this.websocket.addListener(CALLBACKS.WEBSOCKET_DISCONNECTED, this.websocketDisconnected);
|
||||
}
|
||||
}
|
||||
|
||||
// fetch chat history
|
||||
getChatHistory() {
|
||||
fetch(URL_CHAT_HISTORY)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Network response was not ok ${response.ok}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
// extra user names
|
||||
const chatUserNames = extraUserNamesFromMessageHistory(data);
|
||||
this.setState({
|
||||
messages: data,
|
||||
chatUserNames,
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
// this.handleNetworkingError(`Fetch getChatHistory: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
sendUsernameChange(oldName, newName, image) {
|
||||
const nameChange = {
|
||||
type: SOCKET_MESSAGE_TYPES.NAME_CHANGE,
|
||||
oldName,
|
||||
newName,
|
||||
image,
|
||||
};
|
||||
this.websocket.send(nameChange);
|
||||
}
|
||||
|
||||
receivedWebsocketMessage(message) {
|
||||
this.addMessage(message);
|
||||
}
|
||||
|
||||
addMessage(message) {
|
||||
const { messages: curMessages } = this.state;
|
||||
|
||||
// if incoming message has same id as existing message, don't add it
|
||||
const existing = curMessages.filter(function (item) {
|
||||
return item.id === message.id;
|
||||
})
|
||||
|
||||
if (existing.length === 0 || !existing) {
|
||||
const newState = {
|
||||
messages: [...curMessages, message],
|
||||
};
|
||||
const updatedChatUserNames = this.updateAuthorList(message);
|
||||
if (updatedChatUserNames.length) {
|
||||
newState.chatUserNames = [...updatedChatUserNames];
|
||||
}
|
||||
this.setState(newState);
|
||||
}
|
||||
}
|
||||
websocketDisconnected() {
|
||||
// this.websocket = null;
|
||||
this.disableChat();
|
||||
}
|
||||
|
||||
submitChat(content) {
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
const { username, userAvatarImage } = this.props;
|
||||
const message = {
|
||||
body: content,
|
||||
author: username,
|
||||
image: userAvatarImage,
|
||||
type: SOCKET_MESSAGE_TYPES.CHAT,
|
||||
};
|
||||
this.websocket.send(message);
|
||||
}
|
||||
|
||||
disableChat() {
|
||||
this.setState({
|
||||
inputEnabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
enableChat() {
|
||||
this.setState({
|
||||
inputEnabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
updateAuthorList(message) {
|
||||
const { type } = message;
|
||||
const nameList = this.state.chatUserNames;
|
||||
|
||||
if (
|
||||
type === SOCKET_MESSAGE_TYPES.CHAT &&
|
||||
!nameList.includes(message.author)
|
||||
) {
|
||||
return nameList.push(message.author);
|
||||
} else if (type === SOCKET_MESSAGE_TYPES.NAME_CHANGE) {
|
||||
const { oldName, newName } = message;
|
||||
const oldNameIndex = nameList.indexOf(oldName);
|
||||
return nameList.splice(oldNameIndex, 1, newName);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
render(props, state) {
|
||||
const { username, messagesOnly, chatEnabled } = props;
|
||||
const { messages, inputEnabled, chatUserNames } = state;
|
||||
|
||||
const messageList = messages.map((message) => (html`<${Message} message=${message} username=${username} key=${message.id} />`));
|
||||
|
||||
if (messagesOnly) {
|
||||
return (
|
||||
html`
|
||||
<div
|
||||
id="messages-container"
|
||||
ref=${this.scrollableMessagesContainer}
|
||||
class="py-1 overflow-auto"
|
||||
>
|
||||
${messageList}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
return (
|
||||
html`
|
||||
<section id="chat-container-wrap" class="flex flex-col">
|
||||
<div id="chat-container" class="bg-gray-800 flex flex-col justify-end overflow-auto">
|
||||
<div
|
||||
id="messages-container"
|
||||
ref=${this.scrollableMessagesContainer}
|
||||
class="py-1 overflow-auto"
|
||||
>
|
||||
${messageList}
|
||||
</div>
|
||||
<${ChatInput}
|
||||
chatUserNames=${chatUserNames}
|
||||
inputEnabled=${chatEnabled && inputEnabled}
|
||||
handleSendMessage=${this.submitChat}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
`);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
130
webroot/js/components/chat/content-editable.js
Normal file
@ -0,0 +1,130 @@
|
||||
/*
|
||||
Since we can't really import react-contenteditable here, I'm borrowing code for this component from here:
|
||||
github.com/lovasoa/react-contenteditable/
|
||||
|
||||
and here:
|
||||
https://stackoverflow.com/questions/22677931/react-js-onchange-event-for-contenteditable/27255103#27255103
|
||||
|
||||
*/
|
||||
import { Component, createRef, h } from 'https://unpkg.com/preact?module';
|
||||
|
||||
function replaceCaret(el) {
|
||||
// Place the caret at the end of the element
|
||||
const target = document.createTextNode('');
|
||||
el.appendChild(target);
|
||||
// do not move caret if element was not focused
|
||||
const isTargetFocused = document.activeElement === el;
|
||||
if (target !== null && target.nodeValue !== null && isTargetFocused) {
|
||||
var sel = window.getSelection();
|
||||
if (sel !== null) {
|
||||
var range = document.createRange();
|
||||
range.setStart(target, target.nodeValue.length);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
if (el) el.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHtml(str) {
|
||||
return str && str.replace(/ |\u202F|\u00A0/g, ' ');
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default class ContentEditable extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.el = createRef();
|
||||
|
||||
this.lastHtml = '';
|
||||
|
||||
this.emitChange = this.emitChange.bind(this);
|
||||
this.getDOMElement = this.getDOMElement.bind(this);
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps) {
|
||||
const { props } = this;
|
||||
const el = this.getDOMElement();
|
||||
|
||||
// We need not rerender if the change of props simply reflects the user's edits.
|
||||
// Rerendering in this case would make the cursor/caret jump
|
||||
|
||||
// Rerender if there is no element yet... (somehow?)
|
||||
if (!el) return true;
|
||||
|
||||
// ...or if html really changed... (programmatically, not by user edit)
|
||||
if (
|
||||
normalizeHtml(nextProps.html) !== normalizeHtml(el.innerHTML)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle additional properties
|
||||
return props.disabled !== nextProps.disabled ||
|
||||
props.tagName !== nextProps.tagName ||
|
||||
props.className !== nextProps.className ||
|
||||
props.innerRef !== nextProps.innerRef;
|
||||
}
|
||||
|
||||
|
||||
|
||||
componentDidUpdate() {
|
||||
const el = this.getDOMElement();
|
||||
if (!el) return;
|
||||
|
||||
// Perhaps React (whose VDOM gets outdated because we often prevent
|
||||
// rerendering) did not update the DOM. So we update it manually now.
|
||||
if (this.props.html !== el.innerHTML) {
|
||||
el.innerHTML = this.props.html;
|
||||
}
|
||||
this.lastHtml = this.props.html;
|
||||
replaceCaret(el);
|
||||
}
|
||||
|
||||
getDOMElement() {
|
||||
return (this.props.innerRef && typeof this.props.innerRef !== 'function' ? this.props.innerRef : this.el).current;
|
||||
}
|
||||
|
||||
|
||||
emitChange(originalEvt) {
|
||||
const el = this.getDOMElement();
|
||||
if (!el) return;
|
||||
|
||||
const html = el.innerHTML;
|
||||
if (this.props.onChange && html !== this.lastHtml) {
|
||||
// Clone event with Object.assign to avoid
|
||||
// "Cannot assign to read only property 'target' of object"
|
||||
const evt = Object.assign({}, originalEvt, {
|
||||
target: {
|
||||
value: html
|
||||
}
|
||||
});
|
||||
this.props.onChange(evt);
|
||||
}
|
||||
this.lastHtml = html;
|
||||
}
|
||||
|
||||
render(props) {
|
||||
const { html, innerRef } = props;
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
...props,
|
||||
ref: typeof innerRef === 'function' ? (current) => {
|
||||
innerRef(current)
|
||||
this.el.current = current
|
||||
} : innerRef || this.el,
|
||||
onInput: this.emitChange,
|
||||
onBlur: this.props.onBlur || this.emitChange,
|
||||
onKeyup: this.props.onKeyUp || this.emitChange,
|
||||
onKeydown: this.props.onKeyDown || this.emitChange,
|
||||
contentEditable: !this.props.disabled,
|
||||
dangerouslySetInnerHTML: { __html: html },
|
||||
},
|
||||
this.props.children,
|
||||
);
|
||||
}
|
||||
}
|
||||
66
webroot/js/components/chat/message.js
Normal file
@ -0,0 +1,66 @@
|
||||
import { h, Component } from 'https://unpkg.com/preact?module';
|
||||
import htm from 'https://unpkg.com/htm?module';
|
||||
const html = htm.bind(h);
|
||||
|
||||
import { messageBubbleColorForString } from '../../utils/user-colors.js';
|
||||
import { formatMessageText } from '../../utils/chat.js';
|
||||
import { generateAvatar } from '../../utils/helpers.js';
|
||||
import { SOCKET_MESSAGE_TYPES } from '../../utils/websocket.js';
|
||||
|
||||
export default class Message extends Component {
|
||||
render(props) {
|
||||
const { message, username } = props;
|
||||
const { type } = message;
|
||||
|
||||
if (type === SOCKET_MESSAGE_TYPES.CHAT) {
|
||||
const { image, author, body } = message;
|
||||
const formattedMessage = formatMessageText(body, username);
|
||||
const avatar = image || generateAvatar(author);
|
||||
|
||||
const authorColor = messageBubbleColorForString(author);
|
||||
const avatarBgColor = { backgroundColor: authorColor };
|
||||
const authorTextColor = { color: authorColor };
|
||||
return (
|
||||
html`
|
||||
<div class="message flex flex-row items-start p-3">
|
||||
<div
|
||||
class="message-avatar rounded-full flex items-center justify-center mr-3"
|
||||
style=${avatarBgColor}
|
||||
>
|
||||
<img src=${avatar} class="p-1" />
|
||||
</div>
|
||||
<div class="message-content text-sm break-words">
|
||||
<div class="message-author text-white font-bold" style=${authorTextColor}>
|
||||
${author}
|
||||
</div>
|
||||
<div
|
||||
class="message-text text-gray-300 font-normal"
|
||||
dangerouslySetInnerHTML=${
|
||||
{ __html: formattedMessage }
|
||||
}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
} else if (type === SOCKET_MESSAGE_TYPES.NAME_CHANGE) {
|
||||
const { oldName, newName, image } = message;
|
||||
return (
|
||||
html`
|
||||
<div class="message message-name-change flex items-center justify-start p-3">
|
||||
<div class="message-content flex flex-row items-center justify-center text-sm">
|
||||
<div
|
||||
class="message-avatar rounded-full mr-3 bg-gray-900"
|
||||
>
|
||||
<img class="mr-2 p-1" src=${image} />
|
||||
</div>
|
||||
|
||||
<div class="text-white text-center opacity-50">
|
||||
<span class="font-bold">${oldName}</span> is now known as <span class="font-bold">${newName}</span>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
106
webroot/js/components/chat/username.js
Normal file
@ -0,0 +1,106 @@
|
||||
import { h, Component, createRef } from 'https://unpkg.com/preact?module';
|
||||
import htm from 'https://unpkg.com/htm?module';
|
||||
const html = htm.bind(h);
|
||||
|
||||
import { generateAvatar, setLocalStorage } from '../../utils/helpers.js';
|
||||
import { KEY_USERNAME, KEY_AVATAR } from '../../utils/constants.js';
|
||||
|
||||
export default class UsernameForm extends Component {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
displayForm: false,
|
||||
};
|
||||
|
||||
this.textInput = createRef();
|
||||
|
||||
this.handleKeydown = this.handleKeydown.bind(this);
|
||||
this.handleDisplayForm = this.handleDisplayForm.bind(this);
|
||||
this.handleHideForm = this.handleHideForm.bind(this);
|
||||
this.handleUpdateUsername = this.handleUpdateUsername.bind(this);
|
||||
}
|
||||
|
||||
handleDisplayForm() {
|
||||
const { displayForm: curDisplay } = this.state;
|
||||
this.setState({
|
||||
displayForm: !curDisplay,
|
||||
});
|
||||
}
|
||||
|
||||
handleHideForm() {
|
||||
this.setState({
|
||||
displayForm: false,
|
||||
});
|
||||
}
|
||||
|
||||
handleKeydown(event) {
|
||||
if (event.keyCode === 13) { // enter
|
||||
this.handleUpdateUsername();
|
||||
} else if (event.keyCode === 27) { // esc
|
||||
this.handleHideForm();
|
||||
}
|
||||
}
|
||||
|
||||
handleUpdateUsername() {
|
||||
const { username: curName, handleUsernameChange } = this.props;
|
||||
let newName = this.textInput.current.value;
|
||||
newName = newName.trim();
|
||||
if (newName !== '' && newName !== curName) {
|
||||
const newAvatar = generateAvatar(`${newName}${Date.now()}`);
|
||||
setLocalStorage(KEY_USERNAME, newName);
|
||||
setLocalStorage(KEY_AVATAR, newAvatar);
|
||||
if (handleUsernameChange) {
|
||||
handleUsernameChange(newName, newAvatar);
|
||||
}
|
||||
this.handleHideForm();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
render(props, state) {
|
||||
const { username, userAvatarImage } = props;
|
||||
const { displayForm } = state;
|
||||
|
||||
const narrowSpace = document.body.clientWidth < 640;
|
||||
const formDisplayStyle = narrowSpace ? 'inline-block' : 'flex';
|
||||
const styles = {
|
||||
info: {
|
||||
display: displayForm ? 'none' : 'flex',
|
||||
},
|
||||
form: {
|
||||
display: displayForm ? formDisplayStyle : 'none',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
html`
|
||||
<div id="user-info">
|
||||
<div id="user-info-display" style=${styles.info} title="Click to update user name" class="flex flex-row justify-end items-center cursor-pointer py-2 px-4 overflow-hidden w-full opacity-1 transition-opacity duration-200 hover:opacity-75" onClick=${this.handleDisplayForm}>
|
||||
<img
|
||||
src=${userAvatarImage}
|
||||
alt=""
|
||||
id="username-avatar"
|
||||
class="rounded-full bg-black bg-opacity-50 border border-solid border-gray-700 mr-2 h-8 w-8"
|
||||
/>
|
||||
<span id="username-display" class="text-indigo-600 text-xs font-semibold truncate overflow-hidden whitespace-no-wrap">${username}</span>
|
||||
</div>
|
||||
|
||||
<div id="user-info-change" class="flex flex-no-wrap p-1 items-center justify-end" style=${styles.form}>
|
||||
<input type="text"
|
||||
id="username-change-input"
|
||||
class="appearance-none block w-full bg-gray-200 text-gray-700 border border-black-500 rounded py-1 px-1 leading-tight text-xs focus:bg-white"
|
||||
maxlength="100"
|
||||
placeholder="Update username"
|
||||
value=${username}
|
||||
onKeydown=${this.handleKeydown}
|
||||
ref=${this.textInput}
|
||||
/>
|
||||
<button id="button-update-username" onClick=${this.handleUpdateUsername} type="button" class="bg-blue-500 hover:bg-blue-700 text-white text-xs uppercase p-1 mx-1 rounded cursor-pointer user-btn">Update</button>
|
||||
|
||||
<button id="button-cancel-change" onClick=${this.handleHideForm} type="button" class="bg-gray-900 hover:bg-gray-800 py-1 px-2 mx-1 rounded cursor-pointer user-btn text-white text-xs uppercase text-opacity-50" title="cancel">X</button>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
}
|
||||
@ -17,7 +17,6 @@ const VIDEO_OPTIONS = {
|
||||
vhs: {
|
||||
// used to select the lowest bitrate playlist initially. This helps to decrease playback start time. This setting is false by default.
|
||||
enableLowInitialPlaylist: true,
|
||||
|
||||
}
|
||||
},
|
||||
liveTracker: {
|
||||
@ -26,6 +25,8 @@ const VIDEO_OPTIONS = {
|
||||
sources: [VIDEO_SRC],
|
||||
};
|
||||
|
||||
export const POSTER_DEFAULT = `/img/logo.png`;
|
||||
export const POSTER_THUMB = `/thumbnail.jpg`;
|
||||
|
||||
class OwncastPlayer {
|
||||
constructor() {
|
||||
@ -125,27 +126,25 @@ class OwncastPlayer {
|
||||
if (window.WebKitPlaybackTargetAvailabilityEvent) {
|
||||
var videoJsButtonClass = videojs.getComponent('Button');
|
||||
var concreteButtonClass = videojs.extend(videoJsButtonClass, {
|
||||
|
||||
// The `init()` method will also work for constructor logic here, but it is
|
||||
|
||||
// The `init()` method will also work for constructor logic here, but it is
|
||||
// deprecated. If you provide an `init()` method, it will override the
|
||||
// `constructor()` method!
|
||||
constructor: function () {
|
||||
videoJsButtonClass.call(this, player);
|
||||
}, // notice the comma
|
||||
|
||||
},
|
||||
|
||||
handleClick: function () {
|
||||
const videoElement = document.getElementsByTagName('video')[0];
|
||||
videoElement.webkitShowPlaybackTargetPicker();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
var concreteButtonInstance = this.vjsPlayer.controlBar.addChild(new concreteButtonClass());
|
||||
concreteButtonInstance.addClass("vjs-airplay");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
export { OwncastPlayer };
|
||||
42
webroot/js/components/social.js
Normal file
@ -0,0 +1,42 @@
|
||||
import { h } from 'https://unpkg.com/preact?module';
|
||||
import htm from 'https://unpkg.com/htm?module';
|
||||
const html = htm.bind(h);
|
||||
import { SOCIAL_PLATFORMS } from '../utils/social.js';
|
||||
import { classNames } from '../utils/helpers.js';
|
||||
|
||||
export default function SocialIcon(props) {
|
||||
const { platform, url } = props;
|
||||
const platformInfo = SOCIAL_PLATFORMS[platform.toLowerCase()];
|
||||
const inList = !!platformInfo;
|
||||
const imgRow = inList ? platformInfo.imgPos[0] : 0;
|
||||
const imgCol = inList ? platformInfo.imgPos[1] : 0;
|
||||
|
||||
const name = inList ? platformInfo.name : platform;
|
||||
|
||||
const style = `--imgRow: -${imgRow}; --imgCol: -${imgCol};`;
|
||||
const itemClass = classNames({
|
||||
"user-social-item": true,
|
||||
"flex": true,
|
||||
"justify-start": true,
|
||||
"items-center": true,
|
||||
"-mr-1": true,
|
||||
"use-default": !inList,
|
||||
});
|
||||
const labelClass = classNames({
|
||||
"platform-label": true,
|
||||
"visually-hidden": inList,
|
||||
"text-indigo-800": true,
|
||||
"text-xs": true,
|
||||
"uppercase": true,
|
||||
"max-w-xs": true,
|
||||
"inline-block": true,
|
||||
});
|
||||
|
||||
return (
|
||||
html`
|
||||
<a class=${itemClass} target="_blank" href=${url}>
|
||||
<span class="platform-icon rounded-lg bg-no-repeat" style=${style}></span>
|
||||
<span class=${labelClass}>Find me on ${name}</span>
|
||||
</a>
|
||||
`);
|
||||
}
|
||||
@ -1,41 +0,0 @@
|
||||
import { EmojiButton } from 'https://cdn.skypack.dev/@joeattardi/emoji-button'
|
||||
|
||||
fetch('/emoji')
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Network response was not ok ${response.ok}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(json => {
|
||||
setupEmojiPickerWithCustomEmoji(json);
|
||||
})
|
||||
.catch(error => {
|
||||
this.handleNetworkingError(`Emoji Fetch: ${error}`);
|
||||
});
|
||||
|
||||
function setupEmojiPickerWithCustomEmoji(customEmoji) {
|
||||
const picker = new EmojiButton({
|
||||
zIndex: 100,
|
||||
theme: 'dark',
|
||||
custom: customEmoji,
|
||||
initialCategory: 'custom',
|
||||
showPreview: false,
|
||||
position: {
|
||||
top: '50%',
|
||||
right: '100'
|
||||
}
|
||||
});
|
||||
const trigger = document.querySelector('#emoji-button');
|
||||
|
||||
trigger.addEventListener('click', () => picker.togglePicker(picker));
|
||||
picker.on('emoji', emoji => {
|
||||
if (emoji.url) {
|
||||
const url = location.protocol + "//" + location.host + "/" + emoji.url;
|
||||
const name = url.split('\\').pop().split('/').pop();
|
||||
document.querySelector('#message-body-form').innerHTML += "<img class=\"emoji\" alt=\"" + name + "\" src=\"" + url + "\"/>";
|
||||
} else {
|
||||
document.querySelector('#message-body-form').innerHTML += emoji.emoji;
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -1,522 +0,0 @@
|
||||
import SOCKET_MESSAGE_TYPES from './chat/socketMessageTypes.js';
|
||||
|
||||
const KEY_USERNAME = 'owncast_username';
|
||||
const KEY_AVATAR = 'owncast_avatar';
|
||||
const KEY_CHAT_DISPLAYED = 'owncast_chat';
|
||||
const KEY_CHAT_FIRST_MESSAGE_SENT = 'owncast_first_message_sent';
|
||||
const CHAT_INITIAL_PLACEHOLDER_TEXT = 'Type here to chat, no account necessary.';
|
||||
const CHAT_PLACEHOLDER_TEXT = 'Message';
|
||||
const CHAT_PLACEHOLDER_OFFLINE = 'Chat is offline.';
|
||||
|
||||
class Message {
|
||||
constructor(model) {
|
||||
this.author = model.author;
|
||||
this.body = model.body;
|
||||
this.image = model.image || generateAvatar(model.author);
|
||||
this.id = model.id;
|
||||
this.type = model.type;
|
||||
}
|
||||
|
||||
formatText() {
|
||||
showdown.setFlavor('github');
|
||||
let formattedText = new showdown.Converter({
|
||||
emoji: true,
|
||||
openLinksInNewWindow: true,
|
||||
tables: false,
|
||||
simplifiedAutoLink: false,
|
||||
literalMidWordUnderscores: true,
|
||||
strikethrough: true,
|
||||
ghMentions: false,
|
||||
}).makeHtml(this.body);
|
||||
|
||||
formattedText = this.linkify(formattedText, this.body);
|
||||
formattedText = this.highlightUsername(formattedText);
|
||||
|
||||
return addNewlines(formattedText);
|
||||
}
|
||||
|
||||
// TODO: Move this into a util function once we can organize code
|
||||
// and split things up.
|
||||
linkify(text, rawText) {
|
||||
const urls = getURLs(stripTags(rawText));
|
||||
if (urls) {
|
||||
urls.forEach(function (url) {
|
||||
let linkURL = url;
|
||||
|
||||
// Add http prefix if none exist in the URL so it actually
|
||||
// will work in an anchor tag.
|
||||
if (linkURL.indexOf('http') === -1) {
|
||||
linkURL = 'http://' + linkURL;
|
||||
}
|
||||
|
||||
// Remove the protocol prefix in the display URLs just to make
|
||||
// things look a little nicer.
|
||||
const displayURL = url.replace(/(^\w+:|^)\/\//, '');
|
||||
const link = `<a href="${linkURL}" target="_blank">${displayURL}</a>`;
|
||||
text = text.replace(url, link);
|
||||
|
||||
if (getYoutubeIdFromURL(url)) {
|
||||
if (this.isTextJustURLs(text, [url, displayURL])) {
|
||||
text = '';
|
||||
} else {
|
||||
text += '<br/>';
|
||||
}
|
||||
|
||||
const youtubeID = getYoutubeIdFromURL(url);
|
||||
text += getYoutubeEmbedFromID(youtubeID);
|
||||
} else if (url.indexOf('instagram.com/p/') > -1) {
|
||||
if (this.isTextJustURLs(text, [url, displayURL])) {
|
||||
text = '';
|
||||
} else {
|
||||
text += `<br/>`;
|
||||
}
|
||||
text += getInstagramEmbedFromURL(url);
|
||||
} else if (isImage(url)) {
|
||||
if (this.isTextJustURLs(text, [url, displayURL])) {
|
||||
text = '';
|
||||
} else {
|
||||
text += `<br/>`;
|
||||
}
|
||||
text += getImageForURL(url);
|
||||
}
|
||||
}.bind(this));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
isTextJustURLs(text, urls) {
|
||||
for (var i = 0; i < urls.length; i++) {
|
||||
const url = urls[i];
|
||||
if (stripTags(text) === url) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
userColor() {
|
||||
return messageBubbleColorForString(this.author);
|
||||
}
|
||||
|
||||
highlightUsername(message) {
|
||||
const username = document.getElementById('self-message-author').value;
|
||||
const pattern = new RegExp('@?' + username.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'), 'gi');
|
||||
return message.replace(pattern, '<span class="highlighted">$&</span>');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
class MessagingInterface {
|
||||
constructor() {
|
||||
this.chatDisplayed = false;
|
||||
this.username = '';
|
||||
this.messageCharCount = 0;
|
||||
this.maxMessageLength = 500;
|
||||
this.maxMessageBuffer = 20;
|
||||
this.chatUsernames = [];
|
||||
|
||||
this.onReceivedMessages = this.onReceivedMessages.bind(this);
|
||||
this.disableChat = this.disableChat.bind(this);
|
||||
this.enableChat = this.enableChat.bind(this);
|
||||
}
|
||||
|
||||
init() {
|
||||
this.tagAppContainer = document.getElementById('app-container');
|
||||
this.tagChatToggle = document.getElementById('chat-toggle');
|
||||
this.tagUserInfoChanger = document.getElementById('user-info-change');
|
||||
this.tagUsernameDisplay = document.getElementById('username-display');
|
||||
this.tagMessageFormWarning = document.getElementById('message-form-warning');
|
||||
|
||||
this.inputMessageAuthor = document.getElementById('self-message-author');
|
||||
this.inputChangeUserName = document.getElementById('username-change-input');
|
||||
|
||||
this.btnUpdateUserName = document.getElementById('button-update-username');
|
||||
this.btnCancelUpdateUsername = document.getElementById('button-cancel-change');
|
||||
this.btnSubmitMessage = document.getElementById('button-submit-message');
|
||||
|
||||
this.formMessageInput = document.getElementById('message-body-form');
|
||||
|
||||
this.imgUsernameAvatar = document.getElementById('username-avatar');
|
||||
this.textUserInfoDisplay = document.getElementById('user-info-display');
|
||||
|
||||
this.scrollableMessagesContainer = document.getElementById('messages-container');
|
||||
|
||||
// add events
|
||||
this.tagChatToggle.addEventListener('click', this.handleChatToggle.bind(this));
|
||||
this.textUserInfoDisplay.addEventListener('click', this.handleShowChangeNameForm.bind(this));
|
||||
|
||||
this.btnUpdateUserName.addEventListener('click', this.handleUpdateUsername.bind(this));
|
||||
this.btnCancelUpdateUsername.addEventListener('click', this.handleHideChangeNameForm.bind(this));
|
||||
|
||||
this.inputChangeUserName.addEventListener('keydown', this.handleUsernameKeydown.bind(this));
|
||||
this.formMessageInput.addEventListener('keydown', this.handleMessageInputKeydown.bind(this));
|
||||
this.formMessageInput.addEventListener('keyup', this.handleMessageInputKeyup.bind(this));
|
||||
this.formMessageInput.addEventListener('blur', this.handleMessageInputBlur.bind(this));
|
||||
this.btnSubmitMessage.addEventListener('click', this.handleSubmitChatButton.bind(this));
|
||||
|
||||
this.initLocalStates();
|
||||
|
||||
if (hasTouchScreen()) {
|
||||
setVHvar();
|
||||
window.addEventListener("orientationchange", setVHvar);
|
||||
this.tagAppContainer.classList.add('touch-screen');
|
||||
}
|
||||
}
|
||||
|
||||
initLocalStates() {
|
||||
this.username = getLocalStorage(KEY_USERNAME) || generateUsername();
|
||||
this.imgUsernameAvatar.src =
|
||||
getLocalStorage(KEY_AVATAR) || generateAvatar(`${this.username}${Date.now()}`);
|
||||
this.updateUsernameFields(this.username);
|
||||
|
||||
this.chatDisplayed = getLocalStorage(KEY_CHAT_DISPLAYED) || true;
|
||||
this.displayChat();
|
||||
this.disableChat(); // Disabled by default.
|
||||
}
|
||||
|
||||
updateUsernameFields(username) {
|
||||
this.tagUsernameDisplay.innerText = username;
|
||||
this.inputChangeUserName.value = username;
|
||||
this.inputMessageAuthor.value = username;
|
||||
}
|
||||
|
||||
displayChat() {
|
||||
if (this.chatDisplayed) {
|
||||
this.tagAppContainer.classList.add('chat');
|
||||
this.tagAppContainer.classList.remove('no-chat');
|
||||
jumpToBottom(this.scrollableMessagesContainer);
|
||||
} else {
|
||||
this.tagAppContainer.classList.add('no-chat');
|
||||
this.tagAppContainer.classList.remove('chat');
|
||||
}
|
||||
this.setChatPlaceholderText();
|
||||
}
|
||||
|
||||
|
||||
handleChatToggle() {
|
||||
this.chatDisplayed = !this.chatDisplayed;
|
||||
if (this.chatDisplayed) {
|
||||
setLocalStorage(KEY_CHAT_DISPLAYED, this.chatDisplayed);
|
||||
} else {
|
||||
clearLocalStorage(KEY_CHAT_DISPLAYED);
|
||||
}
|
||||
this.displayChat();
|
||||
}
|
||||
|
||||
handleShowChangeNameForm() {
|
||||
this.textUserInfoDisplay.style.display = 'none';
|
||||
this.tagUserInfoChanger.style.display = 'flex';
|
||||
if (document.body.clientWidth < 640) {
|
||||
this.tagChatToggle.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
handleHideChangeNameForm() {
|
||||
this.textUserInfoDisplay.style.display = 'flex';
|
||||
this.tagUserInfoChanger.style.display = 'none';
|
||||
if (document.body.clientWidth < 640) {
|
||||
this.tagChatToggle.style.display = 'inline-block';
|
||||
}
|
||||
}
|
||||
|
||||
handleUpdateUsername() {
|
||||
const oldName = this.username;
|
||||
var newValue = this.inputChangeUserName.value;
|
||||
newValue = newValue.trim();
|
||||
// do other string cleanup?
|
||||
|
||||
if (newValue) {
|
||||
this.username = newValue;
|
||||
this.updateUsernameFields(newValue);
|
||||
this.imgUsernameAvatar.src = generateAvatar(`${newValue}${Date.now()}`);
|
||||
setLocalStorage(KEY_USERNAME, newValue);
|
||||
setLocalStorage(KEY_AVATAR, this.imgUsernameAvatar.src);
|
||||
}
|
||||
this.handleHideChangeNameForm();
|
||||
|
||||
if (oldName !== newValue) {
|
||||
this.sendUsernameChange(oldName, newValue, this.imgUsernameAvatar.src);
|
||||
}
|
||||
}
|
||||
|
||||
handleUsernameKeydown(event) {
|
||||
if (event.keyCode === 13) { // enter
|
||||
this.handleUpdateUsername();
|
||||
} else if (event.keyCode === 27) { // esc
|
||||
this.handleHideChangeNameForm();
|
||||
}
|
||||
}
|
||||
|
||||
sendUsernameChange(oldName, newName, image) {
|
||||
const nameChange = {
|
||||
type: SOCKET_MESSAGE_TYPES.NAME_CHANGE,
|
||||
oldName: oldName,
|
||||
newName: newName,
|
||||
image: image,
|
||||
};
|
||||
|
||||
this.send(nameChange);
|
||||
}
|
||||
|
||||
tryToComplete() {
|
||||
const rawValue = this.formMessageInput.innerHTML;
|
||||
const position = getCaretPosition(this.formMessageInput);
|
||||
const at = rawValue.lastIndexOf('@', position - 1);
|
||||
|
||||
if (at === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var partial = rawValue.substring(at + 1, position).trim();
|
||||
|
||||
if (partial === this.suggestion) {
|
||||
partial = this.partial;
|
||||
} else {
|
||||
this.partial = partial;
|
||||
}
|
||||
|
||||
const possibilities = this.chatUsernames.filter(function (username) {
|
||||
return username.toLowerCase().startsWith(partial.toLowerCase());
|
||||
});
|
||||
|
||||
if (this.completionIndex === undefined || ++this.completionIndex >= possibilities.length) {
|
||||
this.completionIndex = 0;
|
||||
}
|
||||
|
||||
if (possibilities.length > 0) {
|
||||
this.suggestion = possibilities[this.completionIndex];
|
||||
|
||||
// TODO: Fix the space not working. I'm guessing because the DOM ignores spaces and it requires a nbsp or something?
|
||||
this.formMessageInput.innerHTML = rawValue.substring(0, at + 1) + this.suggestion + ' ' + rawValue.substring(position);
|
||||
setCaretPosition(this.formMessageInput, at + this.suggestion.length + 2);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
handleMessageInputKeydown(event) {
|
||||
var okCodes = [37,38,39,40,16,91,18,46,8];
|
||||
var value = this.formMessageInput.innerHTML.trim();
|
||||
var numCharsLeft = this.maxMessageLength - value.length;
|
||||
if (event.keyCode === 13) { // enter
|
||||
if (!this.prepNewLine) {
|
||||
this.submitChat(value);
|
||||
event.preventDefault();
|
||||
this.prepNewLine = false;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (event.keyCode === 16 || event.keyCode === 17) { // ctrl, shift
|
||||
this.prepNewLine = true;
|
||||
}
|
||||
if (event.keyCode === 9) { // tab
|
||||
if (this.tryToComplete()) {
|
||||
event.preventDefault();
|
||||
|
||||
// value could have been changed, update variables
|
||||
value = this.formMessageInput.innerHTML.trim();
|
||||
numCharsLeft = this.maxMessageLength - value.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (numCharsLeft <= this.maxMessageBuffer) {
|
||||
this.tagMessageFormWarning.innerText = `${numCharsLeft} chars left`;
|
||||
if (numCharsLeft <= 0 && !okCodes.includes(event.keyCode)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
this.tagMessageFormWarning.innerText = '';
|
||||
}
|
||||
}
|
||||
|
||||
handleMessageInputKeyup(event) {
|
||||
if (event.keyCode === 16 || event.keyCode === 17) { // ctrl, shift
|
||||
this.prepNewLine = false;
|
||||
}
|
||||
}
|
||||
|
||||
handleMessageInputBlur(event) {
|
||||
this.prepNewLine = false;
|
||||
}
|
||||
|
||||
handleSubmitChatButton(event) {
|
||||
var value = this.formMessageInput.innerHTML.trim();
|
||||
if (value) {
|
||||
this.submitChat(value);
|
||||
event.preventDefault();
|
||||
return false;
|
||||
}
|
||||
event.preventDefault();
|
||||
return false;
|
||||
}
|
||||
|
||||
submitChat(content) {
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
var message = new Message({
|
||||
body: content,
|
||||
author: this.username,
|
||||
image: this.imgUsernameAvatar.src,
|
||||
type: SOCKET_MESSAGE_TYPES.CHAT,
|
||||
});
|
||||
this.send(message);
|
||||
|
||||
// clear out things.
|
||||
this.formMessageInput.innerHTML = '';
|
||||
this.tagMessageFormWarning.innerText = '';
|
||||
|
||||
const hasSentFirstChatMessage = getLocalStorage(KEY_CHAT_FIRST_MESSAGE_SENT);
|
||||
if (!hasSentFirstChatMessage) {
|
||||
setLocalStorage(KEY_CHAT_FIRST_MESSAGE_SENT, true);
|
||||
this.setChatPlaceholderText();
|
||||
}
|
||||
}
|
||||
|
||||
disableChat() {
|
||||
if (this.formMessageInput) {
|
||||
this.formMessageInput.contentEditable = false;
|
||||
this.formMessageInput.innerHTML = '';
|
||||
this.formMessageInput.setAttribute("placeholder", CHAT_PLACEHOLDER_OFFLINE);
|
||||
}
|
||||
}
|
||||
|
||||
enableChat() {
|
||||
if (this.formMessageInput) {
|
||||
this.formMessageInput.contentEditable = true;
|
||||
this.setChatPlaceholderText();
|
||||
}
|
||||
}
|
||||
|
||||
setChatPlaceholderText() {
|
||||
// NOTE: This is a fake placeholder that is being styled via CSS.
|
||||
// You can't just set the .placeholder property because it's not a form element.
|
||||
const hasSentFirstChatMessage = getLocalStorage(KEY_CHAT_FIRST_MESSAGE_SENT);
|
||||
const placeholderText = hasSentFirstChatMessage ? CHAT_PLACEHOLDER_TEXT : CHAT_INITIAL_PLACEHOLDER_TEXT;
|
||||
this.formMessageInput.setAttribute("placeholder", placeholderText);
|
||||
}
|
||||
|
||||
// handle Vue.js message display
|
||||
onReceivedMessages(newMessages, oldMessages) {
|
||||
// update the list of chat usernames
|
||||
newMessages.slice(oldMessages.length).forEach(function (message) {
|
||||
var username;
|
||||
|
||||
switch (message.type) {
|
||||
case SOCKET_MESSAGE_TYPES.CHAT:
|
||||
username = message.author;
|
||||
break;
|
||||
|
||||
case SOCKET_MESSAGE_TYPES.NAME_CHANGE:
|
||||
username = message.newName;
|
||||
break;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.chatUsernames.includes(username)) {
|
||||
this.chatUsernames.push(username);
|
||||
}
|
||||
}, this);
|
||||
|
||||
if (newMessages.length !== oldMessages.length) {
|
||||
// jump to bottom
|
||||
jumpToBottom(this.scrollableMessagesContainer);
|
||||
}
|
||||
}
|
||||
|
||||
send(messageJSON) {
|
||||
console.error('MessagingInterface send() is not linked to the websocket component.');
|
||||
}
|
||||
}
|
||||
|
||||
export { Message, MessagingInterface }
|
||||
|
||||
function stripTags(str) {
|
||||
return str.replace(/<\/?[^>]+(>|$)/g, "");
|
||||
}
|
||||
|
||||
function getURLs(str) {
|
||||
var exp = /((\w+:\/\/\S+)|(\w+[\.:]\w+\S+))[^\s,\.]/ig;
|
||||
return str.match(exp);
|
||||
}
|
||||
|
||||
function getYoutubeIdFromURL(url) {
|
||||
try {
|
||||
var regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/;
|
||||
var match = url.match(regExp);
|
||||
|
||||
if (match && match[2].length == 11) {
|
||||
return match[2];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getYoutubeEmbedFromID(id) {
|
||||
return `<iframe class="chat-embed" src="//www.youtube.com/embed/${id}" frameborder="0" allowfullscreen></iframe>`;
|
||||
}
|
||||
|
||||
function getInstagramEmbedFromURL(url) {
|
||||
const urlObject = new URL(url.replace(/\/$/, ""));
|
||||
urlObject.pathname += "/embed";
|
||||
return `<iframe class="chat-embed instagram-embed" height="150px" src="${urlObject.href}" frameborder="0" allowfullscreen></iframe>`;
|
||||
}
|
||||
|
||||
function isImage(url) {
|
||||
const re = /\.(jpe?g|png|gif)$/;
|
||||
const isImage = re.test(url);
|
||||
return isImage;
|
||||
}
|
||||
|
||||
function getImageForURL(url) {
|
||||
return `<a target="_blank" href="${url}"><img class="embedded-image" src="${url}" width="100%" height="150px"/></a>`;
|
||||
}
|
||||
|
||||
|
||||
// Taken from https://stackoverflow.com/questions/3972014/get-contenteditable-caret-index-position
|
||||
function getCaretPosition(editableDiv) {
|
||||
var caretPos = 0,
|
||||
sel, range;
|
||||
if (window.getSelection) {
|
||||
sel = window.getSelection();
|
||||
if (sel.rangeCount) {
|
||||
range = sel.getRangeAt(0);
|
||||
if (range.commonAncestorContainer.parentNode == editableDiv) {
|
||||
caretPos = range.endOffset;
|
||||
}
|
||||
}
|
||||
} else if (document.selection && document.selection.createRange) {
|
||||
range = document.selection.createRange();
|
||||
if (range.parentElement() == editableDiv) {
|
||||
var tempEl = document.createElement("span");
|
||||
editableDiv.insertBefore(tempEl, editableDiv.firstChild);
|
||||
var tempRange = range.duplicate();
|
||||
tempRange.moveToElementText(tempEl);
|
||||
tempRange.setEndPoint("EndToEnd", range);
|
||||
caretPos = tempRange.text.length;
|
||||
}
|
||||
}
|
||||
return caretPos;
|
||||
}
|
||||
|
||||
// Pieced together from parts of https://stackoverflow.com/questions/6249095/how-to-set-caretcursor-position-in-contenteditable-element-div
|
||||
function setCaretPosition(editableDiv, position) {
|
||||
var range = document.createRange();
|
||||
var sel = window.getSelection();
|
||||
range.selectNode(editableDiv);
|
||||
range.setStart(editableDiv.childNodes[0], position);
|
||||
range.collapse(true);
|
||||
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
@ -1,127 +0,0 @@
|
||||
const SOCIAL_PLATFORMS = {
|
||||
default: {
|
||||
name: "default",
|
||||
imgPos: [0,0], // [row,col]
|
||||
},
|
||||
|
||||
facebook: {
|
||||
name: "Facebook",
|
||||
imgPos: [0,1],
|
||||
},
|
||||
twitter: {
|
||||
name: "Twitter",
|
||||
imgPos: [0,2],
|
||||
},
|
||||
instagram: {
|
||||
name: "Instagram",
|
||||
imgPos: [0,3],
|
||||
},
|
||||
snapchat: {
|
||||
name: "Snapchat",
|
||||
imgPos: [0,4],
|
||||
},
|
||||
tiktok: {
|
||||
name: "TikTok",
|
||||
imgPos: [0,5],
|
||||
},
|
||||
soundcloud: {
|
||||
name: "Soundcloud",
|
||||
imgPos: [0,6],
|
||||
},
|
||||
bandcamp: {
|
||||
name: "Bandcamp",
|
||||
imgPos: [0,7],
|
||||
},
|
||||
patreon: {
|
||||
name: "Patreon",
|
||||
imgPos: [0,1],
|
||||
},
|
||||
youtube: {
|
||||
name: "YouTube",
|
||||
imgPos: [0,9 ],
|
||||
},
|
||||
spotify: {
|
||||
name: "Spotify",
|
||||
imgPos: [0,10],
|
||||
},
|
||||
twitch: {
|
||||
name: "Twitch",
|
||||
imgPos: [0,11],
|
||||
},
|
||||
paypal: {
|
||||
name: "Paypal",
|
||||
imgPos: [0,12],
|
||||
},
|
||||
github: {
|
||||
name: "Github",
|
||||
imgPos: [0,13],
|
||||
},
|
||||
linkedin: {
|
||||
name: "LinkedIn",
|
||||
imgPos: [0,14],
|
||||
},
|
||||
discord: {
|
||||
name: "Discord",
|
||||
imgPos: [0,15],
|
||||
},
|
||||
mastodon: {
|
||||
name: "Mastodon",
|
||||
imgPos: [0,16],
|
||||
},
|
||||
};
|
||||
|
||||
Vue.component('social-list', {
|
||||
props: ['platforms'],
|
||||
|
||||
template: `
|
||||
<ul class="social-list flex" v-if="this.platforms.length">
|
||||
<span class="follow-label">Follow me: </span>
|
||||
<user-social-icon
|
||||
v-for="(item, index) in this.platforms"
|
||||
v-if="item.platform && item.url"
|
||||
v-bind:key="index"
|
||||
v-bind:platform="item.platform"
|
||||
v-bind:url="item.url"
|
||||
/>
|
||||
</ul>
|
||||
`,
|
||||
|
||||
});
|
||||
|
||||
Vue.component('user-social-icon', {
|
||||
props: ['platform', 'url'],
|
||||
data: function() {
|
||||
const platformInfo = SOCIAL_PLATFORMS[this.platform.toLowerCase()];
|
||||
const inList = !!platformInfo;
|
||||
const imgRow = inList ? platformInfo.imgPos[0] : 0;
|
||||
const imgCol = inList ? platformInfo.imgPos[1] : 0;
|
||||
return {
|
||||
name: inList ? platformInfo.name : this.platform,
|
||||
link: this.url,
|
||||
|
||||
style: `--imgRow: -${imgRow}; --imgCol: -${imgCol};`,
|
||||
itemClass: {
|
||||
"user-social-item": true,
|
||||
"flex": true,
|
||||
"use-default": !inList,
|
||||
},
|
||||
labelClass: {
|
||||
"platform-label": true,
|
||||
"visually-hidden": inList,
|
||||
"text-indigo-800": true,
|
||||
},
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<li>
|
||||
<a
|
||||
v-bind:class="itemClass"
|
||||
target="_blank"
|
||||
:href="link"
|
||||
>
|
||||
<span class="platform-icon rounded-lg" :style="style" />
|
||||
<span v-bind:class="labelClass">Find me on {{platform}}</span>
|
||||
</a>
|
||||
</li>
|
||||
`,
|
||||
});
|
||||
@ -1,88 +0,0 @@
|
||||
function getHashFromString(string) {
|
||||
let hash = 1;
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
const codepoint = string.charCodeAt(i);
|
||||
hash *= codepoint;
|
||||
}
|
||||
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
function digitsFromNumber(number) {
|
||||
const numberString = number.toString();
|
||||
let digits = [];
|
||||
|
||||
for (let i = 0, len = numberString.length; i < len; i += 1) {
|
||||
digits.push(numberString.charAt(i));
|
||||
}
|
||||
|
||||
return digits;
|
||||
}
|
||||
|
||||
// function avatarFromString(string) {
|
||||
// const hash = getHashFromString(string);
|
||||
// const digits = digitsFromNumber(hash);
|
||||
// // eslint-disable-next-line
|
||||
// const sum = digits.reduce(function (total, number) {
|
||||
// return total + number;
|
||||
// });
|
||||
// const sumDigits = digitsFromNumber(sum);
|
||||
// const first = sumDigits[0];
|
||||
// const second = sumDigits[1];
|
||||
// let filename = '/avatars/';
|
||||
|
||||
// // eslint-disable-next-line
|
||||
// if (first == 1 || first == 2) {
|
||||
// filename += '1' + second.toString();
|
||||
// // eslint-disable-next-line
|
||||
// } else if (first == 3 || first == 4) {
|
||||
// filename += '2' + second.toString();
|
||||
// // eslint-disable-next-line
|
||||
// } else if (first == 5 || first == 6) {
|
||||
// filename += '3' + second.toString();
|
||||
// // eslint-disable-next-line
|
||||
// } else if (first == 7 || first == 8) {
|
||||
// filename += '4' + second.toString();
|
||||
// } else {
|
||||
// filename += '5';
|
||||
// }
|
||||
|
||||
// return filename + '.svg';
|
||||
// }
|
||||
|
||||
function colorForString(str) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
// eslint-disable-next-line
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
let colour = '#';
|
||||
for (let i = 0; i < 3; i++) {
|
||||
// eslint-disable-next-line
|
||||
let value = (hash >> (i * 8)) & 0xff;
|
||||
colour += ('00' + value.toString(16)).substr(-2);
|
||||
}
|
||||
return colour;
|
||||
}
|
||||
|
||||
function messageBubbleColorForString(str) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
// eslint-disable-next-line
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
let color = '#';
|
||||
for (let i = 0; i < 3; i++) {
|
||||
// eslint-disable-next-line
|
||||
let value = (hash >> (i * 8)) & 0xff;
|
||||
color += ('00' + value.toString(16)).substr(-2);
|
||||
}
|
||||
// Convert to RGBA
|
||||
let result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color);
|
||||
let rgb = result ? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
} : null;
|
||||
return 'rgba(' + rgb.r + ',' + rgb.g + ',' + rgb.b + ', 0.4)';
|
||||
}
|
||||
193
webroot/js/utils/chat.js
Normal file
@ -0,0 +1,193 @@
|
||||
import { addNewlines } from './helpers.js';
|
||||
import {
|
||||
CHAT_INITIAL_PLACEHOLDER_TEXT,
|
||||
CHAT_PLACEHOLDER_TEXT,
|
||||
CHAT_PLACEHOLDER_OFFLINE,
|
||||
} from './constants.js';
|
||||
|
||||
export function formatMessageText(message, username) {
|
||||
showdown.setFlavor('github');
|
||||
let formattedText = new showdown.Converter({
|
||||
emoji: true,
|
||||
openLinksInNewWindow: true,
|
||||
tables: false,
|
||||
simplifiedAutoLink: false,
|
||||
literalMidWordUnderscores: true,
|
||||
strikethrough: true,
|
||||
ghMentions: false,
|
||||
}).makeHtml(message);
|
||||
|
||||
formattedText = linkify(formattedText, message);
|
||||
formattedText = highlightUsername(formattedText, username);
|
||||
|
||||
return addNewlines(formattedText);
|
||||
}
|
||||
|
||||
function highlightUsername(message, username) {
|
||||
const pattern = new RegExp('@?' + username.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'), 'gi');
|
||||
return message.replace(pattern, '<span class="highlighted font-bold bg-orange-500">$&</span>');
|
||||
}
|
||||
|
||||
function linkify(text, rawText) {
|
||||
const urls = getURLs(stripTags(rawText));
|
||||
if (urls) {
|
||||
urls.forEach(function (url) {
|
||||
let linkURL = url;
|
||||
|
||||
// Add http prefix if none exist in the URL so it actually
|
||||
// will work in an anchor tag.
|
||||
if (linkURL.indexOf('http') === -1) {
|
||||
linkURL = 'http://' + linkURL;
|
||||
}
|
||||
|
||||
// Remove the protocol prefix in the display URLs just to make
|
||||
// things look a little nicer.
|
||||
const displayURL = url.replace(/(^\w+:|^)\/\//, '');
|
||||
const link = `<a href="${linkURL}" target="_blank">${displayURL}</a>`;
|
||||
text = text.replace(url, link);
|
||||
|
||||
if (getYoutubeIdFromURL(url)) {
|
||||
if (isTextJustURLs(text, [url, displayURL])) {
|
||||
text = '';
|
||||
} else {
|
||||
text += '<br/>';
|
||||
}
|
||||
|
||||
const youtubeID = getYoutubeIdFromURL(url);
|
||||
text += getYoutubeEmbedFromID(youtubeID);
|
||||
} else if (url.indexOf('instagram.com/p/') > -1) {
|
||||
if (isTextJustURLs(text, [url, displayURL])) {
|
||||
text = '';
|
||||
} else {
|
||||
text += `<br/>`;
|
||||
}
|
||||
text += getInstagramEmbedFromURL(url);
|
||||
} else if (isImage(url)) {
|
||||
if (isTextJustURLs(text, [url, displayURL])) {
|
||||
text = '';
|
||||
} else {
|
||||
text += `<br/>`;
|
||||
}
|
||||
text += getImageForURL(url);
|
||||
}
|
||||
}.bind(this));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function isTextJustURLs(text, urls) {
|
||||
for (var i = 0; i < urls.length; i++) {
|
||||
const url = urls[i];
|
||||
if (stripTags(text) === url) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
function stripTags(str) {
|
||||
return str.replace(/<\/?[^>]+(>|$)/g, "");
|
||||
}
|
||||
|
||||
function getURLs(str) {
|
||||
var exp = /((\w+:\/\/\S+)|(\w+[\.:]\w+\S+))[^\s,\.]/ig;
|
||||
return str.match(exp);
|
||||
}
|
||||
|
||||
function getYoutubeIdFromURL(url) {
|
||||
try {
|
||||
var regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/;
|
||||
var match = url.match(regExp);
|
||||
|
||||
if (match && match[2].length == 11) {
|
||||
return match[2];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getYoutubeEmbedFromID(id) {
|
||||
return `
|
||||
<div class="chat-embed youtube-embed">
|
||||
<lite-youtube videoid="${id}" />
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function getInstagramEmbedFromURL(url) {
|
||||
const urlObject = new URL(url.replace(/\/$/, ""));
|
||||
urlObject.pathname += "/embed";
|
||||
return `<iframe class="chat-embed instagram-embed" src="${urlObject.href}" frameborder="0" allowfullscreen></iframe>`;
|
||||
}
|
||||
|
||||
function isImage(url) {
|
||||
const re = /\.(jpe?g|png|gif)$/i;
|
||||
return re.test(url);
|
||||
}
|
||||
|
||||
function getImageForURL(url) {
|
||||
return `<a target="_blank" href="${url}"><img class="chat-embed embedded-image" src="${url}" /></a>`;
|
||||
}
|
||||
|
||||
// Taken from https://stackoverflow.com/questions/3972014/get-contenteditable-caret-index-position
|
||||
export function getCaretPosition(editableDiv) {
|
||||
var caretPos = 0,
|
||||
sel, range;
|
||||
if (window.getSelection) {
|
||||
sel = window.getSelection();
|
||||
if (sel.rangeCount) {
|
||||
range = sel.getRangeAt(0);
|
||||
if (range.commonAncestorContainer.parentNode == editableDiv) {
|
||||
caretPos = range.endOffset;
|
||||
}
|
||||
}
|
||||
} else if (document.selection && document.selection.createRange) {
|
||||
range = document.selection.createRange();
|
||||
if (range.parentElement() == editableDiv) {
|
||||
var tempEl = document.createElement("span");
|
||||
editableDiv.insertBefore(tempEl, editableDiv.firstChild);
|
||||
var tempRange = range.duplicate();
|
||||
tempRange.moveToElementText(tempEl);
|
||||
tempRange.setEndPoint("EndToEnd", range);
|
||||
caretPos = tempRange.text.length;
|
||||
}
|
||||
}
|
||||
return caretPos;
|
||||
}
|
||||
|
||||
// Might not need this anymore
|
||||
// Pieced together from parts of https://stackoverflow.com/questions/6249095/how-to-set-caretcursor-position-in-contenteditable-element-div
|
||||
export function setCaretPosition(editableDiv, position) {
|
||||
var range = document.createRange();
|
||||
var sel = window.getSelection();
|
||||
range.selectNode(editableDiv);
|
||||
range.setStart(editableDiv.childNodes[0], position);
|
||||
range.collapse(true);
|
||||
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
|
||||
export function generatePlaceholderText(isEnabled, hasSentFirstChatMessage) {
|
||||
if (isEnabled) {
|
||||
return hasSentFirstChatMessage ? CHAT_PLACEHOLDER_TEXT : CHAT_INITIAL_PLACEHOLDER_TEXT;
|
||||
}
|
||||
return CHAT_PLACEHOLDER_OFFLINE;
|
||||
}
|
||||
|
||||
export function extraUserNamesFromMessageHistory(messages) {
|
||||
const list = [];
|
||||
if (messages) {
|
||||
messages.forEach(function(message) {
|
||||
if (!list.includes(message.author)) {
|
||||
list.push(message.author);
|
||||
}
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
34
webroot/js/utils/constants.js
Normal file
@ -0,0 +1,34 @@
|
||||
// misc constants used throughout the app
|
||||
|
||||
export const URL_STATUS = `/status`;
|
||||
export const URL_CHAT_HISTORY = `/chat`;
|
||||
export const URL_CUSTOM_EMOJIS = `/emoji`;
|
||||
export const URL_CONFIG = `/config`;
|
||||
|
||||
// TODO: This directory is customizable in the config. So we should expose this via the config API.
|
||||
export const URL_STREAM = `/hls/stream.m3u8`;
|
||||
export const URL_WEBSOCKET = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/entry`;
|
||||
|
||||
export const TIMER_STATUS_UPDATE = 5000; // ms
|
||||
export const TIMER_DISABLE_CHAT_AFTER_OFFLINE = 5 * 60 * 1000; // 5 mins
|
||||
export const TIMER_STREAM_DURATION_COUNTER = 1000;
|
||||
export const TEMP_IMAGE = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||
|
||||
export const MESSAGE_OFFLINE = 'Stream is offline.';
|
||||
export const MESSAGE_ONLINE = 'Stream is online.';
|
||||
|
||||
export const URL_OWNCAST = 'https://owncast.online'; // used in footer
|
||||
|
||||
|
||||
export const KEY_USERNAME = 'owncast_username';
|
||||
export const KEY_AVATAR = 'owncast_avatar';
|
||||
export const KEY_CHAT_DISPLAYED = 'owncast_chat';
|
||||
export const KEY_CHAT_FIRST_MESSAGE_SENT = 'owncast_first_message_sent';
|
||||
export const CHAT_INITIAL_PLACEHOLDER_TEXT = 'Type here to chat, no account necessary.';
|
||||
export const CHAT_PLACEHOLDER_TEXT = 'Message';
|
||||
export const CHAT_PLACEHOLDER_OFFLINE = 'Chat is offline.';
|
||||
|
||||
|
||||
// app styling
|
||||
export const WIDTH_SINGLE_COL = 730;
|
||||
export const HEIGHT_SHORT_WIDE = 500;
|
||||
@ -1,16 +1,4 @@
|
||||
|
||||
const URL_STATUS = `/status`;
|
||||
const URL_CHAT_HISTORY = `/chat`;
|
||||
// TODO: This directory is customizable in the config. So we should expose this via the config API.
|
||||
const URL_STREAM = `/hls/stream.m3u8`;
|
||||
const URL_WEBSOCKET = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/entry`;
|
||||
|
||||
const POSTER_DEFAULT = `/img/logo.png`;
|
||||
const POSTER_THUMB = `/thumbnail.jpg`;
|
||||
|
||||
const URL_OWNCAST = 'https://github.com/gabek/owncast'; // used in footer
|
||||
|
||||
function getLocalStorage(key) {
|
||||
export function getLocalStorage(key) {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch (e) {
|
||||
@ -18,7 +6,7 @@ function getLocalStorage(key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function setLocalStorage(key, value) {
|
||||
export function setLocalStorage(key, value) {
|
||||
try {
|
||||
if (value !== "" && value !== null) {
|
||||
localStorage.setItem(key, value);
|
||||
@ -30,12 +18,12 @@ function setLocalStorage(key, value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function clearLocalStorage(key) {
|
||||
export function clearLocalStorage(key) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
// jump down to the max height of a div, with a slight delay
|
||||
function jumpToBottom(element) {
|
||||
export function jumpToBottom(element) {
|
||||
if (!element) return;
|
||||
|
||||
setTimeout(() => {
|
||||
@ -48,11 +36,11 @@ function jumpToBottom(element) {
|
||||
}
|
||||
|
||||
// convert newlines to <br>s
|
||||
function addNewlines(str) {
|
||||
export function addNewlines(str) {
|
||||
return str.replace(/(?:\r\n|\r|\n)/g, '<br />');
|
||||
}
|
||||
|
||||
function pluralize(string, count) {
|
||||
export function pluralize(string, count) {
|
||||
if (count === 1) {
|
||||
return string;
|
||||
} else {
|
||||
@ -63,45 +51,45 @@ function pluralize(string, count) {
|
||||
|
||||
// Trying to determine if browser is mobile/tablet.
|
||||
// Source: https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent
|
||||
function hasTouchScreen() {
|
||||
var hasTouchScreen = false;
|
||||
if ("maxTouchPoints" in navigator) {
|
||||
hasTouchScreen = navigator.maxTouchPoints > 0;
|
||||
export function hasTouchScreen() {
|
||||
let hasTouch = false;
|
||||
if ("maxTouchPoints" in navigator) {
|
||||
hasTouch = navigator.maxTouchPoints > 0;
|
||||
} else if ("msMaxTouchPoints" in navigator) {
|
||||
hasTouchScreen = navigator.msMaxTouchPoints > 0;
|
||||
hasTouch = navigator.msMaxTouchPoints > 0;
|
||||
} else {
|
||||
var mQ = window.matchMedia && matchMedia("(pointer:coarse)");
|
||||
if (mQ && mQ.media === "(pointer:coarse)") {
|
||||
hasTouchScreen = !!mQ.matches;
|
||||
hasTouch = !!mQ.matches;
|
||||
} else if ('orientation' in window) {
|
||||
hasTouchScreen = true; // deprecated, but good fallback
|
||||
hasTouch = true; // deprecated, but good fallback
|
||||
} else {
|
||||
// Only as a last resort, fall back to user agent sniffing
|
||||
var UA = navigator.userAgent;
|
||||
hasTouchScreen = (
|
||||
hasTouch = (
|
||||
/\b(BlackBerry|webOS|iPhone|IEMobile)\b/i.test(UA) ||
|
||||
/\b(Android|Windows Phone|iPad|iPod)\b/i.test(UA)
|
||||
);
|
||||
}
|
||||
}
|
||||
return hasTouchScreen;
|
||||
return hasTouch;
|
||||
}
|
||||
|
||||
// generate random avatar from https://robohash.org
|
||||
function generateAvatar(hash) {
|
||||
export function generateAvatar(hash) {
|
||||
const avatarSource = 'https://robohash.org/';
|
||||
const optionSize = '?size=80x80';
|
||||
const optionSet = '&set=set3';
|
||||
const optionSet = '&set=set2';
|
||||
const optionBg = ''; // or &bgset=bg1 or bg2
|
||||
|
||||
return avatarSource + hash + optionSize + optionSet + optionBg;
|
||||
}
|
||||
|
||||
function generateUsername() {
|
||||
export function generateUsername() {
|
||||
return `User ${(Math.floor(Math.random() * 42) + 1)}`;
|
||||
}
|
||||
|
||||
function secondsToHMMSS(seconds = 0) {
|
||||
export function secondsToHMMSS(seconds = 0) {
|
||||
const finiteSeconds = Number.isFinite(+seconds) ? Math.abs(seconds) : 0;
|
||||
|
||||
const hours = Math.floor(finiteSeconds / 3600);
|
||||
@ -116,13 +104,41 @@ function secondsToHMMSS(seconds = 0) {
|
||||
return hoursString + minString + secsString;
|
||||
}
|
||||
|
||||
function setVHvar() {
|
||||
export function setVHvar() {
|
||||
var vh = window.innerHeight * 0.01;
|
||||
// Then we set the value in the --vh custom property to the root of the document
|
||||
document.documentElement.style.setProperty('--vh', `${vh}px`);
|
||||
console.log("== new vh", vh)
|
||||
}
|
||||
|
||||
function doesObjectSupportFunction(object, functionName) {
|
||||
export function doesObjectSupportFunction(object, functionName) {
|
||||
return typeof object[functionName] === "function";
|
||||
}
|
||||
}
|
||||
|
||||
// return a string of css classes
|
||||
export function classNames(json) {
|
||||
const classes = [];
|
||||
|
||||
Object.entries(json).map(function(item) {
|
||||
const [ key, value ] = item;
|
||||
if (value) {
|
||||
classes.push(key);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
return classes.join(' ');
|
||||
}
|
||||
|
||||
|
||||
// taken from
|
||||
// https://medium.com/@TCAS3/debounce-deep-dive-javascript-es6-e6f8d983b7a1
|
||||
export function debounce(fn, time) {
|
||||
let timeout;
|
||||
|
||||
return function() {
|
||||
const functionCall = () => fn.apply(this, arguments);
|
||||
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(functionCall, time);
|
||||
}
|
||||
}
|
||||
72
webroot/js/utils/social.js
Normal file
@ -0,0 +1,72 @@
|
||||
// x, y pixel psitions of /img/social.gif image.
|
||||
export const SOCIAL_PLATFORMS = {
|
||||
default: {
|
||||
name: "default",
|
||||
imgPos: [0,0], // [row,col]
|
||||
},
|
||||
|
||||
facebook: {
|
||||
name: "Facebook",
|
||||
imgPos: [0,1],
|
||||
},
|
||||
twitter: {
|
||||
name: "Twitter",
|
||||
imgPos: [0,2],
|
||||
},
|
||||
instagram: {
|
||||
name: "Instagram",
|
||||
imgPos: [0,3],
|
||||
},
|
||||
snapchat: {
|
||||
name: "Snapchat",
|
||||
imgPos: [0,4],
|
||||
},
|
||||
tiktok: {
|
||||
name: "TikTok",
|
||||
imgPos: [0,5],
|
||||
},
|
||||
soundcloud: {
|
||||
name: "Soundcloud",
|
||||
imgPos: [0,6],
|
||||
},
|
||||
bandcamp: {
|
||||
name: "Bandcamp",
|
||||
imgPos: [0,7],
|
||||
},
|
||||
patreon: {
|
||||
name: "Patreon",
|
||||
imgPos: [0,1],
|
||||
},
|
||||
youtube: {
|
||||
name: "YouTube",
|
||||
imgPos: [0,9 ],
|
||||
},
|
||||
spotify: {
|
||||
name: "Spotify",
|
||||
imgPos: [0,10],
|
||||
},
|
||||
twitch: {
|
||||
name: "Twitch",
|
||||
imgPos: [0,11],
|
||||
},
|
||||
paypal: {
|
||||
name: "Paypal",
|
||||
imgPos: [0,12],
|
||||
},
|
||||
github: {
|
||||
name: "Github",
|
||||
imgPos: [0,13],
|
||||
},
|
||||
linkedin: {
|
||||
name: "LinkedIn",
|
||||
imgPos: [0,14],
|
||||
},
|
||||
discord: {
|
||||
name: "Discord",
|
||||
imgPos: [0,15],
|
||||
},
|
||||
mastodon: {
|
||||
name: "Mastodon",
|
||||
imgPos: [0,16],
|
||||
},
|
||||
};
|
||||
15
webroot/js/utils/user-colors.js
Normal file
@ -0,0 +1,15 @@
|
||||
export function messageBubbleColorForString(str) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
// eslint-disable-next-line
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
|
||||
// Tweak these to adjust the result of the color
|
||||
const saturation = 70;
|
||||
const lightness = 50;
|
||||
const alpha = 1.0;
|
||||
const hue = parseInt(Math.abs(hash), 16) % 300;
|
||||
|
||||
return `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`;
|
||||
}
|
||||
@ -1,17 +1,25 @@
|
||||
import SOCKET_MESSAGE_TYPES from './chat/socketMessageTypes.js';
|
||||
/**
|
||||
* These are the types of messages that we can handle with the websocket.
|
||||
* Mostly used by `websocket.js` but if other components need to handle
|
||||
* different types then it can import this file.
|
||||
*/
|
||||
export const SOCKET_MESSAGE_TYPES = {
|
||||
CHAT: 'CHAT',
|
||||
PING: 'PING',
|
||||
NAME_CHANGE: 'NAME_CHANGE',
|
||||
PONG: 'PONG'
|
||||
};
|
||||
|
||||
const URL_WEBSOCKET = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/entry`;
|
||||
|
||||
const TIMER_WEBSOCKET_RECONNECT = 5000; // ms
|
||||
|
||||
const CALLBACKS = {
|
||||
export const CALLBACKS = {
|
||||
RAW_WEBSOCKET_MESSAGE_RECEIVED: 'rawWebsocketMessageReceived',
|
||||
WEBSOCKET_CONNECTED: 'websocketConnected',
|
||||
WEBSOCKET_DISCONNECTED: 'websocketDisconnected',
|
||||
}
|
||||
|
||||
class Websocket {
|
||||
const URL_WEBSOCKET = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/entry`;
|
||||
const TIMER_WEBSOCKET_RECONNECT = 5000; // ms
|
||||
|
||||
export default class Websocket {
|
||||
constructor() {
|
||||
this.websocket = null;
|
||||
this.websocketReconnectTimer = null;
|
||||
@ -42,7 +50,7 @@ class Websocket {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Interface with other components
|
||||
|
||||
// Outbound: Other components can pass an object to `send`.
|
||||
@ -51,7 +59,7 @@ class Websocket {
|
||||
if (!message.type || !SOCKET_MESSAGE_TYPES[message.type]) {
|
||||
console.warn(`Outbound message: Unknown socket message type: "${message.type}" sent.`);
|
||||
}
|
||||
|
||||
|
||||
const messageJSON = JSON.stringify(message);
|
||||
this.websocket.send(messageJSON);
|
||||
}
|
||||
@ -114,7 +122,7 @@ class Websocket {
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
|
||||
|
||||
// Send PONGs
|
||||
if (model.type === SOCKET_MESSAGE_TYPES.PING) {
|
||||
this.sendPong();
|
||||
@ -133,7 +141,5 @@ class Websocket {
|
||||
|
||||
handleNetworkingError(error) {
|
||||
console.error(`Websocket Error: ${error}`)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default Websocket;
|
||||
@ -2,40 +2,40 @@
|
||||
"name": "App",
|
||||
"icons": [
|
||||
{
|
||||
"src": "\/android-icon-36x36.png",
|
||||
"src": "\/img\/favicon\/android-icon-36x36.png",
|
||||
"sizes": "36x36",
|
||||
"type": "image\/png",
|
||||
"density": "0.75"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-48x48.png",
|
||||
"src": "\/img\/favicon\/android-icon-48x48.png",
|
||||
"sizes": "48x48",
|
||||
"type": "image\/png",
|
||||
"density": "1.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-72x72.png",
|
||||
"src": "\/img\/favicon\/android-icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image\/png",
|
||||
"density": "1.5"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-96x96.png",
|
||||
"src": "\/img\/favicon\/android-icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image\/png",
|
||||
"density": "2.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-144x144.png",
|
||||
"src": "\/img\/favicon\/android-icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image\/png",
|
||||
"density": "3.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-192x192.png",
|
||||
"src": "\/img\/favicon\/android-icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image\/png",
|
||||
"density": "4.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
248
webroot/styles/app.css
Normal file
@ -0,0 +1,248 @@
|
||||
/*
|
||||
Specific styles for main app layout.
|
||||
May have overrides for other components with own stylesheets.
|
||||
*/
|
||||
|
||||
/* variables */
|
||||
:root {
|
||||
--header-height: 3.5em;
|
||||
--right-col-width: 24em;
|
||||
--video-container-height: calc((9 / 16) * 100vw);
|
||||
--header-bg-color: rgba(20,0,40,1);
|
||||
--user-image-width: 10em;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 0px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
* {
|
||||
transition: all .25s;
|
||||
}
|
||||
|
||||
button[disabled] {
|
||||
opacity: .5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute !important;
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(1px 1px 1px 1px); /* IE6, IE7 */
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
white-space: nowrap; /* added line */
|
||||
}
|
||||
|
||||
|
||||
header {
|
||||
height: var(--header-height);
|
||||
background-color: var(--header-bg-color);
|
||||
}
|
||||
|
||||
#logo-container {
|
||||
background-size: 1.35em;
|
||||
}
|
||||
|
||||
#chat-toggle {
|
||||
min-width: 3rem;
|
||||
}
|
||||
|
||||
|
||||
#user-info-change {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
#stream-info span {
|
||||
font-size: .70rem;
|
||||
}
|
||||
|
||||
|
||||
/* ************************************************ */
|
||||
|
||||
#video-container {
|
||||
height: var(--video-container-height);
|
||||
margin-top: var(--header-height);
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
min-height: 480px;
|
||||
background-size: 30%;
|
||||
}
|
||||
#video-container #video {
|
||||
transition: opacity .5s;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.online #video-container #video {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* *********** overrides when chat is off ***************************** */
|
||||
|
||||
|
||||
.no-chat footer {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.no-chat #chat-toggle {
|
||||
opacity: .75;
|
||||
}
|
||||
|
||||
.no-chat #chat-container-wrap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* *********** overrides when chat is on ***************************** */
|
||||
|
||||
.chat {
|
||||
--content-width: calc(100vw - var(--right-col-width));
|
||||
}
|
||||
.chat #chat-container-wrap {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.chat #video-container,
|
||||
.chat #stream-info,
|
||||
.chat #user-content {
|
||||
width: var(--content-width);
|
||||
}
|
||||
|
||||
.chat #video-container {
|
||||
height: calc((9 / 16) * var(--content-width));
|
||||
}
|
||||
|
||||
|
||||
|
||||
.short-wide.chat #video-container {
|
||||
height: calc(100vh - var(--header-height) - 3rem);
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.short-wide #message-input {
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/* *********** single col layout ***************************** */
|
||||
|
||||
.single-col {
|
||||
--right-col-width: 0px;
|
||||
}
|
||||
.single-col main {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 40;
|
||||
}
|
||||
.single-col #chat-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.single-col #video-container {
|
||||
min-height: auto;
|
||||
width: 100%;
|
||||
}
|
||||
.single-col #user-content,
|
||||
.single-col #chat-container-wrap {
|
||||
margin-top: calc(var(--video-container-height) + var(--header-height) + 1rem);
|
||||
}
|
||||
.single-col #user-content .user-content {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.single-col.chat #user-content {
|
||||
display: none;
|
||||
}
|
||||
.single-col #message-input-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.single-col #message-input {
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ************************************************8 */
|
||||
|
||||
|
||||
@media screen and (max-width: 860px) {
|
||||
:root {
|
||||
--right-col-width: 20em;
|
||||
--user-image-width: 6em;
|
||||
}
|
||||
}
|
||||
|
||||
/* ************************************************8 */
|
||||
|
||||
|
||||
/* single col layout */
|
||||
/* @media screen and (max-width: 640px ) {
|
||||
:root {
|
||||
--right-col-width: 0;
|
||||
--video-container-height: 40vh;
|
||||
}
|
||||
#logo-container {
|
||||
display: none;
|
||||
}
|
||||
header h1 {
|
||||
max-width: 58%;
|
||||
}
|
||||
#user-options-container {
|
||||
max-width: 41%;
|
||||
}
|
||||
|
||||
#chat-container {
|
||||
width: 100%;
|
||||
position: static;
|
||||
height: calc(100vh - var(--header-height) - var(--video-container-height) - 3vh)
|
||||
}
|
||||
#messages-container {
|
||||
min-height: unset;
|
||||
}
|
||||
#user-content {
|
||||
width: 100%;
|
||||
}
|
||||
#stream-info {
|
||||
width: 100%;
|
||||
}
|
||||
#video-container {
|
||||
width: 100%;
|
||||
}
|
||||
.chat #video-container {
|
||||
width: 100%;
|
||||
}
|
||||
.chat #user-content {
|
||||
display: none;
|
||||
}
|
||||
.chat footer {
|
||||
display: none;
|
||||
}
|
||||
} */
|
||||
|
||||
|
||||
|
||||
|
||||
/* @media screen and (max-height: 860px ) {
|
||||
:root {
|
||||
--video-container-height: 40vh;
|
||||
}
|
||||
.user-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
} */
|
||||
140
webroot/styles/chat.css
Normal file
@ -0,0 +1,140 @@
|
||||
/* some base styles for chat and messaging components */
|
||||
|
||||
#chat-container {
|
||||
position: fixed;
|
||||
z-index: 9;
|
||||
top: var(--header-height);
|
||||
right: 0;
|
||||
width: var(--right-col-width);
|
||||
|
||||
height: calc(100vh - var(--header-height));
|
||||
}
|
||||
|
||||
#message-input-container {
|
||||
width: var(--right-col-width);
|
||||
}
|
||||
|
||||
#messages-container {
|
||||
padding-bottom: 10rem;
|
||||
}
|
||||
|
||||
/******************************/
|
||||
/******************************/
|
||||
|
||||
#message-input img {
|
||||
display: inline;
|
||||
vertical-align: middle;
|
||||
padding: .25rem;
|
||||
}
|
||||
|
||||
#message-input .emoji {
|
||||
width: 2.2rem;
|
||||
padding: .25rem;
|
||||
}
|
||||
|
||||
|
||||
/* If the div is empty then show the placeholder */
|
||||
#message-input:empty:before{
|
||||
content: attr(placeholderText);
|
||||
pointer-events: none;
|
||||
display: block; /* For Firefox */
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* When chat is enabled (contenteditable=true) */
|
||||
#message-input[contenteditable=true]:before {
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
|
||||
/* When chat is disabled (contenteditable=false) chat input div should appear disabled. */
|
||||
#message-input:disabled,
|
||||
#message-input[contenteditable=false] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
/******************************/
|
||||
/******************************/
|
||||
|
||||
|
||||
.emoji-picker__emoji {
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
|
||||
.message-avatar {
|
||||
height: 3.0em;
|
||||
width: 3.0em;
|
||||
}
|
||||
.message-avatar img {
|
||||
max-width: unset;
|
||||
height: 3.0em;
|
||||
width: 3.0em;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* MESSAGE TEXT HTML */
|
||||
/* MESSAGE TEXT HTML */
|
||||
/* MESSAGE TEXT HTML */
|
||||
.message-text a {
|
||||
color: #7F9CF5; /* indigo-400 */
|
||||
}
|
||||
.message-text a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.message-text img {
|
||||
display: inline;
|
||||
padding-left: 0 .25rem;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.message-text .emoji {
|
||||
width: 3rem;
|
||||
padding: .25rem
|
||||
}
|
||||
|
||||
.message-text code {
|
||||
font-family: monospace;
|
||||
background-color:darkslategrey;
|
||||
padding: .25rem;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.message-text .chat-embed {
|
||||
width: 100%;
|
||||
border-radius: .25rem;
|
||||
}
|
||||
|
||||
.message-text .instagram-embed {
|
||||
height: 24rem;
|
||||
}
|
||||
|
||||
|
||||
.message-text .embedded-image {
|
||||
width: 100%;
|
||||
display: block;
|
||||
/* height: 15rem; */
|
||||
}
|
||||
|
||||
.message-text .youtube-embed {
|
||||
width: 100%;
|
||||
height: 12rem;
|
||||
}
|
||||
|
||||
/* MESSAGE TEXT CONTENT */
|
||||
/* MESSAGE TEXT CONTENT */
|
||||
/* MESSAGE TEXT CONTENT */
|
||||
/* MESSAGE TEXT CONTENT */
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1,3 +1,9 @@
|
||||
/*
|
||||
Overall layout styles for all of owncast app.
|
||||
|
||||
DE[RECATE THIS LAYOUT.CSS FILE.
|
||||
*/
|
||||
|
||||
/* variables */
|
||||
:root {
|
||||
--header-height: 3.5em;
|
||||
@ -7,25 +13,22 @@
|
||||
--user-image-width: 10em;
|
||||
}
|
||||
|
||||
body {
|
||||
font-size: 14px;
|
||||
html {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
/* vuejs attribute to hide things before content ready */
|
||||
[v-cloak] { visibility: hidden; }
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 0px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
|
||||
.visually-hidden {
|
||||
.visually-hidden {
|
||||
position: absolute !important;
|
||||
height: 1px;
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(1px 1px 1px 1px); /* IE6, IE7 */
|
||||
@ -33,243 +36,25 @@ a:hover {
|
||||
white-space: nowrap; /* added line */
|
||||
}
|
||||
|
||||
#app-container {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
header {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
height: var(--header-height);
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: var(--header-bg-color);
|
||||
z-index: 10;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.25em;
|
||||
font-weight: 100;
|
||||
letter-spacing: 1.2;
|
||||
text-transform: uppercase;
|
||||
padding: .5em;
|
||||
white-space: nowrap;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
}
|
||||
#logo-container{
|
||||
height: 1.75em;
|
||||
width: 1.75em;
|
||||
min-height: 1.75em;
|
||||
min-width: 1.75em;
|
||||
margin-right: .5em;
|
||||
display: inline-block;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
#logo-container {
|
||||
background-size: 1.35em;
|
||||
}
|
||||
header .instance-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#chat-toggle {
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
height: 100%;
|
||||
min-width: 3em;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-width: 3rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
font-size: .75em;
|
||||
padding: 2em;
|
||||
opacity: .5;
|
||||
}
|
||||
footer span {
|
||||
display: inline-block;
|
||||
margin: 0 1em;
|
||||
}
|
||||
|
||||
|
||||
/* ************************************************8 */
|
||||
|
||||
#stream-info {
|
||||
padding: .5em 2em;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
|
||||
}
|
||||
#stream-info span {
|
||||
font-size: .7em;
|
||||
}
|
||||
.user-content {
|
||||
padding: 2em;
|
||||
}
|
||||
/* #user-content {
|
||||
display: block;
|
||||
}
|
||||
#user-content-touch {
|
||||
display: none;
|
||||
} */
|
||||
/* ************************************************8 */
|
||||
|
||||
|
||||
.user-content {
|
||||
padding: 3em;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
.user-content .user-image {
|
||||
padding: 1em;
|
||||
margin-right: 2em;
|
||||
min-width: var(--user-image-width);
|
||||
width: var(--user-image-width);
|
||||
height: var(--user-image-width);
|
||||
max-height: var(--user-image-width);
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
background-size: calc(var(--user-image-width) - 1em);
|
||||
}
|
||||
|
||||
/* .user-image img {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
} */
|
||||
.stream-summary {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 3em;
|
||||
}
|
||||
.user-content-header {
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
flex-direction: row;
|
||||
margin: 1em 0;
|
||||
}
|
||||
.tag-list li {
|
||||
font-size: .75em;
|
||||
text-transform: uppercase;
|
||||
margin-right: .75em;
|
||||
padding: .5em;
|
||||
}
|
||||
|
||||
|
||||
.social-list {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.social-list .follow-label {
|
||||
font-weight: bold;
|
||||
font-size: .75em;
|
||||
margin-right: .5em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.user-social-item {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
margin-right: -.25em;
|
||||
}
|
||||
.user-social-item .platform-icon {
|
||||
--icon-width: 40px;
|
||||
height: var(--icon-width);
|
||||
width: var(--icon-width);
|
||||
background-image: url(../img/social-icons.gif);
|
||||
background-repeat: no-repeat;
|
||||
background-position: calc(var(--imgCol) * var(--icon-width)) calc(var(--imgRow) * var(--icon-width));
|
||||
transform: scale(.65);
|
||||
}
|
||||
|
||||
.user-social-item.use-default .platform-label {
|
||||
font-size: .7em;
|
||||
text-transform: uppercase;
|
||||
display: inline-block;
|
||||
max-width: 10em;
|
||||
}
|
||||
|
||||
|
||||
/* ************************************************8 */
|
||||
|
||||
#user-options-container {
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
#user-info-display {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: .5em 1em;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#username-avatar {
|
||||
height: 2.1em;
|
||||
width: 2.1em;
|
||||
margin-right: .5em;
|
||||
}
|
||||
#username-display {
|
||||
font-weight: 600;
|
||||
font-size: .75em;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
#user-info-display:hover {
|
||||
transition: opacity .2s;
|
||||
opacity: .75;
|
||||
}
|
||||
|
||||
|
||||
#user-info-change {
|
||||
display: none;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: .25em;
|
||||
}
|
||||
#username-change-input {
|
||||
font-size: .75em;
|
||||
}
|
||||
#button-update-username {
|
||||
font-size: .65em;
|
||||
text-transform: uppercase;
|
||||
height: 2.5em;
|
||||
}
|
||||
#button-cancel-change {
|
||||
cursor: pointer;
|
||||
height: 2.5em;
|
||||
font-size: .65em;
|
||||
}
|
||||
.user-btn {
|
||||
margin: 0 .25em;
|
||||
}
|
||||
|
||||
/* ************************************************8 */
|
||||
@ -277,20 +62,12 @@ h2 {
|
||||
|
||||
#video-container {
|
||||
height: calc(var(--video-container-height));
|
||||
width: 100%;
|
||||
margin-top: var(--header-height);
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
|
||||
background-size: 30%;
|
||||
background-size: 30%;
|
||||
}
|
||||
|
||||
.owncast-video-container {
|
||||
height: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
}
|
||||
.owncast-video-container .video-js {
|
||||
width: 100%;
|
||||
@ -304,7 +81,6 @@ h2 {
|
||||
}
|
||||
|
||||
.vjs-airplay .vjs-icon-placeholder::before {
|
||||
/* content: 'AP'; */
|
||||
content: url("../img/airplay.png");
|
||||
}
|
||||
|
||||
@ -323,16 +99,10 @@ h2 {
|
||||
/* ************************************************8 */
|
||||
|
||||
|
||||
.no-chat #chat-container-wrap {
|
||||
display: none;
|
||||
}
|
||||
.no-chat footer {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.chat #chat-container-wrap {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.chat #video-container,
|
||||
.chat #stream-info,
|
||||
@ -340,102 +110,9 @@ h2 {
|
||||
width: calc(100% - var(--right-col-width));
|
||||
}
|
||||
|
||||
|
||||
#chat-container {
|
||||
position: fixed;
|
||||
z-index: 9;
|
||||
top: var(--header-height);
|
||||
right: 0;
|
||||
width: var(--right-col-width);
|
||||
|
||||
height: calc(100vh - var(--header-height));
|
||||
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
#stream-info span {
|
||||
font-size: .70rem;
|
||||
}
|
||||
.touch-screen #chat-container {
|
||||
height: calc(100vh - var(--header-height) - 3vh);
|
||||
}
|
||||
|
||||
|
||||
#messages-container {
|
||||
overflow: auto;
|
||||
padding: 1em 0;
|
||||
}
|
||||
#message-input-container {
|
||||
width: 100%;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
#message-form {
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
#message-body-form {
|
||||
font-size: 1em;
|
||||
height: 60px;
|
||||
}
|
||||
#message-body-form:disabled{
|
||||
opacity: .5;
|
||||
}
|
||||
#message-body-form img {
|
||||
display: inline;
|
||||
padding-left: 5px;
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
#message-body-form .emoji {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
#message-form-actions {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.message-text img {
|
||||
display: inline;
|
||||
padding-left: 5px;
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
.message-text .emoji {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
/* ************************************************8 */
|
||||
|
||||
.message {
|
||||
padding: .85em;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.message-avatar {
|
||||
margin-right: .75em;
|
||||
}
|
||||
.message-avatar img {
|
||||
max-width: unset;
|
||||
height: 3.0em;
|
||||
width: 3.0em;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
font-size: .85em;
|
||||
max-width: 85%;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.message-content a {
|
||||
color: #7F9CF5; /* indigo-400 */
|
||||
}
|
||||
.message-content a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
|
||||
/* ************************************************8 */
|
||||
|
||||
@ -452,7 +129,7 @@ h2 {
|
||||
--right-col-width: 20em;
|
||||
--user-image-width: 6em;
|
||||
}
|
||||
|
||||
|
||||
#chat-container {
|
||||
width: var(--right-col-width);
|
||||
}
|
||||
@ -504,21 +181,6 @@ h2 {
|
||||
}
|
||||
}
|
||||
|
||||
/* try not making the video fixed position for now */
|
||||
@media (min-height: 861px) {
|
||||
/* main {
|
||||
position: fixed;
|
||||
z-index: 9;
|
||||
width: 100%;
|
||||
}
|
||||
#user-content {
|
||||
margin-top: calc(var(--video-container-height) + var(--header-height) + 2em)
|
||||
} */
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -530,194 +192,3 @@ h2 {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.extra-user-content {
|
||||
padding: 1em 3em 3em 3em;
|
||||
}
|
||||
|
||||
.extra-user-content ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
.extra-user-content ul {
|
||||
list-style: unset;
|
||||
}
|
||||
|
||||
.extra-user-content h1, .extra-user-content h2, .extra-user-content h3, .extra-user-content h4 {
|
||||
color: #111111;
|
||||
font-weight: 400; }
|
||||
|
||||
.extra-user-content h1, .extra-user-content h2, .extra-user-content h3, .extra-user-content h4, .extra-user-content h5, .extra-user-content p {
|
||||
margin-bottom: 24px;
|
||||
padding: 0; }
|
||||
|
||||
.extra-user-content h1 {
|
||||
font-size: 48px; }
|
||||
|
||||
.extra-user-content h2 {
|
||||
font-size: 36px;
|
||||
margin: 24px 0 6px; }
|
||||
|
||||
.extra-user-content h3 {
|
||||
font-size: 24px; }
|
||||
|
||||
.extra-user-content h4 {
|
||||
font-size: 21px; }
|
||||
|
||||
.extra-user-content h5 {
|
||||
font-size: 18px; }
|
||||
|
||||
.extra-user-content a {
|
||||
color: #0099ff;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
vertical-align: baseline; }
|
||||
|
||||
.extra-user-content ul, .extra-user-content ol {
|
||||
padding: 0;
|
||||
margin: 0; }
|
||||
|
||||
.extra-user-content li {
|
||||
line-height: 24px; }
|
||||
|
||||
.extra-user-content li ul, .extra-user-content li ul {
|
||||
margin-left: 24px; }
|
||||
|
||||
.extra-user-content p, .extra-user-content ul, .extra-user-content ol {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.extra-user-content pre {
|
||||
padding: 0px 24px;
|
||||
max-width: 800px;
|
||||
white-space: pre-wrap; }
|
||||
|
||||
.extra-user-content code {
|
||||
font-family: Consolas, Monaco, Andale Mono, monospace;
|
||||
line-height: 1.5;
|
||||
font-size: 13px; }
|
||||
|
||||
.extra-user-content aside {
|
||||
display: block;
|
||||
float: right;
|
||||
width: 390px; }
|
||||
|
||||
.extra-user-content blockquote {
|
||||
margin: 1em 2em;
|
||||
max-width: 476px; }
|
||||
|
||||
.extra-user-content blockquote p {
|
||||
color: #666;
|
||||
max-width: 460px; }
|
||||
|
||||
.extra-user-content hr {
|
||||
width: 540px;
|
||||
text-align: left;
|
||||
margin: 0 auto 0 0;
|
||||
color: #999; }
|
||||
|
||||
.extra-user-content table {
|
||||
border-collapse: collapse;
|
||||
margin: 1em 1em;
|
||||
border: 1px solid #CCC; }
|
||||
|
||||
.extra-user-content table thead {
|
||||
background-color: #EEE; }
|
||||
|
||||
.extra-user-content table thead td {
|
||||
color: #666; }
|
||||
|
||||
.extra-user-content table td {
|
||||
padding: 0.5em 1em;
|
||||
border: 1px solid #CCC; }
|
||||
|
||||
.message-text iframe {
|
||||
width: 100%;
|
||||
height: 170px;
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
.message-text .instagram-embed {
|
||||
height: 314px;
|
||||
}
|
||||
|
||||
.message-text code {
|
||||
background-color:darkslategrey;
|
||||
padding: 3px;
|
||||
}
|
||||
/* Emoji picker */
|
||||
#emoji-button {
|
||||
position: relative;
|
||||
top: -65px;
|
||||
right: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.message-text .embedded-image {
|
||||
width: 100%;
|
||||
height: 170px;
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
.message-text code {
|
||||
background-color:darkslategrey;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
/* Emoji picker */
|
||||
#emoji-button {
|
||||
position: relative;
|
||||
top: -65px;
|
||||
right: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.message-text .embedded-image {
|
||||
width: 100%;
|
||||
height: 170px;
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
.message-text code {
|
||||
background-color:darkslategrey;
|
||||
padding: 3px;
|
||||
}
|
||||
.message-text .highlighted {
|
||||
color: orange;
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
|
||||
}
|
||||
|
||||
.message-text code {
|
||||
background-color:darkslategrey;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
/*
|
||||
The chat input has a fake placeholder that is styled below.
|
||||
It pulls the placeholder text from the div's placeholder attribute.
|
||||
But really it's just the innerHTML content.
|
||||
*/
|
||||
|
||||
/* If the div is empty then show the placeholder */
|
||||
#message-body-form:empty:before{
|
||||
content: attr(placeholder);
|
||||
pointer-events: none;
|
||||
display: block; /* For Firefox */
|
||||
|
||||
/* Style the div's placeholder text color */
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* When chat is enabled (contenteditable=true) */
|
||||
#message-body-form[contenteditable=true]:before {
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
|
||||
/* When chat is disabled (contenteditable=false) chat input div should appear disabled. */
|
||||
#message-body-form[contenteditable=false] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
|
||||
33
webroot/styles/standalone-chat.css
Normal file
@ -0,0 +1,33 @@
|
||||
/*
|
||||
The styles in this file mostly ovveride those coming from chat.css
|
||||
*/
|
||||
|
||||
/* modify this px number if you want things to be relatively bigger or smaller */
|
||||
#messages-only {
|
||||
font-size: 16px;
|
||||
}
|
||||
#messages-only .message-content {
|
||||
text-shadow: 1px 1px 0px rgba(0,0,0,0.25);
|
||||
}
|
||||
#messages-only .message-avatar {
|
||||
display: none;
|
||||
box-shadow: 0px 0px 3px 0px rgba(0,0,0,0.25);
|
||||
}
|
||||
#messages-only .message-avatar img {
|
||||
height: 1.8em;
|
||||
width: 1.8em;
|
||||
}
|
||||
#messages-only .message {
|
||||
padding: .5em;
|
||||
}
|
||||
|
||||
#messages-only .message-text {
|
||||
font-weight: 400;
|
||||
color: white;
|
||||
}
|
||||
#messages-only .message-text a {
|
||||
color: #fc0;
|
||||
}
|
||||
#messages-only .message-author {
|
||||
color: rgba(20,0,40,1);
|
||||
}
|
||||
150
webroot/styles/user-content.css
Normal file
@ -0,0 +1,150 @@
|
||||
.user-content .user-image {
|
||||
min-width: var(--user-image-width);
|
||||
width: var(--user-image-width);
|
||||
height: var(--user-image-width);
|
||||
max-height: var(--user-image-width);
|
||||
background-size: calc(var(--user-image-width) - 1em);
|
||||
}
|
||||
|
||||
.user-social-item .platform-icon {
|
||||
--icon-width: 40px;
|
||||
height: var(--icon-width);
|
||||
width: var(--icon-width);
|
||||
background-image: url(/img/social-icons.gif);
|
||||
background-position: calc(var(--imgCol) * var(--icon-width)) calc(var(--imgRow) * var(--icon-width));
|
||||
transform: scale(.65);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
EXTRA CUSTOM CONTENT STYLES
|
||||
Assumes markup converted from markdown input.
|
||||
*/
|
||||
|
||||
|
||||
#extra-user-content ul,
|
||||
#extra-user-content ol {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#extra-user-content ol {
|
||||
list-style: decimal;
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
|
||||
#extra-user-content ul {
|
||||
list-style: unset;
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
|
||||
#extra-user-content h1,
|
||||
#extra-user-content h2,
|
||||
#extra-user-content h3,
|
||||
#extra-user-content h4,
|
||||
#extra-user-content h5,
|
||||
#extra-user-content h6 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
margin: 1.5rem 0 .5rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
|
||||
#extra-user-content h1 {
|
||||
font-size: 2.1rem;
|
||||
}
|
||||
|
||||
#extra-user-content h2 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
#extra-user-content h3 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
#extra-user-content h4 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
#extra-user-content h5 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
#extra-user-content h6 {
|
||||
font-weight: 400;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
#extra-user-content p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
#extra-user-content a {
|
||||
color: #0099ff;
|
||||
}
|
||||
|
||||
#extra-user-content li {
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
#extra-user-content li ul,
|
||||
#extra-user-content li ul {
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
|
||||
|
||||
|
||||
#extra-user-content blockquote {
|
||||
border-left: .25rem solid #bbc;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
#extra-user-content blockquote p {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
#extra-user-content pre,
|
||||
#extra-user-content code {
|
||||
font-family: monospace;
|
||||
font-size: .85rem;
|
||||
background-color: #eee;
|
||||
color: #900;
|
||||
}
|
||||
#extra-user-content pre {
|
||||
margin: 1rem 0;
|
||||
padding: 1rem;
|
||||
max-width: 80%;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
#extra-user-content aside {
|
||||
display: block;
|
||||
float: right;
|
||||
width: 35%;
|
||||
}
|
||||
|
||||
#extra-user-content hr {
|
||||
width: 100%;
|
||||
border-top: 1px solid #666;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
#extra-user-content table {
|
||||
border-collapse: collapse;
|
||||
margin: 1em 1rem;
|
||||
border: 1px solid #CCC;
|
||||
}
|
||||
|
||||
#extra-user-content table thead {
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
#extra-user-content table thead td {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
#extra-user-content table td {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #CCC;
|
||||
}
|
||||
30
webroot/styles/video-only.css
Normal file
@ -0,0 +1,30 @@
|
||||
/*
|
||||
The styles in this file mostly ovveride those coming from chat.css
|
||||
*/
|
||||
|
||||
/* modify this px number if you want things to be relatively bigger or smaller */
|
||||
#video-only {
|
||||
font-size: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
#video-only #video-container {
|
||||
background-size: 30%;
|
||||
width: 100%;
|
||||
height: calc((9 / 16) * 100vw);
|
||||
}
|
||||
#video-only #video-container #video {
|
||||
transition: opacity .5s;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
#video-only .online #video-container #video {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
|
||||
#video-only #stream-info {
|
||||
height: 3rem;
|
||||
}
|
||||
55
webroot/styles/video.css
Normal file
@ -0,0 +1,55 @@
|
||||
video.video-js {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
min-height: 100%
|
||||
}
|
||||
|
||||
.vjs-airplay .vjs-icon-placeholder::before {
|
||||
content: url("../img/airplay.png");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* position: relative;
|
||||
width: 100%;
|
||||
height: calc((9 / 16) * 100vw);
|
||||
max-height: calc(100vh - 169px);
|
||||
min-height: 480px;
|
||||
background: #000; */
|
||||
|
||||
/*
|
||||
YOUTUBE
|
||||
style="--ytd-watch-flexy-scrollbar-width: 15px; --ytd-watch-flexy-panel-max-height: 460px; --ytd-watch-flexy-chat-max-height: 460px;"
|
||||
|
||||
--ytd-watch-flexy-scrollbar-width: 15px;
|
||||
--ytd-watch-flexy-panel-max-height: 460px;
|
||||
--ytd-watch-flexy-chat-max-height: 460px;
|
||||
|
||||
--ytd-watch-flexy-width-ratio: 16;
|
||||
--ytd-watch-flexy-height-ratio: 9;
|
||||
--ytd-watch-flexy-space-below-player: 136px;
|
||||
|
||||
--ytd-watch-flexy-non-player-height: calc(var(--ytd-watch-flexy-masthead-height) + var(--ytd-margin-6x) + var(--ytd-watch-flexy-space-below-player));
|
||||
|
||||
--ytd-watch-flexy-non-player-width: calc(var(--ytd-watch-flexy-sidebar-width) + (3 * var(--ytd-margin-6x)));
|
||||
|
||||
--ytd-watch-flexy-min-player-height: 240px;
|
||||
|
||||
--ytd-watch-flexy-min-player-width: calc(var(--ytd-watch-flexy-min-player-height) * (var(--ytd-watch-flexy-width-ratio) / var(--ytd-watch-flexy-height-ratio)));
|
||||
|
||||
--ytd-watch-flexy-max-player-height: calc(100vh -
|
||||
(var(--ytd-watch-flexy-masthead-height) + var(--ytd-margin-6x) + var(--ytd-watch-flexy-space-below-player)));
|
||||
|
||||
--ytd-watch-flexy-max-player-width:
|
||||
calc((100vh - (var(--ytd-watch-flexy-masthead-height) + var(--ytd-margin-6x) + var(--ytd-watch-flexy-space-below-player))) *
|
||||
(var(--ytd-watch-flexy-width-ratio) / var(--ytd-watch-flexy-height-ratio)));
|
||||
|
||||
|
||||
|
||||
--ytd-watch-flexy-sidebar-width: 402px;
|
||||
--ytd-watch-flexy-sidebar-min-width: 300px;
|
||||
--ytd-watch-flexy-masthead-height: 56px;
|
||||
min-width: 0;
|
||||
|
||||
*/
|
||||