Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.4 KiB |
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.4 KiB |
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.6 KiB |
@ -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> |
@ -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> |
@ -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> |
||||
|
@ -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 |
||||
/> |
||||
` |
||||
); |
||||
} |
||||
} |
@ -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> |
||||
`);
|
||||
} |
||||
} |
@ -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' |
||||
} |
@ -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> |
||||
`,
|
||||
}); |
@ -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> |
||||
`);
|
||||
} |
||||
|
||||
} |
@ -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> |
||||
`);
|
||||
} |
||||
|
||||
} |
||||
|
@ -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(/ |\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, |
||||
); |
||||
} |
||||
} |
@ -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> |
||||
` |
||||
); |
||||
} |
||||
} |
||||
} |
@ -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> |
||||
`);
|
||||
} |
||||
} |
@ -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> |
||||
`);
|
||||
} |
@ -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; |
||||
} |
||||
}); |
||||
} |
@ -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); |
||||
} |
@ -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> |
||||
`,
|
||||
}); |
@ -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)'; |
||||
} |
@ -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; |
||||
} |
@ -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; |
@ -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], |
||||
}, |
||||
}; |
@ -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})`; |
||||
} |
@ -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; |
||||
} |
||||
} */ |
@ -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 */ |
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
@ -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); |
||||
} |
@ -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; |
||||
} |
@ -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; |
||||
} |
@ -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; |
||||
|
||||
*/ |