Browse Source

Merge pull request #120 from owncast/0809gw-messagemodule

frontend refactor with Preact
pull/146/head
gingervitis 5 years ago committed by GitHub
parent
commit
21c1de53c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      doc
  2. 36
      static/metadata.html
  3. 0
      webroot/img/favicon/android-icon-144x144.png
  4. 0
      webroot/img/favicon/android-icon-192x192.png
  5. 0
      webroot/img/favicon/android-icon-36x36.png
  6. 0
      webroot/img/favicon/android-icon-48x48.png
  7. 0
      webroot/img/favicon/android-icon-72x72.png
  8. 0
      webroot/img/favicon/android-icon-96x96.png
  9. 0
      webroot/img/favicon/apple-icon-114x114.png
  10. 0
      webroot/img/favicon/apple-icon-120x120.png
  11. 0
      webroot/img/favicon/apple-icon-144x144.png
  12. 0
      webroot/img/favicon/apple-icon-152x152.png
  13. 0
      webroot/img/favicon/apple-icon-180x180.png
  14. 0
      webroot/img/favicon/apple-icon-57x57.png
  15. 0
      webroot/img/favicon/apple-icon-60x60.png
  16. 0
      webroot/img/favicon/apple-icon-72x72.png
  17. 0
      webroot/img/favicon/apple-icon-76x76.png
  18. 0
      webroot/img/favicon/apple-icon-precomposed.png
  19. 0
      webroot/img/favicon/apple-icon.png
  20. 0
      webroot/img/favicon/browserconfig.xml
  21. 0
      webroot/img/favicon/favicon-16x16.png
  22. 0
      webroot/img/favicon/favicon-32x32.png
  23. 0
      webroot/img/favicon/favicon-96x96.png
  24. 0
      webroot/img/favicon/ms-icon-144x144.png
  25. 0
      webroot/img/favicon/ms-icon-150x150.png
  26. 0
      webroot/img/favicon/ms-icon-310x310.png
  27. 0
      webroot/img/favicon/ms-icon-70x70.png
  28. 24
      webroot/index-standalone-chat.html
  29. 28
      webroot/index-video-only.html
  30. 289
      webroot/index.html
  31. 46
      webroot/js/app-standalone-chat.js
  32. 265
      webroot/js/app-video-only.js
  33. 572
      webroot/js/app.js
  34. 11
      webroot/js/chat/socketMessageTypes.js
  35. 62
      webroot/js/components.js
  36. 291
      webroot/js/components/chat/chat-input.js
  37. 218
      webroot/js/components/chat/chat.js
  38. 130
      webroot/js/components/chat/content-editable.js
  39. 66
      webroot/js/components/chat/message.js
  40. 106
      webroot/js/components/chat/username.js
  41. 19
      webroot/js/components/player.js
  42. 42
      webroot/js/components/social.js
  43. 41
      webroot/js/emoji.js
  44. 522
      webroot/js/message.js
  45. 127
      webroot/js/social.js
  46. 88
      webroot/js/usercolors.js
  47. 193
      webroot/js/utils/chat.js
  48. 34
      webroot/js/utils/constants.js
  49. 84
      webroot/js/utils/helpers.js
  50. 72
      webroot/js/utils/social.js
  51. 15
      webroot/js/utils/user-colors.js
  52. 34
      webroot/js/utils/websocket.js
  53. 14
      webroot/manifest.json
  54. 248
      webroot/styles/app.css
  55. 140
      webroot/styles/chat.css
  56. 563
      webroot/styles/layout.css
  57. 33
      webroot/styles/standalone-chat.css
  58. 150
      webroot/styles/user-content.css
  59. 30
      webroot/styles/video-only.css
  60. 55
      webroot/styles/video.css

1
doc

@ -1 +0,0 @@ @@ -1 +0,0 @@
Subproject commit 54a0ee13964c70585c24a9b5869604373faaa926

36
static/metadata.html

@ -29,23 +29,23 @@ @@ -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 @@ @@ -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>

0
webroot/android-icon-144x144.png → webroot/img/favicon/android-icon-144x144.png

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

0
webroot/android-icon-192x192.png → webroot/img/favicon/android-icon-192x192.png

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

0
webroot/android-icon-36x36.png → webroot/img/favicon/android-icon-36x36.png

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

0
webroot/android-icon-48x48.png → webroot/img/favicon/android-icon-48x48.png

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

0
webroot/android-icon-72x72.png → webroot/img/favicon/android-icon-72x72.png

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

0
webroot/android-icon-96x96.png → webroot/img/favicon/android-icon-96x96.png

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

0
webroot/apple-icon-114x114.png → webroot/img/favicon/apple-icon-114x114.png

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

0
webroot/apple-icon-120x120.png → webroot/img/favicon/apple-icon-120x120.png

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

0
webroot/apple-icon-144x144.png → webroot/img/favicon/apple-icon-144x144.png

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

0
webroot/apple-icon-152x152.png → webroot/img/favicon/apple-icon-152x152.png

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

0
webroot/apple-icon-180x180.png → webroot/img/favicon/apple-icon-180x180.png

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

0
webroot/apple-icon-57x57.png → webroot/img/favicon/apple-icon-57x57.png

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

0
webroot/apple-icon-60x60.png → webroot/img/favicon/apple-icon-60x60.png

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

0
webroot/apple-icon-72x72.png → webroot/img/favicon/apple-icon-72x72.png

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

0
webroot/apple-icon-76x76.png → webroot/img/favicon/apple-icon-76x76.png

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

0
webroot/apple-icon-precomposed.png → webroot/img/favicon/apple-icon-precomposed.png

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

0
webroot/apple-icon.png → webroot/img/favicon/apple-icon.png

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

0
webroot/browserconfig.xml → webroot/img/favicon/browserconfig.xml

0
webroot/favicon-16x16.png → webroot/img/favicon/favicon-16x16.png

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

0
webroot/favicon-32x32.png → webroot/img/favicon/favicon-32x32.png

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

0
webroot/favicon-96x96.png → webroot/img/favicon/favicon-96x96.png

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

0
webroot/ms-icon-144x144.png → webroot/img/favicon/ms-icon-144x144.png

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

0
webroot/ms-icon-150x150.png → webroot/img/favicon/ms-icon-150x150.png

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

0
webroot/ms-icon-310x310.png → webroot/img/favicon/ms-icon-310x310.png

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

0
webroot/ms-icon-70x70.png → webroot/img/favicon/ms-icon-70x70.png

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

24
webroot/index-standalone-chat.html

@ -0,0 +1,24 @@ @@ -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

@ -0,0 +1,28 @@ @@ -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>

289
webroot/index.html

@ -1,221 +1,74 @@ @@ -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"/>
<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 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="//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>
<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>
<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>
<div v-html="extraUserContent" class="extra-user-content">{{extraUserContent}}</div>
</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>
<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="/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" />
<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>
<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>
<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>
<noscript>
<style>
.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://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

@ -0,0 +1,46 @@ @@ -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

@ -0,0 +1,265 @@ @@ -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>
`);
}
}

572
webroot/js/app.js

@ -1,28 +1,69 @@ @@ -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';
const MESSAGE_OFFLINE = 'Stream is offline.';
const MESSAGE_ONLINE = 'Stream is online';
const TEMP_IMAGE = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
const URL_CONFIG = `/config`;
const URL_STATUS = `/status`;
const URL_CHAT_HISTORY = `/chat`;
const TIMER_STATUS_UPDATE = 5000; // ms
const TIMER_DISABLE_CHAT_AFTER_OFFLINE = 5 * 60 * 1000; // 5 mins
const TIMER_STREAM_DURATION_COUNTER = 1000;
class Owncast {
constructor() {
this.player;
this.configData;
this.vueApp;
this.messagingInterface = null;
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 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';
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';
export default class App extends Component {
constructor(props, context) {
super(props, context);
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()}`),
configData: {},
extraUserContent: '',
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 { @@ -31,67 +72,30 @@ class Owncast {
this.disableChatTimer = null;
this.streamDurationTimer = null;
// misc
this.streamStatus = null;
Vue.filter('plural', pluralize);
// misc dom events
this.handleChatPanelToggle = this.handleChatPanelToggle.bind(this);
this.handleUsernameChange = this.handleUsernameChange.bind(this);
this.handleWindowResize = debounce(this.handleWindowResize.bind(this), 400);
// 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);
}
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,
});
// fetch events
this.getConfig = this.getConfig.bind(this);
this.getStreamStatus = this.getStreamStatus.bind(this);
this.getExtraUserContent = this.getExtraUserContent.bind(this);
}
// 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 { @@ -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 { @@ -180,7 +150,7 @@ class Owncast {
this.handleOfflineMode();
this.handleNetworkingError(`Stream status: ${error}`);
});
};
}
// fetch content.md
getExtraUserContent(path) {
@ -192,141 +162,319 @@ class Owncast { @@ -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}.`
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();
}
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);
}
};
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.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({
playerActive: true,
streamOnline: true,
chatEnabled: true,
streamStatusMessage: MESSAGE_ONLINE,
});
}
// when videojs player is ready, start polling for stream
handlePlayerReady() {
this.getStreamStatus();
this.statusTimer = setInterval(this.getStreamStatus, TIMER_STATUS_UPDATE);
};
handleUsernameChange(newName, newAvatar) {
this.setState({
username: newName,
userAvatarImage: newAvatar,
});
}
handlePlayerPlaying() {
// do something?
};
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,
});
}
// likely called some time after stream status has gone offline.
// basically hide video and show underlying "poster"
handlePlayerEnded() {
this.vueApp.playerOn = false;
};
handleNetworkingError(error) {
console.log(`>>> App Error: ${error}`);
}
handlePlayerError() {
// do something?
this.handleOfflineMode();
this.handlePlayerEnded();
};
};
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;

11
webroot/js/chat/socketMessageTypes.js

@ -1,11 +0,0 @@ @@ -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'
}

62
webroot/js/components.js

@ -1,62 +0,0 @@ @@ -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

@ -0,0 +1,291 @@ @@ -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

@ -0,0 +1,218 @@ @@ -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

@ -0,0 +1,130 @@ @@ -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(/&nbsp;|\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

@ -0,0 +1,66 @@ @@ -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

@ -0,0 +1,106 @@ @@ -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>
`);
}
}

19
webroot/js/player.js → webroot/js/components/player.js

@ -17,7 +17,6 @@ const VIDEO_OPTIONS = { @@ -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 = { @@ -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 { @@ -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

@ -0,0 +1,42 @@ @@ -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>
`);
}

41
webroot/js/emoji.js

@ -1,41 +0,0 @@ @@ -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;
}
});
}

522
webroot/js/message.js

@ -1,522 +0,0 @@ @@ -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);
}

127
webroot/js/social.js

@ -1,127 +0,0 @@ @@ -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>
`,
});

88
webroot/js/usercolors.js

@ -1,88 +0,0 @@ @@ -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

@ -0,0 +1,193 @@ @@ -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

@ -0,0 +1,34 @@ @@ -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;

84
webroot/js/utils.js → webroot/js/utils/helpers.js

@ -1,16 +1,4 @@ @@ -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) { @@ -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) { @@ -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) { @@ -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) { @@ -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) { @@ -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

@ -0,0 +1,72 @@ @@ -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

@ -0,0 +1,15 @@ @@ -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})`;
}

34
webroot/js/websocket.js → webroot/js/utils/websocket.js

@ -1,17 +1,25 @@ @@ -1,17 +1,25 @@
import SOCKET_MESSAGE_TYPES from './chat/socketMessageTypes.js';
const URL_WEBSOCKET = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/entry`;
const TIMER_WEBSOCKET_RECONNECT = 5000; // ms
const CALLBACKS = {
/**
* 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'
};
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 { @@ -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 { @@ -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 { @@ -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 { @@ -133,7 +141,5 @@ class Websocket {
handleNetworkingError(error) {
console.error(`Websocket Error: ${error}`)
};
}
}
export default Websocket;

14
webroot/manifest.json

@ -2,40 +2,40 @@ @@ -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

@ -0,0 +1,248 @@ @@ -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

@ -0,0 +1,140 @@ @@ -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 */

563
webroot/styles/layout.css

@ -1,3 +1,9 @@ @@ -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 @@ @@ -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 { @@ -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;
}
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;
#chat-toggle {
min-width: 3rem;
}
/* ************************************************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 { @@ -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 { @@ -304,7 +81,6 @@ h2 {
}
.vjs-airplay .vjs-icon-placeholder::before {
/* content: 'AP'; */
content: url("../img/airplay.png");
}
@ -323,16 +99,10 @@ h2 { @@ -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,103 +110,10 @@ h2 { @@ -340,103 +110,10 @@ 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;
}
.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;
#stream-info span {
font-size: .70rem;
}
/* ************************************************8 */
@ -452,7 +129,7 @@ h2 { @@ -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 { @@ -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 { @@ -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

@ -0,0 +1,33 @@ @@ -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

@ -0,0 +1,150 @@ @@ -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

@ -0,0 +1,30 @@ @@ -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

@ -0,0 +1,55 @@ @@ -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;
*/
Loading…
Cancel
Save